Compare commits

...

15 Commits

Author SHA1 Message Date
e977b2c88a Update Kotlin plugin version and remove mavenLocal repository.
Upgraded the Kotlin multiplatform plugin from version 2.0.21 to 2.1.0 for improved features and compatibility. Removed the mavenLocal repository from the common configuration to streamline dependency resolution.
2025-05-15 16:16:56 +02:00
573fc921bb Update vst-ui-base dependency to version 1.1.2
Upgraded the nl.astraeus:vst-ui-base library from version 1.1.1 to 1.1.2 in the build.gradle.kts file. This update aims to take advantage of improvements or bug fixes introduced in the new version. Keeping dependencies up to date helps maintain code reliability and performance.
2024-12-08 10:17:34 +01:00
37a42dd88c Add main class configuration for application plugin
Configured the 'application' plugin with the main class to ensure the application starts with the correct entry point. This change is necessary for proper execution of the program when deploying or running from the build system.
2024-12-08 10:14:28 +01:00
76866eb392 Update dependencies 2024-12-08 10:07:56 +01:00
1eed613b2a Move stuff into base 2024-08-09 19:55:15 +02:00
8df6a4fff6 Also search on name when setting midi port 2024-07-13 16:44:33 +02:00
f2269c8865 Also search on name when setting midi port 2024-07-02 19:17:35 +02:00
6554fd746a Volume click fix 2024-07-01 21:13:06 +02:00
976328ed69 Save patch 2024-06-30 20:32:43 +02:00
194857d687 Cleanup 2024-06-29 20:01:16 +02:00
f22a800c93 Layout 2024-06-28 19:22:16 +02:00
ccc7e9a4e9 Modulation, waveforms 2024-06-28 17:07:58 +02:00
b02c7733b0 Add inputs 2024-06-27 20:08:24 +02:00
0cfd6f31d5 Add input 2024-06-27 16:40:33 +02:00
05764ec588 Use vst-ui-base 2024-06-27 12:32:17 +02:00
45 changed files with 2916 additions and 971 deletions

2
.gitignore vendored
View File

@@ -41,7 +41,7 @@ bin/
### Mac OS ###
.DS_Store
web
/web
.kotlin
.idea

View File

@@ -1,8 +1,6 @@
<component name="ArtifactManager">
<artifact type="jar" name="audio-worklet-js-1.0.0-SNAPSHOT">
<output-path>$PROJECT_DIR$/audio-worklet/build/libs</output-path>
<root id="archive" name="audio-worklet-js-1.0.0-SNAPSHOT.jar">
<element id="module-output" name="vst-chip.audio-worklet.jsMain" />
</root>
<root id="archive" name="audio-worklet-js-1.0.0-SNAPSHOT.jar" />
</artifact>
</component>

View File

@@ -1,8 +1,6 @@
<component name="ArtifactManager">
<artifact type="jar" name="audio-worklet-jvm-1.0.0-SNAPSHOT">
<output-path>$PROJECT_DIR$/audio-worklet/build/libs</output-path>
<root id="archive" name="audio-worklet-jvm-1.0.0-SNAPSHOT.jar">
<element id="module-output" name="vst-chip.audio-worklet.jvmMain" />
</root>
<root id="archive" name="audio-worklet-jvm-1.0.0-SNAPSHOT.jar" />
</artifact>
</component>

View File

@@ -1,8 +1,6 @@
<component name="ArtifactManager">
<artifact type="jar" name="common-js-1.0.0-SNAPSHOT">
<output-path>$PROJECT_DIR$/common/build/libs</output-path>
<root id="archive" name="common-js-1.0.0-SNAPSHOT.jar">
<element id="module-output" name="vst-chip.common.jsMain" />
</root>
<root id="archive" name="common-js-1.0.0-SNAPSHOT.jar" />
</artifact>
</component>

View File

@@ -1,8 +1,6 @@
<component name="ArtifactManager">
<artifact type="jar" name="common-jvm-1.0.0-SNAPSHOT">
<output-path>$PROJECT_DIR$/common/build/libs</output-path>
<root id="archive" name="common-jvm-1.0.0-SNAPSHOT.jar">
<element id="module-output" name="vst-chip.common.jvmMain" />
</root>
<root id="archive" name="common-jvm-1.0.0-SNAPSHOT.jar" />
</artifact>
</component>

View File

@@ -1,7 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="JavaScriptLibraryMappings">
<excludedPredefinedLibrary name="vst-chip/build/js/node_modules" />
<excludedPredefinedLibrary name="vst-chip/build/js/packages/vst-base-test/node_modules" />
</component>
</project>

View File

@@ -4,7 +4,7 @@
<option name="executionName"/>
<option name="externalProjectPath" value="$PROJECT_DIR$"/>
<option name="externalSystemIdString" value="GRADLE"/>
<option name="scriptParameters" value="-DmainClass=nl.astraeus.vst.chip.MainKt --quiet"/>
<option name="scriptParameters" value="-DmainClass=nl.astraeus.vst.string.MainKt --quiet"/>
<option name="taskDescriptions">
<list/>
</option>

View File

