Remove KeyboardInputComponent and add file upload and WebSocket support

Deleted unused `KeyboardInputComponent`. Added file upload functionality to `MainView` with WebSocket event handling. Introduced `WebsocketClient` class for managing server connection and file transmission. Enhanced `KeyboardComponent` with configurable key rounding. Updated `WebsocketHandler` for improved binary message handling and storage.
This commit is contained in:
2025-06-10 21:08:00 +02:00
parent 8ee8f17f96
commit 5da8424c40
7 changed files with 404 additions and 237 deletions

View File

@@ -38,6 +38,7 @@ class KeyboardComponent(
initialOctave: Int = 4,
val keyboardWidth: Int = 210,
val keyboardHeight: Int = keyboardWidth / 2,
val rounding: Int = 4,
val onNoteDown: (Int) -> Unit = {},
val onNoteUp: (Int) -> Unit = {}
) : Komponent() {
@@ -222,7 +223,7 @@ class KeyboardComponent(
0,
whiteKeyWidth,
keyboardHeight,
0,
rounding,
if (isPressed) WhiteKeyPressedCls.name else WhiteKeyCls.name
)
}
@@ -238,7 +239,7 @@ class KeyboardComponent(
0,
blackKeyWidth,
blackKeyHeight,
0,
rounding,
if (isPressed) BlackKeyPressedCls.name else BlackKeyCls.name
)
}

View File

@@ -1,29 +0,0 @@
package nl.astraeus.vst.ui.components
import kotlinx.html.div
import nl.astraeus.komp.HtmlBuilder
import nl.astraeus.komp.Komponent
import nl.astraeus.vst.ui.css.Css.defineCss
import nl.astraeus.vst.ui.css.CssId
import nl.astraeus.vst.ui.css.CssName
class KeyboardInputComponent : Komponent() {
override fun HtmlBuilder.render() {
div {
+"Keyboard component"
}
}
companion object : CssId("keyboard-input") {
object KeyboardInputCss : CssName()
init {
defineCss {
select(KeyboardInputCss.cls()) {
}
}
}
}
}

View File

@@ -45,12 +45,12 @@ fun SVG.rect(
y: Int,
width: Int,
height: Int,
rx: Int,
cls: String
rounding: Int,
cls: String,
) {
this.unsafe {
+ """
<rect class="$cls" x="$x" y="$y" width="$width" height="$height" rx="$rx" />
<rect class="$cls" x="$x" y="$y" width="$width" height="$height" rx="$rounding" rx="$rounding" />
""".trimIndent()
}
}

View File

