diff --git a/index.html b/index.html
index b01d46d69c..c4a5fac8d4 100644
--- a/index.html
+++ b/index.html
@@ -889,6 +889,7 @@
+
diff --git a/js/SaveInterface.js b/js/SaveInterface.js
index 747c63fdb9..a07c5016b8 100644
--- a/js/SaveInterface.js
+++ b/js/SaveInterface.js
@@ -48,7 +48,7 @@ class SaveInterface {
* @member {number}
*/
this.timeLastSaved = -100;
-
+
/**
* HTML template for saving projects.
* @member {string}
@@ -264,9 +264,173 @@ class SaveInterface {
}, 500);
}
+ /**
+ * Save MIDI file.
+ *
+ * This method generates required MIDI data.
+ *
+ * @param {SaveInterface} activity - The activity object to save.
+ * @returns {void}
+ * @memberof SaveInterface
+ * @method
+ * @instance
+ */
+ saveMIDI(activity) {
+ // Suppress music and turtle output when generating
+ activity.logo.runningMIDI = true;
+ activity.logo.runLogoCommands();
+ document.body.style.cursor = "wait";
+ }
+
+ /**
+ * Perform actions after generating MIDI data.
+ *
+ * This method generates a MIDI file using _midiData.
+ *
+ * @returns {void}
+ * @memberof SaveInterface
+ * @method
+ * @instance
+ */
+ afterSaveMIDI() {
+ const generateMidi = (data) => {
+ const normalizeNote = (note) => {
+ return note.replace("♯", "#").replace("♭", "b");
+ };
+ const MIDI_INSTRUMENTS = {
+ default: 0, // Acoustic Grand Piano
+ piano: 0,
+ violin: 40,
+ viola: 41,
+ cello: 42,
+ "double bass": 43,
+ bass: 32,
+ sitar: 104,
+ guitar: 24,
+ "acoustic guitar": 25,
+ "electric guitar": 27,
+ flute: 73,
+ clarinet: 71,
+ saxophone: 65,
+ tuba: 58,
+ trumpet: 56,
+ oboe: 68,
+ trombone: 57,
+ banjo: 105,
+ koto: 107,
+ dulcimer: 15,
+ bassoon: 70,
+ celeste: 8,
+ xylophone: 13,
+ "electronic synth": 81,
+ sine: 81, // Approximate with Lead 2 (Sawtooth)
+ square: 80,
+ sawtooth: 81,
+ triangle: 81, // Approximate with Lead 2 (Sawtooth)
+ vibraphone: 11
+ };
+
+ const DRUM_MIDI_MAP = {
+ "snare drum": 38,
+ "kick drum": 36,
+ "tom tom": 41,
+ "floor tom tom": 43,
+ "cup drum": 47, // Closest: Low-Mid Tom
+ "darbuka drum": 50, // Closest: High Tom
+ "japanese drum": 56, // Closest: Cowbell or Tambourine
+ "hi hat": 42,
+ "ride bell": 53,
+ "cow bell": 56,
+ "triangle bell": 81,
+ "finger cymbals": 69, // Closest: Open Hi-Hat
+ "chime": 82, // Closest: Shaker
+ "gong": 52, // Closest: Chinese Cymbal
+ "clang": 55, // Closest: Splash Cymbal
+ "crash": 49,
+ "clap": 39,
+ "slap": 40,
+ "raindrop": 88 // Custom mapping (not in GM), can use melodic notes
+ };
+
+ const midi = new Midi();
+ midi.header.ticksPerBeat = 480;
+
+ Object.entries(data).forEach(([blockIndex, notes]) => {
+
+ const mainTrack = midi.addTrack();
+ mainTrack.name = `Track ${parseInt(blockIndex) + 1}`;
+
+ let trackMap = new Map();
+ let globalTime = 0;
+
+ notes.forEach((noteData) => {
+ if (!noteData.note || noteData.note.length === 0) return;
+ const duration = ((1 / noteData.duration) * 60 * 4) / noteData.bpm;
+ const instrument = noteData.instrument || "default";
+
+ if (noteData.drum) {
+ const drum = noteData.drum || false;
+ if (!trackMap.has(drum)) {
+ const drumTrack = midi.addTrack();
+ drumTrack.name = `Track ${parseInt(blockIndex) + 1} - ${drum}`;
+ drumTrack.channel = 9; // Drums must be on Channel 10
+ trackMap.set(drum, drumTrack);
+ }
+
+ const drumTrack = trackMap.get(drum);
+
+ const midiNumber = DRUM_MIDI_MAP[drum] || 36; // default to Bass Drum
+ drumTrack.addNote({
+ midi: midiNumber,
+ time: globalTime,
+ duration: duration,
+ velocity: 0.9,
+ });
+
+ } else {
+ if (!trackMap.has(instrument)) {
+ const instrumentTrack = midi.addTrack();
+ instrumentTrack.name = `Track ${parseInt(blockIndex) + 1} - ${instrument}`;
+ instrumentTrack.instrument.number = MIDI_INSTRUMENTS[instrument] ?? MIDI_INSTRUMENTS["default"];
+ trackMap.set(instrument, instrumentTrack);
+ }
+
+ const instrumentTrack = trackMap.get(instrument);
+
+ noteData.note.forEach((pitch) => {
+
+ if (!pitch.includes("R")) {
+ instrumentTrack.addNote({
+ name: normalizeNote(pitch),
+ time: globalTime,
+ duration: duration,
+ velocity: 0.8
+ });
+ }
+ });
+ }
+ globalTime += duration;
+ });
+ globalTime = 0;
+ });
+
+ // Generate the MIDI file and trigger download.
+ const midiData = midi.toArray();
+ const blob = new Blob([midiData], { type: "audio/midi" });
+ const url = URL.createObjectURL(blob);
+ activity.save.download("midi", url, null);
+ };
+ const data = activity.logo._midiData;
+ setTimeout(() => {
+ generateMidi(data);
+ activity.logo._midiData = {};
+ document.body.style.cursor = "default";
+ }, 500);
+ }
+
/**
* This method is to save SVG representation of an activity
- *
+ *
* @param {SaveInterface} activity -The activity object to save
* @returns {void}
* @method
@@ -306,12 +470,12 @@ class SaveInterface {
* @returns {void}
* @method
* @instance
- */
+ */
saveBlockArtwork(activity) {
const svg = "data:image/svg+xml;utf8," + activity.printBlockSVG();
activity.save.download("svg", svg, null);
}
-
+
/**
* This method is to save BlockArtwork and download the PNG representation of block artwork from the provided activity.
*
@@ -319,10 +483,10 @@ class SaveInterface {
* @returns {void}
* @method
* @instance
- */
+ */
saveBlockArtworkPNG(activity) {
activity.printBlockPNG().then((pngDataUrl) => {
- activity.save.download("png", pngDataUrl, null);
+ activity.save.download("png", pngDataUrl, null);
})
}
@@ -587,7 +751,7 @@ class SaveInterface {
tmp.remove();
this.activity.textMsg(
_("The Lilypond code is copied to clipboard. You can paste it here: ") +
- "http://hacklily.org "
+ "http://hacklily.org "
);
}
this.download("ly", "data:text;utf8," + encodeURIComponent(lydata), filename);
diff --git a/js/activity.js b/js/activity.js
index 3b1679ec24..279f09dc2b 100644
--- a/js/activity.js
+++ b/js/activity.js
@@ -1804,6 +1804,7 @@ class Activity {
activity.save.saveHTML.bind(activity.save),
doSVG,
activity.save.saveSVG.bind(activity.save),
+ activity.save.saveMIDI.bind(activity.save),
activity.save.savePNG.bind(activity.save),
activity.save.saveWAV.bind(activity.save),
activity.save.saveLilypond.bind(activity.save),
@@ -6922,6 +6923,7 @@ class Activity {
this.save.saveHTML.bind(this.save),
doSVG,
this.save.saveSVG.bind(this.save),
+ this.save.saveMIDI.bind(this.save),
this.save.savePNG.bind(this.save),
this.save.saveWAV.bind(this.save),
this.save.saveLilypond.bind(this.save),
diff --git a/js/loader.js b/js/loader.js
index e5471c3272..a9b319d668 100644
--- a/js/loader.js
+++ b/js/loader.js
@@ -26,7 +26,8 @@ requirejs.config({
twewn: "../lib/tweenjs",
prefixfree: "../bower_components/prefixfree/prefixfree.min",
samples: "../sounds/samples",
- planet: "../js/planet"
+ planet: "../js/planet",
+ tonejsMidi: "../node_modules/@tonejs/midi/dist/Midi"
},
packages: []
});
diff --git a/js/logo.js b/js/logo.js
index 38f75cfe63..dcff461233 100644
--- a/js/logo.js
+++ b/js/logo.js
@@ -208,6 +208,7 @@ class Logo {
this.collectingStats = false;
this.runningAbc = false;
this.runningMxml = false;
+ this.runningMIDI = false;
this._checkingCompletionState = false;
this.recording = false;
@@ -231,6 +232,9 @@ class Logo {
this.updatingStatusMatrix = false;
this.statusFields = [];
+ // Midi Data
+ this._midiData = {};
+
// When running in step-by-step mode, the next command to run
// is queued here.
this.stepQueue = {};
@@ -796,6 +800,15 @@ class Logo {
}
}
+ notationMIDI(note, drum, duration, turtle, bpm, instrument) {
+
+ if (!this._midiData[turtle]) {
+ this._midiData[turtle] = [];
+ }
+ if (drum) drum = drum[0];
+ this._midiData[turtle].push({ note, duration, bpm, instrument, drum });
+ }
+
// ========================================================================
/**
@@ -900,7 +913,7 @@ class Logo {
this.activity.turtles
.ithTurtle(turtle)
- .initTurtle(this.runningLilypond || this.runningAbc || this.runningMxml);
+ .initTurtle(this.runningLilypond || this.runningAbc || this.runningMxml || this.runningMIDI);
}
/**
@@ -1682,7 +1695,11 @@ class Logo {
// console.log("saving mxml output");
logo.activity.save.afterSaveMxml();
logo.runningMxml = false;
- } else if (tur.singer.suppressOutput) {
+ } else if (logo.runningMIDI) {
+ logo.activity.save.afterSaveMIDI();
+ logo.runningMIDI = false;
+ }
+ else if (tur.singer.suppressOutput) {
// console.debug("finishing compiling");
if (!logo.recording) {
logo.activity.errorMsg(_("Playback is ready."));
diff --git a/js/toolbar.js b/js/toolbar.js
index 4d63f2cd44..dc0e1dd1d8 100644
--- a/js/toolbar.js
+++ b/js/toolbar.js
@@ -80,6 +80,7 @@ class Toolbar {
["save-html-beg", _("Save project as HTML"), "innerHTML"],
["save-png-beg", _("Save mouse artwork as PNG"), "innerHTML"],
["save-html", _("Save project as HTML"), "innerHTML"],
+ ["save-midi", _("Save project as MIDI"), "innerHTML"],
["save-svg", _("Save mouse artwork as SVG"), "innerHTML"],
["save-png", _("Save mouse artwork as PNG"), "innerHTML"],
["save-wav", _("Save music as WAV"), "innerHTML"],
@@ -144,6 +145,7 @@ class Toolbar {
_("Switch to advanced mode"),
_("Select language"),
_("Save project as HTML"),
+ _("Save project as MIDI"),
_("Save mouse artwork as SVG"),
_("Save mouse artwork as PNG"),
_("Save music as WAV"),
@@ -577,6 +579,7 @@ class Toolbar {
*
* @public
* @param {Function} html_onclick - The onclick handler for HTML.
+ * @param {Function} midi_onclick - The onclick handler for MIDI.
* @param {Function} doSVG_onclick - The onclick handler for SVG.
* @param {Function} svg_onclick - The onclick handler for SVG.
* @param {Function} png_onclick - The onclick handler for PNG.
@@ -591,6 +594,7 @@ class Toolbar {
html_onclick,
doSVG_onclick,
svg_onclick,
+ midi_onclick,
png_onclick,
wave_onclick,
ly_onclick,
@@ -685,6 +689,11 @@ class Toolbar {
}
if (_THIS_IS_MUSIC_BLOCKS_) {
+ const saveMIDI = docById("save-midi");
+ saveMIDI.onclick = () => {
+ midi_onclick(this.activity);
+ };
+
const saveWAV = docById('save-wav');
saveWAV.onclick = () => {
wave_onclick(this.activity);
diff --git a/js/turtle-singer.js b/js/turtle-singer.js
index d23f947d7b..4ee6fd534d 100644
--- a/js/turtle-singer.js
+++ b/js/turtle-singer.js
@@ -1246,6 +1246,7 @@ class Singer {
const tur = activity.turtles.ithTurtle(turtle);
const bpmFactor =
TONEBPM / (tur.singer.bpm.length > 0 ? last(tur.singer.bpm) : Singer.masterBPM);
+ let bpmValue = Number(last(tur.singer.bpm));
let noteBeatValue = isOsc
? noteValue === 0
@@ -1958,8 +1959,10 @@ class Singer {
if (
activity.logo.runningLilypond ||
activity.logo.runningMxml ||
- activity.logo.runningAbc
+ activity.logo.runningAbc ||
+ activity.logo.runningMIDI
) {
+ activity.logo.notationMIDI(chordNotes, chordDrums, d, turtle, bpmValue || 90, last(tur.singer.instrumentNames));
activity.logo.updateNotation(chordNotes, d, turtle, -1, chordDrums);
}
}
diff --git a/js/turtledefs.js b/js/turtledefs.js
index d34092408a..18c801882d 100644
--- a/js/turtledefs.js
+++ b/js/turtledefs.js
@@ -566,6 +566,8 @@ const createHelpContent = (activity) => {
_("Save project"),
_("Save project as HTML") +
"
" +
+ _("Save project as MIDI") +
+ "
" +
_("Save music as WAV") +
"
" +
_("Save sheet music as ABC, Lilypond or MusicXML") +
diff --git a/package-lock.json b/package-lock.json
index d72c1ecef0..003a3df7b1 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -9,6 +9,7 @@
"version": "3.4.1",
"license": "AGPL-3.0 License",
"dependencies": {
+ "@tonejs/midi": "^2.0.28",
"autoprefixer": "^10.4.16",
"cssnano": "^6.0.1",
"gulp-concat": "^2.6.1",
@@ -1987,6 +1988,16 @@
"@sinonjs/commons": "^3.0.0"
}
},
+ "node_modules/@tonejs/midi": {
+ "version": "2.0.28",
+ "resolved": "https://registry.npmjs.org/@tonejs/midi/-/midi-2.0.28.tgz",
+ "integrity": "sha512-RII6YpInPsOZ5t3Si/20QKpNqB1lZ2OCFJSOzJxz38YdY/3zqDr3uaml4JuCWkdixuPqP1/TBnXzhQ39csyoVg==",
+ "license": "MIT",
+ "dependencies": {
+ "array-flatten": "^3.0.0",
+ "midi-file": "^1.2.2"
+ }
+ },
"node_modules/@tootallnate/once": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz",
@@ -2407,6 +2418,12 @@
"node": ">=0.10.0"
}
},
+ "node_modules/array-flatten": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-3.0.0.tgz",
+ "integrity": "sha512-zPMVc3ZYlGLNk4mpK1NzP2wg0ml9t7fUgDsayR5Y5rSzxQilzR9FGu/EH2jQOcKSAeAfWeylyW8juy3OkWRvNA==",
+ "license": "MIT"
+ },
"node_modules/array-includes": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.1.tgz",
@@ -7433,6 +7450,12 @@
"node": ">=8.6"
}
},
+ "node_modules/midi-file": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/midi-file/-/midi-file-1.2.4.tgz",
+ "integrity": "sha512-B5SnBC6i2bwJIXTY9MElIydJwAmnKx+r5eJ1jknTLetzLflEl0GWveuBB6ACrQpecSRkOB6fhTx1PwXk2BVxnA==",
+ "license": "MIT"
+ },
"node_modules/mime": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
diff --git a/package.json b/package.json
index 9f293b541c..198ca9f1b5 100644
--- a/package.json
+++ b/package.json
@@ -43,6 +43,7 @@
"jest-environment-jsdom": "^29.7.0"
},
"dependencies": {
+ "@tonejs/midi": "^2.0.28",
"autoprefixer": "^10.4.16",
"cssnano": "^6.0.1",
"gulp-concat": "^2.6.1",