package com.xenoage.zong.desktop.io.midi.out; import com.xenoage.utils.jse.collections.WeakList; import com.xenoage.zong.core.Score; import com.xenoage.zong.core.position.Time; import com.xenoage.zong.io.midi.out.MidiConverter; import com.xenoage.zong.io.midi.out.MidiEvents; import com.xenoage.zong.io.midi.out.MidiSequence; import com.xenoage.zong.io.midi.out.PlaybackListener; import lombok.val; import javax.sound.midi.*; import static com.xenoage.utils.log.Log.log; import static com.xenoage.utils.log.Report.warning; import static com.xenoage.zong.io.midi.out.MidiConverter.Options.optionsForPlayback; import static com.xenoage.zong.io.midi.out.MidiSettings.defaultMidiSettings; /** * This class offers the interface for MIDI playback in * the program to play a given {@link Score}. * * @author Uli Teschemacher * @author Andreas Wenger */ public class MidiScorePlayer implements ControllerEventListener { private static MidiScorePlayer instance = null; private MidiSequence<Sequence> sequence = null; private WeakList<PlaybackListener> listeners = new WeakList<>(); private boolean metronomeEnabled; private float volume = defaultMidiSettings.getDefaultVolume(); private int currentPosition; private TimeThread timeThread = new TimeThread(); /** * Gets the single instance of this class. */ public static MidiScorePlayer midiScorePlayer() { if (instance == null) throw new IllegalStateException(MidiScorePlayer.class.getName() + " not initialized"); return instance; } public static void init() throws MidiUnavailableException { if (instance == null) instance = new MidiScorePlayer(); } private MidiScorePlayer() { setVolume(volume); SynthManager.removeAllControllerEventListeners(); //controller events to listen for (see MidiEvents doc) SynthManager.addControllerEventListener(this, MidiEvents.PlaybackControl.code); SynthManager.addControllerEventListener(this, MidiEvents.PlaybackEnd.code); } /** * Opens the given {@link Score} for playback. */ public void openScore(Score score) { stop(); this.sequence = MidiConverter.convertToSequence( score, optionsForPlayback, new JseMidiSequenceWriter()); try { SynthManager.getSequencer().setSequence(sequence.getSequence()); } catch (InvalidMidiDataException ex) { log(warning(ex)); } applyVolume(); } /** * Registers the given {@link PlaybackListener} which will be * informed about playback events like the current position. * This class stores only a weak reference of the listener, so * removing the listener is optional. */ public void addPlaybackListener(PlaybackListener listener) { listeners.add(listener); } /** * Unregisters the given {@link PlaybackListener}. */ public void removePlaybackListener(PlaybackListener listener) { listeners.remove(listener); } /** * Changes the position of the playback cursor to the given * time in microseconds. */ public void setMicrosecondPosition(long ms) { SynthManager.getSequencer().setMicrosecondPosition(ms); currentPosition = 0; } /** * Changes the position of the playback cursor to the given {@link Time}. * If it is within a repeated section, the first repetition is played. */ public void setTime(Time time) { long tickPosition = sequence.getTimeMap().getByTime(time).tick; SynthManager.getSequencer().setTickPosition(tickPosition); currentPosition = 0; //as we don't know the real position, we set it 0, because the playback will automatically jump to the correct position. } /** * Starts playback at the current position. */ public void start() { Sequencer sequencer = SynthManager.getSequencer(); if (sequencer.getSequence() != null) { sequencer.start(); timeThread.stopTimer(); timeThread = new TimeThread(); timeThread.start(); for (PlaybackListener listener : listeners.getAll()) { listener.playbackStarted(); } applyVolume(); } } /** * Stops the playback without resetting the * current position. */ public void pause() { Sequencer sequencer = SynthManager.getSequencer(); if (sequencer.isRunning()) { sequencer.stop(); timeThread.stopTimer(); for (PlaybackListener listener : listeners.getAll()) { listener.playbackPaused(); } } } /** * Stops the playback and sets the cursor to the start position. */ public void stop() { Sequencer sequencer = SynthManager.getSequencer(); if (sequencer.isRunning()) { sequencer.stop(); timeThread.stopTimer(); } setMicrosecondPosition(0); currentPosition = 0; for (PlaybackListener listener : listeners.getAll()) { listener.playbackStopped(); } } public boolean getMetronomeEnabled() { return metronomeEnabled; } public void setMetronomeEnabled(boolean metronomeEnabled) { this.metronomeEnabled = metronomeEnabled; Integer metronomeTrack = sequence.getMetronomeTrack(); if (metronomeTrack != null) SynthManager.getSequencer().setTrackMute(metronomeTrack, !metronomeEnabled); } /** * This method catches control change events from the sequencer. * For time-specific events, the method notifies the registered listener. */ @Override public void controlChange(ShortMessage message) { if (message.getData1() == MidiEvents.PlaybackControl.code) { long currentTick = SynthManager.getSequencer().getTickPosition(); val midiTime = sequence.getTimeMap().getByTick(currentTick); if (midiTime != null) { val currentMs = SynthManager.getSequencer().getMicrosecondPosition() / 1000L; for (PlaybackListener listener : listeners.getAll()) listener.playbackAtTime(midiTime.repTime.time, currentMs); } } else if (message.getData1() == MidiEvents.PlaybackEnd.code) { stop(); //stop to really ensure the end for (PlaybackListener listener : listeners.getAll()) { listener.playbackAtEnd(); } } } /** * Gets the volume, which is a value between 0 (silent) and 1 (loud). */ public float getVolume() { return volume; } /** * Sets the volume. * @param volume value between 0 (silent) and 1 (loud) */ public void setVolume(float volume) { this.volume = volume; applyVolume(); } private void applyVolume() { MidiChannel[] channels = SynthManager.getSynthesizer().getChannels(); int max = 127; //according to MIDI standard for (val channel : channels) { channel.controlChange(7, Math.round(volume * max)); } } /** * Returns true, if the playback cursor is at the end of the * score, otherwise false. */ public boolean isPlaybackFinished() { Sequencer sequencer = SynthManager.getSequencer(); return sequencer.getMicrosecondPosition() >= sequencer.getMicrosecondLength(); } /** * Gets the length of the current sequence in microseconds, * or 0 if no score is loaded. */ public long getMicrosecondLength() { if (sequence == null) return 0; return sequence.getSequence().getMicrosecondLength(); } /** * Gets the current position within the sequence in microseconds, * or 0 if no score is loaded. */ public long getMicrosecondPosition() { if (sequence == null) return 0; return SynthManager.getSequencer().getMicrosecondPosition(); } public Sequence getSequence() { if (sequence != null) return sequence.getSequence(); else return null; } private class TimeThread extends Thread { private boolean stop = false; public TimeThread() { setDaemon(true); } @Override public void run() { try { while (!stop) { long ms = SynthManager.getSequencer().getMicrosecondPosition() / 1000; for (PlaybackListener listener : listeners.getAll()) { listener.playbackAtMs(ms); } Thread.sleep(1000 / PlaybackListener.timerRate); } } catch (InterruptedException e) { } } public void stopTimer() { stop = true; } } }