Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

.midi Export #4319

Merged
merged 20 commits into from
Feb 16, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -889,6 +889,7 @@

<ul id="saveddropdown" class="dropdown-content">
<li><a id="save-html"></a></li>
<li><a id="save-midi"></a></li>
<li><a id="save-svg"></a></li>
<li><a id="save-png"></a></li>
<li><a id="save-wav"></a></li>
Expand Down
178 changes: 171 additions & 7 deletions js/SaveInterface.js
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ class SaveInterface {
* @member {number}
*/
this.timeLastSaved = -100;

/**
* HTML template for saving projects.
* @member {string}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -306,23 +470,23 @@ 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.
*
* @param {SaveInterface} activity - The activity object containing block artwork to save.
* @returns {void}
* @method
* @instance
*/
*/
saveBlockArtworkPNG(activity) {
activity.printBlockPNG().then((pngDataUrl) => {
activity.save.download("png", pngDataUrl, null);
activity.save.download("png", pngDataUrl, null);
})
}

Expand Down Expand Up @@ -587,7 +751,7 @@ class SaveInterface {
tmp.remove();
this.activity.textMsg(
_("The Lilypond code is copied to clipboard. You can paste it here: ") +
"<a href='http://hacklily.org' target='_blank'>http://hacklily.org</a> "
"<a href='http://hacklily.org' target='_blank'>http://hacklily.org</a> "
);
}
this.download("ly", "data:text;utf8," + encodeURIComponent(lydata), filename);
Expand Down
2 changes: 2 additions & 0 deletions js/activity.js
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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),
Expand Down
3 changes: 2 additions & 1 deletion js/loader.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: []
});
Expand Down
21 changes: 19 additions & 2 deletions js/logo.js
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,7 @@ class Logo {
this.collectingStats = false;
this.runningAbc = false;
this.runningMxml = false;
this.runningMIDI = false;
this._checkingCompletionState = false;
this.recording = false;

Expand All @@ -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 = {};
Expand Down Expand Up @@ -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 });
}

// ========================================================================

/**
Expand Down Expand Up @@ -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);
}

/**
Expand Down Expand Up @@ -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."));
Expand Down
9 changes: 9 additions & 0 deletions js/toolbar.js
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand Down Expand Up @@ -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"),
Expand Down Expand Up @@ -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.
Expand All @@ -591,6 +594,7 @@ class Toolbar {
html_onclick,
doSVG_onclick,
svg_onclick,
midi_onclick,
png_onclick,
wave_onclick,
ly_onclick,
Expand Down Expand Up @@ -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);
Expand Down
5 changes: 4 additions & 1 deletion js/turtle-singer.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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);
}
}
Expand Down
2 changes: 2 additions & 0 deletions js/turtledefs.js
Original file line number Diff line number Diff line change
Expand Up @@ -566,6 +566,8 @@ const createHelpContent = (activity) => {
_("Save project"),
_("Save project as HTML") +
"<br/>" +
_("Save project as MIDI") +
"<br/>" +
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this be Save music as MIDI ???

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I thought about that. You can edit MIDI files, so I thought "project" would be appropriate.

_("Save music as WAV") +
"<br/>" +
_("Save sheet music as ABC, Lilypond or MusicXML") +
Expand Down
Loading
Loading