@@ -1,3 +1,6 @@
@file:OptIn(ExperimentalDistributionDsl::class)
import org.jetbrains.kotlin.gradle.targets.js.dsl.ExperimentalDistributionDsl
import org.jetbrains.kotlin.gradle.targets.js.webpack.KotlinWebpackOutput
buildscript {
@@ -17,13 +20,13 @@ kotlin {
browser {
commonWebpackConfig {
outputFileName = "vst-chip-worklet.js"
outputFileName = "vst-string-worklet.js"
sourceMaps = true
}
webpackTask {
output.libraryTarget = KotlinWebpackOutput.Target.VAR
output.library = "vstChipWorklet"
output.library = "vstStringWorklet"
}
distribution {
@@ -38,7 +41,7 @@ kotlin {
dependencies {
implementation(project(":common"))
implementation("nl.astraeus:vst-worklet-base:1.0.0-SNAPSHOT")
implementation("nl.astraeus:vst-worklet-base:1.0.1")
}
}
val jsMain by getting {

View File

@@ -1,311 +0,0 @@
@file:OptIn(ExperimentalJsExport::class)
package nl.astraeus.vst.chip
import nl.astraeus.vst.AudioWorkletProcessor
import nl.astraeus.vst.Note
import nl.astraeus.vst.registerProcessor
import nl.astraeus.vst.sampleRate
import org.khronos.webgl.ArrayBuffer
import org.khronos.webgl.Float32Array
import org.khronos.webgl.Int32Array
import org.khronos.webgl.Uint8Array
import org.khronos.webgl.get
import org.khronos.webgl.set
import org.w3c.dom.MessageEvent
import kotlin.math.PI
import kotlin.math.sin
val POLYPHONICS = 10
val PI2 = PI * 2
@ExperimentalJsExport
@JsExport
enum class NoteState {
ON,
RELEASED,
OFF
}
@ExperimentalJsExport
@JsExport
class PlayingNote(
val note: Int,
var velocity: Int = 0
) {
fun retrigger(velocity: Int) {
this.velocity = velocity
state = NoteState.ON
sample = 0
attackSamples = 2500
releaseSamples = 10000
}
var state = NoteState.OFF
var cycleOffset = 0.0
var sample = 0
var attackSamples = 2500
var releaseSamples = 10000
var actualVolume = 0f
}
enum class Waveform {
SINE,
SQUARE,
TRIANGLE,
SAWTOOTH
}
@ExperimentalJsExport
@JsExport
class VstChipProcessor : AudioWorkletProcessor() {
var midiChannel = 0
val notes = Array(POLYPHONICS) {
PlayingNote(
0
)
}
var waveform = Waveform.SINE.ordinal
var dutyCycle = 0.5
var fmFreq = 0.0
var fmAmp = 0.0
var amFreq = 0.0
var amAmp = 0.0
val sampleLength = 1 / sampleRate.toDouble()
init {
this.port.onmessage = ::handleMessage
Note.updateSampleRate(sampleRate)
}
private fun handleMessage(message: MessageEvent) {
console.log("VstChipProcessor: Received message:", message.data)
val data = message.data
try {
when (data) {
is String -> {
if (data.startsWith("set_channel")) {
val parts = data.split('\n')
if (parts.size == 2) {
midiChannel = parts[1].toInt()
println("Setting channel: $midiChannel")
}
}
}
is Uint8Array -> {
val data32 = Int32Array(data.length)
for (i in 0 until data.length) {
data32[i] = (data[i].toInt() and 0xff)
}
playMidi(data32)
}
is Int32Array -> {
playMidi(data)
}
else ->
console.error("Don't kow how to handle message", message)
}
} catch(e: Exception) {
console.log(e.message, e)
}
}
private fun playMidi(bytes: Int32Array) {
console.log("playMidi", bytes)
if (bytes.length > 0) {
var cmdByte = bytes[0]
val channelCmd = ((cmdByte shr 4) and 0xf) in 0x8 .. 0xe
val channel = cmdByte and 0xf
println("Channel cmd: $channelCmd")
if (channelCmd && channel != midiChannel) {
console.log("Wrong channel", midiChannel, bytes)
return
}
cmdByte = cmdByte and 0xf0
//console.log("Received", bytes)
when(cmdByte) {
0x90 -> {
if (bytes.length == 3) {
val note = bytes[1]
val velocity = bytes[2]
if (velocity > 0) {
noteOn(note, velocity)
} else {
noteOff(note)
}
}
}
0x80 -> {
if (bytes.length >= 2) {
val note = bytes[1]
noteOff(note)
}
}
0xc9 -> {
if (bytes.length >= 1) {
val waveform = bytes[1]
if (waveform < 4) {
this.waveform = waveform
}
}
}
0xb0 -> {
if (bytes.length == 3) {
val knob = bytes[1]
val value = bytes[2]
when (knob) {
0x4a -> {
dutyCycle = value / 127.0
}
0x4b -> {
fmFreq = value / 127.0
}
0x4c -> {
fmAmp = value / 127.0
}
0x47 -> {
amFreq = value / 127.0
}
0x48 -> {
amAmp = value / 127.0
}
}
}
}
0xe0 -> {
if (bytes.length == 3) {
val lsb = bytes[1]
val msb = bytes[2]
amFreq = (((msb - 0x40) + (lsb / 127.0)) / 0x40) * 10.0
}
}
}
}
}
private fun noteOn(note: Int, velocity: Int) {
for (i in 0 until POLYPHONICS) {
if (notes[i].note == note) {
notes[i].retrigger(velocity)
return
}
}
for (i in 0 until POLYPHONICS) {
if (notes[i].state == NoteState.OFF) {
notes[i] = PlayingNote(
note,
velocity
)
notes[i].state = NoteState.ON
val n = Note.fromMidi(note)
//console.log("Playing note: ${n.sharp} (${n.freq})")
break
}
}
}
private fun noteOff(note: Int) {
for (i in 0 until POLYPHONICS) {
if (notes[i].note == note && notes[i].state == NoteState.ON) {
notes[i].state = NoteState.RELEASED
break
}
}
}
override fun process (
inputs: Array<Array<Float32Array>>,
outputs: Array<Array<Float32Array>>,
parameters: dynamic
) : Boolean {
val samples = outputs[0][0].length
val left = outputs[0][0]
val right = outputs[0][1]
for (note in notes) {
if (note.state != NoteState.OFF) {
val sampleDelta = Note.fromMidi(note.note).sampleDelta
for (i in 0 until samples) {
var targetVolume = note.velocity / 127f
if (note.state == NoteState.ON && note.sample < note.attackSamples) {
note.attackSamples--
targetVolume *= ( 1f - (note.attackSamples / 2500f))
} else if (note.state == NoteState.RELEASED) {
note.releaseSamples--
targetVolume *= (note.releaseSamples / 10000f)
}
note.actualVolume += (targetVolume - note.actualVolume) * 0.0001f
if (note.state == NoteState.RELEASED && note.actualVolume <= 0) {
note.state = NoteState.OFF
}
var cycleOffset = note.cycleOffset
val fmModulation = sin(sampleLength * fmFreq * 10f * PI2 * note.sample).toFloat() * fmAmp * 5f
val amModulation = 1f + (sin(sampleLength * amFreq * 10f * PI2 * note.sample) * amAmp).toFloat()
cycleOffset += fmModulation
val waveValue: Float = when (waveform) {
0 -> {
sin(cycleOffset * PI2).toFloat()
}
1 -> {
if (cycleOffset < dutyCycle) { 1f } else { -1f }
}
2 -> when {
cycleOffset < 0.25 -> 4 * cycleOffset
cycleOffset < 0.75 -> 2 - 4 * cycleOffset
else -> 4 * cycleOffset - 4
}.toFloat()
3 -> {
((cycleOffset * 2f) - 1f).toFloat()
}
else -> {
if (cycleOffset < 0.5) { 1f } else { -1f }
}
}
left[i] = left[i] + waveValue * note.actualVolume * 0.3f * amModulation
right[i] = right[i] + waveValue * note.actualVolume * 0.3f * amModulation
note.cycleOffset += sampleDelta
if (cycleOffset > 1f) {
note.cycleOffset -= 1f
}
note.sample++
}
}
}
return true
}
}
fun main() {
registerProcessor("vst-chip-processor", VstChipProcessor::class.js)
println("VstChipProcessor registered!")
}

View File

@@ -0,0 +1,264 @@
@file:OptIn(ExperimentalJsExport::class)
package nl.astraeus.vst.string
import nl.astraeus.vst.AudioWorkletProcessor
import nl.astraeus.vst.Note
import nl.astraeus.vst.currentTime
import nl.astraeus.vst.registerProcessor
import nl.astraeus.vst.sampleRate
import org.khronos.webgl.Float32Array
import org.khronos.webgl.Int32Array
import org.khronos.webgl.Uint8Array
import org.khronos.webgl.get
import org.khronos.webgl.set
import org.w3c.dom.MessageEvent
import kotlin.math.PI
import kotlin.math.min
val POLYPHONICS = 10
val PI2 = PI * 2
@ExperimentalJsExport
@JsExport
class PlayingNote(
val note: Int,
var velocity: Int = 0
) {
fun retrigger(velocity: Int) {
this.velocity = velocity
sample = 0
noteStart = currentTime
noteRelease = null
}
var noteStart = currentTime
var noteRelease: Double? = null
var cycleOffset = 0.0
var sample = 0
var actualVolume = 0f
}
@ExperimentalJsExport
@JsExport
enum class RecordingState {
STOPPED,
WAITING_TO_START,
RECORDING
}
@ExperimentalJsExport
@JsExport
class VstStringProcessor : AudioWorkletProcessor() {
var midiChannel = 0
var volume = 0.75f
var damping = 0.996
val recordingBuffer = Float32Array(sampleRate / 60)
var recordingState = RecordingState.STOPPED
var recordingSample = 0
var recordingStart = 0
val strings = Array(POLYPHONICS) {
PhysicalString(sampleRate, damping)
}
init {
this.port.onmessage = ::handleMessage
Note.updateSampleRate(sampleRate)
}
private fun handleMessage(message: MessageEvent) {
//console.log("VstChipProcessor: Received message:", message.data)
val data = message.data
try {
when (data) {
is String -> {
when (data) {
"start_recording" -> {
port.postMessage(recordingBuffer)
if (recordingState == RecordingState.STOPPED) {
recordingState = RecordingState.WAITING_TO_START
recordingSample = 0
}
}
else ->
if (data.startsWith("set_channel")) {
val parts = data.split('\n')
if (parts.size == 2) {
midiChannel = parts[1].toInt()
println("Setting channel: $midiChannel")
}
}
}
}
is Uint8Array -> {
val data32 = Int32Array(data.length)
for (i in 0 until data.length) {
data32[i] = (data[i].toInt() and 0xff)
}
playMidi(data32)
}
is Int32Array -> {
playMidi(data)
}
else ->
console.error("Don't kow how to handle message", message)
}
} catch (e: Exception) {
console.log(e.message, e)
}
}
private fun playMidi(bytes: Int32Array) {
console.log("playMidi", bytes)
if (bytes.length > 0) {
var cmdByte = bytes[0]
val channelCmd = ((cmdByte shr 4) and 0xf) != 0xf0
val channel = cmdByte and 0xf
println("Channel cmd: $channelCmd")
if (channelCmd && channel != midiChannel) {
console.log("Wrong channel", midiChannel, bytes)
return
}
cmdByte = cmdByte and 0xf0
//console.log("Received", bytes)
when (cmdByte) {
0x90 -> {
if (bytes.length == 3) {
val note = bytes[1]
val velocity = bytes[2]
if (velocity > 0) {
noteOn(note, velocity)
} else {
noteOff(note)
}
}
}
0x80 -> {
if (bytes.length >= 2) {
val note = bytes[1]
noteOff(note)
}
}
0xb0 -> {
if (bytes.length == 3) {
val knob = bytes[1]
val value = bytes[2]
when (knob) {
7 -> {
volume = value / 127f
}
0x47 -> {
damping = 0.8 + value / 127.0
}
}
}
}
}
}
}
private fun noteOn(midiNote: Int, velocity: Int) {
val note = Note.fromMidi(midiNote)
for (i in 0 until POLYPHONICS) {
if (strings[i].currentNote == note) {
strings[i].pluck(note, velocity / 127.0)
strings[i].damping = damping
return
}
}
for (i in 0 until POLYPHONICS) {
if (strings[i].available) {
strings[i].pluck(note, velocity / 127.0)
strings[i].damping = damping
break
}
}
}
private fun noteOff(midiNote: Int) {
val note = Note.fromMidi(midiNote)
for (i in 0 until POLYPHONICS) {
if (strings[i].currentNote.ordinal == note.ordinal) {
strings[i].available = true
strings[i].damping = damping * 0.9
}
}
}
override fun process(
inputs: Array<Array<Float32Array>>,
outputs: Array<Array<Float32Array>>,
parameters: dynamic
): Boolean {
val samples = outputs[0][0].length
val left = outputs[0][0]
val right = outputs[0][1]
var lowestNote = 200
for (string in strings) {
if (string.available) {
lowestNote = min(lowestNote, string.currentNote.ordinal)
}
}
if (lowestNote == 200 && recordingState == RecordingState.WAITING_TO_START) {
recordingState = RecordingState.RECORDING
recordingSample = 0
recordingStart = 0
}
for (string in strings) {
for (i in 0 until samples) {
val waveValue: Float = string.tick().toFloat()
left[i] = left[i] + waveValue * volume
right[i] = right[i] + waveValue * volume
if (lowestNote == string.currentNote.ordinal && recordingState == RecordingState.WAITING_TO_START && string.index == 0) {
recordingState = RecordingState.RECORDING
recordingSample = 0
recordingStart = 0
}
}
}
if (recordingState == RecordingState.RECORDING) {
for (i in recordingStart until samples) {
recordingBuffer[recordingSample] = (left[i] + right[i]) / 2f
if (recordingSample < recordingBuffer.length - 1) {
recordingSample++
} else {
recordingState = RecordingState.STOPPED
}
}
recordingStart = 0
}
return true
}
}
fun main() {
registerProcessor("vst-string-processor", VstStringProcessor::class.js)
println("VstStringProcessor registered!")
}

View File

@@ -1,7 +1,9 @@
import org.jetbrains.kotlin.gradle.targets.js.webpack.KotlinWebpackOutput
import java.nio.file.Files
import java.nio.file.Paths
buildscript {
apply(from = "common.gradle.kts")
apply(from = "version.gradle.kts")
}
plugins {
@@ -21,7 +23,7 @@ kotlin {
binaries.executable()
browser {
commonWebpackConfig {
outputFileName = "vst-chip-worklet-ui.js"
outputFileName = "vst-string-worklet-ui.js"
sourceMaps = true
}
@@ -30,7 +32,7 @@ kotlin {
}
}
}
jvm{
jvm {
withJava()
}
@@ -39,13 +41,15 @@ kotlin {
dependencies {
implementation(project(":common"))
//base
api("nl.astraeus:kotlin-css-generator:1.0.7")
implementation("nl.astraeus:kotlin-css-generator:1.0.10")
implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.4.0")
implementation("nl.astraeus:vst-ui-base:1.1.2")
}
}
val jsMain by getting {
dependencies {
//base
implementation("nl.astraeus:kotlin-komponent-js:1.2.2")
implementation("nl.astraeus:kotlin-komponent-js:1.2.4")
}
}
val jsTest by getting {
@@ -57,9 +61,101 @@ kotlin {
dependencies {
//base
implementation("io.undertow:undertow-core:2.3.13.Final")
implementation("io.undertow:undertow-core:2.3.14.Final")
implementation("io.undertow:undertow-websockets-jsr:2.3.14.Final")
implementation("org.jboss.xnio:xnio-nio:3.8.16.Final")
implementation("org.xerial:sqlite-jdbc:3.46.0.0")
implementation("com.zaxxer:HikariCP:4.0.3")
implementation("nl.astraeus:simple-jdbc-stats:1.6.1")
implementation("org.jetbrains.kotlinx:kotlinx-html-jvm:0.11.0")
}
}
}
}
application {
mainClass.set("nl.astraeus.vst.string.MainKt")
}
/* Hardcoded deploy configuration */
val deployDirectory = "vst-string.midi-vst.com"
tasks.register<Copy>("unzipDistribution") {
mustRunAfter("removeSymbolicLink")
val zipDir = layout.projectDirectory.dir("build/distributions")
val zipFile = zipDir.file("${project.name}-${project.version}.zip")
val outputDir = file("/home/rnentjes/www/${deployDirectory}")
from(zipTree(zipFile))
into(outputDir)
}
tasks.register("createSymbolicLink") {
mustRunAfter("unzipDistribution")
doLast {
val targetDir =
Paths.get("/home/rnentjes/www/${deployDirectory}/${project.name}-${project.version}") // Directory to link to
val symlink =
Paths.get("/home/rnentjes/www/${deployDirectory}/${project.name}") // Path for the symbolic link
if (!Files.exists(targetDir)) {
throw IllegalArgumentException("Target directory does not exist: $targetDir")
}
if (Files.exists(symlink)) {
println("Symbolic link already exists: $symlink")
} else {
Files.createSymbolicLink(symlink, targetDir)
println("Symbolic link created: $symlink -> $targetDir")
}
}
}
tasks.register<Copy>("copyWeb") {
val webDir = layout.projectDirectory.dir("web")
val outputDir = file("/home/rnentjes/www/${deployDirectory}/web")
from(webDir)
into(outputDir)
}
tasks.named<Task>("build") {
dependsOn("generateVersionProperties")
}
tasks.named("kotlinUpgradeYarnLock") {
mustRunAfter("clean")
}
tasks.named("build") {
mustRunAfter("kotlinUpgradeYarnLock")
}
tasks.named("build") {
mustRunAfter("kotlinUpgradeYarnLock")
}
tasks.named("copyWeb") {
mustRunAfter("build")
}
tasks.register("removeSymbolicLink") {
mustRunAfter("build")
doLast {
delete(layout.projectDirectory.file("/home/rnentjes/www/${deployDirectory}/${project.name}"))
}
}
tasks.register("deploy") {
dependsOn("clean")
dependsOn("kotlinUpgradeYarnLock")
dependsOn("build")
dependsOn("copyWeb")
dependsOn("removeSymbolicLink")
dependsOn("unzipDistribution")
dependsOn("createSymbolicLink")
}

View File

@@ -1,13 +1,11 @@
group = "nl.astraeus"
version = "1.0.0-SNAPSHOT"
version = "0.1.0"
allprojects {
repositories {
mavenLocal()
mavenCentral()
maven("https://reposilite.astraeus.nl/releases")
maven {
url = uri("https://nexus.astraeus.nl/nexus/content/groups/public")
url = uri("https://gitea.astraeus.nl:8443/api/packages/rnentjes/maven")
}
}
}

View File

@@ -1,7 +1,3 @@
@file:OptIn(ExperimentalKotlinGradlePluginApi::class)
import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi
buildscript {
apply(from = "../common.gradle.kts")
}
@@ -20,13 +16,8 @@ kotlin {
jvm()
sourceSets {
val commonMain by getting {
dependencies {
}
}
val jsMain by getting {
dependencies {
}
}
val commonMain by getting
val jsMain by getting
val jvmMain by getting
}
}

View File

@@ -1,5 +1,9 @@
@file:OptIn(ExperimentalJsExport::class)
package nl.astraeus.vst
import kotlin.js.ExperimentalJsExport
import kotlin.js.JsExport
import kotlin.math.max
import kotlin.math.min
import kotlin.math.pow
@@ -11,6 +15,8 @@ import kotlin.math.round
* Time: 11:50
*/
@ExperimentalJsExport
@JsExport
enum class Note(
val sharp: String,
val flat: String

View File

@@ -0,0 +1,86 @@
@file:OptIn(ExperimentalJsExport::class)
package nl.astraeus.vst.string
import nl.astraeus.vst.Note
import kotlin.js.ExperimentalJsExport
import kotlin.js.JsExport
import kotlin.math.round
expect fun randomDouble(): Double
const val BUFFER_MULTIPLY = 4
@ExperimentalJsExport
@JsExport
class PhysicalString(
val sampleRate: Int,
var damping: Double,
) {
val sampleLength = 1.0 / sampleRate.toDouble()
val maxLength = sampleRate / Note.NO01.freq
var length = 1
val buffer = Array((maxLength * BUFFER_MULTIPLY).toInt() + 1) { 0.0 }
var sample = 0
var index = 0
var remaining = 0.0
var currentNote = Note.C4
var available = true
fun pluck(note: Note, velocity: Double, smoothing: Int = 0) {
available = false
currentNote = note
length = round(BUFFER_MULTIPLY * sampleRate / note.freq).toInt()
sample = 0
index = 0
for (i in 0..<length) {
if (i < length / 2) {
buffer[i] = randomDouble() * velocity
} else {
buffer[i] = -randomDouble() * velocity
}
//buffer[i] = (randomDouble() - 0.5) * 2.0 * velocity
//buffer[i] = sin(PI * 2 * i/length) * randomDouble() * velocity
//buffer[i] = (i/length.toDouble() * 2.0) - 1.0 //if (i < length / 2) { 1.0 } else { -1.0 }
}
repeat(smoothing) {
for (i in 0..<length) {
tick()
}
}
}
fun update(time: Double) {
remaining += (time / 1000.0)
while (remaining > sampleLength) {
remaining -= sampleLength
tick()
}
}
fun tick(): Double {
val result = buffer[index]
repeat(BUFFER_MULTIPLY) {
var newValue = 0.0
newValue += getValueFromBuffer(index + 1) * 0.1
newValue += getValueFromBuffer(index + 2) * 0.15
newValue += getValueFromBuffer(index + 3) * 0.25
newValue += getValueFromBuffer(index + 4) * 0.25
newValue += getValueFromBuffer(index + 5) * 0.15
newValue += getValueFromBuffer(index + 6) * 0.1
newValue *= damping
buffer[index] = newValue
index = (index + 1) % length
}
return result
}
private fun getValueFromBuffer(index: Int): Double {
return buffer[(index + length) % length]
}
}

View File

@@ -0,0 +1,5 @@
package nl.astraeus.vst.string
import kotlin.random.Random
actual fun randomDouble(): Double = Random.nextDouble()

View File

@@ -0,0 +1,5 @@
package nl.astraeus.vst.string
actual fun randomDouble(): Double {
TODO("Not yet implemented")
}

View File

@@ -1,7 +1,6 @@
pluginManagement {
plugins {
kotlin("multiplatform") version "2.0.0"
id("org.gradle.toolchains.foojay-resolver-convention") version "0.5.0"
kotlin("multiplatform") version "2.1.0"
}
repositories {
gradlePluginPortal()

View File

@@ -1,6 +1,6 @@
apply(from = "settings.common.gradle.kts")
rootProject.name = "vst-chip"
rootProject.name = "vst-string"
include(":common")
include(":audio-worklet")

View File

@@ -0,0 +1,24 @@
package nl.astraeus.vst.string
import kotlin.js.ExperimentalJsExport
import kotlin.js.JsExport
import kotlin.js.JsName
@ExperimentalJsExport
@JsExport
data class PatchDTO(
@JsName("midiId")
val midiId: String = "",
@JsName("midiName")
val midiName: String = "",
@JsName("midiChannel")
var midiChannel: Int = 0,
@JsName("volume")
var volume: Double = 0.75,
@JsName("damping")
var damping: Double = 0.5,
@JsName("delay")
var delay: Double = 0.0,
@JsName("delayDepth")
var delayDepth: Double = 0.0,
)

View File

@@ -1,29 +0,0 @@
package nl.astraeus.vst.chip
import kotlinx.browser.document
import kotlinx.browser.window
import nl.astraeus.komp.Komponent
import nl.astraeus.vst.chip.midi.Broadcaster
import nl.astraeus.vst.chip.midi.MidiMessage
import nl.astraeus.vst.chip.midi.Midi
import nl.astraeus.vst.chip.view.MainView
import org.khronos.webgl.Uint8Array
fun main() {
Komponent.create(document.body!!, MainView)
Midi.start()
console.log("Performance", window.performance)
Broadcaster.getChannel(0).postMessage(
MidiMessage(
Uint8Array(arrayOf(0x80.toByte(), 60, 60)),
window.performance.now()
)
)
window.setInterval({
Broadcaster.sync()
}, 1000)
}

View File

@@ -1,10 +0,0 @@
package nl.astraeus.vst.chip.audio
import nl.astraeus.vst.chip.AudioContext
object AudioContextHandler {
val audioContext: dynamic = AudioContext()
}

View File

@@ -1,14 +0,0 @@
package nl.astraeus.vst.chip.audio
import org.w3c.dom.MessageEvent
object VstChipWorklet : AudioNode(
"vst-chip-worklet.js",
"vst-chip-processor"
) {
override fun onMessage(message: MessageEvent) {
console.log("Message from worklet: ", message)
}
}

View File

@@ -1,273 +0,0 @@
package nl.astraeus.vst.chip.view
import daw.style.Css
import daw.style.Css.defineCss
import daw.style.Css.noTextSelect
import daw.style.CssId
import daw.style.CssName
import daw.style.hover
import kotlinx.browser.window
import kotlinx.html.*
import kotlinx.html.js.onChangeFunction
import kotlinx.html.js.onClickFunction
import kotlinx.html.js.onInputFunction
import nl.astraeus.css.properties.*
import nl.astraeus.css.style.cls
import nl.astraeus.komp.HtmlBuilder
import nl.astraeus.komp.Komponent
import nl.astraeus.vst.chip.audio.VstChipWorklet
import nl.astraeus.vst.chip.midi.Midi
import org.khronos.webgl.Uint8Array
import org.w3c.dom.HTMLInputElement
import org.w3c.dom.HTMLSelectElement
object MainView : Komponent() {
private var messages: MutableList<String> = ArrayList()
private var started = false
init {
MainViewCss
}
fun addMessage(message: String) {
messages.add(message)
while (messages.size > 10) {
messages.removeAt(0)
}
requestUpdate()
}
override fun HtmlBuilder.render() {
div(MainViewCss.MainDivCss.name) {
if (!started) {
div(MainViewCss.StartSplashCss.name) {
div(MainViewCss.StartBoxCss.name) {
div(MainViewCss.StartButtonCss.name) {
+"START"
onClickFunction = {
started = true
VstChipWorklet.create {
requestUpdate()
}
}
}
}
}
}
h1 {
+"VST Chip"
}
div {
span {
+"Midi input: "
select {
option {
+"None"
value = "none"
}
for (mi in Midi.inputs) {
option {
+mi.name
value = mi.id
}
}
onChangeFunction = { event ->
val target = event.target as HTMLSelectElement
if (target.value == "none") {
Midi.setInput(null)
} else {
val selected = Midi.inputs.find { it.id == target.value }
if (selected != null) {
Midi.setInput(selected)
} else if (target.value == "midi-broadcast") {
//
}
}
}
}
}
span {
+"channel:"
input {
type = InputType.number
value = Midi.inputChannel.toString()
onInputFunction = { event ->
val target = event.target as HTMLInputElement
Midi.inputChannel = target.value.toInt()
println("onInput channel: ${Midi.inputChannel}")
VstChipWorklet.postMessage("set_channel\n${Midi.inputChannel}")
}
}
}
}
div {
span {
+"Midi output: "
select {
option {
+"None"
value = "none"
}
for (mi in Midi.outputs) {
option {
+mi.name
value = mi.id
}
}
onChangeFunction = { event ->
val target = event.target as HTMLSelectElement
if (target.value == "none") {
Midi.setOutput(null)
} else {
val selected = Midi.outputs.find { it.id == target.value }
if (selected != null) {
Midi.setOutput(selected)
}
}
}
}
}
span {
+"channel:"
input {
type = InputType.number
value = Midi.outputChannel.toString()
onInputFunction = { event ->
val target = event.target as HTMLInputElement
Midi.outputChannel = target.value.toInt()
}
}
}
}
div(MainViewCss.ButtonCss.name) {
+"Send note on to output"
onClickFunction = {
val data = Uint8Array(
arrayOf(
0x90.toByte(),
0x3c.toByte(),
0x70.toByte()
)
)
Midi.send(data, window.performance.now() + 1000)
Midi.send(data, window.performance.now() + 2000)
}
}
div(MainViewCss.ButtonCss.name) {
+"Send note off to output"
onClickFunction = {
val data = Uint8Array(
arrayOf(
0x90.toByte(),
0x3c.toByte(),
0x0.toByte(),
)
)
Midi.send(data)
}
}
}
}
object MainViewCss : CssId("main") {
object MainDivCss : CssName()
object ActiveCss : CssName()
object ButtonCss : CssName()
object NoteBarCss : CssName()
object StartSplashCss : CssName()
object StartBoxCss : CssName()
object StartButtonCss : 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(cls(ButtonCss)) {
margin(1.rem)
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())
}
}
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.entryFontColor)
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, 50, 0.6))
select(cls(StartBoxCss)) {
position(Position.relative)
left(25.vw)
top(25.vh)
width(50.vw)
height(50.vh)
backgroundColor(hsla(0, 0, 50, 0.25))
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")
}
}
}
}
}
}
}

