/******************************************************************************
 *  Compilation:  javac MidiSource.java
 *  Execution:    java MidiSource  [-p] [filename.mid]
 *
 *  A MidiSource object produces MIDI (Musical Instrument Digital Interface) 
 *  messages, where the source can be a  hardware MIDI controller 
 *  keyboard or a MIDI file.   Generating MIDI messages from a MIDI
 *  file uses the default Java MIDI Sequencer, so that MIDI messages are 
 *  scheduled appropriately. 
 * 
 *  Version: .3
 *
 ******************************************************************************/

import javax.sound.midi.*;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.util.concurrent.LinkedBlockingDeque;
import java.lang.reflect.Field;
import java.lang.reflect.Modifier;
import java.util.HashMap;
/**
 *  The {@code MidiSource} class is used to create objects that produce MIDI
 *  (Musical Instrument Digital Interface) messages.  The source can be a 
 *  hardware MIDI controller keyboard or a MIDI file.   Generating MIDI messages 
 *  from a MIDIfile uses the default Java MIDI Sequencer, so that MIDI messages 
 *  are scheduled appropriately. 
 *
 *  @author Nico Toy
 *  @author Alan Kaplan
 */

public final class MidiSource {

    // keep track if source if "live" controller or static file
    private static final int MIDI_CONTROLLER = 0;
    private static final int MIDI_FILE = 1;
    private int sourceType;

    // queue for midi messages- produced by MIDI transmitter (keyboard controller or sequencer)
    private LinkedBlockingDeque<MidiMessage> midiMessageQueue;
    private MidiDevice    device;       // hardware keyboard controller
    private Sequencer     sequencer;    // Java MIDI sequencer

    private boolean verbose = false;    // indicates if MidiSource should print information
                                        // about MidiMessages to stdout as messages are
                                        // produced

    private boolean playSynth = false;  // indicates if MidiSource should play notes using
                                        // default Java Synthesizer as messages are 
                                        // produced


    // MetaMessage event code for end of track
    private static final int MIDI_END_OF_TRACK = 47;

    // short message field names
    private static final HashMap<Integer, String> SM_FIELDS = MidiSource.setShortMessageFields();
    private static HashMap<Integer, String> setShortMessageFields() {
        HashMap<Integer, String> map = new HashMap<Integer, String>();
        Field[] declaredFields = ShortMessage.class.getDeclaredFields();
        for (Field field : declaredFields) {
            if (java.lang.reflect.Modifier.isStatic(field.getModifiers())) {
                try {
                    map.put(field.getInt(null), field.getName());
                } catch (IllegalAccessException e) {
                    throw new RuntimeException(e.getMessage());
                }
            }
        }
        return map;
    }

    /**
     * Helper method - pretty prints a MidiMessage
     */
    private static void print(MidiMessage message) {
        if (message instanceof ShortMessage) {
            ShortMessage shortMessage = (ShortMessage) message;
            if (shortMessage.getCommand() != 240) { // some MIDI controllers continousouly output a 240 
                System.out.print("ShortMessage: ");
                System.out.print(" Command: " + SM_FIELDS.get(shortMessage.getCommand()) +
                                 " (" + shortMessage.getCommand() + ") ");
                System.out.print(" Channel: " + shortMessage.getChannel());
                if (shortMessage.getCommand() ==  ShortMessage.NOTE_ON) {
                    System.out.print(" Number:   " + shortMessage.getData1());
                    System.out.print(" Velocity: " + shortMessage.getData2());
                }
                else if (shortMessage.getCommand() == ShortMessage.NOTE_OFF) {
                    System.out.print(" Number:   " + shortMessage.getData1());
                    System.out.print(" Velocity: " + shortMessage.getData2());
                }
                else if (shortMessage.getCommand() == ShortMessage.CONTROL_CHANGE) {
                    System.out.print(" Number:   " + shortMessage.getData1());
                    System.out.print(" Data2:    " + shortMessage.getData2());
                }
                else {
                    System.out.print(" Data1:    " + shortMessage.getData1());
                    System.out.print(" Data2:    " + shortMessage.getData2());
                }
                System.out.println();
            }
            else if (message instanceof SysexMessage) {
                System.out.println("SysexMessage");
            }
            else if (message instanceof MetaMessage) {
                System.out.print("MetaMessage: ");
                MetaMessage metaMessage = (MetaMessage) message;
                System.out.println(metaMessage.getType());                  
            }
            else {
            }
        }
    }


