Introduce PPQ parameter to MIDI sequencer

This commit is contained in:
2020-03-16 18:38:08 +01:00
parent c2103dab54
commit eaf501f367
8 changed files with 214 additions and 134 deletions

View File

@@ -3,7 +3,7 @@ package io.smnp.ext
import io.smnp.environment.Environment
import io.smnp.ext.function.MidiFunction
import io.smnp.ext.function.MidiHelpFunction
import io.smnp.ext.midi.MidiSequencer
import io.smnp.ext.midi.Midi
import org.pf4j.Extension
@Extension
@@ -13,10 +13,10 @@ class MidiModule : NativeModuleProvider("smnp.audio.midi") {
override fun dependencies() = listOf("smnp.music")
override fun onModuleLoad(environment: Environment) {
MidiSequencer.init()
Midi.init()
}
override fun beforeModuleDisposal(environment: Environment) {
MidiSequencer.dispose()
Midi.dispose()
}
}

View File

@@ -5,7 +5,7 @@ import io.smnp.callable.function.FunctionDefinitionTool
import io.smnp.callable.signature.Signature.Companion.simple
import io.smnp.callable.signature.Signature.Companion.vararg
import io.smnp.error.CustomException
import io.smnp.ext.midi.MidiSequencer
import io.smnp.ext.midi.Midi
import io.smnp.type.enumeration.DataType.*
import io.smnp.type.matcher.Matcher.Companion.anyType
import io.smnp.type.matcher.Matcher.Companion.listOf
@@ -27,7 +27,8 @@ class MidiFunction : Function("midi") {
throw CustomException("MIDI standard supports max to 16 channels and that number has been exceeded")
}
MidiSequencer.playLines(unwrappedLines, unwrapConfig(config))
Midi.with(unwrapConfig(config)).run(unwrappedLines)
Value.void()
}
@@ -41,7 +42,8 @@ class MidiFunction : Function("midi") {
throw CustomException("MIDI standard supports max to 16 channels and that number has been exceeded")
}
MidiSequencer.playChannels(unwrappedChannels, unwrapConfig(config))
Midi.with(unwrapConfig(config)).run(unwrappedChannels)
Value.void()
}
}
@@ -52,6 +54,8 @@ class MidiFunction : Function("midi") {
key to when (key) {
"bpm" -> value as? Int
?: throw CustomException("Invalid parameter type: 'bpm' is supposed to be of int type")
"ppq" -> value as? Int
?: throw CustomException("Invalid parameter type: 'ppq' is supposed to be of int type")
else -> value
}
}.toMap()

View File