View File

@@ -1,115 +0,0 @@
package daw.style
import kotlinx.browser.document
import nl.astraeus.css.properties.*
import nl.astraeus.css.style
import nl.astraeus.css.style.ConditionalStyle
import nl.astraeus.css.style.Style
class StyleDefinition(
val mainFontColor: Color = hsla(178, 70, 55, 1.0),
val mainBackgroundColor: Color = hsl(239, 50, 10),
//val entryFontColor: Color = hsl(Css.mainFontColorNumber, 70, 55),
val inputBackgroundColor : Color = mainBackgroundColor.lighten(15),
val buttonBackgroundColor : Color = mainBackgroundColor.lighten(15),
val buttonBorderColor : Color = mainFontColor.changeAlpha(0.25),
val buttonBorderWidth : Measurement = 1.px,
)
object NoTextSelectCls : CssName("no-text-select")
object SelectedCls : CssName("selected")
object ActiveCls : CssName("active")
fun Color.hover(): Color = if (Css.currentStyle == Css.darkStyle) {
this.lighten(15)
} else {
this.darken(15)
}
object Css {
var minified = false
var dynamicStyles = mutableMapOf<CssId, ConditionalStyle.() -> Unit>()
fun CssId.defineCss(conditionalStyle: ConditionalStyle.() -> Unit) {
check(!dynamicStyles.containsKey(this)) {
"CssId with name ${this.name} already defined!"
}
updateCss(conditionalStyle)
}
private fun CssId.updateCss(conditionalStyle: ConditionalStyle.() -> Unit) {
val elementId = this.description()
var dynamicStyleElement = document.getElementById(elementId)
dynamicStyles[this] = conditionalStyle
if (dynamicStyleElement == null) {
dynamicStyleElement = document.createElement("style")
dynamicStyleElement.id = elementId
document.head?.append(dynamicStyleElement)
}
val css = style(conditionalStyle)
dynamicStyleElement.innerHTML = css.generateCss(minified = false)
}
val darkStyle = StyleDefinition(
)
val lightStyle = StyleDefinition(
mainBackgroundColor = hsl(239+180, 50, 15),
)
var currentStyle: StyleDefinition = darkStyle
fun updateStyle() {
for ((cssId, dynStyle) in dynamicStyles) {
cssId.apply {
updateCss(dynStyle)
}
}
}
fun switchLayout() {
currentStyle = if (currentStyle == darkStyle) {
lightStyle
} else {
darkStyle
}
updateStyle()
}
fun Style.transition() {
transition("all 0.5s ease")
}
fun Style.noTextSelect() {
plain("-webkit-touch-callout", "none")
plain("-webkit-user-select", "none")
plain("-moz-user-select", "none")
plain("-ms-user-select", "none")
userSelect(UserSelect.none)
select("::selection") {
background("none")
}
}
object GenericCss : CssId("generic") {
init {
fun generateStyle(): String {
val css = style {
}
return css.generateCss(minified = minified)
}
}
}
}