    /**
     * Private helper class that receives MidiMessages, and
     * adds each MIDI message received to a MidiSource queue. Optionally
     * (1) prints messages to stdout and (2) plays messages using Java
     * Synthesizer
     */
    private class MidiKeyboardControllerReceiver implements Receiver {
        private boolean       verbose   = false; // default - do not print message to stdout
        private boolean       playSynth = false; // default - do not play synthesizer
        private Synthesizer   synth     = null;  // default Java Synthesizer
        private MidiChannel[] channels  = null;  // defaul - Java Sythesizer channels
        public MidiKeyboardControllerReceiver(boolean verbose, boolean playSynth) {
            midiMessageQueue = new LinkedBlockingDeque<MidiMessage>();
            this.verbose   = verbose;
            this.playSynth = playSynth;

            // if this Receiver needs to play notes, set up channels
            if (playSynth) {
                try {
                    synth = MidiSystem.getSynthesizer();
                }
                catch (MidiUnavailableException e) { 
                    e.printStackTrace();
                    System.exit(1);
                }
                try {
                    synth.open();
                }
                catch (MidiUnavailableException e) {
                    e.printStackTrace();
                    System.exit(1);
                }
                channels = synth.getChannels();
            }
        }

        @Override
        // Invoked each time Receiver gets a MidiMessage
        public void send(MidiMessage message, long timeStamp) {
            // add the message to the queue
            midiMessageQueue.add(message);
            
            // print message?
            if (verbose)
                print(message);

            // play this note for a keyboard controller
            if (playSynth)
                if (message instanceof ShortMessage) {
                    ShortMessage shortMessage = (ShortMessage) message;
                    if (shortMessage.getCommand() == ShortMessage.NOTE_ON) 
                        channels[shortMessage.getChannel()].noteOn(shortMessage.getData1(), shortMessage.getData2());
                    else if (shortMessage.getCommand() == ShortMessage.NOTE_OFF) {
                        channels[shortMessage.getChannel()].noteOff(shortMessage.getData1(), shortMessage.getData2());
                    }
                }
        }
                                                    
                            
        // close the Receiver stream
        public void close() {
            synth.close();
            midiMessageQueue = null;

        }
    }


    /**
     * Private helper class that receives MidiMessages, and
     * adds each MIDI message received to a MidiSource queue. Optionally
     * prints messages to stdout 
     */
    private class MidiFileReceiver implements Receiver {
        private boolean     verbose    = false; // default - do not print message to stdout
        public MidiFileReceiver(boolean verbose) {
            midiMessageQueue = new LinkedBlockingDeque<MidiMessage>();
            this.verbose   = verbose;
        }

        @Override
        // Invoked each time Receiver gets a MidiMessage
        public void send(MidiMessage message, long timeStamp) {
            // add the message to the queue
            midiMessageQueue.add(message);
            
            // print message?
            if (verbose)
                print(message);

        }
                                                    
                            
        // close the Receiver stream
        public void close() {
            midiMessageQueue = null;

        }
        
    }




    /**
     * Search for connected Midi Keyboard controller.   If found, returns a
     * a openned MidiDevice.
     *
     * @param verbose          log information about the device to stdout
     */
    private static MidiDevice openMidiController(boolean verbose) {

        // get installed Midi devices 
        MidiDevice.Info deviceInfo[] = MidiSystem.getMidiDeviceInfo();
        MidiDevice device = null;
        for (int i = 0; i < deviceInfo.length; i++) {
            if (verbose) {
                System.out.print("DEVICE " + i + ": ");
                System.out.print(deviceInfo[i].getName()   + ", ");
                System.out.print(deviceInfo[i].getVendor() + ", ");
                System.out.print(deviceInfo[i].getDescription() + ", ");
            }
            try {
                device = MidiSystem.getMidiDevice(deviceInfo[i]);
                if (verbose)
                    System.out.print("Midi device available, ");
            } catch (MidiUnavailableException e) {
                if (verbose)
                    System.out.println("Midi unavailable, trying next...");
                continue;
            }

            // To detect if a MidiDevice represents a hardware MIDI port:
            // https://docs.oracle.com/javase/7/docs/api/javax/sound/midi/MidiDevice.html
            if ( ! (device instanceof Sequencer) && ! (device instanceof Synthesizer)) {
                if (!(device.isOpen())) {
                    try {
                        device.open();
                    } catch (MidiUnavailableException e) {
                        if (verbose)
                            System.out.println("Unable to open Midi device, trying next...");
                        continue;
                    }
                }

                // check for a valid Transmitter
                try {
                    Transmitter transmitter = device.getTransmitter();
                } catch (MidiUnavailableException e) {
                    if (verbose)
                        System.out.println("Failed to get transmitter, trying next...");
                    device.close();
                    continue;
                }
                if (verbose)
                    System.out.println("Valid MIDI controller connected.");
                break;
            }
            else {
                if (verbose)
                    System.out.println("Not a MIDI keyboard controller, trying next...");
                device = null;
            }
        }
        return device;
    }



