Introduce PPQ parameter to MIDI sequencer
This commit is contained in:
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
54
modules/midi/src/main/kotlin/io/smnp/ext/midi/Midi.kt
Normal file
54
modules/midi/src/main/kotlin/io/smnp/ext/midi/Midi.kt
Normal 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()
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user