View File

@@ -1,65 +0,0 @@
package daw.style
import nl.astraeus.css.style.DescriptionProvider
import nl.astraeus.css.style.cls
private val CAPITAL_LETTER = Regex("[A-Z]")
fun String.hyphenize(): String =
replace(CAPITAL_LETTER) {
"-${it.value.lowercase()}"
}
private val shortId = false
private var nextCssId = 1
private fun nextShortId(): String {
var id = nextCssId++
val result = StringBuilder()
while(id > 0) {
val ch = ((id % 26) + 'a'.code).toChar()
result.append(ch)
id /= 26
}
return result.toString()
}
open class CssName(name: String? = null) : DescriptionProvider {
val name: String = if (shortId) {
nextShortId()
} else if (name != null) {
"daw-$name"
} else {
"daw${this::class.simpleName?.hyphenize() ?: this::class}"
}
override fun description() = name
}
fun CssName.cls() : DescriptionProvider = cls(this)
open class CssId(name: String) : DescriptionProvider {
val name: String = if (shortId) {
nextShortId()
} else {
"daw-$name-css"
}
override fun description() = name
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is CssId) return false
if (name != other.name) return false
return true
}
override fun hashCode(): Int {
return name.hashCode()
}
}

View File

@@ -1,4 +1,4 @@
package nl.astraeus.vst.chip
package nl.astraeus.vst.string
external class AudioContext {
var sampleRate: Int

View File

@@ -0,0 +1,30 @@
package nl.astraeus.vst.string
import kotlinx.browser.document
import nl.astraeus.komp.Komponent
import nl.astraeus.komp.UnsafeMode
import nl.astraeus.vst.string.audio.VstStringWorklet
import nl.astraeus.vst.string.logger.log
import nl.astraeus.vst.string.midi.Midi
import nl.astraeus.vst.string.view.MainView
import nl.astraeus.vst.string.ws.WebsocketClient
import nl.astraeus.vst.ui.css.CssSettings
import nl.astraeus.vst.ui.view.BaseVstView
fun main() {
CssSettings.shortId = false
CssSettings.preFix = "vst-string"
Komponent.unsafeMode = UnsafeMode.UNSAFE_SVG_ONLY
Komponent.create(document.body!!, BaseVstView("VST Guiter", MainView) {
VstStringWorklet.create {
WebsocketClient.send("LOAD\n")
}
})
Midi.start()
WebsocketClient.connect {
log.debug { "Connected to server" }
}
}

View File

@@ -0,0 +1,10 @@
package nl.astraeus.vst.string.audio
import nl.astraeus.vst.string.AudioContext
object AudioContextHandler {
val audioContext: dynamic = AudioContext()
}

View File

@@ -1,8 +1,8 @@
package nl.astraeus.vst.chip.audio
package nl.astraeus.vst.string.audio
import nl.astraeus.vst.chip.AudioWorkletNode
import nl.astraeus.vst.chip.AudioWorkletNodeParameters
import nl.astraeus.vst.chip.audio.AudioContextHandler.audioContext
import nl.astraeus.vst.string.AudioWorkletNode
import nl.astraeus.vst.string.AudioWorkletNodeParameters
import nl.astraeus.vst.string.audio.AudioContextHandler.audioContext
import org.w3c.dom.MessageEvent
import org.w3c.dom.MessagePort

View File

@@ -0,0 +1,123 @@
@file:OptIn(ExperimentalJsExport::class)
package nl.astraeus.vst.string.audio
import nl.astraeus.vst.string.PatchDTO
import nl.astraeus.vst.string.view.MainView
import nl.astraeus.vst.string.view.WaveformView
import nl.astraeus.vst.ui.util.uInt8ArrayOf
import org.khronos.webgl.Float32Array
import org.khronos.webgl.Uint8Array
import org.khronos.webgl.get
import org.w3c.dom.MessageEvent
import kotlin.experimental.and
object VstStringWorklet : AudioNode(
"/vst-string-worklet.js",
"vst-string-processor"
) {
var midiChannel = 0
set(value) {
check(value in 0..15) {
"Midi channel must be between 0 and 15."
}
field = value
postMessage("set_channel\n${midiChannel}")
}
var volume = 0.75
set(value) {
field = value
super.postMessage(
uInt8ArrayOf(0xb0 + midiChannel, 7, (value * 127).toInt())
)
}
var damping = 0.996
set(value) {
field = value
super.postMessage(
uInt8ArrayOf(0xb0 + midiChannel, 0x47, ((value - 0.8) * 127).toInt())
)
}
var delay = 0.0
set(value) {
field = value
super.postMessage(
uInt8ArrayOf(0xb0 + midiChannel, 0x4e, (value * 127).toInt())
)
}
var delayDepth = 0.0
set(value) {
field = value
super.postMessage(
uInt8ArrayOf(0xb0 + midiChannel, 0x4f, (value * 127).toInt())
)
}
var recording: Float32Array? = null
override fun onMessage(message: MessageEvent) {
//console.log("Message from worklet: ", message)
val data = message.data
if (data is Float32Array) {
this.recording = data
WaveformView.requestUpdate()
}
}
fun postDirectlyToWorklet(msg: Any) {
super.postMessage(msg)
}
override fun postMessage(msg: Any) {
if (msg is Uint8Array) {
if (
msg.length == 3
&& (msg[0] and 0xf == midiChannel.toByte())
&& (msg[0] and 0xf0.toByte() == 0xb0.toByte())
) {
val knob = msg[1]
val value = msg[2]
handleIncomingMidi(knob, value)
} else {
super.postMessage(msg)
}
} else {
super.postMessage(msg)
}
}
private fun handleIncomingMidi(knob: Byte, value: Byte) {
when (knob) {
0x46.toByte() -> {
volume = value / 127.0
MainView.requestUpdate()
}
0x4a.toByte() -> {
damping = value / 127.0
MainView.requestUpdate()
}
}
}
fun load(patch: PatchDTO) {
midiChannel = patch.midiChannel
volume = patch.volume
damping = patch.damping
delay = patch.delay
delayDepth = patch.delayDepth
}
fun save(): PatchDTO {
return PatchDTO(
midiChannel = midiChannel,
volume = volume,
damping = damping,
delay = delay,
delayDepth = delayDepth,
)
}
}

View File

@@ -1,6 +1,6 @@
@file:OptIn(ExperimentalJsExport::class)
package nl.astraeus.vst.chip.midi
package nl.astraeus.vst.string.midi
import kotlinx.browser.window
import org.khronos.webgl.Uint8Array

View File

@@ -1,8 +1,8 @@
package nl.astraeus.vst.chip.midi
package nl.astraeus.vst.string.midi
import kotlinx.browser.window
import nl.astraeus.vst.chip.audio.VstChipWorklet
import nl.astraeus.vst.chip.view.MainView
import nl.astraeus.vst.string.audio.VstStringWorklet
import nl.astraeus.vst.string.view.MainView
import org.khronos.webgl.Uint8Array
import org.khronos.webgl.get
@@ -37,7 +37,6 @@ external class MIDIOutput {
}
object Midi {
var inputChannel: Int = -1
var outputChannel: Int = -1
var inputs = mutableListOf<MIDIInput>()
@@ -75,6 +74,39 @@ object Midi {
)
}
fun setInput(id: String, name: String = "") {
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)
}
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?) {
console.log("Setting input", input)
currentInput?.close()
@@ -93,7 +125,7 @@ object Midi {
hex.append(" ")
}
console.log("Midi message:", hex)
VstChipWorklet.postMessage(
VstStringWorklet.postMessage(
message.data
)
}
@@ -110,7 +142,7 @@ object Midi {
currentOutput?.open()
}
fun send(data: Uint8Array, timestamp: dynamic? = null) {
fun send(data: Uint8Array, timestamp: dynamic = null) {
currentOutput?.send(data, timestamp)
}

View File

@@ -0,0 +1,246 @@
@file:OptIn(ExperimentalJsExport::class)
package nl.astraeus.vst.string.view
import kotlinx.html.InputType
import kotlinx.html.div
import kotlinx.html.h1
import kotlinx.html.input
import kotlinx.html.js.onChangeFunction
import kotlinx.html.js.onClickFunction
import kotlinx.html.js.onInputFunction
import kotlinx.html.option
import kotlinx.html.select
import kotlinx.html.span
import nl.astraeus.css.properties.AlignItems
import nl.astraeus.css.properties.BoxSizing
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.em
import nl.astraeus.css.properties.prc
import nl.astraeus.css.properties.px
import nl.astraeus.css.properties.rem
import nl.astraeus.css.style.Style
import nl.astraeus.css.style.cls
import nl.astraeus.komp.HtmlBuilder
import nl.astraeus.komp.Komponent
import nl.astraeus.vst.string.PhysicalString
import nl.astraeus.vst.string.audio.VstStringWorklet
import nl.astraeus.vst.string.audio.VstStringWorklet.midiChannel
import nl.astraeus.vst.string.midi.Midi
import nl.astraeus.vst.string.ws.WebsocketClient
import nl.astraeus.vst.ui.components.ExpKnobComponent
import nl.astraeus.vst.ui.css.Css
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.util.uInt8ArrayOf
import org.w3c.dom.HTMLInputElement
import org.w3c.dom.HTMLSelectElement
object MainView : Komponent(), CssName {
private var messages: MutableList<String> = ArrayList()
val playString = PhysicalStringView(
PhysicalString(
sampleRate = 48000,
damping = 0.996,
)
)
init {
css()
}
fun addMessage(message: String) {
messages.add(message)
while (messages.size > 10) {
messages.removeAt(0)
}
requestUpdate()
}
override fun HtmlBuilder.render() {
div(MainDivCss.name) {
h1 {
+"VST Guitar"
}
div {
span {
+"Midi input: "
select {
option {
+"None"
value = "none"
}
for (mi in Midi.inputs) {
option {
+mi.name
value = mi.id
selected = mi.id == Midi.currentInput?.id
}
}
onChangeFunction = { event ->
val target = event.target as HTMLSelectElement
if (target.value == "none") {
Midi.setInput(null)
} else {
Midi.setInput(target.value)
}
}
}
}
span {
+"channel:"
input {
type = InputType.number
value = midiChannel.toString()
onInputFunction = { event ->
val target = event.target as HTMLInputElement
println("onInput channel: $target")
VstStringWorklet.midiChannel = target.value.toInt()
}
}
}
}
div {
span(ButtonBarCss.name) {
+"SAVE"
onClickFunction = {
val patch = VstStringWorklet.save().copy(
midiId = Midi.currentInput?.id ?: "",
midiName = Midi.currentInput?.name ?: ""
)
WebsocketClient.send("SAVE\n${JSON.stringify(patch)}")
}
}
span(ButtonBarCss.name) {
+"STOP"
onClickFunction = {
VstStringWorklet.postDirectlyToWorklet(
uInt8ArrayOf(0xb0 + midiChannel, 123, 0)
)
}
}
}
div(ControlsCss.name) {
include(
ExpKnobComponent(
value = VstStringWorklet.volume,
label = "Volume",
minValue = 0.005,
maxValue = 1.0,
step = 5.0 / 127.0,
width = 100,
height = 120,
) { value ->
VstStringWorklet.volume = value
}
)
}
include(WaveformView)
include(playString)
}
}
object MainDivCss : CssName
object ActiveCss : CssName
object ButtonCss : CssName
object ButtonBarCss : CssName
object SelectedCss : CssName
object NoteBarCss : CssName
object ControlsCss : CssName
private fun css() {
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(ControlsCss.cls()) {
display(Display.flex)
flexDirection(FlexDirection.row)
justifyContent(JustifyContent.flexStart)
alignItems(AlignItems.center)
margin(1.rem)
padding(1.rem)
backgroundColor(Css.currentStyle.mainBackgroundColor)
}
}
}
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())
}
}
}

