001package net.kreatious.pianoleopard.midi; 002 003import java.io.Closeable; 004import java.io.File; 005import java.io.FileInputStream; 006import java.io.IOException; 007import java.io.InputStream; 008import java.lang.Thread.State; 009import java.util.List; 010import java.util.Optional; 011import java.util.concurrent.CopyOnWriteArrayList; 012import java.util.concurrent.TimeUnit; 013import java.util.function.BiFunction; 014import java.util.function.Consumer; 015import java.util.function.LongConsumer; 016 017import javax.sound.midi.InvalidMidiDataException; 018import javax.sound.midi.MidiDevice; 019import javax.sound.midi.MidiDevice.Info; 020import javax.sound.midi.MidiMessage; 021import javax.sound.midi.MidiSystem; 022import javax.sound.midi.MidiUnavailableException; 023import javax.sound.midi.Receiver; 024import javax.sound.midi.Sequencer; 025import javax.sound.midi.ShortMessage; 026 027import net.kreatious.pianoleopard.midi.event.Event; 028import net.kreatious.pianoleopard.midi.event.EventFactory; 029import net.kreatious.pianoleopard.midi.track.ParsedSequence; 030 031import com.google.common.annotations.VisibleForTesting; 032 033/** 034 * Model for the MIDI output sequencer, allows controllers to listen for events. 035 * 036 * @author Jay-R Studer 037 */ 038public class OutputModel implements AutoCloseable { 039 /** 040 * Indicates which action to take in response to an event handler. 041 * <p> 042 * The action with the highest priority takes precedence. 043 * 044 * @author Jay-R Studer 045 */ 046 public enum EventAction { 047 /** 048 * Return if the event should be played. Has the highest priority. 049 */ 050 PLAY, 051 052 /** 053 * Returned if the event should be muted. Has medium priority. 054 */ 055 MUTE, 056 057 /** 058 * Returned if it doesn't matter if the event is muted. Has the lowest 059 * priority. 060 * <p> 061 * An event that is not handled by all handlers will be played. 062 * <p> 063 * Event handlers are allowed to change the MidiMessage before it is 064 * sent. 065 */ 066 UNHANDLED; 067 } 068 069 private static final long ALWAYS_SEND = -10; 070 private final Sequencer sequencer; 071 private ParsedSequence sequence = ParsedSequence.createEmpty(); 072 private Optional<MidiDevice> output = Optional.empty(); 073 private Optional<Receiver> receiver = Optional.empty(); 074 075 private final List<Consumer<? super Info>> outputDeviceListeners = new CopyOnWriteArrayList<>(); 076 private final List<Consumer<? super ParsedSequence>> openListeners = new CopyOnWriteArrayList<>(); 077 private final List<Runnable> playListeners = new CopyOnWriteArrayList<>(); 078 private final List<LongConsumer> currentTimeListeners = new CopyOnWriteArrayList<>(); 079 private final List<BiFunction<MidiMessage, Optional<Event>, EventAction>> eventHandlers = new CopyOnWriteArrayList<>(); 080 private final List<Closeable> closeables = new CopyOnWriteArrayList<>(); 081 082 private final Thread tickThread = new Thread("output model current tick thread") { 083 @Override 084 public void run() { 085 try { 086 while (true) { 087 currentTimeListeners.forEach(listener -> listener.accept(sequencer.getMicrosecondPosition())); 088 Thread.sleep(TimeUnit.SECONDS.toMillis(1) / 120); 089 } 090 } catch (final InterruptedException e) { 091 Thread.currentThread().interrupt(); 092 } 093 }; 094 }; 095 096 /** 097 * Constructs a new {@link OutputModel} with the specified initial state. 098 * <p> 099 * The output model is initially unconnected to any MIDI devices. After 100 * construction, it is expected that an output device will be set by the 101 * consumer. 102 * 103 * @param sequencerFactory 104 * A factory for producing the {@link Sequencer}, such as 105 * {@link SystemSequencerFactory}. 106 * @throws MidiUnavailableException 107 * if the MIDI system is unavailable. 108 */ 109 public OutputModel(SequencerFactory sequencerFactory) throws MidiUnavailableException { 110 sequencer = sequencerFactory.getSequencer(); 111 setOutputDevice(new InitialMidiDevice()); 112 } 113 114 /** 115 * Reconnects the sequencer to a different MIDI output device. 116 * 117 * @param output 118 * the new output MIDI device to reconnect to 119 * @throws MidiUnavailableException 120 * if the MIDI system is unavailable. 121 */ 122 public synchronized void setOutputDevice(MidiDevice output) throws MidiUnavailableException { 123 try { 124 sequencer.close(); 125 this.output.ifPresent(MidiDevice::close); 126 this.output = Optional.of(output); 127 128 output.open(); 129 receiver = Optional.of(new MutingReceiverProxy(output.getReceiver())); 130 sequencer.getTransmitter().setReceiver(receiver.get()); 131 sequencer.open(); 132 sequencer.setSequence(sequence.getSequence()); 133 outputDeviceListeners.forEach(listener -> listener.accept(output.getDeviceInfo())); 134 } catch (final InvalidMidiDataException e) { 135 // Sequence should still be valid since openMidiFile didn't throw 136 throw new IllegalStateException(e); 137 } 138 } 139 140 /** 141 * Starts playback of the currently loaded MIDI file. 142 * <p> 143 * Any registered start listeners will be called with the last opened MIDI 144 * sequence. 145 */ 146 public void start() { 147 sequencer.stop(); 148 sequencer.setMicrosecondPosition(0); 149 resetReceiver(); 150 playListeners.forEach(Runnable::run); 151 sequencer.start(); 152 } 153 154 private class MutingReceiverProxy implements Receiver { 155 private final Receiver wrapped; 156 157 private MutingReceiverProxy(Receiver wrapped) { 158 this.wrapped = wrapped; 159 } 160 161 @Override 162 public void send(MidiMessage message, long timeStamp) { 163 if (timeStamp == ALWAYS_SEND) { 164 wrapped.send(message, -1); 165 return; 166 } 167 168 final Optional<Event> event = EventFactory.create(message, sequencer.getMicrosecondPosition()); 169 if (eventHandlers.stream().map(eventHandler -> eventHandler.apply(message, event)).min(Enum::compareTo) 170 .orElse(EventAction.UNHANDLED) != EventAction.MUTE) { 171 wrapped.send(message, timeStamp); 172 } 173 } 174 175 @Override 176 public void close() { 177 wrapped.close(); 178 } 179 } 180 181 /** 182 * Adjusts the tempo of played back sequences. 183 * <p> 184 * Values higher than 1.0 are faster than normal, less than 1.0 are slower 185 * than normal. A value of 1.0 indicates that the regular tempo should be 186 * applied. Tempo factors do not affect the microsecond values of MIDI 187 * events. 188 * 189 * @param factor 190 * the tempo factor to set 191 */ 192 public void setTempoFactor(float factor) { 193 sequencer.setTempoFactor(factor); 194 } 195 196 /** 197 * Seeks the sequence to the specified time 198 * 199 * @param time 200 * the time in microseconds to seek to 201 */ 202 public void setCurrentTime(long time) { 203 if (sequencer.isRunning()) { 204 sequencer.stop(); 205 sequencer.setMicrosecondPosition(time); 206 sequencer.start(); 207 } else { 208 sequencer.setMicrosecondPosition(time); 209 } 210 } 211 212 /** 213 * Parses a MIDI file and prepares it for playback. 214 * <p> 215 * Any registered start listeners will be called with the parsed sequence. 216 * The file of the parsed sequence will be the specified MIDI file. 217 * 218 * @param midi 219 * the MIDI file to open 220 * @throws IOException 221 * if an I/O error occurs 222 */ 223 public void openMidiFile(File midi) throws IOException { 224 try (InputStream in = new FileInputStream(midi)) { 225 openMidiFile(in, Optional.of(midi)); 226 } 227 } 228 229 @VisibleForTesting 230 void openMidiFile(InputStream midiStream, Optional<File> midi) throws IOException { 231 try { 232 if (tickThread.getState() == State.NEW) { 233 tickThread.start(); 234 } 235 236 sequence = ParsedSequence.parseByTracks(MidiSystem.getSequence(midiStream)); 237 sequence.setFile(midi); 238 sequencer.stop(); 239 sequencer.setSequence(sequence.getSequence()); 240 sequencer.setMicrosecondPosition(0); 241 resetReceiver(); 242 openListeners.forEach(listener -> listener.accept(sequence)); 243 } catch (final InvalidMidiDataException e) { 244 throw new IOException(e); 245 } 246 } 247 248 /** 249 * Adds a listener to notify when the output device has changed. 250 * 251 * @param listener 252 * the listener to add 253 */ 254 public void addOutputDeviceListener(Consumer<? super Info> listener) { 255 outputDeviceListeners.add(listener); 256 } 257 258 /** 259 * Adds a listener to notify when a parsed MIDI file is opened. 260 * 261 * @param listener 262 * the listener to add 263 */ 264 public void addOpenListener(Consumer<? super ParsedSequence> listener) { 265 openListeners.add(listener); 266 } 267 268 /** 269 * Adds a listener to notify when a parsed MIDI file is played from the 270 * beginning. 271 * 272 * @param listener 273 * the listener to add 274 */ 275 public void addPlayListener(Runnable listener) { 276 playListeners.add(listener); 277 } 278 279 /** 280 * Adds an event handler to handle MIDI events. 281 * <p> 282 * The return value of the handler determines the action to take. A list of 283 * actions is provided on {@link EventAction}. The default action is to play 284 * the event. 285 * <p> 286 * Event handlers are allowed to mutate the channel of the MidiMessage 287 * object before returning. The {@link Event} object contains the original 288 * message before any mutations are applied. Handlers are encouraged to use 289 * the Event object whenever possible. 290 * 291 * @param handler 292 * the event handler to add. 293 */ 294 public void addEventHandler(BiFunction<MidiMessage, Optional<Event>, EventAction> handler) { 295 eventHandlers.add(handler); 296 } 297 298 /** 299 * Adds a listener to notify when the current playback time in microseconds 300 * has changed. 301 * <p> 302 * This listener is called asynchronously several times per second for the 303 * lifetime of this object from a different thread than the one which 304 * invokes this method. 305 * 306 * @param listener 307 * the listener to add 308 */ 309 public void addCurrentTimeListener(LongConsumer listener) { 310 currentTimeListeners.add(listener); 311 } 312 313 /** 314 * Adds a closeable to close when this output model is closed. 315 * <p> 316 * Used for releasing resources closely tied with the lifetime of this 317 * output model. Resources will be released in the same order they are 318 * registered. 319 * 320 * @param closeable 321 * the closeable to add 322 */ 323 public void addCloseable(Closeable closeable) { 324 closeables.add(closeable); 325 } 326 327 /** 328 * Sends a MIDI message to the output. 329 * 330 * @param message 331 * the MIDI message to send to the connected output device 332 */ 333 public synchronized void sendMessage(MidiMessage message) { 334 receiver.ifPresent(receive -> receive.send(message, ALWAYS_SEND)); 335 } 336 337 @Override 338 public void close() throws InterruptedException, IOException { 339 tickThread.interrupt(); 340 tickThread.join(); 341 342 sequencer.close(); 343 resetReceiver(); 344 output.ifPresent(MidiDevice::close); 345 346 Optional<IOException> exception = Optional.empty(); 347 for (final Closeable closeable : closeables) { 348 try { 349 closeable.close(); 350 } catch (final IOException e) { 351 if (exception.isPresent()) { 352 exception.get().addSuppressed(e); 353 } else { 354 exception = Optional.of(e); 355 } 356 } 357 } 358 if (exception.isPresent()) { 359 throw exception.get(); 360 } 361 } 362 363 private synchronized void resetReceiver() { 364 try { 365 for (int channel = 0; channel != 16; channel++) { 366 // All notes off, reset all controllers, reset programs 367 sendMessage(new ShortMessage(ShortMessage.CONTROL_CHANGE, channel, 123, 0)); 368 sendMessage(new ShortMessage(ShortMessage.CONTROL_CHANGE, channel, 121, 0)); 369 sendMessage(new ShortMessage(ShortMessage.PROGRAM_CHANGE, channel, 0, 0)); 370 } 371 } catch (final InvalidMidiDataException e) { 372 // Unreachable 373 throw new IllegalStateException(e); 374 } 375 } 376}