Compare commits
17 Commits
b48d6f3aca
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| ed0b76dc06 | |||
| 7c87274a04 | |||
| 5da8424c40 | |||
| 8ee8f17f96 | |||
| 0bdaa5c94f | |||
| 3746ced387 | |||
| 4d7c46093c | |||
| 2871697329 | |||
| c1f756eb79 | |||
| 1d02a6ee16 | |||
| 9c9962d7db | |||
| 5c16b57ae9 | |||
| 9ab909cf6c | |||
| 538aa6b9ae | |||
| 770607d5e6 | |||
| e6b7c9b288 | |||
| 68a15bab8b |
@@ -10,7 +10,7 @@ plugins {
|
||||
}
|
||||
|
||||
group = "nl.astraeus"
|
||||
version = "2.1.0-SNAPSHOT"
|
||||
version = "2.2.0-alpha-5"
|
||||
|
||||
repositories {
|
||||
mavenCentral()
|
||||
@@ -37,9 +37,7 @@ kotlin {
|
||||
}
|
||||
}
|
||||
}
|
||||
jvm {
|
||||
withJava()
|
||||
}
|
||||
jvm {}
|
||||
|
||||
sourceSets {
|
||||
val commonMain by getting {
|
||||
@@ -49,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 {
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,13 +1,20 @@
|
||||
package nl.astraeus.vst.ui.components
|
||||
|
||||
import kotlinx.html.SVG
|
||||
import kotlinx.html.div
|
||||
import kotlinx.html.js.onMouseDownFunction
|
||||
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,24 +35,44 @@ 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 rounding: Int = 4,
|
||||
val onNoteDown: (Int) -> Unit = {},
|
||||
val onNoteUp: (Int) -> Unit = {}
|
||||
) : Komponent() {
|
||||
|
||||
// Set to track which notes are currently pressed
|
||||
private val pressedNotes = mutableSetOf<Int>()
|
||||
// Define a data class for keyboard state
|
||||
data class KeyboardState(
|
||||
val octave: Int,
|
||||
val pressedNotes: Set<Int> = emptySet()
|
||||
)
|
||||
|
||||
// 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)
|
||||
// Use immutable state
|
||||
private var state = KeyboardState(initialOctave)
|
||||
|
||||
// Current octave with range validation
|
||||
var octave: Int
|
||||
get() = state.octave
|
||||
set(value) {
|
||||
updateOctave(value)
|
||||
}
|
||||
|
||||
// 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 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(
|
||||
@@ -57,28 +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) {
|
||||
@@ -87,14 +142,44 @@ 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) {
|
||||
updateOctave(octave - 1)
|
||||
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
|
||||
// minus one to keep it in line with custom notes ranges
|
||||
+"Octave: ${octave -1}"
|
||||
}
|
||||
}
|
||||
|
||||
// Increase octave button
|
||||
div(OctaveButtonCls.name) {
|
||||
style = "width: ${whiteKeyWidth}px"
|
||||
+">"
|
||||
onMouseDownFunction = { event ->
|
||||
if (event is MouseEvent) {
|
||||
updateOctave(octave + 1)
|
||||
event.preventDefault()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
div(KeyboardKeysCls.name) {
|
||||
@@ -107,111 +192,88 @@ 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 - 4) * 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 - 4) * 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)
|
||||
}
|
||||
}
|
||||
|
||||
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 - 4) * 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 - 4) * 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)
|
||||
}
|
||||
}
|
||||
|
||||
getMidiNoteFromMousePosition(event.offsetX, event.offsetY)?.let { noteUp(it) }
|
||||
event.preventDefault()
|
||||
}
|
||||
}
|
||||
|
||||
// Draw white keys
|
||||
for (i in 0 until 7) {
|
||||
rect(
|
||||
i * whiteKeyWidth,
|
||||
0,
|
||||
whiteKeyWidth,
|
||||
keyboardHeight,
|
||||
0,
|
||||
WhiteKeyCls.name
|
||||
)
|
||||
}
|
||||
this.renderWhiteKeys()
|
||||
|
||||
// Draw black keys
|
||||
for (i in 0 until 5) {
|
||||
rect(
|
||||
blackKeyPositions[i],
|
||||
0,
|
||||
blackKeyWidth,
|
||||
blackKeyHeight,
|
||||
0,
|
||||
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()
|
||||
object KeyboardTitleCls : CssName()
|
||||
object KeyboardOctaveCls : CssName()
|
||||
object KeyboardKeysCls : CssName()
|
||||
object OctaveButtonCls : CssName()
|
||||
object WhiteKeyCls : CssName()
|
||||
object BlackKeyCls : CssName()
|
||||
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 {
|
||||
@@ -219,31 +281,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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -253,11 +341,23 @@ class KeyboardComponent(
|
||||
plain("stroke-width", "1")
|
||||
}
|
||||
|
||||
select(cls(WhiteKeyPressedCls)) {
|
||||
plain("fill", "#E6E6E6") // 10% darker than white
|
||||
plain("stroke", "#000000")
|
||||
plain("stroke-width", "1")
|
||||
}
|
||||
|
||||
select(cls(BlackKeyCls)) {
|
||||
plain("fill", "#000000")
|
||||
plain("stroke", "#000000")
|
||||
plain("stroke-width", "1")
|
||||
}
|
||||
|
||||
select(cls(BlackKeyPressedCls)) {
|
||||
plain("fill", "#333333") // 10% lighter than black
|
||||
plain("stroke", "#000000")
|
||||
plain("stroke-width", "1")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
11
src/jvmMain/kotlin/nl/astraeus/vst/base/db/BinaryDao.kt
Normal file
11
src/jvmMain/kotlin/nl/astraeus/vst/base/db/BinaryDao.kt
Normal file
@@ -0,0 +1,11 @@
|
||||
package nl.astraeus.vst.base.db
|
||||
|
||||
object BinaryDao {
|
||||
val queryProvider = SampleEntityQueryProvider
|
||||
|
||||
fun getSample(waveHash: String): ByteArray {
|
||||
return byteArrayOf()
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
49
src/jvmMain/kotlin/nl/astraeus/vst/base/db/BinaryEntity.kt
Normal file
49
src/jvmMain/kotlin/nl/astraeus/vst/base/db/BinaryEntity.kt
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
197
src/jvmMain/kotlin/nl/astraeus/vst/base/web/WebsocketHandler.kt
Normal file
197
src/jvmMain/kotlin/nl/astraeus/vst/base/web/WebsocketHandler.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user