View File

@@ -0,0 +1,107 @@
package nl.astraeus.vst.string.view
import kotlinx.browser.window
import kotlinx.html.canvas
import kotlinx.html.div
import kotlinx.html.js.onClickFunction
import kotlinx.html.span
import nl.astraeus.komp.HtmlBuilder
import nl.astraeus.komp.Komponent
import nl.astraeus.komp.currentElement
import nl.astraeus.vst.Note
import nl.astraeus.vst.string.PhysicalString
import nl.astraeus.vst.string.audio.VstStringWorklet
import nl.astraeus.vst.string.view.MainView.ControlsCss
import nl.astraeus.vst.ui.components.KnobComponent
import nl.astraeus.vst.util.formatDouble
import org.w3c.dom.CanvasRenderingContext2D
import org.w3c.dom.HTMLCanvasElement
class PhysicalStringView(
val string: PhysicalString
) : Komponent() {
var context: CanvasRenderingContext2D? = null
var interval: Int = -1
var lastUpdateTime: Double = window.performance.now()
init {
window.requestAnimationFrame(::onAnimationFrame)
interval = window.setInterval({
if (context?.canvas?.isConnected == true) {
val now: Double = window.performance.now()
val time = now - lastUpdateTime
lastUpdateTime = now
string.update(time)
} else {
window.clearInterval(interval)
}
}, 1)
}
private fun onAnimationFrame(time: Double) {
draw()
window.requestAnimationFrame(::onAnimationFrame)
}
override fun HtmlBuilder.render() {
div {
div(ControlsCss.name) {
include(
KnobComponent(
value = VstStringWorklet.damping,
label = "Damping",
minValue = 0.8,
maxValue = 1.0,
step = 0.2 / 127.0,
width = 100,
height = 120,
renderer = { formatDouble(it, 3) }
) { value ->
VstStringWorklet.damping = value
}
)
}
div {
span {
+"Play C3"
onClickFunction = {
string.pluck(Note.C3, 1.0)
}
}
span {
+"Play C4"
onClickFunction = {
string.pluck(Note.C4, 1.0)
}
}
}
canvas {
width = "1000"
height = "400"
context = (currentElement() as? HTMLCanvasElement)?.getContext("2d") as? CanvasRenderingContext2D
}
}
}
private fun draw() {
val ctx = context
if (ctx != null && ctx.canvas.isConnected) {
val width = ctx.canvas.width.toDouble()
val height = ctx.canvas.height.toDouble()
val halfHeight = height / 2.0
ctx.lineWidth = 2.0
ctx.clearRect(0.0, 0.0, width, height)
val step = width / string.length
ctx.beginPath()
ctx.strokeStyle = "rgba(0, 255, 255, 0.5)"
for (i in 0 until string.length) {
ctx.moveTo(i * step, halfHeight)
ctx.lineTo(i * step, halfHeight + string.buffer[i] * halfHeight)
}
ctx.stroke()
}
}
}