    /**
     * Creates a MIDISource object listens to the first found connected MIDI input device.
     *
     * @param verbose true turns on logging
     * @param connectToSynth use default Java sound synthesizer
     * @throws RuntimeException if no device was found or if writing to the log
     *                          file failed
     */
    public MidiSource(boolean verbose, boolean connectToSynth) {
        MidiDevice  keyboard = openMidiController(verbose);
        if (keyboard == null)
            throw new RuntimeException("Unable to connect to a MIDI keyboard controller.");

        try {
            Transmitter transmitter = keyboard.getTransmitter();
            transmitter.setReceiver(new MidiKeyboardControllerReceiver(verbose, connectToSynth));
            sourceType = MIDI_CONTROLLER;
        }
        catch (MidiUnavailableException e) {
            e.printStackTrace();
            System.exit(1);
        }
    }


    /**
     *
     * @param connectToSynth use default Java sound synthesizer
     * @throws RuntimeException if no device was found or if writing to the log
     *                          file failed
     */


    /**
     * Creates a MIDISource object the produces MIDI messages from a 
     * time-stamped MIDI file, where each
     * each message is buffered and becomes available for consumption by the
     * client once it is "played" from the file
     * @param filename          the name of the file to play from
     * @param verbose true turns on logging
     * @param connectToSynth    true if Sequencer should connect to Sequencer
     * @throws RuntimeException if the file is not found or not a valid MIDI
     *                          file, or if reading from the file failed
     */
    public MidiSource(String filename, boolean verbose, boolean connectToSynth) {
        
        playSynth  = connectToSynth;
        sourceType = MIDI_FILE;
        try {
            sequencer  = MidiSystem.getSequencer(connectToSynth);
        }
        catch  (MidiUnavailableException e) {
            e.printStackTrace();
        }

        FileInputStream fileInputStream;
        try {
            fileInputStream = new FileInputStream(filename);
        } catch (FileNotFoundException e) {
            throw new RuntimeException("File not found");
        }

        
        // connect file to sequencer
        try {
            sequencer.setSequence(fileInputStream);
            sequencer.getTransmitter().setReceiver(new MidiFileReceiver(verbose));
        } catch (IOException e) {
            throw new RuntimeException("Error reading file: " + filename);
        } catch (InvalidMidiDataException e) {
            throw new RuntimeException("Invalid MIDI file: " + filename);
        } catch (MidiUnavailableException e) {
            throw new RuntimeException("MIDI unavailable: " + filename);
        }
        
        try {
            // Add a listener for meta message events
            sequencer.addMetaEventListener(new MetaEventListener() {
                    public void meta(MetaMessage event) {
                        // close the Sequencer when done
                        if (event.getType() == MIDI_END_OF_TRACK) {
                            // Sequencer is done playing
                            close();
                            
                        }
                    }
                });
            sequencer.open();
        } catch (MidiUnavailableException e) {
            e.printStackTrace();
        }

    }

    /**
     * Starts the MIDISource so it can produce messages.
     *
     */
    public void start () {
        if (sourceType == MIDI_CONTROLLER) {
        }

        else if (sourceType == MIDI_FILE)
            sequencer.start();
        else
            throw new RuntimeException("MidiSource: Illegal source type: " + sourceType);
    }
    
    /**
     * Return whether there are new MIDI messages available.
     *
     * @return true if and only if there are new messages available to consume
     */
    public boolean isEmpty() {
        return midiMessageQueue.size() == 0;
    }

