Compare commits

...

13 Commits

Author SHA1 Message Date
ed0b76dc06 Update to version 2.2.0-alpha-5 and adjust octave display logic in KeyboardComponent 2025-06-15 13:16:23 +02:00
7c87274a04 Update to version 2.2.0-alpha-4 and bump kotlin-komponent to 1.2.8 2025-06-12 19:48:27 +02:00
5da8424c40 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.
2025-06-10 21:08:00 +02:00
8ee8f17f96 Refactor WebsocketHandler into a standalone class file 2025-06-10 20:08:28 +02:00
0bdaa5c94f Add binary file handling with hash-based storage and retrieval
Introduced hash-based storage for binary files in `RequestHandler`, with subdirectory organization. Added methods for creating hashes, saving, and retrieving files. Enabled binary file transmission via WebSocket commands. Updated `Settings` to support configurable data directory.
2025-06-10 20:07:11 +02:00
3746ced387 Refactor SampleEntity to BinaryEntity and update related files and queries 2025-06-10 19:46:19 +02:00
4d7c46093c Update package declarations for SampleEntity files
Standardized package paths in `SampleEntity`, `SampleEntityQueryProvider`, and `SampleDao` from `nl.astraeus.vst.chip.db` to `nl.astraeus.vst.base.db`.
2025-06-10 19:35:10 +02:00
2871697329 Update to version 2.2.0-alpha-2 and add database query provider
Bumped project version to `2.2.0-alpha-2` in `build.gradle.kts`. Introduced `SampleEntity`, `SamplePartEntity`, and `SampleEntityQueryProvider` for database handling. Added `SampleDao` with a sample query function. Updated SVG utilities with a `viewbox` extension and enhanced `RequestHandler` to set content type for HTML responses.
2025-06-09 14:06:17 +02:00
c1f756eb79 Update to version 2.1.2 in build.gradle.kts 2025-06-07 13:21:18 +02:00
1d02a6ee16 Refactor KeyboardComponent to use immutable state for octave and pressed notes
Replaced mutable state with an immutable `KeyboardState` data class to track octave and pressed notes. Updated state management logic with functional updates for improved consistency and immutability. Simplified note handling and rendering to reference the unified state object.
2025-06-07 13:12:03 +02:00
9c9962d7db Refactor KeyboardComponent key rendering logic
Extracted white and black key rendering into separate `renderWhiteKeys` and `renderBlackKeys` functions for improved readability and modularity. Simplified drawing logic by delegating key rendering to these helper functions.
2025-06-07 11:57:17 +02:00
5c16b57ae9 Refactor KeyboardComponent with constants for note and key handling
Replaced hardcoded values for octaves, keys, and dimensions with named constants for improved readability and maintainability. Simplified calculations and loops using these constants. Enhanced clarity in key rendering and MIDI note calculations.
2025-06-07 11:39:21 +02:00
9ab909cf6c Refactor KeyboardComponent note calculation logic
Extracted MIDI note calculation into a reusable `getMidiNoteFromMousePosition` function. Replaced redundant inline logic in mouse event handlers and key drawing sections for improved readability and maintainability.
2025-06-07 11:24:30 +02:00
13 changed files with 777 additions and 396 deletions

View File

@@ -10,7 +10,7 @@ plugins {
}
group = "nl.astraeus"
version = "2.1.1-SNAPSHOT"
version = "2.2.0-alpha-5"
repositories {
mavenCentral()
@@ -47,7 +47,7 @@ kotlin {
}
val jsMain by getting {
dependencies {
api("nl.astraeus:kotlin-komponent:1.2.7")
api("nl.astraeus:kotlin-komponent:1.2.8")
}
}
val jsTest by getting {

View File

@@ -1,5 +1,6 @@
package nl.astraeus.vst.ui.components
import kotlinx.html.SVG
import kotlinx.html.div
import kotlinx.html.js.onMouseDownFunction
import kotlinx.html.js.onMouseLeaveFunction
@@ -37,34 +38,41 @@ 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() {
// Define a data class for keyboard state
data class KeyboardState(
val octave: Int,
val pressedNotes: Set<Int> = emptySet()
)
// Use immutable state
private var state = KeyboardState(initialOctave)
// Current octave with range validation
private var _octave: Int = initialOctave
var octave: Int
get() = _octave
get() = state.octave
set(value) {
_octave = when {
value < 0 -> 0
value > 9 -> 9
else -> value
}
requestUpdate()
updateOctave(value)
}
// Set to track which notes are currently pressed
private val pressedNotes = mutableSetOf<Int>()
// Update state with functions that return new state
private fun updateOctave(newOctave: Int) {
state = state.copy(octave = newOctave.coerceIn(MIN_OCTAVE, MAX_OCTAVE))
requestUpdate()
}
// MIDI note numbers for C4 to B4 (one octave)
private val whiteKeys = listOf(60, 62, 64, 65, 67, 69, 71)
private val blackKeys = listOf(61, 63, 66, 68, 70)
// MIDI note numbers for one octave
private val whiteKeys = BASE_WHITE_KEYS
private val blackKeys = BASE_BLACK_KEYS
// Key dimensions
private val whiteKeyWidth = keyboardWidth / 7
private val blackKeyWidth = (keyboardWidth / 9)
private val blackKeyHeight = keyboardHeight * 60 / 100
private val whiteKeyWidth = keyboardWidth / WHITE_KEYS_PER_OCTAVE
private val blackKeyWidth = (keyboardWidth / BLACK_KEY_WIDTH_DIVISOR)
private val blackKeyHeight = keyboardHeight * BLACK_KEY_HEIGHT_PERCENTAGE / 100
// Calculate positions for black keys
private val blackKeyPositions = listOf(
@@ -76,30 +84,56 @@ class KeyboardComponent(
)
fun noteDown(midiNote: Int) {
pressedNotes.add(midiNote)
state = state.copy(pressedNotes = state.pressedNotes + midiNote)
onNoteDown(midiNote)
requestUpdate()
}
fun noteUp(midiNote: Int) {
pressedNotes.remove(midiNote)
state = state.copy(pressedNotes = state.pressedNotes - midiNote)
onNoteUp(midiNote)
requestUpdate()
}
private fun releaseAllNotes() {
// Create a copy of the set to avoid concurrent modification
val notesToRelease = pressedNotes.toSet()
val notesToRelease = state.pressedNotes.toSet()
for (note in notesToRelease) {
noteUp(note)
}
// Clear the set just to be safe
pressedNotes.clear()
// Just to be safe, clear all pressed notes
state = state.copy(pressedNotes = emptySet())
}
private fun getOctaveOffset(): Int = (octave - OCTAVE_BASE) * NOTES_PER_OCTAVE
private fun getMidiNoteFromMousePosition(x: Double, y: Double): Int? {
// Check if click is on a black key (black keys are on top of white keys)
if (y <= blackKeyHeight) {
for (j in 0 until BLACK_KEYS_PER_OCTAVE) {
if (x >= blackKeyPositions[j] && x <= blackKeyPositions[j] + blackKeyWidth) {
return blackKeys[j] + getOctaveOffset()
}
}
// If no black key was pressed, check for white key
val keyIndex = (x / whiteKeyWidth).toInt()
if (keyIndex in 0 until WHITE_KEYS_PER_OCTAVE) {
return whiteKeys[keyIndex] + getOctaveOffset()
}
} else {
// If y > blackKeyHeight, it's definitely a white key
val keyIndex = (x / whiteKeyWidth).toInt()
if (keyIndex in 0 until WHITE_KEYS_PER_OCTAVE) {
return whiteKeys[keyIndex] + getOctaveOffset()
}
}
return null
}
override fun HtmlBuilder.render() {
div(KeyboardCls.name) {
style = "width: ${keyboardWidth}px; height: ${keyboardHeight + 60}px"
style = "width: ${keyboardWidth}px; height: ${keyboardHeight + KEYBOARD_CONTROLS_HEIGHT}px"
onMouseLeaveFunction = { event ->
if (event is MouseEvent) {
@@ -115,7 +149,7 @@ class KeyboardComponent(
+"<"
onMouseDownFunction = { event ->
if (event is MouseEvent) {
octave--
updateOctave(octave - 1)
event.preventDefault()
}
}
@@ -130,7 +164,8 @@ class KeyboardComponent(
div(KeyboardOctaveCls.name) {
// Show current octave the piano is being played at
+"Octave: $octave"
// minus one to keep it in line with custom notes ranges
+"Octave: ${octave -1}"
}
}
@@ -140,7 +175,7 @@ class KeyboardComponent(
+">"
onMouseDownFunction = { event ->
if (event is MouseEvent) {
octave++
updateOctave(octave + 1)
event.preventDefault()
}
}
@@ -157,109 +192,62 @@ class KeyboardComponent(
// Define mouse event handlers at the SVG level
onMouseDownFunction = { event ->
if (event is MouseEvent) {
val x = event.offsetX
val y = event.offsetY
// Check if click is on a black key (black keys are on top of white keys)
if (y <= blackKeyHeight) {
var blackKeyPressed = false
for (j in 0 until 5) {
if (x >= blackKeyPositions[j] && x <= blackKeyPositions[j] + blackKeyWidth) {
noteDown(blackKeys[j] + (octave - 5) * 12)
blackKeyPressed = true
break
}
}
// If no black key was pressed, check for white key
if (!blackKeyPressed) {
// Check if click is on a white key
val keyIndex = (x / whiteKeyWidth).toInt()
if (keyIndex in 0..6) {
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 - 5) * 12)
}
}
getMidiNoteFromMousePosition(event.offsetX, event.offsetY)?.let { noteDown(it) }
event.preventDefault()
}
}
onMouseUpFunction = { event ->
if (event is MouseEvent) {
val x = event.offsetX
val y = event.offsetY
// Check if release is on a black key (black keys are on top of white keys)
if (y <= blackKeyHeight) {
var blackKeyReleased = false
for (j in 0 until 5) {
if (x >= blackKeyPositions[j] && x <= blackKeyPositions[j] + blackKeyWidth) {
noteUp(blackKeys[j] + (octave - 5) * 12)
blackKeyReleased = true
break
}
}
// If no black key was released, check for white key
if (!blackKeyReleased) {
// Check if release is on a white key
val keyIndex = (x / whiteKeyWidth).toInt()
if (keyIndex in 0..6) {
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 - 5) * 12)
}
}
getMidiNoteFromMousePosition(event.offsetX, event.offsetY)?.let { noteUp(it) }
event.preventDefault()
}
}
// Draw white keys
for (i in 0 until 7) {
val midiNote = whiteKeys[i] + (octave - 5) * 12
val isPressed = pressedNotes.contains(midiNote)
rect(
i * whiteKeyWidth,
0,
whiteKeyWidth,
keyboardHeight,
0,
if (isPressed) WhiteKeyPressedCls.name else WhiteKeyCls.name
)
}
this.renderWhiteKeys()
// Draw black keys
for (i in 0 until 5) {
val midiNote = blackKeys[i] + (octave - 5) * 12
val isPressed = pressedNotes.contains(midiNote)
rect(
blackKeyPositions[i],
0,
blackKeyWidth,
blackKeyHeight,
0,
if (isPressed) BlackKeyPressedCls.name else BlackKeyCls.name
)
}
this.renderBlackKeys()
}
}
}
}
// Render white keys
private fun SVG.renderWhiteKeys() {
for (i in 0 until WHITE_KEYS_PER_OCTAVE) {
val midiNote = whiteKeys[i] + getOctaveOffset()
val isPressed = state.pressedNotes.contains(midiNote)
rect(
i * whiteKeyWidth,
0,
whiteKeyWidth,
keyboardHeight,
rounding,
if (isPressed) WhiteKeyPressedCls.name else WhiteKeyCls.name
)
}
}
// Render black keys
private fun SVG.renderBlackKeys() {
for (i in 0 until BLACK_KEYS_PER_OCTAVE) {
val midiNote = blackKeys[i] + getOctaveOffset()
val isPressed = state.pressedNotes.contains(midiNote)
rect(
blackKeyPositions[i],
0,
blackKeyWidth,
blackKeyHeight,
rounding,
if (isPressed) BlackKeyPressedCls.name else BlackKeyCls.name
)
}
}
companion object : CssId("keyboard") {
// CSS class names
object KeyboardCls : CssName()
object KeyboardControlsCls : CssName()
object KeyboardInfoCls : CssName()
@@ -272,6 +260,21 @@ class KeyboardComponent(
object WhiteKeyPressedCls : CssName()
object BlackKeyPressedCls : CssName()
// Constants
private const val OCTAVE_BASE = 5
private const val NOTES_PER_OCTAVE = 12
private const val BLACK_KEY_HEIGHT_PERCENTAGE = 60
private const val MIN_OCTAVE = 0
private const val MAX_OCTAVE = 9
private const val WHITE_KEYS_PER_OCTAVE = 7
private const val BLACK_KEYS_PER_OCTAVE = 5
private const val BLACK_KEY_WIDTH_DIVISOR = 9
private const val KEYBOARD_CONTROLS_HEIGHT = 60
// MIDI note numbers for C5 to B5 (one octave)
private val BASE_WHITE_KEYS = listOf(60, 62, 64, 65, 67, 69, 71) // C, D, E, F, G, A, B
private val BASE_BLACK_KEYS = listOf(61, 63, 66, 68, 70) // C#, D#, F#, G#, A#
init {
defineCss {
select(cls(KeyboardCls)) {

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

@@ -14,6 +14,10 @@ fun SVG.height(height: Int) {
this.attributes["height"] = "$height"
}
fun SVG.viewbox(viewbox: String) {
this.attributes["viewbox"] = viewbox
}
fun SVG.svgStyle(
name: String,
vararg props: Pair<String, String>
@@ -41,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

@@ -8,10 +8,11 @@ object Settings {
var port = 9004
var connectionTimeout = 30000
var jdbcStatsPort = 6001
var dataDir = "data"
var jdbcDriver = "nl.astraeus.jdbc.Driver"
val jdbcConnectionUrl
get() = "jdbc:stat:webServerPort=$jdbcStatsPort:jdbc:sqlite:data/vst.db"
get() = "jdbc:stat:webServerPort=$jdbcStatsPort:jdbc:sqlite:$dataDir/vst.db"
var jdbcUser = "sa"
var jdbcPassword = ""
@@ -35,6 +36,8 @@ object Settings {
port = properties.getProperty("port", port.toString()).toInt()
jdbcStatsPort = properties.getProperty("jdbcStatsPort", jdbcStatsPort.toString()).toInt()
dataDir = properties.getProperty("dataDir", dataDir)
connectionTimeout =
properties.getProperty("connectionTimeout", connectionTimeout.toString()).toInt()
jdbcDriver = properties.getProperty("jdbcDriver", jdbcDriver)

View File

@@ -0,0 +1,11 @@
package nl.astraeus.vst.base.db
object BinaryDao {
val queryProvider = SampleEntityQueryProvider
fun getSample(waveHash: String): ByteArray {
return byteArrayOf()
}
}

View File

@@ -0,0 +1,49 @@
package nl.astraeus.vst.base.db
import kotlinx.datetime.Clock
import kotlinx.datetime.Instant
data class SampleEntity(
var sha1Hash: String,
var filename: String,
var length: Int,
var created: Instant = Clock.System.now(),
var updated: Instant = Clock.System.now(),
) : Entity {
override fun getPK(): Array<Any> = arrayOf(sha1Hash)
override fun setPK(pks: Array<Any>) {
sha1Hash = pks[0] as String
}
}
data class SamplePartEntity(
var sha1Hash: String,
var part: Int,
var from: Int,
var to: Int,
var data: ByteArray,
) : Entity {
override fun getPK(): Array<Any> = arrayOf(sha1Hash, part)
override fun setPK(pks: Array<Any>) {
sha1Hash = pks[0] as String
part = pks[1] as Int
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is SamplePartEntity) return false
if (sha1Hash != other.sha1Hash) return false
if (part != other.part) return false
return true
}
override fun hashCode(): Int {
var result = sha1Hash.hashCode()
result = 31 * result + part
return result
}
}

View File

@@ -0,0 +1,45 @@
package nl.astraeus.vst.base.db
import java.sql.ResultSet
val BINARY_CREATE_QUERY = """
CREATE TABLE BINARIESYSAMPLES (
SHA1HASH TEXT,
FILENAME TEXT,
LENGTH NUMBER,
CREATED TIMESTAMP
)
""".trimIndent()
object SampleEntityQueryProvider : QueryProvider<SampleEntity>() {
override val tableName: String
get() = "SAMPLES"
override val resultSetMapper: (ResultSet) -> SampleEntity
get() = { rs ->
SampleEntity(
rs.getString(1),
rs.getString(2),
rs.getInt(3),
rs.getTimestamp(4).toDateTimeInstant()
)
}
override val insert: SqlStatement<SampleEntity>
get() = SqlStatement(
"""
INSERT INTO $tableName (
SHA1HASH,
LENGTH,
CREATED
) VALUES (
?,?,?,?
)
""".trimIndent()
) { ps ->
ps.setString(1, sha1Hash)
ps.setString(2, filename)
ps.setInt(3, length)
ps.setTimestamp(4, updated.toSqlTimestamp())
}
override val update: SqlStatement<SampleEntity>
get() = TODO("Not yet implemented")
}

View File

@@ -10,16 +10,6 @@ import io.undertow.server.session.Session
import io.undertow.server.session.SessionCookieConfig
import io.undertow.server.session.SessionManager
import io.undertow.util.Headers
import io.undertow.websockets.WebSocketConnectionCallback
import io.undertow.websockets.core.AbstractReceiveListener
import io.undertow.websockets.core.BufferedBinaryMessage
import io.undertow.websockets.core.BufferedTextMessage
import io.undertow.websockets.core.WebSocketChannel
import io.undertow.websockets.core.WebSockets
import io.undertow.websockets.spi.WebSocketHttpExchange
import nl.astraeus.vst.base.db.Database
import nl.astraeus.vst.base.db.PatchDao
import nl.astraeus.vst.base.db.PatchEntity
import java.nio.file.Paths
object VstSessionConfig {
@@ -30,61 +20,6 @@ object VstSessionConfig {
}
}
class WebsocketHandler(
val session: Session?
) : AbstractReceiveListener(), WebSocketConnectionCallback {
override fun onConnect(exchange: WebSocketHttpExchange, channel: WebSocketChannel) {
channel.receiveSetter.set(this)
channel.resumeReceives()
}
override fun onFullTextMessage(channel: WebSocketChannel, message: BufferedTextMessage) {
val vstSession = session?.getAttribute("html-session") as? VstSession
val data = message.data
val commandLength = data.indexOf('\n')
if (commandLength > 0) {
val command = data.substring(0, commandLength)
val value = data.substring(commandLength + 1)
when (command) {
"SAVE" -> {
val patchId = vstSession?.patchId
if (patchId != null) {
Database.transaction {
val patchEntity = PatchDao.findById(patchId)
if (patchEntity != null) {
PatchDao.update(patchEntity.copy(patch = value))
} else {
PatchDao.insert(PatchEntity(0, patchId, value))
}
}
WebSockets.sendText("SAVED\n$patchId", channel, null)
}
}
"LOAD" -> {
val patchId = vstSession?.patchId
if (patchId != null) {
Database.transaction {
val patchEntity = PatchDao.findById(patchId)
if (patchEntity != null) {
WebSockets.sendText("LOAD\n${patchEntity.patch}", channel, null)
}
}
}
}
}
}
}
override fun onFullBinaryMessage(channel: WebSocketChannel?, message: BufferedBinaryMessage?) {
// do nothing yet
}
}
object WebsocketConnectHandler : HttpHandler {
override fun handleRequest(exchange: HttpServerExchange) {
@@ -112,6 +47,7 @@ class PatchHandler(
}
httpSession?.setAttribute("html-session", VstSession(patchId))
exchange.responseHeaders.put(Headers.CONTENT_TYPE, "text/html; charset=utf-8")
exchange.responseSender.send(generateIndex(title, scriptName, null))
} else {
val patchId = generateId()

View File

@@ -0,0 +1,197 @@
package nl.astraeus.vst.base.web
import io.undertow.server.session.Session
import io.undertow.websockets.WebSocketConnectionCallback
import io.undertow.websockets.core.AbstractReceiveListener
import io.undertow.websockets.core.BufferedBinaryMessage
import io.undertow.websockets.core.BufferedTextMessage
import io.undertow.websockets.core.WebSocketChannel
import io.undertow.websockets.core.WebSockets
import io.undertow.websockets.spi.WebSocketHttpExchange
import nl.astraeus.vst.base.Settings
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
class WebsocketHandler(
val session: Session?
) : AbstractReceiveListener(), WebSocketConnectionCallback {
companion object {
// Ensure the data directory exists
private val filesDir = File(Settings.dataDir, "files").apply {
if (!exists()) {
mkdirs()
}
}
fun fileExists(hash: String): Boolean {
check(hash.length == 64) { "Hash must be 64 characters long" }
var currentDir = filesDir
var remaining = hash
while(remaining.length > 8) {
val subDir = remaining.substring(0, 8)
currentDir = File(currentDir, subDir)
if (!currentDir.exists()) {
return false
}
remaining = remaining.substring(8)
}
return File(currentDir, remaining).exists()
}
// Get file from hash, using subdirectories based on hash
fun getFileFromHash(hash: String): File {
check(hash.length == 64) { "Hash must be 64 characters long" }
var currentDir = filesDir
var remaining = hash
while(remaining.length > 8) {
val subDir = remaining.substring(0, 8)
currentDir = File(currentDir, subDir)
if (!currentDir.exists()) {
currentDir.mkdirs()
}
remaining = remaining.substring(8)
}
return File(currentDir, remaining)
}
// Create SHA-1 hash from binary data
fun createHashFromBytes(bytes: ByteArray): String {
val md = MessageDigest.getInstance("SHA-256")
val digest = md.digest(bytes)
return digest.joinToString("") { "%02x".format(it) }
}
}
override fun onConnect(exchange: WebSocketHttpExchange, channel: WebSocketChannel) {
channel.receiveSetter.set(this)
channel.resumeReceives()
}
override fun onFullTextMessage(channel: WebSocketChannel, message: BufferedTextMessage) {
val vstSession = session?.getAttribute("html-session") as? VstSession
val data = message.data
val commandLength = data.indexOf('\n')
if (commandLength > 0) {
val command = data.substring(0, commandLength)
val value = data.substring(commandLength + 1)
when (command) {
"SAVE" -> {
val patchId = vstSession?.patchId
if (patchId != null) {
Database.transaction {
val patchEntity = PatchDao.findById(patchId)
if (patchEntity != null) {
PatchDao.update(patchEntity.copy(patch = value))
} else {
PatchDao.insert(PatchEntity(0, patchId, value))
}
}
WebSockets.sendText(
"SAVED\n$patchId",
channel,
null
)
}
}
"LOAD" -> {
val patchId = vstSession?.patchId
if (patchId != null) {
Database.transaction {
val patchEntity = PatchDao.findById(patchId)
if (patchEntity != null) {
WebSockets.sendText(
"LOAD\n${patchEntity.patch}",
channel,
null
)
}
}
}
}
"LOAD_BINARY" -> {
val hash = value.trim()
if (hash.isNotEmpty()) {
if (fileExists(hash)) {
val file = getFileFromHash(hash)
if (file.exists() && file.isFile) {
val bytes = file.readBytes()
WebSockets.sendBinary(
ByteBuffer.wrap(bytes),
channel,
null
)
}
}
}
}
}
}
}
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 resources = pooled.resource
// Collect all bytes from all buffers
var totalSize = 0
// First pass: collect all bytes and calculate total size
for (buffer in resources) {
totalSize += buffer.remaining()
}
// 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)
// 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)
}
}