/*
Copyright (c) 2006, Geomatics and Cartographic Research Centre, Carleton 
University All rights reserved.

Redistribution and use in source and binary forms, with or without 
modification, are permitted provided that the following conditions are met:

 - Redistributions of source code must retain the above copyright notice, 
   this list of conditions and the following disclaimer.
 - Redistributions in binary form must reproduce the above copyright notice,
   this list of conditions and the following disclaimer in the documentation
   and/or other materials provided with the distribution.
 - Neither the name of the Geomatics and Cartographic Research Centre, 
   Carleton University nor the names of its contributors may be used to 
   endorse or promote products derived from this software without specific 
   prior written permission.

THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE 
ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE 
LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR 
CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF 
SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS 
INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN 
CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) 
ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 
POSSIBILITY OF SUCH DAMAGE.

$Id$
*/
package ca.carleton.gcrc.atlas;

import java.net.URL;
import java.util.Timer;
import java.lang.IllegalArgumentException;
import java.lang.reflect.Array;
import javax.sound.midi.ControllerEventListener;
import javax.sound.midi.InvalidMidiDataException;
import javax.sound.midi.MidiSystem;
import javax.sound.midi.MidiUnavailableException;
import javax.sound.midi.MetaMessage;
import javax.sound.midi.MetaEventListener;
import javax.sound.midi.ShortMessage;
import javax.sound.midi.Sequence;
import javax.sound.midi.Sequencer;
import javax.sound.midi.Synthesizer;
import javax.sound.midi.Receiver;
import javax.sound.midi.Transmitter;
import javax.sound.midi.Track;
import javax.sound.midi.MidiChannel;

/**
 * A standard MIDI file (SMF) player that supports:
 * <ul>
 * <li> simple MIDI file playback (single-pass or looping) with
 *      start/stop/pause/resume controls.
 * <li> playback with interactive control of the following parameters:
 *      <ul>
 *      <li> master volume scaling (as a percentage) and muting control
 *           of the MIDI composition.
 *      <li> muting control of individual tracks in type 1 (multitrack)
 *           MIDI files.
 *      <li> master volume control of individual synthesizer channels
 *           (roughly translating to individual instruments).
 *      <li> composition key transposition.
 *      </ul>
 * </ul>
 * 
 * @author  Glenn Brauen
 */
public class StandardMidiFilePlayer implements FadeTimerParent<Integer> {
	
	private URL m_url = null;
	private Sequencer m_sequencer = null;
	private Synthesizer m_synth = null;
	private OnePassMidiTransformer m_transformer = null;
	protected Timer m_fadeTimer = null;
	protected FadeTimerTask<Integer> m_fadeTimerTask = null;

	private final class SequencerState {
		private static final int NOT_LOADED = 0;
		private static final int LOADED = 1;
		private static final int RUNNING = 2;
	}
	private int m_sequencerState = SequencerState.NOT_LOADED;

