@@ -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 ) ;
0 commit comments