Compare commits

...

3 Commits

Author SHA1 Message Date
770607d5e6 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.
2025-06-06 19:55:44 +02:00
e6b7c9b288 Extend octave range to 0-9 and adjust MIDI note calculations 2025-06-06 19:47:05 +02:00
68a15bab8b Add octave controls to KeyboardComponent
Introduced buttons to dynamically adjust the octave within the range of 0 to 8. Added new CSS classes for layout and styling of octave controls, updated component logic with range validation, and reorganized the title and octave display for improved UI.
2025-06-06 19:32:15 +02:00
4 changed files with 374 additions and 27 deletions

View File

@@ -10,7 +10,7 @@ plugins {
} }
group = "nl.astraeus" group = "nl.astraeus"
version = "2.1.0-SNAPSHOT" version = "2.1.0"
repositories { repositories {
mavenCentral() mavenCentral()

View 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())
}
}
}

View 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)
}
}

View File

@@ -6,8 +6,14 @@ import kotlinx.html.js.onMouseLeaveFunction
import kotlinx.html.js.onMouseUpFunction import kotlinx.html.js.onMouseUpFunction
import kotlinx.html.style import kotlinx.html.style
import kotlinx.html.svg import kotlinx.html.svg
import nl.astraeus.css.properties.AlignItems
import nl.astraeus.css.properties.Display
import nl.astraeus.css.properties.FlexDirection
import nl.astraeus.css.properties.FontWeight
import nl.astraeus.css.properties.JustifyContent
import nl.astraeus.css.properties.Position import nl.astraeus.css.properties.Position
import nl.astraeus.css.properties.TextAlign import nl.astraeus.css.properties.TextAlign
import nl.astraeus.css.properties.prc
import nl.astraeus.css.properties.px import nl.astraeus.css.properties.px
import nl.astraeus.css.properties.rem import nl.astraeus.css.properties.rem
import nl.astraeus.css.style.cls import nl.astraeus.css.style.cls
@@ -28,13 +34,26 @@ import org.w3c.dom.events.MouseEvent
*/ */
class KeyboardComponent( class KeyboardComponent(
val title: String = "Keyboard", val title: String = "Keyboard",
val octave: Int = 4, initialOctave: Int = 4,
val keyboardWidth: Int = 210, val keyboardWidth: Int = 210,
val keyboardHeight: Int = keyboardWidth / 2, val keyboardHeight: Int = keyboardWidth / 2,
val onNoteDown: (Int) -> Unit = {}, val onNoteDown: (Int) -> Unit = {},
val onNoteUp: (Int) -> Unit = {} val onNoteUp: (Int) -> Unit = {}
) : Komponent() { ) : Komponent() {
// Current octave with range validation
private var _octave: Int = initialOctave
var octave: Int
get() = _octave
set(value) {
_octave = when {
value < 0 -> 0
value > 9 -> 9
else -> value
}
requestUpdate()
}
// Set to track which notes are currently pressed // Set to track which notes are currently pressed
private val pressedNotes = mutableSetOf<Int>() private val pressedNotes = mutableSetOf<Int>()
@@ -87,14 +106,43 @@ class KeyboardComponent(
} }
} }
div(KeyboardTitleCls.name) { div(KeyboardControlsCls.name) {
// Show title of the keyboard // Decrease octave button
+title div(OctaveButtonCls.name) {
} style = "width: ${whiteKeyWidth}px"
+"<"
onMouseDownFunction = { event ->
if (event is MouseEvent) {
octave--
event.preventDefault()
}
}
}
div(KeyboardOctaveCls.name) { // Title and octave display container
// Show current octave the piano is being played at div(KeyboardInfoCls.name) {
+"Octave: $octave" div(KeyboardTitleCls.name) {
// Show title of the keyboard
+title
}
div(KeyboardOctaveCls.name) {
// Show current octave the piano is being played at
+"Octave: $octave"
}
}
// Increase octave button
div(OctaveButtonCls.name) {
style = "width: ${whiteKeyWidth}px"
+">"
onMouseDownFunction = { event ->
if (event is MouseEvent) {
octave++
event.preventDefault()
}
}
}
} }
div(KeyboardKeysCls.name) { div(KeyboardKeysCls.name) {
@@ -115,7 +163,7 @@ class KeyboardComponent(
var blackKeyPressed = false var blackKeyPressed = false
for (j in 0 until 5) { for (j in 0 until 5) {
if (x >= blackKeyPositions[j] && x <= blackKeyPositions[j] + blackKeyWidth) { if (x >= blackKeyPositions[j] && x <= blackKeyPositions[j] + blackKeyWidth) {
noteDown(blackKeys[j] + (octave - 4) * 12) noteDown(blackKeys[j] + (octave - 5) * 12)
blackKeyPressed = true blackKeyPressed = true
break break
} }
@@ -126,14 +174,14 @@ class KeyboardComponent(
// Check if click is on a white key // Check if click is on a white key
val keyIndex = (x / whiteKeyWidth).toInt() val keyIndex = (x / whiteKeyWidth).toInt()
if (keyIndex in 0..6) { if (keyIndex in 0..6) {
noteDown(whiteKeys[keyIndex] + (octave - 4) * 12) noteDown(whiteKeys[keyIndex] + (octave - 5) * 12)
} }
} }
} else { } else {
// If y > blackKeyHeight, it's definitely a white key // If y > blackKeyHeight, it's definitely a white key
val keyIndex = (x / whiteKeyWidth).toInt() val keyIndex = (x / whiteKeyWidth).toInt()
if (keyIndex in 0..6) { if (keyIndex in 0..6) {
noteDown(whiteKeys[keyIndex] + (octave - 4) * 12) noteDown(whiteKeys[keyIndex] + (octave - 5) * 12)
} }
} }
@@ -151,7 +199,7 @@ class KeyboardComponent(
var blackKeyReleased = false var blackKeyReleased = false
for (j in 0 until 5) { for (j in 0 until 5) {
if (x >= blackKeyPositions[j] && x <= blackKeyPositions[j] + blackKeyWidth) { if (x >= blackKeyPositions[j] && x <= blackKeyPositions[j] + blackKeyWidth) {
noteUp(blackKeys[j] + (octave - 4) * 12) noteUp(blackKeys[j] + (octave - 5) * 12)
blackKeyReleased = true blackKeyReleased = true
break break
} }
@@ -162,14 +210,14 @@ class KeyboardComponent(
// Check if release is on a white key // Check if release is on a white key
val keyIndex = (x / whiteKeyWidth).toInt() val keyIndex = (x / whiteKeyWidth).toInt()
if (keyIndex in 0..6) { if (keyIndex in 0..6) {
noteUp(whiteKeys[keyIndex] + (octave - 4) * 12) noteUp(whiteKeys[keyIndex] + (octave - 5) * 12)
} }
} }
} else { } else {
// If y > blackKeyHeight, it's definitely a white key // If y > blackKeyHeight, it's definitely a white key
val keyIndex = (x / whiteKeyWidth).toInt() val keyIndex = (x / whiteKeyWidth).toInt()
if (keyIndex in 0..6) { if (keyIndex in 0..6) {
noteUp(whiteKeys[keyIndex] + (octave - 4) * 12) noteUp(whiteKeys[keyIndex] + (octave - 5) * 12)
} }
} }
@@ -207,9 +255,12 @@ class KeyboardComponent(
companion object : CssId("keyboard") { companion object : CssId("keyboard") {
object KeyboardCls : CssName() object KeyboardCls : CssName()
object KeyboardControlsCls : CssName()
object KeyboardInfoCls : CssName()
object KeyboardTitleCls : CssName() object KeyboardTitleCls : CssName()
object KeyboardOctaveCls : CssName() object KeyboardOctaveCls : CssName()
object KeyboardKeysCls : CssName() object KeyboardKeysCls : CssName()
object OctaveButtonCls : CssName()
object WhiteKeyCls : CssName() object WhiteKeyCls : CssName()
object BlackKeyCls : CssName() object BlackKeyCls : CssName()
@@ -219,31 +270,57 @@ class KeyboardComponent(
position(Position.relative) position(Position.relative)
margin(5.px) margin(5.px)
select(cls(KeyboardControlsCls)) {
position(Position.relative)
display(Display.flex)
flexDirection(FlexDirection.row)
justifyContent(JustifyContent.spaceBetween)
alignItems(AlignItems.center)
width(100.prc)
height(50.px)
marginBottom(10.px)
}
select(cls(KeyboardInfoCls)) {
display(Display.flex)
flexDirection(FlexDirection.column)
alignItems(AlignItems.center)
justifyContent(JustifyContent.center)
flex("1")
}
select(cls(KeyboardTitleCls)) { select(cls(KeyboardTitleCls)) {
position(Position.absolute)
width(100.px)
textAlign(TextAlign.center) textAlign(TextAlign.center)
fontSize(1.2.rem) fontSize(1.2.rem)
color(Css.currentStyle.mainFontColor) color(Css.currentStyle.mainFontColor)
top(5.px)
left(0.px)
right(0.px)
} }
select(cls(KeyboardOctaveCls)) { select(cls(KeyboardOctaveCls)) {
position(Position.absolute)
width(100.px)
textAlign(TextAlign.center) textAlign(TextAlign.center)
fontSize(1.0.rem) fontSize(1.0.rem)
color(Css.currentStyle.mainFontColor) color(Css.currentStyle.mainFontColor)
top(30.px) marginTop(5.px)
left(0.px) }
right(0.px)
select(cls(OctaveButtonCls)) {
height(50.px)
plain("background-color", Css.currentStyle.buttonBackgroundColor.toString())
color(Css.currentStyle.mainFontColor)
border("1px solid ${Css.currentStyle.buttonBorderColor}")
textAlign(TextAlign.center)
lineHeight(50.px)
fontSize(1.5.rem)
fontWeight(FontWeight.bold)
cursor("pointer")
plain("-webkit-touch-callout", "none")
plain("-webkit-user-select", "none")
plain("-moz-user-select", "none")
plain("-ms-user-select", "none")
plain("user-select", "none")
} }
select(cls(KeyboardKeysCls)) { select(cls(KeyboardKeysCls)) {
position(Position.absolute) position(Position.relative)
top(60.px)
} }
} }