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

На этот раз я написал скрипт, который автоматизирует создание модели для 3D-печати барабана музыкальной шкатулки прямиком из MIDI-файла с мелодией.

Механизм музыкальной шкатулки с кастомной мелодией
Механизм музыкальной шкатулки с кастомной мелодией

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

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

Постановка задачи

Имея MIDI-файл с нотами для музыкальной шкатулки нам нужно получить STL-файл, готовый для использования в качестве модели для трёхмерной печати. Немного декомпозируем задачу:

  1. Парсинг MIDI-файла: нужно учесть физические ограничения музыкальной шкатулки и максимально простым способом сконвертировать файл MIDI в набор нот, разделённый на временные промежутки, в которые их нужно "сыграть".

  2. Сопоставление нот и соответствующих им номеров "зубов" гребёнки.

  3. Создание модели барабана вместе с пинами, расположенными в нужных местах.

Парсинг MIDI-файла

На самом деле, это самая простая часть. Я использовал Python и библиотеку mido (т. к. мой основной язык — Python).

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

Так как у нас есть только один инструмент (наша музыкальная шкатулка) и этот инструмент "не умеет" играть ноты разной длительности или изменять темп игры, следует ряд упрощений:

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

  2. Можно не учитывать различные длительности звучания и взять за основу, например, длительность звучания первой ноты в файле в качестве "кванта времени".

В качестве целевой структуры данных я выбрал двумерный массив (или список списков на языке Python). Так, первый список представляет собой набор точек во времени, в которые должны играться ноты. Списки внутри — собственно, ноты, играемые в данной точке. Пример:

music_score = [
  [],             # Ничего не играть в первый момент времени
  [],             # И во второй момент
  ['C#4', 'A6'],  # Одновременно сыграть C4 в 4-й октаве и A в 6-й.
  ['G5'],         # Затем сразу G в 5-й.
]

Собственно, код для такой конвертации выглядит так:

...
score = [[]]
for track in midi_file.tracks:
    for message in track:
        if message.type not in ('note_on', 'note_off'):  # Нас интересуют только ноты
            continue
        elapsed = message.time // time_quant
        score.extend([] for _ in range(elapsed))  # Добавляем промежутки, если с момента последнего сообщения должно пройти время
        if message.type == 'note_on':
            current_frame = score[-1]
            note = number_to_note(message.note)
            current_frame.append(note)

Здесь же можно заметить вызов функции number_to_note, но никакого rocket science: в спецификации MIDI определены номера нот, т. к. формат бинарный, конвертируем их для удобства в текст:

NOTES = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B']
OCTAVES = list(range(11))
NOTES_IN_OCTAVE = len(NOTES)

def number_to_note(number: int) -> str:
    octave = number // NOTES_IN_OCTAVE - 1
    assert octave in OCTAVES, 'Invalid octave number'
    assert 0 <= number <= 127, 'Invalid MIDI note number'
    note = NOTES[number % NOTES_IN_OCTAVE]

    return f'{note}{octave}'

Сопоставление нот с гребёнкой шкатулки

Казалось бы, C - 1, C# - 2, D - 3 и так далее... Но нет, есть пара интересных моментов.

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

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

class NoteNumberGetter:
    def __init__(self, available_notes: list[str]) -> None:
        self._notes_mapping = {
            note: (_indices(available_notes, note, start=1), -1)  # функция _indices возвращает все индексы, по которым в списке найден указанный элемент.
            for note in available_notes
        }

    def get_note_number(self, note: str) -> int:
        note_numbers, last_used_index = self._notes_mapping[note]
        last_used_index = (last_used_index + 1) % len(note_numbers)
        note_number = note_numbers[last_used_index]
        self._notes_mapping[note] = (note_numbers, last_used_index)
        return note_number

Генерация модели

Для создания параметрических 3D-моделей, в основе построения которых лежит алгоритм, принимающий произвольные входные данные есть, пожалуй, одно зарекомендовавшее себя популярное решение — это OpenSCAD. Благо, для быстрого старта мне достался готовый скрипт, который можно взять за основу, но его всё равно пришлось значительно переписать и отрефакторить с учётом того, что данные я могу готовить автоматически, а не прописывать вручную, а оригинальный скрипт не поддерживал проигрывание двух нот в один и тот же момент времени. Исходя из этого, стоит рассмотреть весь процесс написания пошагово.

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

