your own melody for the music box

One of my favorite things about programming is when I get to use my skills not only in my day job, but also in some new and unexpected subject area, making someone else's job easier and solving problems that haven't been automated before. Most often, my wife becomes the source of inspiration in such matters: one of the previous times I had to write a lot of code, when we wrote a thesis on hydrology together (and I still haven’t gotten around to writing an article about it), but this time the task was to do a music box with a unique melody.

This time I wrote a script that automates the creation of a model for 3D printing a music box drum directly from a MIDI file with a melody.

Music box mechanism with custom melody

Music box mechanism with custom melody

The mechanism of a music box is simple: it consists of a winding mechanism with a spring, a comb with teeth tuned to certain sound frequencies, and a drum, which is a cylinder with protruding “pins” that, when the drum rotates, pull the teeth of the comb.

I started by searching for information on the Internet: I wanted to check how feasible the task of creating your own drum using 3D printing is (taking into account the characteristics of traditional printing materials). We managed to find not only success stories in the comments on Reddit, but also a ready-made script for OpenSCAD that generates a drum model. However, it still had to be finalized, because it was limited in its capabilities, and subsequently most of it was rewritten.

Formulation of the problem

Having a MIDI file with sheet music for a music box, we need to get an STL file ready to be used as a model for 3D printing. Let's decompose the problem a little:

  1. Parsing a MIDI file: You need to take into account the physical limitations of the music box and convert the MIDI file in the simplest possible way into a set of notes, divided into time intervals in which they need to be “played”.

  2. Comparison of notes and the corresponding numbers of the “teeth” of the comb.

  3. Creating a drum model along with pins located in the right places.

Parsing a MIDI file

Actually, this is the easiest part. I used Python and the mido library (since my main language is Python).

The MIDI file format determines the possibility of the existence in one file of several trackswhich in turn consist of messages. Messages may have type, note And time, which must pass from the previous message to the activation of this one. In addition, there are meta messages that set various settings of the musical instrument (such as tempo).

Since we only have one instrument (our music box) and this instrument does not “know how” to play notes of different durations or change the tempo of the game, a number of simplifications follow:

  1. All tracks can be processed sequentially, ignoring messageswhich select an instrument, set the playback tempo, etc.

  2. You can ignore different sound durations and take as a basis, for example, the duration of the first note in the file as a “time quantum”.

I chose a two-dimensional array (or list of lists in Python) as my target data structure. So, the first list is a set of points in time at which notes should be played. The lists inside are actually the notes played at a given point. Example:

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

Actually, the code for such a conversion looks like this:

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

Here you can see the function call number_to_notebut no rocket science: the MIDI specification defines note numbers, since the format is binary, we convert them into text for convenience:

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

Matching notes with the comb of the box

It would seem that C is 1, C# is 2, D is 3 and so on… But no, there are a couple of interesting points.

Firstly, these Chinese mechanisms, which have flooded all marketplaces and have no accessible analogues, have an unpredictable set of notes. We have to disassemble the mechanism, pick up a piece of paper, a pen and a phone with a tuner application in order to write out the notes available to us in fact. And then add a list to the script and, in one step, check that we have all the notes from the MIDI file “available”.

Secondly, the mechanism has a limitation: in some melodies the same note can be too close in a row, and then the comb tooth sticks into the next pin of the drum and rattles disgustingly. We have to change the melody to adapt to this limitation. However, there is a silver lining: an unpredictable set of notes can have repetitions, that is, one note can be played with several different comb teeth. This allows us to partially circumvent the limitation if each time we come across such a note, we use a different clove than the one we used last time. To do this, the following code was written, which is used to match each next note with the tooth number:

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

Model generation

To create parametric 3D models, the construction of which is based on an algorithm that accepts arbitrary input data, there is perhaps one proven popular solution – this is OpenSCAD. Fortunately, for a quick start, I got a ready-made script that can be taken as a basis, but it still had to be significantly rewritten and refactored, taking into account the fact that I can prepare the data automatically and not write it out manually, and the original script did not support playing two notes in the same point in time. Based on this, it is worth considering the entire writing process step by step.

We can conditionally divide the construction of a drum model for a music box into two stages: the actual creation of the drum itself and filling its surface with pins in accordance with a given melody.

The reel base is created using the following code:

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);
	}
}

Here a thin-walled cylinder is created with grooves at the top and a hole for a mounting screw at the bottom. The dimensions are taken with a caliper from the “original” drum of the box. It has a gear on the top that is easily removed and installed on the printed drum, and recesses help secure it so that the gear does not spin. This eliminates the need to develop and print the gear from scratch, which greatly simplifies everything.

The resulting drum model

The resulting drum model

Pins are created according to the following algorithm:

  1. Create a truncated cone and place it horizontally.

  2. Rotate and move it in the desired direction.

    1. The rotation angle is determined quite simply: we need to map our “sound track” onto a circle; to do this, we simply multiply the index of the moment of time corresponding to the note for which this pin is created by 360º, divided by the total length of the composition.

    2. The displacement along the X and Y axes is determined just as easily: you need to multiply the radius of the drum by the cosine and sine of the rotation angle, respectively.

    3. The Z-axis offset is defined as the distance between the center of the first comb tooth from the coordinate zero + the tooth number multiplied by the distance between the centers of adjacent teeth.

This algorithm runs in a loop on a given composition, previously converted into a suitable format.

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
                            );
            }
        }
    }
}
Ready-made drum model with melody

Ready-made drum model with melody

The Python script uses a file template for OpenSCAD and, when executed, fills the template with data based on the MIDI melody. The resulting file can only be opened in OpenSCAD, exported to STL and printed.

Seal

We print the resulting model on an SLA printer. It is best to use a resin with increased strength for this, usually called UV Tough Resin or ABS-like Resin. The model does not require supports and is printed with sufficient quality. Also in the photos of the original sprint you can see the drum printed on an FDM printer, but I have not tried printing this on FDM yet.

What can be improved

  1. It would be nice to modify the drum model so that there are stiffening ribs inside that protect it from deformation, especially when tightening the fastening bolt.

  2. The script can be modified so that it works adequately with MIDI files in which note durations and intervals are different. Now, for simplicity, the duration of the first note in the file is taken as the basis for the time quantum, and you can look for the greatest common divisor of all time intervals (so as not to create too many empty lines in the list of notes that is transferred to the SCAD script).

  3. I would like to take the time to attach a web interface to the script to make it easier to use for users who do not have a technical background.

Repository with script

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *