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"
version = "2.1.0-SNAPSHOT"
version = "2.1.0"
repositories {
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.style
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.TextAlign
import nl.astraeus.css.properties.prc
import nl.astraeus.css.properties.px
import nl.astraeus.css.properties.rem
import nl.astraeus.css.style.cls
@@ -28,13 +34,26 @@ import org.w3c.dom.events.MouseEvent
*/
class KeyboardComponent(
val title: String = "Keyboard",
val octave: Int = 4,
initialOctave: Int = 4,
val keyboardWidth: Int = 210,
val keyboardHeight: Int = keyboardWidth / 2,
val onNoteDown: (Int) -> Unit = {},
val onNoteUp: (Int) -> Unit = {}
) : 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
private val pressedNotes = mutableSetOf<Int>()
@@ -87,14 +106,43 @@ class KeyboardComponent(
}
}
div(KeyboardTitleCls.name) {
// Show title of the keyboard
+title
}
div(KeyboardControlsCls.name) {
// Decrease octave button
div(OctaveButtonCls.name) {
style = "width: ${whiteKeyWidth}px"
+"<"
onMouseDownFunction = { event ->
if (event is MouseEvent) {
octave--
event.preventDefault()
}
}
}
div(KeyboardOctaveCls.name) {
// Show current octave the piano is being played at
+"Octave: $octave"
// Title and octave display container
div(KeyboardInfoCls.name) {
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) {
@@ -115,7 +163,7 @@ class KeyboardComponent(
var blackKeyPressed = false
for (j in 0 until 5) {
if (x >= blackKeyPositions[j] && x <= blackKeyPositions[j] + blackKeyWidth) {
noteDown(blackKeys[j] + (octave - 4) * 12)
noteDown(blackKeys[j] + (octave - 5) * 12)
blackKeyPressed = true
break
}
@@ -126,14 +174,14 @@ class KeyboardComponent(
// Check if click is on a white key
val keyIndex = (x / whiteKeyWidth).toInt()
if (keyIndex in 0..6) {
noteDown(whiteKeys[keyIndex] + (octave - 4) * 12)
noteDown(whiteKeys[keyIndex] + (octave - 5) * 12)
}
}
} else {
// If y > blackKeyHeight, it's definitely a white key
val keyIndex = (x / whiteKeyWidth).toInt()
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
for (j in 0 until 5) {
if (x >= blackKeyPositions[j] && x <= blackKeyPositions[j] + blackKeyWidth) {
noteUp(blackKeys[j] + (octave - 4) * 12)
noteUp(blackKeys[j] + (octave - 5) * 12)
blackKeyReleased = true
break
}
@@ -162,14 +210,14 @@ class KeyboardComponent(
// Check if release is on a white key
val keyIndex = (x / whiteKeyWidth).toInt()
if (keyIndex in 0..6) {
noteUp(whiteKeys[keyIndex] + (octave - 4) * 12)
noteUp(whiteKeys[keyIndex] + (octave - 5) * 12)
}
}
} else {
// If y > blackKeyHeight, it's definitely a white key
val keyIndex = (x / whiteKeyWidth).toInt()
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") {
object KeyboardCls : CssName()
object KeyboardControlsCls : CssName()
object KeyboardInfoCls : CssName()
object KeyboardTitleCls : CssName()
object KeyboardOctaveCls : CssName()
object KeyboardKeysCls : CssName()
object OctaveButtonCls : CssName()
object WhiteKeyCls : CssName()
object BlackKeyCls : CssName()
@@ -219,31 +270,57 @@ class KeyboardComponent(
position(Position.relative)
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)) {
position(Position.absolute)
width(100.px)
textAlign(TextAlign.center)
fontSize(1.2.rem)
color(Css.currentStyle.mainFontColor)
top(5.px)
left(0.px)
right(0.px)
}
select(cls(KeyboardOctaveCls)) {
position(Position.absolute)
width(100.px)
textAlign(TextAlign.center)
fontSize(1.0.rem)
color(Css.currentStyle.mainFontColor)
top(30.px)
left(0.px)
right(0.px)
marginTop(5.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)) {
position(Position.absolute)
top(60.px)
position(Position.relative)
}
}