	/**
	 * Create a new StandardMidiFilePlayer and set it up to play the URL specified by
	 * url_.
	 * @param url_ sequence to be loaded by the player.
	 */
	public StandardMidiFilePlayer(URL url_) {
		Sequence sequence;
		m_url = url_;
		
		/*
		 *	Now, we need a Sequencer to play the sequence - would normally just use default.
		 */
		try {
			m_sequencer = MidiSystem.getSequencer(false);
		}
		catch (MidiUnavailableException e) {
			e.printStackTrace();
			return;
		}
		
		 /*
		  * Register an event listener to pick up the end of the track and stop
		  * the sequencer if the track is not playing in a loop.
		  */
		m_sequencer.addMetaEventListener(new MetaEventListener() {
			public void meta(MetaMessage event) {
				if (event.getType() == 47) { // 0x2f or 47 is end of track.
					endOfTrackReached();
				}
			}
		});

		/*
		 *	If we are in debug mode, we set additional listeners
		 *	to produce interesting (?) debugging output.
		 */
		if (Debug.ON && Debug.standardLevel < Debug.LEVEL) {
			if (Debug.mediumLevel <= Debug.LEVEL) {
				m_sequencer.addMetaEventListener(new MetaEventListener() {
					/**
					 * Print out information about meta events received through a listerner interface.
					 * 
					 * @param message, the reeived message.
					 */
					public void meta(MetaMessage message) {
						DebugLog("MetaMessage: " + message + " type: " + message.getType() +
								" length: " + message.getLength());
					}
				});					
			}

			if (Debug.detailLevel <= Debug.LEVEL) {
				int[] controllers = new int[128];
				for (int i = 0; i < controllers.length; i++) {
					controllers[i] = i;
				}
				m_sequencer.addControllerEventListener(new ControllerEventListener() {
					/**
					 * Print out details of received control change message
					 * 
					 * @param message, the received message.
					 */
					public void controlChange(ShortMessage message) {
						DebugLog("ShortMessage: " + message + " controller: " +
								message.getData1() + " value: " + message.getData2());
					}
				}, controllers);					
			}
		}

		// open the sequencer - allocates resources to it.
		try {
			m_sequencer.open();
			DebugLog("Sequencer opened.");
		} catch (MidiUnavailableException e) {
			e.printStackTrace();
			return; // get out - no point continuing.
		}

		/*
		 *	Create a sequence for the URL passed in and then give that to the
		 *  sequencer.
		 */
		try {
			sequence = MidiSystem.getSequence(m_url);
		} catch (Exception e) {
			e.printStackTrace();
			return; // get out - no point continuing.
		}
		
		try {
			m_sequencer.setSequence(sequence);
			DebugLog("Sequence set.");
		}
		catch (InvalidMidiDataException e) {
			e.printStackTrace();
		}

		/*
		 * get the one-pass transformer.  Software-only device so should be no problems.
		 */
		m_transformer = new OnePassMidiTransformer();
		if (null == m_transformer) {
			DebugLog("Failed to create one-pass transformer");
			return;
		}
		
		/*
		 * Get the default synthesizer, open() it and then chain the output of the
		 * sequencer to the input of the transformer and the output of the transformer
		 * to the input of the synthesizer.  This configuration allows the transformer
		 * access to the MIDI message stream so it can parse/monitor/modify messages in
		 * the stream according to its configuration.
		 */
		try {
			m_synth = MidiSystem.getSynthesizer();
			m_synth.open();
			Receiver	synthReceiver = m_synth.getReceiver();
			Transmitter	seqTransmitter = m_sequencer.getTransmitter();
			Receiver transReceiver = m_transformer.getReceiver();
			Transmitter transTransmitter = m_transformer.getTransmitter();
			seqTransmitter.setReceiver(transReceiver); // chain sequncer-transformer-synthesizer
			transTransmitter.setReceiver(synthReceiver);
		} catch (MidiUnavailableException e) {
			e.printStackTrace();
			return;
		}
// test code to see default volume settings: coarse (7) = 127 and fine (39) = 0...
//		MidiChannel[] channels = m_synth.getChannels();
//		DebugLog("coarse volume setting: " + channels[0].getController(7) + " fine volume setting: " + channels[0].getController(39));
// 
// note that with the Java MixerSynth, the fine volume control seems not to be implemented.  Set it to anything you want, it always
// reads back as 0. (comment based on Java 1.5_06 on a mac os X 10.4 system).
		
		m_sequencerState = SequencerState.LOADED;
	} // end constructor

	protected final class Debug {
		  protected static final int standardLevel = 0;
		  protected static final int mediumLevel = 1;
		  protected static final int detailLevel = 2;
		  
		  protected static final boolean ON = false;
		  protected static final int LEVEL = standardLevel;
	} 
	
	protected void DebugLog(String str) {
		if (Debug.ON) System.out.println(str);
	}

	protected void DebugLogNoNL(String str) { // no new line
		if (Debug.ON) System.out.print(str);
	}
	
	protected void DebugLog(String str, int level) {
		if (Debug.ON && level <= Debug.LEVEL) System.out.println(str);
	}

	public void start() {
		if (SequencerState.NOT_LOADED != m_sequencerState) {
			DebugLog("StandardMidiFilePlayer: starting sequencer...");
			m_sequencer.start();
			m_sequencerState = SequencerState.RUNNING;
		}
	} // end start()
	
	public void resume() {
		if (m_sequencerState == SequencerState.RUNNING) {
			DebugLog("StandardMidiFilePlayer: resuming sequencer...");
			m_sequencer.start();
		}
	}
	
	public void pause() {
		if (m_sequencerState == SequencerState.RUNNING) {
			DebugLog("StandardMidiFilePlayer: pausing sequencer...");
			m_sequencer.stop();
		}
	}
	