View File

@@ -0,0 +1,57 @@
package nl.astraeus.vst.string.view
import kotlinx.browser.window
import kotlinx.html.canvas
import kotlinx.html.div
import nl.astraeus.komp.HtmlBuilder
import nl.astraeus.komp.Komponent
import nl.astraeus.komp.currentElement
import nl.astraeus.vst.string.audio.VstStringWorklet
import org.khronos.webgl.get
import org.w3c.dom.CanvasRenderingContext2D
import org.w3c.dom.HTMLCanvasElement
object WaveformView : Komponent() {
init {
window.requestAnimationFrame(::onAnimationFrame)
}
fun onAnimationFrame(time: Double) {
if (VstStringWorklet.recording == null) {
VstStringWorklet.postMessage("start_recording")
}
window.requestAnimationFrame(::onAnimationFrame)
}
override fun HtmlBuilder.render() {
div {
if (VstStringWorklet.recording != null) {
canvas {
width = "1000"
height = "400"
val ctx = (currentElement() as? HTMLCanvasElement)?.getContext("2d") as? CanvasRenderingContext2D
val data = VstStringWorklet.recording
if (ctx != null && data != null) {
val width = ctx.canvas.width.toDouble()
val height = ctx.canvas.height.toDouble()
val halfHeight = height / 2.0
ctx.lineWidth = 2.0
ctx.clearRect(0.0, 0.0, width, height)
val step = 1000.0 / data.length
ctx.beginPath()
ctx.strokeStyle = "rgba(0, 255, 255, 0.5)"
ctx.moveTo(0.0, halfHeight)
for (i in 0 until data.length) {
ctx.lineTo(i * step, halfHeight - data[i] * halfHeight)
}
ctx.stroke()
}
}
VstStringWorklet.recording = null
}
}
}
}

