/***************************************************************
  MidiWrapper.java -- makes Midi output easy
  
  (C) 2011 Marc Groenewegen
  Hogeschool voor de Kunsten Utrecht

  Wrapper around complicated Java MIDI support.
  Midi input is not yet supported

  Exports the following functions:
    - constructor MidiWrapper() opens a default receiver
    - constructor MidiWrapper(device) opens a device and a receiver
    - listDevices() shows available MIDI devices
    - selectDevice(device) opens a receiver for the given device

    - pause(duration) sleeps for specified time in milliseconds

    - playNote(noteNumber,velocity) switches a note on but not off
    - playNote(noteNumber,velocity,duration) switches a note on and
       after duration switches it off again
    - endNote(noteNumber) switches a note off
    
    - setBlocking(on/off) sets (non)blocking mode
      Default: blocking
      
    The user has to keep track of MIDI channels to switch notes off for
    the correct channel.

    - setMidiChannel(midiChannel)
    - programChange(midiChannel,midiProgram)

Last update: December 7, 2011 -- Added synchronized EndEventList{}
***************************************************************/

import javax.sound.midi.*;
import java.util.ArrayList;	// needed for the event list
import java.util.Collections;	// needed for sorting the event list


public class MidiWrapper
{
private ShortMessage voiceMessage = new ShortMessage();
//private MidiMessage rawMessage = new MidiMessage();
private int midiChannel = 0;

private boolean blocking=true; // true for backwards compatibility

/* Receiver receives commands but actually sends MIDI events to a
 * MIDI device so it acts as an output device.
 */
private Receiver midireceiver = null;
private Transmitter miditransmitter = null;

private MidiDevice.Info[] deviceInfo;
private MidiDevice device;

private boolean debug=false;

/*
 * NoteEndEvent holds the timestamp, channel and notenumber for the note to
 * switch off at some point in the future. These events can be put into a
 * time-sorted event-queue
 *
 * Optimisation:
 *  remove NoteEndEvents for notes that have been re-triggered from the queue
 */
class NoteEndEvent implements Comparable<NoteEndEvent>
{
  long timestamp;
  int midiChannel;
  int note;

  NoteEndEvent(long timestamp,int midiChannel, int noteNumber)
  {
    this.timestamp=timestamp;
    this.midiChannel=midiChannel;
    this.note=noteNumber;
  }

  int getMidiChannel()
  {
    return midiChannel;
  }

  int getNoteNumber()
  {
    return note;
  }

  long getTimestamp()
  {
    return timestamp;
  }
  
  void updateTimestamp(long timestamp)
  {
    this.timestamp=timestamp;
  }

  public int compareTo(NoteEndEvent anotherEvent) throws ClassCastException
  {
    if(!(anotherEvent instanceof NoteEndEvent))
      throw new ClassCastException("Expecting a NoteEvent");

    // ascending
    return (int)(this.timestamp - anotherEvent.getTimestamp());
    // decending
    //return (int)(((NoteEndEvent)anotherEvent).getTimestamp()-this.timestamp);
  } // compareTo
} // NoteEvent{}


/*
 * EndEventList creates an event queue for NoteEndEvents and offers
 * functionality to add events.
 * The queue will be sorted by timestamp with every new insertion.
 *
 * noteStatus: array indicating whether an end-event is already in the
 * queue for a note that is being re-triggered.
 */
class EndEventList
{
ArrayList<NoteEndEvent> eventList = new ArrayList<NoteEndEvent>();
boolean [][] noteStatus = new boolean[128][16];

  public synchronized void init()
  {
    for(int note=0; note<128; note++) // note number
      for(int channel=0; channel<16; channel++) // channel
        noteStatus[note][channel]=false;
  }

  public synchronized NoteEndEvent getHead()
  {
      if(!eventList.isEmpty()) return eventList.get(0);
      else return null;
  }

  public synchronized void addEndEvent(int noteNumber,int midiChannel,long timestamp)
  {
    if(noteStatus[noteNumber][midiChannel])
    {
      int event;

      // find the event in the list
      for(event=0; event<eventList.size(); event++)
      {
        if(eventList.get(event).getNoteNumber() == noteNumber &&
           eventList.get(event).getMidiChannel() == midiChannel) break;
      } // for

      /* update the event's timestamp
       * NB: make sure event list is not empty
       */
      if(eventList.size() > 0 && timestamp > eventList.get(event).getTimestamp())
      {
        eventList.get(event).updateTimestamp(timestamp);
        if(debug)System.out.println("Event updated at\n" + timestamp);
      }
    }
    else
    {
      eventList.add(new NoteEndEvent(timestamp,midiChannel,noteNumber));
      if(debug) {
        System.out.println("Time is now " + System.currentTimeMillis());
        System.out.println("Note off event queued at " + timestamp);
        System.out.println("eventList size: " + eventList.size() + "\n");
      }
      Collections.sort(eventList); // Order by timestamp
      noteStatus[noteNumber][midiChannel]=true;
    }
  } // add

