Творчество в OpenSCAD: своя мелодия для музыкальной шкатулки

в 15:05, , рубрики: 3D-печать, DIY, diy-проекты, OpenSCAD, python, музыка

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

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

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

Автор: Шишмарёв Павел

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js