package com.xenoage.zong.io.midi.out; import com.xenoage.utils.annotations.Const; import com.xenoage.utils.math.Fraction; import com.xenoage.zong.core.Score; import com.xenoage.zong.core.instrument.PitchedInstrument; import com.xenoage.zong.core.music.*; import com.xenoage.zong.core.music.chord.Chord; import com.xenoage.zong.core.music.chord.Note; import com.xenoage.zong.core.music.time.TimeSignature; import com.xenoage.zong.core.position.MP; import com.xenoage.zong.io.midi.out.channels.ChannelMap; import com.xenoage.zong.io.midi.out.dynamics.Dynamics; import com.xenoage.zong.io.midi.out.dynamics.DynamicsFinder; import com.xenoage.zong.io.midi.out.dynamics.DynamicsInterpretation; import com.xenoage.zong.io.midi.out.repetitions.Repetition; import com.xenoage.zong.io.midi.out.repetitions.Repetitions; import com.xenoage.zong.io.midi.out.repetitions.RepetitionsFinder; import com.xenoage.zong.io.midi.out.time.RepTime; import com.xenoage.zong.io.midi.out.time.TimeMap; import com.xenoage.zong.io.midi.out.time.TimeMapBuilder; import com.xenoage.zong.io.midi.out.time.TimeMapper; import lombok.AllArgsConstructor; import lombok.Data; import lombok.experimental.Wither; import lombok.val; import static com.xenoage.utils.kernel.Range.range; import static com.xenoage.utils.math.Fraction.*; import static com.xenoage.zong.core.position.MP.atVoice; import static com.xenoage.zong.core.position.Time.time; import static com.xenoage.zong.io.midi.out.MidiSettings.defaultMidiSettings; import static com.xenoage.zong.io.midi.out.MidiTools.getNoteNumber; import static com.xenoage.zong.io.midi.out.channels.ChannelMap.unused; import static com.xenoage.zong.io.midi.out.channels.ChannelMapper.createChannelMap; import static java.lang.Math.round; /** * This class creates a {@link MidiSequence} from a given {@link Score}. * * @param <T> the platform-specific sequence class * * TODO: ZONG-101: Playback transposition changes * TODO: ZONG-102: Play tempo changes * TODO: ZONG-103: Play gradual tempo changes * TODO: ZONG-104: Play grace chords * * @author Andreas Wenger */ @AllArgsConstructor public class MidiConverter<T> { /** Settings for the conversion. */ @Const @Data @AllArgsConstructor public static class Options { public static final Options optionsForPlayback = new Options( true, true, defaultMidiSettings); public static final Options optionsForFileExport = new Options( false, false, defaultMidiSettings); /** True, iff controller events containing the current musical position * should be inserted in the sequence. */ @Wither public final boolean addTimeEvents; /** True to add metronome ticks, otherwise false. */ @Wither public final boolean metronome; /** MIDI midiSettings for the conversion. */ @Wither public final MidiSettings midiSettings; } /** Index for channel 10. It is 9, because the index is 0-based. */ public static final int channel10 = 9; /** In MIDI "Format 1" files, the track for tempo changes and so on is track 0 by convention. */ private static int systemTrackIndex = 0; /** The maximum value of a MIDI value, 127 ( = 7 bits). */ private static int midiMaxValue = 127; //state private Score score; private Options options; private MidiSequenceWriter<T> writer; private int resolution; private ChannelMap channelMap; private Repetitions repetitions; private TimeMap timeMap; private Dynamics dynamics; /** * Converts a {@link Score} to a {@link MidiSequence}. * @param score the score to convert * @param options midiSettings for the conversion * @param writer the writer for the midi data */ public static <T> MidiSequence<T> convertToSequence(Score score, Options options, MidiSequenceWriter<T> writer) { return new MidiConverter<>(score, writer, options).convertToSequence(); } private MidiConverter(Score score, MidiSequenceWriter<T> writer, Options options) { this.score = score; this.options = options; this.writer = writer; } private MidiSequence<T> convertToSequence() { //compute mapping of staff indices to channel numbers channelMap = createChannelMap(score); //compute repetitions (repeat barlines, segnos, ...) repetitions = RepetitionsFinder.findRepetitions(score); //compute the mappings from application time to MIDI time timeMap = new TimeMapper(score, repetitions, options.midiSettings.resolutionFactor).createTimeMap(); //find all dynamics dynamics = DynamicsFinder.findDynamics(score, new DynamicsInterpretation(), repetitions); //one track for each staff and one system track for program changes, tempos and so on, //and another track for the metronome int stavesCount = score.getStavesCount(); int tracksCount = stavesCount + 1; Integer metronomeTrack = null; if (options.metronome) { metronomeTrack = tracksCount; tracksCount++; } //resolution in ticks per quarter resolution = score.getDivisions() * options.midiSettings.getResolutionFactor(); //init writer writer.init(tracksCount, resolution); //set MIDI programs and init volume and pan for (val part : score.getStavesList().getParts()) { val instrument = part.getFirstInstrument(); int partFirstStaff = score.getStavesList().getPartStaffIndices(part).getStart(); int channel = channelMap.getChannel(partFirstStaff); if (channel != unused) { if (instrument instanceof PitchedInstrument) { val pitchedInstrument = (PitchedInstrument) instrument; writer.writeProgramChange(systemTrackIndex, channel, 0, (pitchedInstrument).getMidiProgram()); } writer.writeVolumeChange(systemTrackIndex, channel, 0, instrument.getVolume()); writer.writePanChange(systemTrackIndex, channel, 0, instrument.getPan()); } } //fill tracks for (int iStaff : range(stavesCount)) { int channel = channelMap.getChannel(iStaff); if (channel == unused) continue; //no MIDI channel left for this staff Staff staff = score.getStaff(iStaff); int voicesCount = staff.getVoicesCount(); int track = iStaff + 1; //first track is reserved; see declaration of tracksCount for (int iRepetition : range(repetitions)) { val rep = repetitions.get(iRepetition); int transpose = 0; //TODO for (int iMeasure : range(rep.start.measure, rep.end.measure)) { if (iMeasure == score.getMeasuresCount()) continue; Measure measure = staff.getMeasure(iMeasure); /* transposition changes can happen everywhere in the measure - TODO: ZONG-101: Playback transposition changes Transpose t = measure.getInstrumentChanges()... if (t != null) { transposing = t.chromatic; } //*/ for (int iVoice : range(measure.getVoices())) { writeVoice(atVoice(iStaff, iMeasure, iVoice), iRepetition); } } } } //Add Tempo Changes //TODO: ZONG-102: Play tempo changes //TODO: ZONG-103: Play gradual tempo changes /*ArrayList<MidiElement> tempo = MidiTempoConverter.getTempo(score, playList); for (MidiElement midiElement : tempo) { long startTick = measureStartTick.get(midiElement.getMeasure()); long beat = startTick + (long)midiElement.getPosition().toFloat() * resolution; MidiEvent event = new MidiEvent(midiElement.getMidiMessage(), beat); tempoTrack.add(event); }* / MidiTempoConverter.writeTempoTrack(score, repetitions, resolution, writer, systemTrackIndex); return writer.finish(metronomeTrack, timePool, measureStartTicks); */ //write events for time mapping between the MIDI sequence and the score if (options.addTimeEvents) writePlaybackControlEvents(); //write metronome track if (options.metronome) writeMetronomeTrack(metronomeTrack); return writer.finish(metronomeTrack, timeMap); } /** * Writes the given voice into the MIDI sequence. * @param voiceMp the staff, measure and voice index * @param repetition the index of the current {@link Repetition} */ private void writeVoice(MP voiceMp, int repetition) { val voice = score.getVoice(voiceMp); for (VoiceElement element : voice.getElements()) { //ignore rests. only chords are played if (false == MusicElementType.Chord.is(element)) continue; val chord = (Chord) element; //grace chords are not supported yet - TODO: ZONG-104: Play grace chords if (chord.isGrace()) continue; //start beat of the element Fraction duration = chord.getDuration(); val startBeat = voice.getBeat(chord); val rep = repetitions.get(repetition); if (false == rep.contains(time(voiceMp.measure, startBeat))) continue; //start beat out of range: ignore element //MIDI ticks val startMidiTime = timeMap.getByRepTime(repetition, time(voiceMp.measure, startBeat)); long startTick = startMidiTime.tick; long endTick = startTick + durationToTick(duration, resolution); long stopTick = endTick; if (false == options.midiSettings.durationFactor.equals(_1)) { //custom duration factor stopTick = startTick + round((endTick - startTick) * options.midiSettings.durationFactor.toFloat()); } //play note if (startTick < stopTick) { float volume = dynamics.getVolumeAt(voiceMp.withBeat(startBeat), repetition); int midiVelocity = round(midiMaxValue * volume); for (Note note : chord.getNotes()) { addNoteToTrack(note.getPitch(), voiceMp.staff, startTick, stopTick, midiVelocity, 0); } } //TODO Timidity doesn't like the following midi events /*MetaMessage m = null; if (musicelement instanceof Clef) { Clef c = (Clef) musicelement; m = createMidiEvent(c, tracknumber); } else if (musicelement instanceof NormalTime) { NormalTime t = (NormalTime) musicelement; m = createMidiEvent(t, resolution, tracknumber); } else if (musicelement instanceof Key) { Key k = (Key) musicelement; m = createMidiEvent(k, tracknumber); } else if (musicelement instanceof Tempo) { Tempo tempo = (Tempo)musicelement; m = MidiTempoConverter.createMetaMessage(tempo); } if (m != null) { MidiEvent event = new MidiEvent(m, starttick); track.add(event); }*-/ currenttickinvoice = endtick; }*/ } } /** * Adds the given note to the given track. */ private void addNoteToTrack(Pitch pitch, int staff, long startTick, long endTick, int velocity, int transpose) { int midiNote = getNoteNumber(pitch, transpose); int channel = channelMap.getChannel(staff); int track = staff + 1; writer.writeNote(track, channel, startTick, midiNote, true, velocity); writer.writeNote(track, channel, endTick, midiNote, false, 0); } /** * Writes the control events for the playback cursor by using the * control message {@link MidiEvents#PlaybackControl} and updates the * internal {@link TimeMap} with the MIDI millisecond positions. * At the end of the sequence, a {@link MidiEvents#PlaybackEnd} control * event is added. */ private void writePlaybackControlEvents() { //write playback events and collect millisecond timing val newTimeMap = new TimeMapBuilder(); for (int iRep : range(timeMap.getRepetitionsCount())) { for (val time : timeMap.getTimesSorted(iRep)) { val midiTime = timeMap.getByRepTime(iRep, time); writer.writeControlChange(systemTrackIndex, 0, midiTime.tick, MidiEvents.PlaybackControl.code, 0); long ms = writer.tickToMicrosecond(midiTime.tick) / 1000; newTimeMap.addTime(midiTime.tick, new RepTime(iRep, time), ms); } } //update time map timeMap = newTimeMap.build(); //write playback end event writer.writeControlChange(systemTrackIndex, 0, writer.getLength(), MidiEvents.PlaybackEnd.code, 0); } /** * Writes the metronome beats into the metronome track. */ private void writeMetronomeTrack(int track) { int strongBeatNote = options.midiSettings.metronomeStrongBeatNote; int weakBeatNote = options.midiSettings.metronomeWeakBeatNote; for (int iRep : range(repetitions)) { val rep = repetitions.get(iRep); for (int iMeasure : range(rep.start.measure, rep.end.measure)) { TimeSignature timeSig = score.getHeader().getTimeAtOrBefore(iMeasure); Fraction startBeat = (rep.start.measure == iMeasure ? rep.start.beat : _0); Fraction endBeat = (rep.end.measure == iMeasure ? rep.end.beat : score.getMeasureBeats(iMeasure)); if (timeSig != null) { boolean[] accentuation = timeSig.getType().getBeatsAccentuation(); int timeDenominator = timeSig.getType().getDenominator(); long measureStartTick = timeMap.getByRepTime(iRep, time(iMeasure, _0)).tick; for (int beatNumerator : range(timeSig.getType().getNumerator())) { //compute start and stop tick val beat = fr(beatNumerator, timeDenominator); val time = time(iMeasure, beat); if (false == rep.contains(time)) continue; long tickStart = measureStartTick + durationToTick(fr(beatNumerator, timeDenominator), resolution); long tickStop = tickStart + durationToTick(fr(1, timeDenominator), resolution); //write metronome note int note = (accentuation[beatNumerator] ? strongBeatNote : weakBeatNote); int velocity = midiMaxValue; writer.writeNote(track, channel10, tickStart, note, true, velocity); writer.writeNote(track, channel10, tickStop, note, false, 0); } } } } } /** * Returns the number of ticks of the given {@link Fraction}. */ private int durationToTick(Fraction fraction, int resolution) { return fraction.getNumerator() * 4 * resolution / fraction.getDenominator(); } }