View File

@@ -0,0 +1,120 @@
package nl.astraeus.vst.string.ws
import kotlinx.browser.window
import nl.astraeus.vst.string.PatchDTO
import nl.astraeus.vst.string.audio.VstStringWorklet
import nl.astraeus.vst.string.midi.Midi
import nl.astraeus.vst.string.view.MainView
import org.w3c.dom.MessageEvent
import org.w3c.dom.WebSocket
import org.w3c.dom.events.Event
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")
if (data.startsWith("LOAD")) {
val patchJson = data.substring(5)
val patch = JSON.parse<PatchDTO>(patchJson)
Midi.setInput(patch.midiId, patch.midiName)
VstStringWorklet.load(patch)
MainView.requestUpdate()
}
}
}
}
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)
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,2 +0,0 @@
package nl.astraeus.vst.chip

View File

@@ -1,20 +0,0 @@
package nl.astraeus.vst.chip
import io.undertow.Undertow
import io.undertow.UndertowOptions
fun main() {
Thread.setDefaultUncaughtExceptionHandler { _, e ->
e.printStackTrace()
}
val server = Undertow.builder()
.addHttpListener(Settings.port, "localhost")
.setIoThreads(4)
.setHandler(RequestHandler)
.setServerOption(UndertowOptions.SHUTDOWN_TIMEOUT, 1000)
.build()
println("Starting server at port ${Settings.port}...")
server?.start()
}

