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}