  public synchronized void removeHead()
  {
    // indicate that note is no longer in the list
    NoteEndEvent event = eventList.get(0);
    noteStatus[event.getNoteNumber()][event.getMidiChannel()] = false;
    eventList.remove(0);
    if(debug)System.out.println("eventList size: " + eventList.size() + "\n");
  }
} // EndEventList{}


/*
 * Scheduler creates an event scheduler for NoteEndEvents
 * It sends events to the midi port at the time indicated by the timestamp.
 *
 * Operation
 *  NOTE: because of synchronisation several actions are implicitly done by the queue itself
 *
 * For a new note, first check its status.
 * - If not in the queue, add it, mark it as present and sort the queue.
 * - If already in the queue, find its end-event in the queue,
 *     update the timestamp and sort the queue.
 * - when played, clear its presence
 */
class Scheduler extends Thread
{
long ts=0;
EndEventList endEventList = new EndEventList();
NoteEndEvent nextEvent;

  public Scheduler()
  {
    endEventList.init();
  }

  public void addEndEvent(int noteNumber,int midiChannel,int duration)
  {
    long endTimestamp = System.currentTimeMillis()+(long)duration;

    endEventList.addEndEvent(noteNumber,midiChannel,endTimestamp);
  }

  public void run()
  {
    int note,channel;

    // Process the contents of the event list
    while(true)
    {
      nextEvent = endEventList.getHead();
      if(nextEvent != null)
      {
        ts = nextEvent.getTimestamp();

	/*
	 * If it's not time to play the next event, take a short nap
	 *  to relieve the processor somewhat
	 */
        if(System.currentTimeMillis() < ts)
	{
	  try
          {
            Thread.sleep(5);
          }
          catch(InterruptedException e)
          {
            System.out.println(e);
          }
	  /*
	   * go back to top of loop to check head of the list again
	   *   as new events may have been inserted
	   */
	  continue;
        } // if

        // It's time to play an event
        note=nextEvent.getNoteNumber();
        channel=nextEvent.getMidiChannel();

        try{ // construct a new message
          if(debug){
	    System.out.println("Constructing note off message for note: " +
	    note + " on channel: " + channel);
          }
	  voiceMessage.setMessage(ShortMessage.NOTE_OFF,channel,note,0);
        }
        catch(InvalidMidiDataException e)
        {
          System.out.println(e);
          System.exit(1);
        }

        if(debug){
          System.out.println("Time is now " + System.currentTimeMillis());
	  System.out.println("Sending NOTE OFF");
	}
        midireceiver.send(voiceMessage, -1);

	/* remove the first event in the queue, which is probably the one
	 * we've just processed
	 */
        endEventList.removeHead();
      } // if endeventList not empty
      else // list is empty, so wait a while
      {
        try
          {
            Thread.sleep(10);
          }
          catch(InterruptedException e)
          {
            System.out.println(e);
          }
      } // else

    } // while
  } // run()
} // Scheduler{}

Scheduler scheduler = new Scheduler();


  // parameter-less constructor for backwards compatibility
  public MidiWrapper()
  {
    deviceInfo = MidiSystem.getMidiDeviceInfo();
    try
    {
      // get a default receiver
      midireceiver = MidiSystem.getReceiver();
    }
    catch (MidiUnavailableException e)
    {
      System.out.println(e);
      System.exit(1);
    }

    if (midireceiver == null)
    {
      System.out.println("No midireceiver found");
      System.exit(1);
    }

    scheduler.start();
  } // MidiWrapper()


  public MidiWrapper(int initialDevice)
  {
    selectDevice(initialDevice);
  } // MidiWrapper()


  public void listDevices() // show all available devices
  {
    for(int i=0; i<deviceInfo.length; i++)
    {
      try
      {
        MidiDevice device = MidiSystem.getMidiDevice(deviceInfo[i]);
        boolean allowsInput = (device.getMaxTransmitters() != 0);
        boolean allowsOutput = (device.getMaxReceivers() != 0);

        if ((allowsInput) || (allowsOutput))
        {
          System.out.println("" + i + "  "
          + (allowsInput?"IN ":"   ")
          + (allowsOutput?"OUT ":"    ")
          + deviceInfo[i].getName() + ", "
          + deviceInfo[i].getVendor() + ", "
          + deviceInfo[i].getVersion() + ", "
          + deviceInfo[i].getDescription());
        } // if allows input or output
      } // try
      catch (MidiUnavailableException e)
      {
        // device unavailable...
      }
    } // for deviceInfo

    if (deviceInfo.length == 0)
    {
      System.out.println("No devices available");
    }
  } // listDevices()


