001package net.kreatious.pianoleopard.midi.event; 002 003import static java.util.stream.Collectors.collectingAndThen; 004import static java.util.stream.Collectors.toMap; 005 006import java.nio.ByteBuffer; 007import java.util.Collections; 008import java.util.Map; 009import java.util.Map.Entry; 010import java.util.NavigableMap; 011import java.util.TreeMap; 012import java.util.concurrent.TimeUnit; 013import java.util.stream.IntStream; 014import java.util.stream.Stream; 015 016import javax.sound.midi.MetaMessage; 017import javax.sound.midi.MidiEvent; 018import javax.sound.midi.Sequence; 019 020import com.google.common.annotations.VisibleForTesting; 021 022/** 023 * Maps from MIDI ticks to microseconds. 024 * <p> 025 * This class is intended to be used only by the midi package and its 026 * subpackages. 027 * 028 * @author Jay-R Studer 029 */ 030public class TempoCache { 031 private final int resolution; 032 033 private final NavigableMap<Long, Integer> tempos; 034 private final NavigableMap<Long, Long> microseconds = new TreeMap<>(); 035 036 /** 037 * Constructs a new TempoCache with the specified MIDI Sequence 038 * 039 * @param sequence 040 * the {@link Sequence} to build a tempo cache for 041 */ 042 public TempoCache(Sequence sequence) { 043 resolution = sequence.getResolution(); 044 045 if (sequence.getDivisionType() != Sequence.PPQ) { 046 // SMPTE time divisions are constant throughout 047 tempos = new TreeMap<>(Collections.singletonMap(Long.MIN_VALUE, 048 (int) (TimeUnit.SECONDS.toMicros(1) / sequence.getDivisionType()))); 049 microseconds.put(0L, 0L); 050 return; 051 } 052 053 // Get set tempo messages in micros per quarter note, keyed by ticks 054 tempos = Stream 055 .of(sequence.getTracks()) 056 .limit(1) 057 .flatMap(track -> IntStream.range(0, track.size()).mapToObj(track::get)) 058 .filter(midiEvent -> midiEvent.getMessage().getStatus() == MetaMessage.META) 059 .filter(midiEvent -> midiEvent.getMessage().getMessage()[1] == 0x51) 060 .filter(midiEvent -> midiEvent.getMessage().getMessage()[2] == 0x03) 061 .collect( 062 collectingAndThen( 063 toMap(MidiEvent::getTick, TempoCache::extractTempo, (oldValue, newValue) -> newValue, 064 TreeMap::new), (Map<Long, Integer> map) -> (NavigableMap<Long, Integer>) map)); 065 066 // The default unspecified PPQ tempo is 0.5s per quarter note 067 int previousTempo = 500000; 068 tempos.putIfAbsent(0L, previousTempo); 069 070 // Cache the total elapsed microsecond durations, keyed by ticks 071 long previousEventTick = 0; 072 long elapsedMicroseconds = 0; 073 for (final Entry<Long, Integer> tempo : tempos.entrySet()) { 074 elapsedMicroseconds += (tempo.getKey() - previousEventTick) * previousTempo / resolution; 075 previousEventTick = tempo.getKey(); 076 previousTempo = tempo.getValue(); 077 microseconds.put(tempo.getKey(), elapsedMicroseconds); 078 } 079 } 080 081 /** 082 * Extracts the new tempo from a Set Tempo message. 083 * <p> 084 * The Set Tempo message is formatted as {@code FF 51 03 xx xx xx}. 085 * {@code FF} signifies a {@link MetaMessage#META META} event. {@code 51} 086 * indicates it is a set tempo meta event. {@code 03} is the length of the 087 * following byte array and is always 3. {@code xx xx xx} is the new tempo 088 * in microseconds per quarter note, encoded as a 24-bit big endian integer. 089 * 090 * @param event 091 * the set tempo {@link MidiEvent} to extract new tempo from 092 * @return the extracted tempo in microseconds per quarter note 093 */ 094 @VisibleForTesting 095 static int extractTempo(MidiEvent event) { 096 // Read a 4 byte int that includes the 03 header, then remove the header 097 return ByteBuffer.wrap(event.getMessage().getMessage()).getInt(2) & 0xFFFFFF; 098 } 099 100 /** 101 * Converts a MIDI tick into elapsed microseconds 102 * 103 * @param ticks 104 * the MIDI ticks to convert into microseconds 105 * @return the corresponding number of microseconds 106 */ 107 public long ticksToMicroseconds(long ticks) { 108 final long previousEventTick = microseconds.floorKey(ticks); 109 final long elapsedMicroseconds = microseconds.get(previousEventTick); 110 final int currentTempo = tempos.floorEntry(ticks).getValue(); 111 112 return elapsedMicroseconds + (ticks - previousEventTick) * currentTempo / resolution; 113 } 114}