	public void stop() { // how would this differ from pause?  Should it revert to beginning of file??
		m_sequencer.stop();
	}

	/**
	 * Set the muting on all tracks in the current sequence according to the input flag_.
	 * @param flag_ true means mute, false means unmute.
	 */
	public void setAllTrackMutes(boolean flag_) {
		Sequence seq = m_sequencer.getSequence();
		
		if (null != seq) {
			Track tracks[] = seq.getTracks();
			int numTracks = Array.getLength(tracks);
			for (int i=0; i < numTracks; i++) {
				m_sequencer.setTrackMute(i, flag_);
			}
		}
	} // setAllTrackMutes()
	
	/**
	 * Set the muting on the specified track_ in the current sequence according to the 
	 * input_ flag.  If specified for an invalid track, the request is ignored.
	 * @param track_ the track to set muting on.
	 * @param flag_ true means mute, false means unmute.
	 */
	public void setTrackMute(int track_, boolean flag_) {
		Sequence seq = m_sequencer.getSequence();
		
		if (null != seq) {
			Track tracks[] = seq.getTracks();
			int numTracks = Array.getLength(tracks);
			if (track_ >= 0 && track_ < numTracks) {
				m_sequencer.setTrackMute(track_, flag_);
			}
		}
	} // setTrackMute()
	
	/**
	 * Set looping or the player, depending on the input flag.  Whereas the underlying
	 * sequencer supports looping on a count basis, the player only supports infinite
	 * looping (until told otherwise) or no looping.
	 * 
	 * @param flag_ true indicates loop, false indicates don't loop.
	 */
	public void setLooping(boolean flag_) {
		if (true == flag_) {
			m_sequencer.setLoopCount(Sequencer.LOOP_CONTINUOUSLY);
		} else {
			m_sequencer.setLoopCount(0);
		}
	}
	
	private void endOfTrackReached() {
		DebugLog("StandardMidiFilePlayer: end of track reached", Debug.mediumLevel);
		if (Sequencer.LOOP_CONTINUOUSLY != m_sequencer.getLoopCount()) {
			m_sequencer.stop(); // stop but don't close - leave that for destroy()
		}
	}
	
	public void destroy() {
		if (null != m_fadeTimer) {
			m_fadeTimerTask.cancel();
			m_fadeTimerTask = null;
		}
		if (null != m_fadeTimer) {
			m_fadeTimer.cancel();
			m_fadeTimer = null;
		}
		
		m_sequencer.close();
		m_sequencer = null;
		m_transformer.close();
		m_transformer = null;
		m_synth.close();
		m_synth = null;
	} // end destroy()
	
	/**
	 * Set the master gain scale multiplier - fade to new setting over a period of 3
	 * fade timer intervals.
	 * @param gainScale_ new value for gain scale multiplier (as percentage).
	 */
	public void setMasterGainScaleMultiplier(int gainScale_) {
		fadeMasterGainScale(gainScale_, 300); // fade over 300 milliseconds
	}
	
	/**
	 * Set the channel gain scale multiplier - fade to new setting over a period of 3
	 * fade timer intervals.
	 * @param channel_ the channel to be adjusted.
	 * @param gainScale_ new value for gain scale multiplier (as percentage).
	 */
	public void setChannelGainScaleMultiplier(int channel_, int gainScale_) {
		fadeChannelGainScale(channel_, gainScale_, 300); // fade over 300 milliseconds
	}

	/*
	 * I N T E R F A C E :  FadeTimerParent routines start
	 */
	/*
	 * Gain Scale fade timer handling.
	 * The scale value (0 .. 100 as percent) is a master gain multiplier used by
	 * the synthesizer to adjust the channel gain settings in use on each of its
	 * channels.  For the StandardMidiFilePlayer, there is only a single gain scale
	 * setting for the entire synth.
	 */
	private final int minChannelGainScaleTimerId = 0; // channel numbers for channel gains scales
	private final int maxChannelGainScaleTimerId = 15;
	private final int gainScaleTimerId = (maxChannelGainScaleTimerId + 1);
	