@@ -13,6 +13,7 @@ import nl.astraeus.vst.base.db.Database
import nl.astraeus.vst.base.db.PatchDao
import nl.astraeus.vst.base.db.PatchEntity
import java.io.File
import java.io.FileOutputStream
import java.nio.ByteBuffer
import java.security.MessageDigest
@@ -22,7 +23,7 @@ class WebsocketHandler(
companion object {
// Ensure the data directory exists
private val dataDir = File(Settings.dataDir).apply {
private val filesDir = File(Settings.dataDir, "files").apply {
if (!exists()) {
mkdirs()
}
@@ -31,7 +32,7 @@ class WebsocketHandler(
fun fileExists(hash: String): Boolean {
check(hash.length == 64) { "Hash must be 64 characters long" }
var currentDir = dataDir
var currentDir = filesDir
var remaining = hash
while(remaining.length > 8) {
val subDir = remaining.substring(0, 8)
@@ -49,7 +50,7 @@ class WebsocketHandler(
fun getFileFromHash(hash: String): File {
check(hash.length == 64) { "Hash must be 64 characters long" }
var currentDir = dataDir
var currentDir = filesDir
var remaining = hash
while(remaining.length > 8) {
val subDir = remaining.substring(0, 8)
@@ -98,7 +99,11 @@ class WebsocketHandler(
PatchDao.insert(PatchEntity(0, patchId, value))
}
}
WebSockets.sendText("SAVED\n$patchId", channel, null)
WebSockets.sendText(
"SAVED\n$patchId",
channel,
null
)
}
}
@@ -109,7 +114,11 @@ class WebsocketHandler(
val patchEntity = PatchDao.findById(patchId)
if (patchEntity != null) {
WebSockets.sendText("LOAD\n${patchEntity.patch}", channel, null)
WebSockets.sendText(
"LOAD\n${patchEntity.patch}",
channel,
null
)
}
}
}
@@ -122,7 +131,11 @@ class WebsocketHandler(
val file = getFileFromHash(hash)
if (file.exists() && file.isFile) {
val bytes = file.readBytes()
WebSockets.sendBinary(ByteBuffer.wrap(bytes), channel, null)
WebSockets.sendBinary(
ByteBuffer.wrap(bytes),
channel,
null
)
}
}
}
@@ -131,31 +144,51 @@ class WebsocketHandler(
}
}
override fun onFullBinaryMessage(channel: WebSocketChannel?, message: BufferedBinaryMessage?) {
override fun onFullBinaryMessage(
channel: WebSocketChannel?,
message: BufferedBinaryMessage?
) {
// Process binary message: create hash from binary, save file in data/files/ directory,
// sub directories are 5 characters of the hash per directory
if (channel != null && message != null) {
try {
// Get the binary data
val pooled = message.data
val buffer = pooled.resource[0] // Get the first ByteBuffer
val resources = pooled.resource
// Convert ByteBuffer to ByteArray
val bytes = ByteArray(buffer.remaining())
buffer.get(bytes)
// Collect all bytes from all buffers
var totalSize = 0
// Free the pooled resource
pooled.free()
// First pass: collect all bytes and calculate total size
for (buffer in resources) {
totalSize += buffer.remaining()
}
// Create hash from binary data
val hash = createHashFromBytes(bytes)
// Combine all bytes into a single array
val combinedBytes = ByteArray(totalSize)
var position = 0
for (buffer in resources) {
val remaining = buffer.remaining()
buffer.get(combinedBytes, position, remaining)
position += remaining
}
// Create hash from combined binary data
val hash = createHashFromBytes(combinedBytes)
// Save file in data/files/ directory with subdirectories
val file = getFileFromHash(hash)
file.writeBytes(bytes)
// Use FileOutputStream to write all bytes at once
FileOutputStream(file).use { outputStream ->
outputStream.write(combinedBytes)
}
// Send the hash back to the client
WebSockets.sendText("BINARY_SAVED\n$hash", channel, null)
// Free the pooled resource after processing
pooled.free()
} catch (e: Exception) {
WebSockets.sendText("ERROR\n${e.message}", channel, null)
}

View File

@@ -5,6 +5,7 @@ import nl.astraeus.komp.Komponent
import nl.astraeus.komp.UnsafeMode
import nl.astraeus.vst.ui.css.CssSettings
import nl.astraeus.vst.ui.view.MainView
import nl.astraeus.vst.ui.ws.WebsocketClient
val mainView: MainView by lazy {
MainView()
@@ -16,4 +17,8 @@ fun main() {
Komponent.unsafeMode = UnsafeMode.UNSAFE_SVG_ONLY
Komponent.create(document.body!!, mainView)
WebsocketClient.connect {
println("Connected")
}
}

View File

@@ -2,11 +2,15 @@
package nl.astraeus.vst.ui.view
import kotlinx.html.InputType
import kotlinx.html.div
import kotlinx.html.h1
import kotlinx.html.hr
import kotlinx.html.input
import kotlinx.html.js.onChangeFunction
import kotlinx.html.js.onClickFunction
import kotlinx.html.option
import kotlinx.html.org.w3c.dom.events.Event
import kotlinx.html.select
import kotlinx.html.span
import kotlinx.html.style
@@ -37,6 +41,11 @@ import nl.astraeus.vst.ui.css.Css.defineCss
import nl.astraeus.vst.ui.css.Css.noTextSelect
import nl.astraeus.vst.ui.css.CssName
import nl.astraeus.vst.ui.css.hover
import nl.astraeus.vst.ui.ws.WebsocketClient
import org.w3c.dom.HTMLInputElement
import org.w3c.files.File
import org.w3c.files.FileList
import org.w3c.files.get
class MainView : Komponent() {
private var messages: MutableList<String> = ArrayList()
@@ -113,6 +122,16 @@ class MainView : Komponent() {
}
*/
}
span {
+"Upload file: "
input {
type = InputType.file
onChangeFunction = {
fileInputSelectHandler(it)
requestUpdate()
}
}
}
}
}
div {
@@ -134,209 +153,226 @@ class MainView : Komponent() {
}
hr {}
div {
include(KeyboardComponent(
keyboardWidth = keyboardWidth,
keyboardHeight = keyboardWidth / 2,
onNoteDown = { println("Note down: $it") },
onNoteUp = { println("Note up: $it") },
))
include(
KeyboardComponent(
keyboardWidth = keyboardWidth,
keyboardHeight = keyboardWidth / 2,
onNoteDown = { println("Note down: $it") },
onNoteUp = { println("Note up: $it") },
)
)
}
/*
div {
span(ButtonBarCss.name) {
+"SAVE"
onClickFunction = {
val patch = VstChipWorklet.save().copy(
midiId = Midi.currentInput?.id ?: "",
midiName = Midi.currentInput?.name ?: ""
)
/*
div {
span(ButtonBarCss.name) {
+"SAVE"
onClickFunction = {
val patch = VstChipWorklet.save().copy(
midiId = Midi.currentInput?.id ?: "",
midiName = Midi.currentInput?.name ?: ""
)
WebsocketClient.send("SAVE\n${JSON.stringify(patch)}")
}
}
span(ButtonBarCss.name) {
+"STOP"
onClickFunction = {
VstChipWorklet.postDirectlyToWorklet(
TimedMidiMessage(getCurrentTime(), (0xb0 + midiChannel).toByte(), 123, 0)
.data.buffer.data
)
}
WebsocketClient.send("SAVE\n${JSON.stringify(patch)}")
}
}
span(ButtonBarCss.name) {
+"STOP"
onClickFunction = {
VstChipWorklet.postDirectlyToWorklet(
TimedMidiMessage(getCurrentTime(), (0xb0 + midiChannel).toByte(), 123, 0)
.data.buffer.data
)
}
}
}
*/
div(ControlsCss.name) {
include(
ExpKnobComponent(
value = 0.001,
label = "Volume",
minValue = 0.001,
maxValue = 1.0,
step = 10.0 / 127.0,
width = 100,
height = 120,
) { value ->
div(ControlsCss.name) {
include(
ExpKnobComponent(
value = 0.001,
label = "Volume",
minValue = 0.001,
maxValue = 1.0,
step = 10.0 / 127.0,
width = 100,
height = 120,
) { value ->
}
)
include(
KnobComponent(
value = 0.5,
label = "Duty cycle",
minValue = 0.0,
maxValue = 1.0,
step = 2.0 / 127.0,
width = 100,
height = 120,
) { value ->
}
)
include(
KnobComponent(
value = 0.5,
label = "Duty cycle",
minValue = 0.0,
maxValue = 1.0,
step = 2.0 / 127.0,
width = 100,
height = 120,
) { value ->
}
)
include(
KnobComponent(
value = 0.5,
label = "Duty cycle",
minValue = 0.0,
maxValue = 1.0,
step = 2.0 / 127.0,
width = 500,
height = 600,
) { value ->
}
)
include(
KnobComponent(
value = 0.5,
label = "Duty cycle",
minValue = 0.0,
maxValue = 1.0,
step = 2.0 / 127.0,
width = 500,
height = 600,
) { value ->
}
)
}
}
}
private fun fileInputSelectHandler(event: Event) {
val target = event.target as? HTMLInputElement
val list: FileList? = target?.files
if (list == null || list.length != 1) {
return
}
val file: File? = list[0]
file?.let { f: File ->
WebsocketClient.send(f)
}
}
companion object MainViewCss : CssName() {
object MainDivCss : CssName()
object ActiveCss : CssName()
object ButtonCss : CssName()
object ButtonBarCss : CssName()
object SelectedCss : CssName()
object NoteBarCss : CssName()
object StartSplashCss : CssName()
object StartBoxCss : CssName()
object StartButtonCss : CssName()
object ControlsCss : CssName()
init {
defineCss {
select("*") {
select("*:before") {
select("*:after") {
boxSizing(BoxSizing.borderBox)
}
)
}
}
select("html", "body") {
margin(0.px)
padding(0.px)
height(100.prc)
}
select("html", "body") {
backgroundColor(Css.currentStyle.mainBackgroundColor)
color(Css.currentStyle.mainFontColor)
fontFamily("JetbrainsMono, monospace")
fontSize(14.px)
fontWeight(FontWeight.bold)
//transition()
noTextSelect()
}
select("input", "textarea") {
backgroundColor(Css.currentStyle.inputBackgroundColor)
color(Css.currentStyle.mainFontColor)
border("none")
}
select(cls(ButtonCss)) {
margin(1.rem)
commonButton()
}
select(cls(ButtonBarCss)) {
margin(1.rem, 0.px)
commonButton()
}
select(cls(ActiveCss)) {
//backgroundColor(Css.currentStyle.selectedBackgroundColor)
}
select(cls(NoteBarCss)) {
minHeight(4.rem)
}
select(cls(MainDivCss)) {
margin(1.rem)
}
select("select") {
plain("appearance", "none")
border("0")
outline("0")
width(20.rem)
padding(0.5.rem, 2.rem, 0.5.rem, 0.5.rem)
backgroundImage("url('https://upload.wikimedia.org/wikipedia/commons/9/9d/Caret_down_font_awesome_whitevariation.svg')")
background("right 0.8em center/1.4em")
backgroundColor(Css.currentStyle.inputBackgroundColor)
color(Css.currentStyle.mainFontColor)
borderRadius(0.25.em)
}
select(cls(StartSplashCss)) {
position(Position.fixed)
left(0.px)
top(0.px)
width(100.vw)
height(100.vh)
zIndex(100)
backgroundColor(hsla(32, 0, 5, 0.65))
select(cls(StartBoxCss)) {
position(Position.relative)
left(25.vw)
top(25.vh)
width(50.vw)
height(50.vh)
backgroundColor(hsla(239, 50, 10, 1.0))
borderColor(Css.currentStyle.mainFontColor)
borderWidth(2.px)
select(cls(StartButtonCss)) {
position(Position.absolute)
left(50.prc)
top(50.prc)
transform(Transform("translate(-50%, -50%)"))
padding(1.rem)
backgroundColor(Css.currentStyle.buttonBackgroundColor)
cursor("pointer")
}
}
}
select(ControlsCss.cls()) {
display(Display.flex)
flexDirection(FlexDirection.row)
justifyContent(JustifyContent.flexStart)
alignItems(AlignItems.center)
margin(1.rem)
padding(1.rem)
backgroundColor(Css.currentStyle.mainBackgroundColor)
}
}
}
companion object MainViewCss : CssName() {
object MainDivCss : CssName()
object ActiveCss : CssName()
object ButtonCss : CssName()
object ButtonBarCss : CssName()
object SelectedCss : CssName()
object NoteBarCss : CssName()
object StartSplashCss : CssName()
object StartBoxCss : CssName()
object StartButtonCss : CssName()
object ControlsCss : CssName()
private fun Style.commonButton() {
display(Display.inlineBlock)
padding(1.rem)
backgroundColor(Css.currentStyle.buttonBackgroundColor)
borderColor(Css.currentStyle.buttonBorderColor)
borderWidth(Css.currentStyle.buttonBorderWidth)
color(Css.currentStyle.mainFontColor)
init {
defineCss {
select("*") {
select("*:before") {
select("*:after") {
boxSizing(BoxSizing.borderBox)
}
}
}
select("html", "body") {
margin(0.px)
padding(0.px)
height(100.prc)
}
select("html", "body") {
backgroundColor(Css.currentStyle.mainBackgroundColor)
color(Css.currentStyle.mainFontColor)
fontFamily("JetbrainsMono, monospace")
fontSize(14.px)
fontWeight(FontWeight.bold)
//transition()
noTextSelect()
}
select("input", "textarea") {
backgroundColor(Css.currentStyle.inputBackgroundColor)
color(Css.currentStyle.mainFontColor)
border("none")
}
select(cls(ButtonCss)) {
margin(1.rem)
commonButton()
}
select(cls(ButtonBarCss)) {
margin(1.rem, 0.px)
commonButton()
}
select(cls(ActiveCss)) {
//backgroundColor(Css.currentStyle.selectedBackgroundColor)
}
select(cls(NoteBarCss)) {
minHeight(4.rem)
}
select(cls(MainDivCss)) {
margin(1.rem)
}
select("select") {
plain("appearance", "none")
border("0")
outline("0")
width(20.rem)
padding(0.5.rem, 2.rem, 0.5.rem, 0.5.rem)
backgroundImage("url('https://upload.wikimedia.org/wikipedia/commons/9/9d/Caret_down_font_awesome_whitevariation.svg')")
background("right 0.8em center/1.4em")
backgroundColor(Css.currentStyle.inputBackgroundColor)
color(Css.currentStyle.mainFontColor)
borderRadius(0.25.em)
}
select(cls(StartSplashCss)) {
position(Position.fixed)
left(0.px)
top(0.px)
width(100.vw)
height(100.vh)
zIndex(100)
backgroundColor(hsla(32, 0, 5, 0.65))
select(cls(StartBoxCss)) {
position(Position.relative)
left(25.vw)
top(25.vh)
width(50.vw)
height(50.vh)
backgroundColor(hsla(239, 50, 10, 1.0))
borderColor(Css.currentStyle.mainFontColor)
borderWidth(2.px)
select(cls(StartButtonCss)) {
position(Position.absolute)
left(50.prc)
top(50.prc)
transform(Transform("translate(-50%, -50%)"))
padding(1.rem)
backgroundColor(Css.currentStyle.buttonBackgroundColor)
cursor("pointer")
}
}
}
select(ControlsCss.cls()) {
display(Display.flex)
flexDirection(FlexDirection.row)
justifyContent(JustifyContent.flexStart)
alignItems(AlignItems.center)
margin(1.rem)
padding(1.rem)
backgroundColor(Css.currentStyle.mainBackgroundColor)
}
}
hover {
backgroundColor(Css.currentStyle.buttonBackgroundColor.hover())
}
private fun Style.commonButton() {
display(Display.inlineBlock)
padding(1.rem)
backgroundColor(Css.currentStyle.buttonBackgroundColor)
borderColor(Css.currentStyle.buttonBorderColor)
borderWidth(Css.currentStyle.buttonBorderWidth)
color(Css.currentStyle.mainFontColor)
hover {
backgroundColor(Css.currentStyle.buttonBackgroundColor.hover())
}
and(SelectedCss.cls()) {
backgroundColor(Css.currentStyle.buttonBackgroundColor.hover().hover().hover())
}
and(SelectedCss.cls()) {
backgroundColor(Css.currentStyle.buttonBackgroundColor.hover().hover().hover())
}
}
}
}

View File

@@ -0,0 +1,121 @@
@file:OptIn(ExperimentalJsExport::class)
package nl.astraeus.vst.ui.ws
import kotlinx.browser.window
import org.khronos.webgl.ArrayBuffer
import org.w3c.dom.MessageEvent
import org.w3c.dom.WebSocket
import org.w3c.dom.events.Event
import org.w3c.files.Blob
private const val WEBSOCKET_PART_SIZE = 65000
object WebsocketClient {
var websocket: WebSocket? = null
var interval: Int = 0
fun connect(onConnect: () -> Unit) {
close()
websocket =
if (window.location.hostname.contains("localhost") || window.location.hostname.contains("192.168")) {
WebSocket("ws://${window.location.hostname}:${window.location.port}/ws")
} else {
WebSocket("wss://${window.location.hostname}/ws")
}
websocket?.also { ws ->
ws.onopen = {
onOpen(ws, it)
onConnect()
}
ws.onmessage = { onMessage(ws, it) }
ws.onclose = { onClose(ws, it) }
ws.onerror = { onError(ws, it) }
}
}
fun close() {
websocket?.close(-1, "Application closed socket.")
}
fun onOpen(
ws: WebSocket,
event: Event
) {
interval = window.setInterval({
val actualWs = websocket
if (actualWs == null) {
window.clearInterval(interval)
console.log("Connection to the server was lost!\\nPlease try again later.")
reconnect()
}
}, 10000)
}
fun reconnect() {
val actualWs = websocket
if (actualWs != null) {
if (actualWs.readyState == WebSocket.OPEN) {
console.log("Connection to the server was lost!\\nPlease try again later.")
} else {
window.setTimeout({
reconnect()
}, 1000)
}
} else {
connect {}
window.setTimeout({
reconnect()
}, 1000)
}
}
fun onMessage(
ws: WebSocket,
event: Event
) {
if (event is MessageEvent) {
val data = event.data
if (data is String) {
console.log("Received message: $data")
} else if (data is ArrayBuffer) {
console.log("Received binary message")
}
}
}
fun onClose(
ws: WebSocket,
event: Event
): dynamic {
websocket = null
return "dynamic"
}
fun onError(
ws: WebSocket,
event: Event
): dynamic {
console.log("Error websocket!", ws, event)
websocket = null
return "dynamic"
}
fun send(message: String) {
websocket?.send(message)
}
fun send(file: Blob) {
websocket?.send(file)
}
}