Skip to content

Commit 7b17e9b

Browse files
authored
.midi Export (#4319)
* midi basic * basic midi * clean up * data extraction success * update * update 2 * update 3 * instrument mapping * drums added * fix bugs * bug fix * afterSaveMIDI * comments * package * removed comment * space after comma * midi export * resolve
1 parent ee12481 commit 7b17e9b

10 files changed

+234
-11
lines changed

index.html

+1
Original file line numberDiff line numberDiff line change
@@ -890,6 +890,7 @@
890890

891891
<ul id="saveddropdown" class="dropdown-content">
892892
<li><a id="save-html"></a></li>
893+
<li><a id="save-midi"></a></li>
893894
<li><a id="save-svg"></a></li>
894895
<li><a id="save-png"></a></li>
895896
<li><a id="save-wav"></a></li>

js/SaveInterface.js

+171-7
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ class SaveInterface {
4848
* @member {number}
4949
*/
5050
this.timeLastSaved = -100;
51-
51+
5252
/**
5353
* HTML template for saving projects.
5454
* @member {string}
@@ -264,9 +264,173 @@ class SaveInterface {
264264
}, 500);
265265
}
266266

267+
/**
268+
* Save MIDI file.
269+
*
270+
* This method generates required MIDI data.
271+
*
272+
* @param {SaveInterface} activity - The activity object to save.
273+
* @returns {void}
274+
* @memberof SaveInterface
275+
* @method
276+
* @instance
277+
*/
278+
saveMIDI(activity) {
279+
// Suppress music and turtle output when generating
280+
activity.logo.runningMIDI = true;
281+
activity.logo.runLogoCommands();
282+
document.body.style.cursor = "wait";
283+
}
284+
285+
/**
286+
* Perform actions after generating MIDI data.
287+
*
288+
* This method generates a MIDI file using _midiData.
289+
*
290+
* @returns {void}
291+
* @memberof SaveInterface
292+
* @method
293+
* @instance
294+
*/
295+
afterSaveMIDI() {
296+
const generateMidi = (data) => {
297+
const normalizeNote = (note) => {
298+
return note.replace("♯", "#").replace("♭", "b");
299+
};
300+
const MIDI_INSTRUMENTS = {
301+
default: 0, // Acoustic Grand Piano
302+
piano: 0,
303+
violin: 40,
304+
viola: 41,
305+
cello: 42,
306+
"double bass": 43,
307+
bass: 32,
308+
sitar: 104,
309+
guitar: 24,
310+
"acoustic guitar": 25,
311+
"electric guitar": 27,
312+
flute: 73,
313+
clarinet: 71,
314+
saxophone: 65,
315+
tuba: 58,
316+
trumpet: 56,
317+
oboe: 68,
318+
trombone: 57,
319+
banjo: 105,
320+
koto: 107,
321+
dulcimer: 15,
322+
bassoon: 70,
323+
celeste: 8,
324+
xylophone: 13,
325+
"electronic synth": 81,
326+
sine: 81, // Approximate with Lead 2 (Sawtooth)
327+
square: 80,
328+
sawtooth: 81,
329+
triangle: 81, // Approximate with Lead 2 (Sawtooth)
330+
vibraphone: 11
331+
};
332+
333+
const DRUM_MIDI_MAP = {
334+
"snare drum": 38,
335+
"kick drum": 36,
336+
"tom tom": 41,
337+
"floor tom tom": 43,
338+
"cup drum": 47, // Closest: Low-Mid Tom
339+
"darbuka drum": 50, // Closest: High Tom
340+
"japanese drum": 56, // Closest: Cowbell or Tambourine
341+
"hi hat": 42,
342+
"ride bell": 53,
343+
"cow bell": 56,
344+
"triangle bell": 81,
345+
"finger cymbals": 69, // Closest: Open Hi-Hat
346+
"chime": 82, // Closest: Shaker
347+
"gong": 52, // Closest: Chinese Cymbal
348+
"clang": 55, // Closest: Splash Cymbal
349+
"crash": 49,
350+
"clap": 39,
351+
"slap": 40,
352+
"raindrop": 88 // Custom mapping (not in GM), can use melodic notes
353+
};
354+
355+
const midi = new Midi();
356+
midi.header.ticksPerBeat = 480;
357+
358+
Object.entries(data).forEach(([blockIndex, notes]) => {
359+
360+
const mainTrack = midi.addTrack();
361+
mainTrack.name = `Track ${parseInt(blockIndex) + 1}`;
362+
363+
let trackMap = new Map();
364+
let globalTime = 0;
365+
366+
notes.forEach((noteData) => {
367+
if (!noteData.note || noteData.note.length === 0) return;
368+
const duration = ((1 / noteData.duration) * 60 * 4) / noteData.bpm;
369+
const instrument = noteData.instrument || "default";
370+
371+
if (noteData.drum) {
372+
const drum = noteData.drum || false;
373+
if (!trackMap.has(drum)) {
374+
const drumTrack = midi.addTrack();
375+
drumTrack.name = `Track ${parseInt(blockIndex) + 1} - ${drum}`;
376+
drumTrack.channel = 9; // Drums must be on Channel 10
377+
trackMap.set(drum, drumTrack);
378+
}
379+
380+
const drumTrack = trackMap.get(drum);
381+
382+
const midiNumber = DRUM_MIDI_MAP[drum] || 36; // default to Bass Drum
383+
drumTrack.addNote({
384+
midi: midiNumber,
385+
time: globalTime,
386+
duration: duration,
387+
velocity: 0.9,
388+
});
389+
390+
} else {
391+
if (!trackMap.has(instrument)) {
392+
const instrumentTrack = midi.addTrack();
393+
instrumentTrack.name = `Track ${parseInt(blockIndex) + 1} - ${instrument}`;
394+
instrumentTrack.instrument.number = MIDI_INSTRUMENTS[instrument] ?? MIDI_INSTRUMENTS["default"];
395+
trackMap.set(instrument, instrumentTrack);
396+
}
397+
398+
const instrumentTrack = trackMap.get(instrument);
399+
400+
noteData.note.forEach((pitch) => {
401+
402+
if (!pitch.includes("R")) {
403+
instrumentTrack.addNote({
404+
name: normalizeNote(pitch),
405+
time: globalTime,
406+
duration: duration,
407+
velocity: 0.8
408+
});
409+
}
410+
});
411+
}
412+
globalTime += duration;
413+
});
414+
globalTime = 0;
415+
});
416+
417+
// Generate the MIDI file and trigger download.
418+
const midiData = midi.toArray();
419+
const blob = new Blob([midiData], { type: "audio/midi" });
420+
const url = URL.createObjectURL(blob);
421+
activity.save.download("midi", url, null);
422+
};
423+
const data = activity.logo._midiData;
424+
setTimeout(() => {
425+
generateMidi(data);
426+
activity.logo._midiData = {};
427+
document.body.style.cursor = "default";
428+
}, 500);
429+
}
430+
267431
/**
268432
* This method is to save SVG representation of an activity
269-
*
433+
*
270434
* @param {SaveInterface} activity -The activity object to save
271435
* @returns {void}
272436
* @method
@@ -306,23 +470,23 @@ class SaveInterface {
306470
* @returns {void}
307471
* @method
308472
* @instance
309-
*/
473+
*/
310474
saveBlockArtwork(activity) {
311475
const svg = "data:image/svg+xml;utf8," + activity.printBlockSVG();
312476
activity.save.download("svg", svg, null);
313477
}
314-
478+
315479
/**
316480
* This method is to save BlockArtwork and download the PNG representation of block artwork from the provided activity.
317481
*
318482
* @param {SaveInterface} activity - The activity object containing block artwork to save.
319483
* @returns {void}
320484
* @method
321485
* @instance
322-
*/
486+
*/
323487
saveBlockArtworkPNG(activity) {
324488
activity.printBlockPNG().then((pngDataUrl) => {
325-
activity.save.download("png", pngDataUrl, null);
489+
activity.save.download("png", pngDataUrl, null);
326490
})
327491
}
328492

@@ -587,7 +751,7 @@ class SaveInterface {
587751
tmp.remove();
588752
this.activity.textMsg(
589753
_("The Lilypond code is copied to clipboard. You can paste it here: ") +
590-
"<a href='http://hacklily.org' target='_blank'>http://hacklily.org</a> "
754+
"<a href='http://hacklily.org' target='_blank'>http://hacklily.org</a> "
591755
);
592756
}
593757
this.download("ly", "data:text;utf8," + encodeURIComponent(lydata), filename);

js/activity.js

+2
Original file line numberDiff line numberDiff line change
@@ -1804,6 +1804,7 @@ class Activity {
18041804
activity.save.saveHTML.bind(activity.save),
18051805
doSVG,
18061806
activity.save.saveSVG.bind(activity.save),
1807+
activity.save.saveMIDI.bind(activity.save),
18071808
activity.save.savePNG.bind(activity.save),
18081809
activity.save.saveWAV.bind(activity.save),
18091810
activity.save.saveLilypond.bind(activity.save),
@@ -6922,6 +6923,7 @@ class Activity {
69226923
this.save.saveHTML.bind(this.save),
69236924
doSVG,
69246925
this.save.saveSVG.bind(this.save),
6926+
this.save.saveMIDI.bind(this.save),
69256927
this.save.savePNG.bind(this.save),
69266928
this.save.saveWAV.bind(this.save),
69276929
this.save.saveLilypond.bind(this.save),

js/loader.js

+2-1
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,8 @@ requirejs.config({
2626
twewn: "../lib/tweenjs",
2727
prefixfree: "../bower_components/prefixfree/prefixfree.min",
2828
samples: "../sounds/samples",
29-
planet: "../js/planet"
29+
planet: "../js/planet",
30+
tonejsMidi: "../node_modules/@tonejs/midi/dist/Midi"
3031
},
3132
packages: []
3233
});

js/logo.js

+19-2
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,7 @@ class Logo {
208208
this.collectingStats = false;
209209
this.runningAbc = false;
210210
this.runningMxml = false;
211+
this.runningMIDI = false;
211212
this._checkingCompletionState = false;
212213
this.recording = false;
213214

@@ -231,6 +232,9 @@ class Logo {
231232
this.updatingStatusMatrix = false;
232233
this.statusFields = [];
233234

235+
// Midi Data
236+
this._midiData = {};
237+
234238
// When running in step-by-step mode, the next command to run
235239
// is queued here.
236240
this.stepQueue = {};
@@ -796,6 +800,15 @@ class Logo {
796800
}
797801
}
798802

803+
notationMIDI(note, drum, duration, turtle, bpm, instrument) {
804+
805+
if (!this._midiData[turtle]) {
806+
this._midiData[turtle] = [];
807+
}
808+
if (drum) drum = drum[0];
809+
this._midiData[turtle].push({ note, duration, bpm, instrument, drum });
810+
}
811+
799812
// ========================================================================
800813

801814
/**
@@ -900,7 +913,7 @@ class Logo {
900913

901914
this.activity.turtles
902915
.ithTurtle(turtle)
903-
.initTurtle(this.runningLilypond || this.runningAbc || this.runningMxml);
916+
.initTurtle(this.runningLilypond || this.runningAbc || this.runningMxml || this.runningMIDI);
904917
}
905918

906919
/**
@@ -1682,7 +1695,11 @@ class Logo {
16821695
// console.log("saving mxml output");
16831696
logo.activity.save.afterSaveMxml();
16841697
logo.runningMxml = false;
1685-
} else if (tur.singer.suppressOutput) {
1698+
} else if (logo.runningMIDI) {
1699+
logo.activity.save.afterSaveMIDI();
1700+
logo.runningMIDI = false;
1701+
}
1702+
else if (tur.singer.suppressOutput) {
16861703
// console.debug("finishing compiling");
16871704
if (!logo.recording) {
16881705
logo.activity.errorMsg(_("Playback is ready."));

js/toolbar.js

+9
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ class Toolbar {
8080
["save-html-beg", _("Save project as HTML"), "innerHTML"],
8181
["save-png-beg", _("Save mouse artwork as PNG"), "innerHTML"],
8282
["save-html", _("Save project as HTML"), "innerHTML"],
83+
["save-midi", _("Save project as MIDI"), "innerHTML"],
8384
["save-svg", _("Save mouse artwork as SVG"), "innerHTML"],
8485
["save-png", _("Save mouse artwork as PNG"), "innerHTML"],
8586
["save-wav", _("Save music as WAV"), "innerHTML"],
@@ -144,6 +145,7 @@ class Toolbar {
144145
_("Switch to advanced mode"),
145146
_("Select language"),
146147
_("Save project as HTML"),
148+
_("Save project as MIDI"),
147149
_("Save mouse artwork as SVG"),
148150
_("Save mouse artwork as PNG"),
149151
_("Save music as WAV"),
@@ -577,6 +579,7 @@ class Toolbar {
577579
*
578580
* @public
579581
* @param {Function} html_onclick - The onclick handler for HTML.
582+
* @param {Function} midi_onclick - The onclick handler for MIDI.
580583
* @param {Function} doSVG_onclick - The onclick handler for SVG.
581584
* @param {Function} svg_onclick - The onclick handler for SVG.
582585
* @param {Function} png_onclick - The onclick handler for PNG.
@@ -591,6 +594,7 @@ class Toolbar {
591594
html_onclick,
592595
doSVG_onclick,
593596
svg_onclick,
597+
midi_onclick,
594598
png_onclick,
595599
wave_onclick,
596600
ly_onclick,
@@ -685,6 +689,11 @@ class Toolbar {
685689
}
686690

687691
if (_THIS_IS_MUSIC_BLOCKS_) {
692+
const saveMIDI = docById("save-midi");
693+
saveMIDI.onclick = () => {
694+
midi_onclick(this.activity);
695+
};
696+
688697
const saveWAV = docById('save-wav');
689698
saveWAV.onclick = () => {
690699
wave_onclick(this.activity);

js/turtle-singer.js

+4-1
Original file line numberDiff line numberDiff line change
@@ -1246,6 +1246,7 @@ class Singer {
12461246
const tur = activity.turtles.ithTurtle(turtle);
12471247
const bpmFactor =
12481248
TONEBPM / (tur.singer.bpm.length > 0 ? last(tur.singer.bpm) : Singer.masterBPM);
1249+
let bpmValue = Number(last(tur.singer.bpm));
12491250

12501251
let noteBeatValue = isOsc
12511252
? noteValue === 0
@@ -1958,8 +1959,10 @@ class Singer {
19581959
if (
19591960
activity.logo.runningLilypond ||
19601961
activity.logo.runningMxml ||
1961-
activity.logo.runningAbc
1962+
activity.logo.runningAbc ||
1963+
activity.logo.runningMIDI
19621964
) {
1965+
activity.logo.notationMIDI(chordNotes, chordDrums, d, turtle, bpmValue || 90, last(tur.singer.instrumentNames));
19631966
activity.logo.updateNotation(chordNotes, d, turtle, -1, chordDrums);
19641967
}
19651968
}

js/turtledefs.js

+2
Original file line numberDiff line numberDiff line change
@@ -565,6 +565,8 @@ const createHelpContent = (activity) => {
565565
_("Save project"),
566566
_("Save project as HTML") +
567567
"<br/>" +
568+
_("Save project as MIDI") +
569+
"<br/>" +
568570
_("Save music as WAV") +
569571
"<br/>" +
570572
_("Save sheet music as ABC, Lilypond or MusicXML") +

0 commit comments

Comments
 (0)