	public Integer getCurrentFadeTimerValue(Integer timerId_) throws IllegalArgumentException {
		if (timerId_ >= minChannelGainScaleTimerId && timerId_ <= maxChannelGainScaleTimerId) {
			return (Integer) m_transformer.getChannelGainScaleModifier((int) timerId_); // timerId_ is channel number.
		} else if (timerId_ == gainScaleTimerId) {
			return m_transformer.getMasterGainScaleModifier();			
		} else {
			throw new IllegalArgumentException("StandardMidiFilePlayer::getCurrentFadeTimer - invalid timer Id: " + timerId_);
		}
	} // getCurrentFadeTimerValue()
	
	private final int CHANNEL_VOLUME = 7;
	public void setCurrentFadeTimerValue(Number newValue_, Integer timerId_) throws IllegalArgumentException {
		Integer val = (Integer) newValue_;
		if (timerId_ >= minChannelGainScaleTimerId && timerId_ <= maxChannelGainScaleTimerId) {
			m_transformer.setChannelGainScaleModifier((int)timerId_, (int)val); // catch future control changes...
			MidiChannel[] channels = m_synth.getChannels(); // and adjust immediately
			channels[timerId_].controlChange(CHANNEL_VOLUME,
					(m_transformer.getLastChannelVoiceGainSetting((int)timerId_) *
					 m_transformer.getMasterGainScaleModifier() / 100) *  // master gain scale
					(int)val / 100); //channel gain scale
		} else if (timerId_ == gainScaleTimerId) {
			m_transformer.setMasterGainScaleModifier((int)val); // catch future control changes...
			MidiChannel[] channels = m_synth.getChannels(); // and adjust immediately
			for (int i = 0; i<channels.length; i++) {
				channels[i].controlChange(CHANNEL_VOLUME,
						(m_transformer.getLastChannelVoiceGainSetting(i) *
						 (int)val / 100) * // master gain scale
						m_transformer.getChannelGainScaleModifier(i) / 100); // channel gain scale modifier
			}
		} else {
			throw new IllegalArgumentException("StandardMidiFilePlayer::getCurrentFadeTimer - invalid timer Id: " + timerId_);
		}
	} // setCurrentFadeTimerValue()

	public void fadeCompleted(Integer timerId_) {
		// do nothing
	} // fadeCompleted()
	
	/*
	 * I N T E R F A C E :  FadeTimerParent routines end
	 */
	
	private void createOrUpdateTimerTask(int scale_, int increment_, int timerId_) { // all callers are synchronized so this does not need to be.
		DebugLogNoNL("StandardMidiFilePlayer::createOrUpdateTimerTask: timerId=" + timerId_ + ", scale=" +
				scale_ + ", increment=" + increment_);
		if (null == m_fadeTimer) { // no fade in progress - start a new fade...
			DebugLog(" - starting new timer task.");
			m_fadeTimer = new Timer();	
			m_fadeTimerTask = new FadeTimerTask<Integer>(this, (Integer)scale_, (Integer)increment_,
					(Integer)timerId_);
			m_fadeTimer.schedule(m_fadeTimerTask, (long) 0, FadeTimerParent.TIMER_PERIOD);
		} else { // else - reset the target gain and increment on the existing task ...
			     // timer thread already scheduled
			DebugLog(" - reusing existing timer task.");
			m_fadeTimerTask.setTargetValueAndIncrement((Integer)scale_, (Integer)increment_, (Integer)timerId_);
		}		
	} // createOrUpdateTimerTask()
	
	/**
	 * Fades the value of the master gain setting to the specified value over the specified
	 * fadeDuration.  The variable is adjusted in increments.  Synchronized because of
	 * interactions with the fadeTimerTask object.
	 * 
	 * If the fadeDuration is less than a timer period then just do it in one step and
	 * don't bother to create a timer task.
	 * 
	 * @param gain_ target gain value
	 * @param fadeDuration_ period over which the target is to be reached.
	 */
	synchronized private void fadeMasterGainScale(int scale_, long fadeDuration_) {
		int steps = (int) fadeDuration_ / FadeTimerParent.TIMER_PERIOD;
		
		if (steps > 0 || null != m_fadeTimer) { // more than one step required or timer already running
			int current = m_transformer.getMasterGainScaleModifier();
			int increment = (scale_ - current) / steps; // approx - timerTask will do extra step if needed.
			if (increment == 0) {
				increment = ((scale_ - current) >= 0 ? 1 : -1); // minimum - steps of  +/- 1% - may reduce fade duration.
			}
			createOrUpdateTimerTask(scale_, (int) increment, gainScaleTimerId);
		} else {
			m_transformer.setMasterGainScaleModifier(scale_); // duration too small - just set the target gain scale immediately
		}
	} // fadeMasterGainScale()