  public void selectDevice(int deviceNumber)
  {
    midireceiver.close(); // close current receiver
    deviceInfo = MidiSystem.getMidiDeviceInfo();
    try
    {
      device = MidiSystem.getMidiDevice(deviceInfo[deviceNumber]);
      device.open();
      midireceiver = device.getReceiver();
      //miditransmitter = device.getTransmitter();
    }
    catch (MidiUnavailableException e)
    {
      System.out.println(e);
      System.exit(1);
    }

    if (midireceiver == null)
    {
      System.out.println("No midireceiver found");
      System.exit(1);
    }
  } // selectDevice()


  protected void finalize()
  {
    midireceiver.close();
    if(device.isOpen()) device.close();
  } // finalize()

  public void end()
  {
    midireceiver.close();
    System.exit(0);
    //if(device.isOpen()) device.close();
  } // end()

  /*
   * Blocking
   *  true indicates that playnote with duration will block the thread
   *  (i.e. wait for the current note to finish before playing the next
   *
   *  false does not wait for the current note to finish but puts its end
   *  event in the end event scheduler
   */
  public void setBlocking(boolean blocking)
  {
    this.blocking = blocking;
  } // setBlocking()


  public void pause(int duration)
  {
    try
    {
      Thread.sleep(duration);
    }
    catch(InterruptedException e)
    {
      System.out.println(e);
    }
  } // pause()


  public void setMidiChannel(int midiChannel)
  {
    if(midiChannel >=0 && midiChannel<=15) this.midiChannel=midiChannel;
  } // setMidiChannel()


  public void programChange(int forChannel,int midiProgram)
  {
    // Prevent out-of-bounds exceptions
    if(forChannel<0 || forChannel>15 ||
       midiProgram<0 || midiProgram>127) return;

    // construct a new message
    try{
      voiceMessage.setMessage(ShortMessage.PROGRAM_CHANGE,
        forChannel,midiProgram,0);
    }
    catch(InvalidMidiDataException e)
    {
      System.out.println(e);
      System.exit(1);
    }
  } // programChange()


  /*
   * This version of playNote() does not expect a duration and only
   *  switches the note ON
   * To switch the note OFF, use endNote()
   */
  public void playNote(int noteNumber,int velocity)
  {
    // Prevent out-of-bounds exceptions
    if(noteNumber > 127) noteNumber = 127;
    if(noteNumber < 0) noteNumber = 0;
    if(velocity > 127) velocity = 127;
    if(velocity < 0) velocity = 0;

    try{ // construct a new message
      voiceMessage.setMessage(ShortMessage.NOTE_ON,
        midiChannel,noteNumber,velocity);
    }
    catch(InvalidMidiDataException e)
    {
      System.out.println(e);
      System.exit(1);
    }

    // Send the message
    midireceiver.send(voiceMessage, -1);
  } // playNote()


  /*
   * This version of playNote() expects a duration and switches
   *  the note ON and after a pause switches it OFF again
   */
  public void playNote(int noteNumber,int velocity,int duration)
  {
    // Prevent out-of-bounds exceptions
    if(noteNumber > 127) noteNumber = 127;
    if(noteNumber < 0) noteNumber = 0;
    if(velocity > 127) velocity = 127;
    if(velocity < 0) velocity = 0;

    playNote(noteNumber,velocity);

    if(blocking)
    {
      pause(duration);
      endNote(noteNumber);
    } // if blocking
    else
    {
      // calculate when the endnote event must take place and
      // insert an event into the event list
      scheduler.addEndEvent(noteNumber,midiChannel,duration);
    } //else
  } // playNote()


  public void endNote(int noteNumber)
  {
    // Prevent out-of-bounds exceptions
    if(noteNumber > 127) noteNumber = 127;
    if(noteNumber < 0) noteNumber = 0;

    try{ // construct a new message
      voiceMessage.setMessage(ShortMessage.NOTE_OFF,
        midiChannel,noteNumber,0);
    }
    catch(InvalidMidiDataException e)
    {
      System.out.println(e);
      System.exit(1);
    }

    midireceiver.send(voiceMessage, -1);
  } // endNote()

} // MidiWrapper{}