    /**
     * Return the next available short MIDI message (in FIFO order). All messages, 
     * including the short MIDI message are  "consumed", i.e., it will no longer be 
     * available after this call.  Returns null if queue is empty.
     *
     * @return The next available {@link MidiMessage}
     */
    private ShortMessage getMidiMessage() {
        while (!this.isEmpty()) {
            MidiMessage message = midiMessageQueue.remove();
            if (message instanceof ShortMessage) 
                return (ShortMessage) message;
            // ignore other messages
        }
        return null; // if empty
    }

    /**
     * Return the code of the MIDIController key pressed. 
     *
     * @return code of key pressed
     */
    public int nextKeyPressed() {
        ShortMessage message = getMidiMessage();
        if (message == null)
            return -1;
        else
            if (message.getCommand() == ShortMessage.NOTE_ON)
                return message.getData1();
            else
                return -1;
    }

    /**
     * Return the next short MIDI message in the queue.
     *
     * @return Short MIDI message, null otherwise
     */
    public ShortMessage nextMessage() {
        ShortMessage message = getMidiMessage();
        if (message == null)
            return null;
        else
            return message;
    }

    /**
     * Static helper method. Extract the key code from a short
     * MIDI message, where commmand == NOTE_ON
     *
     * @param message ShortMessage object
     * @return key code number
     */
    public static int getKey(ShortMessage message) {
        return message.getData1();
    }

    /**
     * Static helper method. Extract the velocity from a short
     * MIDI message, where commmand == NOTE_ON
     *
     * @param message ShortMessage object
     * @return key code number
     */
    public static int getVelocity(ShortMessage message) {
        return message.getData2();
    }

    /**
     * Static helper method. Extract the channel from a short
     * MIDI message.
     *
     * @param message ShortMessage object
     * @return channel number
     */
    public static int getChannel(ShortMessage message) {
        return message.getChannel();
    }

    /**
     * Either stop listening for input from the device or stop playback from
     * the MIDI file.
     */
    public void close() {
        if (sourceType == MIDI_CONTROLLER && device.isOpen()) {
            device.close();
        }
        else if (sourceType == MIDI_FILE) {
            sequencer.stop();
            sequencer.close();
        }
    }

    /**
     * Return whether this MidiSource is still active
     *
     * @return if listening from device, true if and only if this instance is
     *         still listening; if using from file, true if and only if the
     *         playback is still active
     */
    public boolean isActive() {
        if (sourceType == MIDI_CONTROLLER) {
            return device.isOpen();
        }
        else if (sourceType == MIDI_FILE) {
            return sequencer.isRunning();
        }
        else {
            return false;
        }
    }


   /**
     * Tests this {@code MIDISource} data type.
     *  To test a MIDI keyboard controller connected to a computer:
     *     java MidiSource [-p]
     *  where the optional argument:
     *     -p -  indicates that the default JavaMIDI Synthesizer will 
     *           be used to play notes
     *  
     *  To test a MIDI file:
     *     java MidiSource [-p] filename.mid
     *  where the optional argument:
     *     -p -  indicates that the default JavaMIDI Synthesizer will 
     *           be used to play notes
     *  and the argument:
     *     filename - name of MIDI file
     * 
     *
     * @param args the command-line arguments
     */
    public static void main(String args[]) {
        String USAGE = "java MidiSource [-p] [<midifile.mid>]";
        String PLAY  = "-p";
        String VERSION = "MidiSource verison .3";
        boolean VERBOSE = true;
        MidiSource source = null;

        System.out.println(VERSION);
        // make this receiver listen for input from first MIDI input device found
        if (args.length == 0) {                               // java MidiSource
            source = new MidiSource(VERBOSE, false);
        }
        else if (args.length == 1) {
            if (args[0].equals(PLAY))                         // java MidiSource -p
                source = new MidiSource(VERBOSE, true);
            else {                                             // java MidiSource somefile.mid
                source = new MidiSource(args[0], VERBOSE, false);
                source.start();
            }
        }
        else if (args.length == 2) {
            if (args[0].equals(PLAY)) {                        // java MidiSource -p somefile.mid
                source = new MidiSource(args[1], VERBOSE, true);
                source.start();
            }
            else if (args[1].equals(PLAY)) {                   // java MidiSource somefile.mid -p
                source = new MidiSource(args[0], VERBOSE, true);
                source.start();
            }
            else
                System.out.println(USAGE);
        }
        else
            System.out.println(USAGE);

    }
}