Update: I fixed up the script to pull (usually) proper timing from the MIDI, threw together some minimal documentation and released it to the public (see link below).
Basically, it’s possible to compute a combination of (distance, feedrate) along an axis that will cause the stepper motor for that axis to spin at an exact frequency corresponding to a musical note. With a little vector magic, the same can be done for (x, y, z, feedrate) to produce chords as the machine follows a 3D line through space.
*whew* That was the easy part. The real magic will happen in a future post, if I ever get around to it :-) Hint: The fact that notes can and do swap arbitrarily among different axes (while still sounding passable) is important.
How This Works (for CNC-heads):
We have code G1 [pos]x F[feedrate] for linear interpolation at a specific feedrate. Thus need to convert between feedrate in IPM and frequency in Hz (steps per inch or inches per step). My machine as currently configured is 36000 steps/in, so if we wanted it to play middle A (440Hz) (440*60 = 26400 steps/min) we would want to move along a single axis at feedrate (26400/36000 = 0.7333..) IPM.
or more generally, (freq/600) IPM.
Here are the frequencies for one octave. The formula to convert semitones (notes) to their actual frequencies is
f = fRef*2^(x/12)
where fRef is an arbitrarily chosen reference frequency corresponding to a specific note, and x is the number of semitones difference between the note you want and the reference. Middle A (440Hz) is as good a reference note as any, and its MIDI note number is 69, so the formula to calculate frequency for any MIDI note number becomes:
f = 440*2^((x-69)/12)
; C4 = 261.63Hz
; D4 = 293.66
; E4 = 329.63
; F4 = 349.23
; G4 = 392.00
; A4 = 440.00
; B4 = 493.88
; C5 = 523.25
And the G-code with the resulting feedrates to play this scale on my machine:
G1 X1 F0.43605
G1 X2 F0.48943333333333333333333333333333
G1 X3 F0.54938333333333333333333333333333
G1 X4 F0.58205
G1 X5 F0.65333333333333333333333333333333
G1 X6 F0.73333333333333333333333333333333
G1 X7 F0.82313333333333333333333333333333
G1 X8 F0.87208333333333333333333333333333
; Unfortunately, our note duration is now frequency-dependent. If we wanted it to play for 1 minute, we should make the distance
; equal to the feedrate in IPM (or 1/60 of that to play for 1 second, etc.). Easy-peasy so far.
Now let’s complicate things a bit. Suppose we want to play 2 or 3 notes at once. G-code linear interpolation scheme is that in, say, an XYZ move, all the axes arrive at the same time. Feedrate is the speed the tool moves along this *vector*, not the speed of the fastest/arbitrary axis. In other words, you cannot specify individual feedrates for the (x,y,z) axis moves, only one for the resulting vector as a whole. So, since the vector that results from adding 2 ore more axis moves will always be longer than either of the individual axis moves (for the 2-axis case, think the hypotenuse of a right triangle) the feedrate we set will be faster than the highest note, and will depend on the individual notes and their contributions to that vector.
Assume the bog-standard C-E-G chord. To play each on its own for 1 second…
G1 X0.0072675 F0.43605 ; move this distance
G1 X0.0164238 F0.54938 ; move 0.009156333…
G1 X0.0273126 F0.65333 ; move 0.010888833…
…but we want to combine these into a single (x,y,z) vector at a single feedrate.
The vector is obviously (0,0,0 to .00726, .00915, .01088), and its length is given by sqrt(x^2 + y^2 + z^2). Remember we are playing all three notes for the same length of time. The vector has lengthened, but the desired playing time has not, so we need to choose the feedrate for this new distance that yields the same travel time.
Regardless of how the length or rate changes, the (x,y,z) components remain proportional to one another. Just pick one of the individual axes/notes as a reference, compare the final vector length to the length of the reference note and bump the feedrate proportionally to the change in length. In this case we arbitrarily select the highest note as the reference, and the ratio of the final feedrate (unknown) to the reference feedrate (known) should equal the ratio of the 3D vector length (known) to the reference length (known). It’s almost too easy!
3D Vector length: 0.015975658808286373765422932349422
Feedrate: (newlength/oldlength) * oldfeed
= 1.4671598699591015644580950363551 * 0.65333 = 0.9585395578403798251074072301019
G1 X0.0072675 Y0.009156333 Z0.010888833 F0.95853955
Just remember that *any* change of any note requires computing a fresh new vector, so long notes will have to be split up wherever another concurrent note changes.