Основа барабана создаётся с помощью следующего кода:

module generateBody() {
	difference() {
		cylinder(d=cylinderDiameter-cylinderTolerance, h=cylinderHeight, $fn=100, center=false);

		cylinder(d=cylinderDiameter-cylinderTolerance-cylinderThickness*2, h=cylinderHeight, $fn=100, center=false);

		// top hole
		translate([cylinderDiameter/-2,cylinderTopHoleWidth/-2, cylinderHeight-cylinderTopHoleHeight])
		cube([cylinderDiameter, cylinderTopHoleWidth, cylinderTopHoleHeight]);
	}
	// bottom
	translate([0, 0, -cylinderBottomHeight])
	difference() {
		cylinder(d=cylinderBottomDiameter, h=cylinderBottomHeight, $fn=64, center=false);
		cylinder(d1=cylinderBottomHoleD1,d2=cylinderBottomHoleD2, h=cylinderBottomHeight, $fn=64, center=false);
	}
}

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

Полученная модель барабана
Полученная модель барабана

Пины же создаются по следующему алгоритму:

  1. Создаём усечённый конус, располагаем его горизонтально.

  2. Поворачиваем и смещаем его в нужную сторону.

    1. Угол поворота определяется достаточно просто: нам нужно отобразить нашу "звуковую дорожку" на окружность, для этого просто умножаем индекс момента времени, соответствующего ноте, для которой этот пин создаётся, на 360º, делённые на общую длину композиции.

    2. Смещение по осям X и Y определяется так же легко: нужно умножить радиус барабана на косинус и синус угла поворота соответственно.

    3. Смещение по оси Z определяется как расстояние между центром первого зубчика гребёнки от нуля координат + номер зубчика, умноженный на расстояние между центрами соседних зубчиков.

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

module generatePinsFromScore(score) {
    scoreLength = len(score);
    offsetAngle = 360 / scoreLength;

    for (i = [0:scoreLength - 1]) {
        notes = score[i];
        if (len(notes) - 1 > 0) {
            for (noteIndex = [0:len(notes) - 1]) {
                toothId = notes[noteIndex];
                angle = (isCounterclockwise ? -1 : 1) * (offsetAngle * i);
                rOffset = 0.3;  // How deep pins would protrude the cylinder
                radius = cylinderDiameter / 2 - rOffset;
                x = radius * cos(angle);
                y = radius * sin(angle);
                z = firstPinPosition + pinOffset * (isTopFirst ? (tonesTotalNumber - toothId) : toothId);
                translate([x, y, z])
                    rotate([0, 0, angle])
                        rotate([0, 90, 0])
                            cylinder(
                            d1 = pinBaseDiameter,
                            d2 = pinDiameter,
                            h = pinHeight + rOffset,
                            $fn = 20,
                            center = false
                            );
            }
        }
    }
}
Готовая модель барабана с мелодией
Готовая модель барабана с мелодией

Скрипт на Python использует шаблон файла для OpenSCAD и при выполнении заполняет шаблон данными на основе мелодии в формате MIDI. Полученный файл остаётся только открыть в OpenSCAD, экспортировать в STL и напечатать.

Печать

Полученную модель печатаем на SLA-принтере. Лучше всего использовать для этого смолу с повышенной прочностью. Обычно, такая называется UV Tough Resin или ABS-like Resin. Модель не требует поддержек и печатается достаточно качественно. Также на фотографиях автора оригинального спринта можно увидеть барабан, напечатанный на FDM-принтере, но я пока не пробовал печатать это на FDM.

Что можно улучшить

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

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

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

Репозиторий со скриптом

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


  1. dlinyj
    24.06.2024 15:28
    +1

    Потрясающе крутой проект!

    Ещё не все мидишки одинаковые. Я тоже делал конвертер на питоне, и там есть свои подводные камни. Но ваш проект прям очень классный!


    1. PashaWNN Автор
      24.06.2024 15:28
      +1

      Да, я тоже столкнулся с некоторыми отличиями при экспорте одной и той же мелодии из разных редакторов. В моём случае изначально был затык с тем, что одна и та же последовательность в момент "отпускания" одних "клавиш" и "нажатия" других одновременно, оно может кодироваться в разной последовательности. Ну то есть, note 1 on | note 1 off, note 2 on | note 2 off и note 1 on | note 2 on, note 1 off | note 2 off — одно и то же (при одинаковых таймингах) и нужно это учитывать.


  1. SanSeich_78
    24.06.2024 15:28
    +2

    АААА!!!! Вы реализовали моё тайное желание!! ))))) Давно хочу сделать нечто похожее, но ни слуха, ни и мозгов на такое не хватило ))
    КРУТО!!!


  1. ivanstor
    24.06.2024 15:28

    Восторг и упоение! Кстати, механическая часть продается на озоне за меньше 1000₽.


    1. LAutour
      24.06.2024 15:28
      +2

      1000р - дорого. На Ali с доставкой сейчас нашел за 200р.


      1. SiG66
        24.06.2024 15:28

        На озоне с доставкой на завтра муз.шкатулка в фанерном корпусе стоит 260-280 руб.


        1. LAutour
          24.06.2024 15:28

          Они без пружинного завода - неинтересно.


  1. REPISOT
    24.06.2024 15:28
    +1

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


    1. strvv
      24.06.2024 15:28

      И для гашения длительности - треугольник с плавным спуском.

      А для большей длительности последовательность бинов.


    1. PashaWNN Автор
      24.06.2024 15:28

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


      1. LAutour
        24.06.2024 15:28

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


        1. PashaWNN Автор
          24.06.2024 15:28

          Можно растянуть цилиндрики вдоль барабана

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


  1. strvv
    24.06.2024 15:28

    Предложение:

    • делать основной барабан как шпульку у катушки, т.е. сплошной цилиндр с сквозным отверстием под болт, для жёсткости.

    • Музыкальную часть делать дополнительно, тонкостенным цилиндром, поверх основного. Это позволит менять мелодии и создавать взамен изношенных новые.

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


    1. LAutour
      24.06.2024 15:28
      +2

      Музыкальную часть делать дополнительно, тонкостенным цилиндром, поверх основного

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


      1. strvv
        24.06.2024 15:28

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

        А в селекторе основа цилиндр с прорезью, там фотодиоды, в параллель, а внешний цилиндр с отверстиями.

        Брал 0.5 мм зазора, имхо, на pla.


        1. PashaWNN Автор
          24.06.2024 15:28

          Здесь всё же допуски другие. Отклонение даже в 0,1–0,2 мм в длине штырьков, например, может губительно влиять на качество звука (они либо не достают до гребёнки, либо слишком жёстко её дёргают, в результате чего возникает дребезг). На видео попал момент: слышно, что в отдельном промежутке музыка играет тише — это результат незначительной, незаметной глазу деформации цилиндра.

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


          1. LAutour
            24.06.2024 15:28

            Можно и полную заливку сделать (не тот объем чобы экономить). Плюс это даст возможность сделать барабан с дырками под проволочки по образу из часов Наири. .


            1. PashaWNN Автор
              24.06.2024 15:28
              +1

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


  1. cjey
    24.06.2024 15:28
    +1

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

    Вот пример параметрической модели для коробки которую я делал когда я пробовал с ней работать

    Больше примеров можно глянуть в документации

    Вот в этом ролике можно посмотреть пример использования библиотеки в соревновании по 3Д моделированию


    1. PashaWNN Автор
      24.06.2024 15:28

      Блин, а это офигенно! Изначально мой выбор OpenSCAD был обоснован двумя причинами:

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

      2. Я не задумывался об аналогах и не стал их искать из-за пункта 1.

      При этом я знаком с CAD (часто пользуюсь Fusion 360) и код на скриншоте выглядит приятно и понятно. Хотел закинуть плюсик в карму, но Хабр говорит, что нельзя пользователю дать кармы >4, если у него нет публикаций (хотя текущая карма отображается равной девяти).