Release version 2.1.0 and add MIDI broadcasting and handling
Updated `build.gradle.kts` to finalize version 2.1.0. Introduced `Broadcaster` and `Midi` classes for MIDI message broadcasting, synchronization, and handling. Added support for MIDI input/output devices with state management and message processing capabilities.
This commit is contained in:
@@ -10,7 +10,7 @@ plugins {
|
||||
}
|
||||
|
||||
group = "nl.astraeus"
|
||||
version = "2.1.0-SNAPSHOT"
|
||||
version = "2.1.0"
|
||||
|
||||
repositories {
|
||||
mavenCentral()
|
||||
|
||||
110
src/jsMain/kotlin/nl/astraeus/vst/midi/Broadcaster.kt
Normal file
110
src/jsMain/kotlin/nl/astraeus/vst/midi/Broadcaster.kt
Normal file
@@ -0,0 +1,110 @@
|
||||
@file:OptIn(ExperimentalJsExport::class)
|
||||
|
||||
package nl.astraeus.vst.midi
|
||||
|
||||
import kotlinx.browser.window
|
||||
import org.khronos.webgl.Uint8Array
|
||||
import org.w3c.dom.BroadcastChannel
|
||||
import org.w3c.dom.MessageEvent
|
||||
import kotlin.math.min
|
||||
|
||||
@JsExport
|
||||
enum class MessageType {
|
||||
SYNC,
|
||||
MIDI
|
||||
}
|
||||
|
||||
@JsExport
|
||||
class SyncMessage(
|
||||
@JsName("timeOrigin")
|
||||
val timeOrigin: Double = window.performance.asDynamic().timeOrigin,
|
||||
@JsName("now")
|
||||
val now: Double = window.performance.now()
|
||||
) {
|
||||
@JsName("type")
|
||||
val type: String = MessageType.SYNC.name
|
||||
}
|
||||
|
||||
// time -> syncOrigin
|
||||
// receive syn message
|
||||
// syncOrigin = my timeOrigin - sync.timeOrigin
|
||||
// - sync.timeOrigin = 50
|
||||
// - my.timeOrigin = 100
|
||||
// - syncOrigin = -50
|
||||
|
||||
// - sync.timeOrigin = 49
|
||||
// - my.timeOrigin = 100
|
||||
// - syncOrigin = update to -51
|
||||
|
||||
@JsExport
|
||||
class MidiMessage(
|
||||
@JsName("data")
|
||||
val data: Uint8Array,
|
||||
@JsName("timestamp")
|
||||
val timestamp: dynamic = null,
|
||||
@JsName("timeOrigin")
|
||||
val timeOrigin: dynamic = window.performance.asDynamic().timeOrigin
|
||||
) {
|
||||
@JsName("type")
|
||||
val type = MessageType.MIDI.name
|
||||
}
|
||||
|
||||
object Sync {
|
||||
var syncOrigin = 0.0
|
||||
|
||||
fun update(sync: SyncMessage) {
|
||||
syncOrigin = min(syncOrigin, window.performance.asDynamic().timeOrigin - sync.timeOrigin)
|
||||
}
|
||||
|
||||
fun now(): Double = window.performance.now() + syncOrigin
|
||||
}
|
||||
|
||||
object Broadcaster {
|
||||
val channels = mutableMapOf<Int, BroadcastChannel>()
|
||||
|
||||
fun getChannel(channel: Int): BroadcastChannel = channels.getOrPut(channel) {
|
||||
println("Opening broadcast channel $channel")
|
||||
val bcChannel = BroadcastChannel("audio-worklet-$channel")
|
||||
|
||||
bcChannel.onmessage = { event ->
|
||||
onMessage(channel, event)
|
||||
}
|
||||
|
||||
bcChannel
|
||||
}
|
||||
|
||||
private fun onMessage(channel: Int, event: MessageEvent) {
|
||||
val data: dynamic = event.data.asDynamic()
|
||||
|
||||
console.log(
|
||||
"Received broadcast message on channel $channel",
|
||||
event
|
||||
)
|
||||
if (data.type == MessageType.SYNC.name) {
|
||||
val syncMessage = SyncMessage(
|
||||
data.timeOrigin,
|
||||
data.now
|
||||
)
|
||||
Sync.update(syncMessage)
|
||||
} else {
|
||||
console.log(
|
||||
"Received broadcast message on channel $channel",
|
||||
event,
|
||||
window.performance,
|
||||
window.performance.now() - event.timeStamp.toDouble()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun send(channel: Int, message: Any) {
|
||||
console.log("Sending broadcast message on channel $channel:", message)
|
||||
getChannel(channel).postMessage(message)
|
||||
}
|
||||
|
||||
fun sync() {
|
||||
for (channel in channels.values) {
|
||||
channel.postMessage(SyncMessage())
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
160
src/jsMain/kotlin/nl/astraeus/vst/midi/Midi.kt
Normal file
160
src/jsMain/kotlin/nl/astraeus/vst/midi/Midi.kt
Normal file
@@ -0,0 +1,160 @@
|
||||
package nl.astraeus.vst.midi
|
||||
|
||||
import kotlinx.browser.window
|
||||
import org.khronos.webgl.Uint8Array
|
||||
import org.khronos.webgl.get
|
||||
|
||||
external class MIDIInput {
|
||||
val connection: String
|
||||
val id: String
|
||||
val manufacturer: String
|
||||
val name: String
|
||||
val state: String
|
||||
val type: String
|
||||
val version: String
|
||||
var onmidimessage: (dynamic) -> Unit
|
||||
var onstatechange: (dynamic) -> Unit
|
||||
|
||||
fun open()
|
||||
fun close()
|
||||
}
|
||||
|
||||
external class MIDIOutput {
|
||||
val connection: String
|
||||
val id: String
|
||||
val manufacturer: String
|
||||
val name: String
|
||||
val state: String
|
||||
val type: String
|
||||
val version: String
|
||||
|
||||
fun send(message: dynamic, timestamp: dynamic)
|
||||
|
||||
fun open()
|
||||
fun close()
|
||||
}
|
||||
|
||||
object Midi {
|
||||
var outputChannel: Int = -1
|
||||
|
||||
var inputs = mutableListOf<MIDIInput>()
|
||||
var outputs = mutableListOf<MIDIOutput>()
|
||||
var currentInput: MIDIInput? = null
|
||||
var currentOutput: MIDIOutput? = null
|
||||
|
||||
fun start(
|
||||
onUpdate: () -> Unit,
|
||||
) {
|
||||
val navigator = window.navigator.asDynamic()
|
||||
|
||||
navigator.requestMIDIAccess().then(
|
||||
{ midiAccess ->
|
||||
val inp = midiAccess.inputs
|
||||
val outp = midiAccess.outputs
|
||||
|
||||
console.log("Midi inputs:", inputs)
|
||||
console.log("Midi outputs:", outputs)
|
||||
|
||||
inp.forEach() { input ->
|
||||
console.log("Midi input:", input)
|
||||
inputs.add(input)
|
||||
console.log("Name: ${(input as? MIDIInput)?.name}")
|
||||
}
|
||||
|
||||
outp.forEach() { output ->
|
||||
console.log("Midi output:", output)
|
||||
outputs.add(output)
|
||||
}
|
||||
|
||||
onUpdate()
|
||||
},
|
||||
{ e ->
|
||||
println("Failed to get MIDI access - $e")
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
fun setInput(
|
||||
id: String,
|
||||
name: String = "",
|
||||
onMidiInput: (data: Uint8Array) -> Unit
|
||||
) {
|
||||
var selected = inputs.find { it.id == id }
|
||||
if (selected == null) {
|
||||
var maxMatchChar = 0
|
||||
inputs.forEach {
|
||||
val matchChars = matchChars(it.name, name)
|
||||
if (matchChars > maxMatchChar) {
|
||||
selected = it
|
||||
maxMatchChar = matchChars
|
||||
}
|
||||
}
|
||||
}
|
||||
setInput(selected, onMidiInput)
|
||||
}
|
||||
|
||||
private fun matchChars(str1: String, str2: String): Int {
|
||||
var result = 0
|
||||
if (str1.length > str2.length) {
|
||||
for (ch in str1.toCharArray()) {
|
||||
if (str2.contains(ch)) {
|
||||
result++
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (ch in str2.toCharArray()) {
|
||||
if (str1.contains(ch)) {
|
||||
result++
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
fun setInput(
|
||||
input: MIDIInput?,
|
||||
onMidiInput: (data: Uint8Array) -> Unit,
|
||||
) {
|
||||
console.log("Setting input", input)
|
||||
currentInput?.close()
|
||||
|
||||
currentInput = input
|
||||
|
||||
currentInput?.onstatechange = { message ->
|
||||
console.log("State change:", message)
|
||||
}
|
||||
|
||||
currentInput?.onmidimessage = { message ->
|
||||
val data = message.data as Uint8Array
|
||||
val hex = StringBuilder()
|
||||
for (index in 0 until data.length) {
|
||||
hex.append(data[index].toString(16))
|
||||
hex.append(" ")
|
||||
}
|
||||
console.log("Midi message:", hex)
|
||||
|
||||
onMidiInput(message.data)
|
||||
}
|
||||
|
||||
currentInput?.open()
|
||||
}
|
||||
|
||||
fun setOutput(
|
||||
output: MIDIOutput?
|
||||
) {
|
||||
console.log("Setting output", output)
|
||||
currentOutput?.close()
|
||||
|
||||
currentOutput = output
|
||||
|
||||
currentOutput?.open()
|
||||
}
|
||||
|
||||
fun send(
|
||||
data: Uint8Array,
|
||||
timestamp: dynamic = null
|
||||
) {
|
||||
currentOutput?.send(data, timestamp)
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user