@@ -6,7 +6,7 @@ import io.smnp.callable.signature.Signature.Companion.simple
import io.smnp.data.entity.Note
import io.smnp.data.enumeration.Pitch
import io.smnp.error.CustomException
import io.smnp.ext.midi.MidiSequencer
import io.smnp.ext.midi.Midi
import io.smnp.type.enumeration.DataType.*
import io.smnp.type.matcher.Matcher.Companion.ofType
import io.smnp.type.matcher.Matcher.Companion.optional
@@ -17,7 +17,7 @@ class MidiHelpFunction : Function("midiHelp") {
new function simple(ofType(STRING)) body { _, (command) ->
val cmd = command.value as String
when (cmd.toLowerCase()) {
"instruments" -> MidiSequencer.instruments.forEachIndexed { index, instrument -> println("$index: $instrument") }
"instruments" -> Midi.instruments.forEachIndexed { index, instrument -> println("$index: $instrument") }
else -> throw CustomException("Unknown command '$cmd' - available commands: 'instruments', '<instrumentId>'")
}
@@ -48,7 +48,7 @@ class MidiHelpFunction : Function("midiHelp") {
).value as List<Value>
).unwrap() as List<Any>
println(MidiSequencer.instruments[instrument])
println(Midi.instruments[instrument])
println("Channel: $channel")
println("BPM: $bpm")
println("Range: ${begin.value} - ${end.value}")
@@ -56,7 +56,9 @@ class MidiHelpFunction : Function("midiHelp") {
notes.forEachIndexed { index, it ->
if (index > 0) {
println(it)
MidiSequencer.playChannels(mapOf(channel to listOf(listOf("i:$instrument", it))), mapOf("bpm" to bpm))
Midi
.with(mapOf("bpm" to bpm))
.run(mapOf(channel to listOf(listOf("i:$instrument", it))))
Thread.sleep(100)
}
}

View File

@@ -0,0 +1,22 @@
package io.smnp.ext.midi
import io.smnp.data.entity.Note
import javax.sound.midi.Track
class DefaultSequenceCompiler : SequenceCompiler() {
override fun compileRest(noteOnTick: Long, item: Int, ppq: Int) = noteOnTick + 4L * ppq / item
override fun compileNote(
item: Note,
channel: Int,
noteOnTick: Long,
track: Track,
ppq: Int
): Long {
val noteDuration = ((if (item.dot) 1.5 else 1.0) * 4L * ppq / item.duration).toLong()
val noteOffTick = noteOnTick + noteDuration
track.add(noteOn(item, channel, noteOnTick))
track.add(noteOff(item, channel, noteOffTick))
return noteOffTick
}
}

View File

@@ -0,0 +1,54 @@
package io.smnp.ext.midi
import javax.sound.midi.MidiSystem
import javax.sound.midi.Sequence
import javax.sound.midi.Sequencer
object Midi {
private const val DEFAULT_PPQ = 1000
private const val DEFAULT_BPM = 120
private val sequencer = MidiSystem.getSequencer()
private val synthesizer = MidiSystem.getSynthesizer()
val instruments: List<String>
get() = synthesizer.availableInstruments.map { it.toString() }
fun with(config: Map<String, Any>): SequenceExecutor {
return SequenceExecutor(sequencer, config)
}
class SequenceExecutor(private val sequencer: Sequencer, private val config: Map<String, Any>) {
fun run(lines: List<List<Any>>) {
val sequence = Sequence(Sequence.PPQ, (config.getOrDefault("ppq", DEFAULT_PPQ) as Int))
provideCompiler(config).compileLines(lines, sequence)
run(sequence)
}
fun run(channels: Map<Int, List<List<Any>>>) {
val sequence = Sequence(Sequence.PPQ, (config.getOrDefault("ppq", DEFAULT_PPQ) as Int))
provideCompiler(config).compileChannels(channels, sequence)
run(sequence)
}
private fun run(sequence: Sequence) {
sequencer.sequence = sequence
sequencer.tempoInBPM = (config.getOrDefault("bpm", DEFAULT_BPM) as Int).toFloat()
Midi.sequencer.start()
while (Midi.sequencer.isRunning) Thread.sleep(20)
Midi.sequencer.stop()
}
fun provideCompiler(config: Map<String, Any>): SequenceCompiler =
if(config.containsKey("ppq")) PpqSequenceCompiler()
else DefaultSequenceCompiler()
}
fun init() {
sequencer.open()
}
fun dispose() {
sequencer.close()
}
}

View File

@@ -1,124 +0,0 @@
package io.smnp.ext.midi
import io.smnp.data.entity.Note
import io.smnp.error.EvaluationException
import io.smnp.error.ShouldNeverReachThisLineException
import javax.sound.midi.*
object MidiSequencer {
private const val PPQ = 1000
private const val DEFAULT_BPM = 120
private val sequencer = MidiSystem.getSequencer()
private object Command {
const val NOTE_OFF = 0x80
const val NOTE_ON = 0x90
const val PROGRAM_CHANGE = 0xC0
const val CONTROL_CHANGE = 0xB0
}
private object Data1 {
const val ALL_NOTES_OFF = 0x7B
}
private object Data2 {
const val ZERO = 0x00
}
val instruments: List<String>
get() = MidiSystem.getSynthesizer().availableInstruments.map { it.toString() }
fun playChannels(channels: Map<Int, List<List<Any>>>, config: Map<String, Any>) {
val sequence = Sequence(Sequence.PPQ, PPQ)
channels.forEach { (channel, lines) ->
lines.forEach { line -> playLine(line, channel-1, sequence) }
}
sequencer.sequence = sequence
sequencer.tempoInBPM = (config.getOrDefault("bpm", DEFAULT_BPM) as Int).toFloat()
sequencer.start()
while(sequencer.isRunning) Thread.sleep(20)
sequencer.stop()
}
fun playLines(lines: List<List<Any>>, config: Map<String, Any>) {
val sequence = Sequence(Sequence.PPQ, PPQ)
lines.forEachIndexed { channel, line -> playLine(line, channel, sequence) }
sequencer.sequence = sequence
sequencer.tempoInBPM = (config.getOrDefault("bpm", DEFAULT_BPM) as Int).toFloat()
sequencer.start()
while(sequencer.isRunning) Thread.sleep(20)
sequencer.stop()
}
private fun playLine(line: List<Any>, channel: Int, sequence: Sequence) {
val track = sequence.createTrack()
val lastTick = line.fold(0L) { noteOnTick, item ->
when (item) {
is Note -> {
note(item, channel, noteOnTick, track)
}
is Int -> noteOnTick + 4L * PPQ / item
is String -> command(item, channel, noteOnTick, track)
else -> throw ShouldNeverReachThisLineException()
}
}
track.add(allNotesOff(channel, lastTick))
}
private fun command(instruction: String, channel: Int, beginTick: Long, track: Track): Long {
if(instruction.isBlank()) {
throw EvaluationException("Empty strings are not allowed here")
}
val commandWithArguments = instruction.split(":")
val (command, args) = if(commandWithArguments.size == 2) commandWithArguments else listOf(commandWithArguments[0], "0,0")
val arguments = args.split(",")
val cmdCode = when(command) {
"i" -> Command.PROGRAM_CHANGE + channel
else -> throw EvaluationException("Unknown command '$command'")
}
track.add(event(cmdCode, channel, arguments.getOrNull(0)?.toInt() ?: 0, arguments.getOrNull(1)?.toInt() ?: 0, beginTick))
return beginTick
}
private fun note(note: Note, channel: Int, noteOnTick: Long, track: Track): Long {
val noteDuration = ((if (note.dot) 1.5 else 1.0) * 4L * PPQ / note.duration).toLong()
val noteOffTick = noteOnTick + noteDuration
track.add(noteOn(note, channel, noteOnTick))
track.add(noteOff(note, channel, noteOffTick))
return noteOffTick
}
private fun noteOn(note: Note, channel: Int, tick: Long): MidiEvent {
return event(Command.NOTE_ON, channel, note.intPitch() + 12, 127, tick)
}
private fun noteOff(note: Note, channel: Int, tick: Long): MidiEvent {
return event(Command.NOTE_OFF, channel, note.intPitch() + 12, 127, tick)
}
private fun allNotesOff(channel: Int, tick: Long): MidiEvent {
return event(Command.CONTROL_CHANGE, channel, Data1.ALL_NOTES_OFF, Data2.ZERO, tick)
}
private fun event(command: Int, channel: Int, data1: Int, data2: Int, tick: Long): MidiEvent {
val message = ShortMessage(command, channel, data1, data2)
return MidiEvent(message, tick)
}
fun init() {
sequencer.open()
}
fun dispose() {
sequencer.close()
}
}

View File

@@ -0,0 +1,21 @@
package io.smnp.ext.midi
import io.smnp.data.entity.Note
import javax.sound.midi.Track
class PpqSequenceCompiler : SequenceCompiler() {
override fun compileRest(noteOnTick: Long, item: Int, ppq: Int) = noteOnTick + item
override fun compileNote(
item: Note,
channel: Int,
noteOnTick: Long,
track: Track,
ppq: Int
): Long {
val noteOffTick = noteOnTick + item.duration
track.add(noteOn(item, channel, noteOnTick))
track.add(noteOff(item, channel, noteOffTick))
return noteOffTick
}
}

View File

@@ -0,0 +1,101 @@
package io.smnp.ext.midi
import io.smnp.data.entity.Note
import io.smnp.error.EvaluationException
import io.smnp.error.ShouldNeverReachThisLineException
import javax.sound.midi.MidiEvent
import javax.sound.midi.Sequence
import javax.sound.midi.ShortMessage
import javax.sound.midi.Track
abstract class SequenceCompiler {
protected object Command {
const val NOTE_OFF = 0x80
const val NOTE_ON = 0x90
const val PROGRAM_CHANGE = 0xC0
const val CONTROL_CHANGE = 0xB0
}
protected object Data1 {
const val ALL_NOTES_OFF = 0x7B
}
protected object Data2 {
const val ZERO = 0x00
}
fun compileLines(lines: List<List<Any>>, sequence: Sequence) =
lines.forEachIndexed { channel, line -> compileLine(line, channel, sequence) }
fun compileChannels(channels: Map<Int, List<List<Any>>>, sequence: Sequence) = channels.forEach { (channel, lines) ->
lines.forEach { line -> compileLine(line, channel - 1, sequence) }
}
protected abstract fun compileNote(
item: Note,
channel: Int,
noteOnTick: Long,
track: Track,
ppq: Int
): Long
abstract fun compileRest(noteOnTick: Long, item: Int, ppq: Int): Long
private fun compileLine(line: List<Any>, channel: Int, sequence: Sequence) {
val track = sequence.createTrack()
val lastTick = line.fold(0L) { noteOnTick, item ->
when (item) {
is Note -> compileNote(item, channel, noteOnTick, track, sequence.resolution)
is Int -> compileRest(noteOnTick, item, sequence.resolution)
is String -> command(item, channel, noteOnTick, track)
else -> throw ShouldNeverReachThisLineException()
}
}
track.add(allNotesOff(channel, lastTick))
}
private fun command(instruction: String, channel: Int, beginTick: Long, track: Track): Long {
if (instruction.isBlank()) {
throw EvaluationException("Empty strings are not allowed here")
}
val commandWithArguments = instruction.split(":")
val (command, args) = if (commandWithArguments.size == 2) commandWithArguments else listOf(
commandWithArguments[0],
"0,0"
)
val arguments = args.split(",")
val cmdCode = when (command) {
"i" -> Command.PROGRAM_CHANGE + channel
else -> throw EvaluationException("Unknown command '$command'")
}
track.add(
event(
cmdCode,
channel,
arguments.getOrNull(0)?.toInt() ?: 0,
arguments.getOrNull(1)?.toInt() ?: 0,
beginTick
)
)
return beginTick
}
protected fun noteOn(note: Note, channel: Int, tick: Long): MidiEvent {
return event(Command.NOTE_ON, channel, note.intPitch() + 12, 127, tick)
}
protected fun noteOff(note: Note, channel: Int, tick: Long): MidiEvent {
return event(Command.NOTE_OFF, channel, note.intPitch() + 12, 127, tick)
}
private fun allNotesOff(channel: Int, tick: Long): MidiEvent {
return event(Command.CONTROL_CHANGE, channel, Data1.ALL_NOTES_OFF, Data2.ZERO, tick)
}
private fun event(command: Int, channel: Int, data1: Int, data2: Int, tick: Long): MidiEvent {
val message = ShortMessage(command, channel, data1, data2)
return MidiEvent(message, tick)
}
}