	/**
	 * Fades the value of the specified channel gain setting to the specified value over the specified
	 * fadeDuration.  The variable is adjusted in increments.  Synchronized because of
	 * interactions with the fadeTimerTask object.
	 * 
	 * If the fadeDuration is less than a timer period then just do it in one step and
	 * don't bother to create a timer task.
	 * 
	 * @param gain_ target gain value
	 * @param fadeDuration_ period over which the target is to be reached.
	 * @throws IllegalArgumentException if channel_ out of range (0..15).
	 */
	synchronized private void fadeChannelGainScale(int channel_, int scale_, long fadeDuration_) {
		if (channel_ < minChannelGainScaleTimerId || channel_ > maxChannelGainScaleTimerId) {
			throw new IllegalArgumentException("StandardMidiFilePlayer::fadeChannelGainScale - invalid channel: " + channel_);
		}
		int steps = (int) fadeDuration_ / FadeTimerParent.TIMER_PERIOD;
		
		if (steps > 0 || null != m_fadeTimer) { // more than one step required or timer already running
			int current = m_transformer.getChannelGainScaleModifier(channel_);
			int increment = (scale_ - current) / steps; // approx - timerTask will do extra step if needed.
			if (increment == 0) {
				increment = ((scale_ - current) >= 0 ? 1 : -1); // minimum - steps of  +/- 1% - may reduce fade duration.
			}
			createOrUpdateTimerTask(scale_, increment, channel_);
		} else {
			m_transformer.setChannelGainScaleModifier(channel_, scale_); // duration too small - just set the target gain scale immediately
		}
	} // fadeChannelGainScale()

	public void allFadeTimersFinished() {
		destroyFadeTimer();
	} // fadeTimerFinished()

	/**
	 * synchronized function to clean up fade timer and its associated timer task.  Checks
	 * first to verify that the Track's current gain settings have all been adjusted according
	 * to the fade timer tasks own data structures.  If not, then a new fade command must have
	 * just been received and the fade timer has been reconfigured so let it run.
	 */
	synchronized private void destroyFadeTimer() {
		DebugLogNoNL("StandardMidiFilePlayer::destroyFadeTimer: ");
		if (m_fadeTimerTask.allTargetsReached()) {
			// if this is not the case, then another fade command must have just been issued -
			// leave the fadeTimerTask running.
			DebugLog("destroying.");
			m_fadeTimerTask.cancel();
			m_fadeTimerTask = null;
			m_fadeTimer.cancel();
			m_fadeTimer = null;
		} else {
			DebugLog("not destroying.");			
		}
	} // destroyFadeTimer()
	
	public void setNoteTranspose(int transpose_) {
		m_transformer.setNoteTranspose(transpose_);
	}

	/*
	 * D e b u g   d i s p l a y   r o u t i n e s
	 */
	
	/**
	 * Display status of Midi File Player on the java console.  Non-command-path display routine
	 * used to periodically dump the current state of the applet.
	 */
	public void displayAppletState() {
		String seqState = "";
		if (m_sequencerState == SequencerState.RUNNING) {
			seqState = " (running)";
		} else if (m_sequencerState == SequencerState.LOADED) {
			seqState = " (loaded)";
		} else {
			seqState = " (not loaded)";
		}

		System.out.println(" Midi Sequence Loaded: " + m_url + seqState + ":");

		if (m_sequencer != null) {
			System.out.println((m_sequencer.getLoopCount() == Sequencer.LOOP_CONTINUOUSLY) ?
					"   Sequence looping.":"   Sequence not looping.");

			Sequence seq = m_sequencer.getSequence();

			if (null != seq) {
				Track tracks[] = seq.getTracks();
				int numTracks = Array.getLength(tracks);
				System.out.print("   Track Muting: ");
				for (int i=0; i < numTracks; i++) {
					if (i > 0) {
						System.out.print(", ");
					}
					boolean flag = m_sequencer.getTrackMute(i);
					System.out.print(i + "=" + flag);
				}
				System.out.println();
			}
		} // end if (sequencer defined)

		if (m_transformer != null) {
			m_transformer.displayAppletState();
		}
	} // displayAppletState()

} // end class StandardMidiFilePlayer