View File

@@ -1,15 +0,0 @@
package nl.astraeus.vst.chip
import io.undertow.server.HttpHandler
import io.undertow.server.HttpServerExchange
import io.undertow.server.handlers.resource.PathResourceManager
import io.undertow.server.handlers.resource.ResourceHandler
import java.nio.file.Paths
object RequestHandler : HttpHandler {
val resourceHandler = ResourceHandler(PathResourceManager(Paths.get("web")))
override fun handleRequest(exchange: HttpServerExchange) {
resourceHandler.handleRequest(exchange)
}
}

View File

@@ -1,50 +0,0 @@
package nl.astraeus.vst.chip
import java.io.File
import java.io.FileInputStream
import java.util.*
object Settings {
var runningAsRoot: Boolean = false
var port = 9000
var sslPort = 8443
var connectionTimeout = 30000
var jdbcDriver = "nl.astraeus.jdbc.Driver"
var jdbcConnectionUrl = "jdbc:stat:webServerPort=6001:jdbc:sqlite:data/srp.db"
var jdbcUser = "sa"
var jdbcPassword = ""
var adminUser = "rnentjes"
var adminPassword = "9/SG_Bd}9gWz~?j\\A.U]n9]OO"
fun getPropertiesFromFile(filename: String): Properties? {
val propertiesFile = File(filename)
return if (propertiesFile.exists()) {
val properties = Properties()
FileInputStream(propertiesFile).use {
properties.load(it)
}
properties
} else {
null
}
}
fun readProperties(args: Array<String>) {
val filename = if (args.isNotEmpty()) args[0] else "srp.properties"
val properties = getPropertiesFromFile(filename) ?: return // return if properties couldn't be loaded
runningAsRoot = properties.getProperty("runningAsRoot", runningAsRoot.toString()).toBoolean()
port = properties.getProperty("port", port.toString()).toInt()
sslPort = properties.getProperty("sslPort", sslPort.toString()).toInt()
connectionTimeout = properties.getProperty("connectionTimeout", connectionTimeout.toString()).toInt()
jdbcDriver = properties.getProperty("jdbcDriver", jdbcDriver)
jdbcConnectionUrl = properties.getProperty("jdbcConnectionUrl", jdbcConnectionUrl)
jdbcUser = properties.getProperty("jdbcUser", jdbcUser)
jdbcPassword = properties.getProperty("jdbcPassword", jdbcPassword)
adminUser = properties.getProperty("adminUser", adminUser)
adminPassword = properties.getProperty("adminPassword", adminPassword)
}
}

View File

@@ -0,0 +1,25 @@
package nl.astraeus.vst.string
import nl.astraeus.vst.base.Settings
import nl.astraeus.vst.base.db.Database
import nl.astraeus.vst.base.web.UndertowServer
import nl.astraeus.vst.string.logger.LogLevel
import nl.astraeus.vst.string.logger.Logger
fun main() {
Logger.level = LogLevel.DEBUG
Thread.setDefaultUncaughtExceptionHandler { _, e ->
e.printStackTrace()
}
Settings.port = 9004
Settings.jdbcStatsPort = 6004
Database.start()
UndertowServer.start(
"Vst String",
"/vst-string-worklet-ui.js"
)
}

View File

@@ -1,6 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<body>
<script type="application/javascript" src="vst-chip-worklet-ui.js"></script>
</body>
</html>

21
version.gradle.kts Normal file
View File

@@ -0,0 +1,21 @@
import java.util.Date
import java.util.Properties
tasks.register("generateVersionProperties") {
doLast {
val versionDir = layout.buildDirectory.dir("processedResources/jvm/main")
val versionFile = versionDir.get().file("version.properties").asFile
versionDir.get().asFile.mkdirs()
val properties = Properties().apply {
setProperty("group", project.group.toString())
setProperty("name", project.name.toString())
setProperty("version", project.version.toString())
setProperty("buildTime", Date().toString())
}
versionFile.writer().use { writer ->
properties.store(writer, "Version information")
}
}
}