32 Commits

Author SHA1 Message Date
7110188d33 Add project documentation and update .gitignore
Introduces development guidelines for the VST Chip synthesizer, including setup, build, and testing instructions. Adds a placeholder README for the data directory and updates `.gitignore` to include new project-specific build artifacts and paths.
2025-05-06 19:13:43 +02:00
2cfc8a8201 Update output directories and Kotlin version, add buildJS task
Modified outputDirectory paths in build scripts and upgraded the Kotlin multiplatform plugin to version 2.1.20. Added a new buildJS task to handle copying files from multiple directories into the web folder. These changes streamline the build process and ensure compatibility with updated tooling.
2025-05-06 18:59:15 +02:00
ce353d3113 Add MidiMessageHandler for MIDI event handling
Introduced `MidiMessageHandler` to process and handle MIDI messages with customizable handlers for specific byte patterns. This addition improves extensibility and keeps MIDI message processing modular and organized.
2025-03-28 13:47:00 +01:00
ff8a4dbf92 Update dependencies and clean up unused code
Upgraded `vst-ui-base` to version 2.0.0 and removed `kotlin-css-generator`. Cleaned up commented-out and unnecessary dependency blocks for better readability and maintenance.
2025-03-27 19:46:36 +01:00
dc50084e84 Merge remote-tracking branch 'origin/master'
# Conflicts:
#	build.gradle.kts
2025-03-27 19:34:30 +01:00
7fe29916f7 Refactor MainView and enhance View management
Replaced `MainView` object with a `Views` singleton for better modularity and lazy initialization. Adjusted CSS structure, updated dependencies, and improved FM/AM modulation logic for greater flexibility. Additionally, upgraded Kotlin multiplatform version and added inline source mapping.
2025-03-27 19:33:43 +01:00
310f77fc3a Update Gradle config, dependencies, and Kotlin plugin version
Configured Gradle to use "corretto-21" JVM and bumped the Kotlin Multiplatform plugin to version 2.1.10. Updated the "midi-arrays" dependency to version 0.3.4 across relevant modules. These changes ensure compatibility and leverage the latest library improvements.
2025-03-17 18:16:47 +00:00
60a21bbd79 Update dependencies and refine MIDI handling.
Upgraded various dependencies, including `vst-ui-base` to 1.2.0 and build configurations to include `mavenLocal`. Refined MIDI handling by removing redundant logging to improve performance and clarity. Adjusted knob component value ranges for better user experience.
2024-12-26 14:23:16 +01:00
d58fb9c7b5 Refactor MIDI handling and update dependencies.
Streamlined MIDI message handling by introducing `MidiMessageHandler` and removed redundant code. Added better handler support for specific message types and parameters. Also upgraded Kotlin to version 2.1.0 and adjusted build configurations.
2024-12-21 20:42:19 +01:00
fbba6d1422 Refactor MIDI handling and improve audio processing
Replaced `uInt8ArrayOf` with simplified integer arrays for MIDI messages. Introduced `TimedMidiMessage` and buffer handling for better synchronization in audio processing. Updated Gradle dependencies and added timing-aware MIDI utilities.
2024-12-17 20:51:32 +01:00
4c00356dff Increase polyphony and comment out debug logs
Updated the polyphony level from 10 to 20 to enhance sound capability and commented out several debug logs for cleaner console output. Additionally, commented out a block of code related to sine wave modulation that appears unnecessary at this stage. The console log message for registering the processor was slightly modified for consistency.
2024-12-09 19:51:57 +01:00
29aac228e5 Update VST Worklet Base dependency version
Upgrade the nl.astraeus:vst-worklet-base dependency from version 1.0.0-SNAPSHOT to 1.0.1 in audio-worklet/build.gradle.kts. This change ensures compatibility with the updated library while bringing in any fixes or enhancements included in the new version.
2024-12-08 20:43:00 +01:00
52c7495f43 Refactor MainView onClick and cleanup project config
Moved the onClickFunction outside of the START button div in MainView for better event handling. Removed outdated project directory from gradle.xml to clean up project configuration. Corrected sampleRate calculation in ChipProcessor for accurate audio processing.
2024-12-08 20:10:14 +01:00
dc3a940942 Fix sample length calculation using fixed sample rate.
The calculation for `sampleLength` now uses a fixed sample rate of 48000.0 instead of potentially incorrect dynamic sample rate. This change ensures consistent audio processing regardless of the sample rate provided elsewhere in the application.
2024-12-08 15:00:53 +01:00
4f5b30c52a Reorder sampleLength initialization.
Move the sampleLength variable declaration to improve code readability and maintain consistency in variable initialization within the constructor. This change ensures that related calculations are grouped logically.
2024-12-08 14:59:57 +01:00
ee76d4c4a3 Refactor project structure by removing "common" module
Removed the "common" module and associated configurations from the project. Updated dependencies and file placements to reflect these changes across build and source files. Migrated necessary code from the "common" module to relevant existing modules to maintain functionality.
2024-12-08 14:57:01 +01:00
b20b2266ba Upgrade JDK and simplify artifact configurations.
Updated project settings to use JDK 21 and its specific SDK naming. Simplified XML artifact configurations by removing module-output elements. Applied experimental annotation for JavaScript exports in Kotlin source files.
2024-12-08 13:57:15 +01:00
31f2d8060c Remove unused import from settings file
The import statement for 'jdk.tools.jlink.resources.plugins' was removed as it was not being used anywhere in the settings.common.gradle.kts file. This cleanup helps to maintain a more organized and efficient codebase, avoiding potential confusion for developers.
2024-12-08 13:48:43 +01:00
9d67e742f3 Remove unused database and web-related kotlin source files
This commit deletes several Kotlin source files related to database and web handling that are no longer used in the project. This includes DAO classes, web service handlers, and supporting utility files, contributing to a cleaner and more maintainable codebase. Additionally, an import statement has been removed from the `settings.common.gradle.kts` file to tidy up the build configuration.
2024-12-08 13:43:50 +01:00
92e68cdc47 Refactor build and server setup
Introduced a versioning task in a new `version.gradle.kts` file to auto-generate version properties. The main server setup in `Main.kt` was refactored to streamline server initialization using `UndertowServer`. Dependencies and configurations in `build.gradle.kts` were updated to deploy effectively, including improved yarn lock handling and symbolic link integration.
2024-12-08 13:41:00 +01:00
b412dd9b4e Playing with settings 2024-08-12 20:36:30 +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
0281d2751f Cleanup 2024-06-26 14:17:04 +02:00
f7e088bb67 Add channel selection 2024-06-26 14:10:03 +02:00
41 changed files with 1856 additions and 881 deletions

6
.gitignore vendored
View File

@@ -41,7 +41,11 @@ bin/
### Mac OS ###
.DS_Store
web
/web
/web1
/web2
/data/*.db*
**/kotlin-js-store/*
.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,8 +1,6 @@
<component name="ArtifactManager">
<artifact type="jar" name="vst-chip-js-1.0.0-SNAPSHOT">
<output-path>$PROJECT_DIR$/build/libs</output-path>
<root id="archive" name="vst-chip-js-1.0.0-SNAPSHOT.jar">
<element id="module-output" name="vst-chip.jsMain" />
</root>
<root id="archive" name="vst-chip-js-1.0.0-SNAPSHOT.jar" />
</artifact>
</component>

View File

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

3
.idea/gradle.xml generated
View File

@@ -5,12 +5,11 @@
<option name="linkedExternalProjectsSettings">
<GradleProjectSettings>
<option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="gradleHome" value="" />
<option name="gradleJvm" value="corretto-21" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$" />
<option value="$PROJECT_DIR$/audio-worklet" />
<option value="$PROJECT_DIR$/common" />
</set>
</option>
</GradleProjectSettings>

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>

2
.idea/misc.xml generated
View File

@@ -4,7 +4,7 @@
<component name="FrameworkDetectionExcludesConfiguration">
<file type="web" url="file://$PROJECT_DIR$" />
</component>
<component name="ProjectRootManager" version="2" languageLevel="JDK_17" default="true" project-jdk-name="17" project-jdk-type="JavaSDK">
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="temurin-21" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/out" />
</component>
<component name="accountSettings">

105
.junie/guidelines.md Normal file
View File

@@ -0,0 +1,105 @@
# VST Chip Synthesizer Development Guidelines
## Project Overview
VST Chip is a Kotlin multiplatform project that implements a chip-style synthesizer with a web interface. The project consists of:
- JVM backend that serves the web application
- JS frontend that runs in the browser
- Audio worklet processor for real-time audio processing
## Build/Configuration Instructions
### Prerequisites
- JDK 11 or higher
- Gradle 7.x or higher
- Node.js and npm (for JS development)
### Building the Project
1. **Full Build**:
```bash
./gradlew build
```
2. **JS Build Only**:
```bash
./gradlew buildJS
```
3. **Deployment**:
```bash
./gradlew deploy
```
This will build the project, copy web assets, and deploy to the configured server location.
### Configuration
- Server port: 9005 (configured in `Main.kt`)
- JDBC stats port: 6005
- Deployment directory: configured in `build.gradle.kts` as `vst-chip.midi-vst.com`
## Testing Information
- **Do not generate tests** for this project. The audio processing code is highly specialized and requires manual testing with audio
equipment.
- Manual testing should be performed using MIDI controllers and audio monitoring tools.
## Development Information
### Project Structure
- **src/commonMain**: Shared code between JS and JVM
- **src/jsMain**: Browser-specific code
- **src/jvmMain**: Server-specific code
- **audio-worklet**: Audio processing code that runs in a separate thread in the browser
### Key Components
1. **JVM Server** (`src/jvmMain/kotlin/nl/astraeus/vst/chip/Main.kt`):
- Undertow server that serves the web application
- Database initialization
- Logging setup
2. **JS Frontend** (`src/jsMain/kotlin/nl/astraeus/vst/chip/Main.kt`):
- UI implementation using Komponent library
- MIDI handling
- WebSocket client for server communication
3. **Audio Processor** (`audio-worklet/src/jsMain/kotlin/nl/astraeus/vst/chip/ChipProcessor.kt`):
- Real-time audio synthesis
- MIDI message handling
- Sound generation with multiple waveforms (sine, square, triangle, sawtooth)
- Effects processing (FM, AM, ADSR envelope, delay, feedback)
### Development Workflow
1. Make changes to the code
2. Run `./gradlew buildJS` to build the JS part
3. Run the JVM application to test locally
4. Use `./gradlew deploy` to deploy to the server
### Audio Worklet Development
The audio worklet runs in a separate thread in the browser and handles real-time audio processing. When modifying the audio worklet code:
1. Understand that it runs in a separate context from the main JS code
2. Communication happens via message passing
3. Performance is critical - avoid garbage collection and heavy operations in the audio processing loop
### MIDI Implementation
The synthesizer responds to standard MIDI messages:
- Note On/Off (0x90/0x80)
- Control Change (0xb0) for various parameters
- Program Change (0xc9) for waveform selection
## Deployment
The project is configured to deploy to a specific server location. The deployment process:
1. Builds the project
2. Copies web assets
3. Creates a symbolic link for the latest version

27
.run/Main [jvm].run.xml Normal file
View File

@@ -0,0 +1,27 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="Main [jvm]" type="GradleRunConfiguration" factoryName="Gradle">
<ExternalSystemSettings>
<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="taskDescriptions">
<list/>
</option>
<option name="taskNames">
<list>
<option value="jvmRun"/>
</list>
</option>
<option name="vmOptions"/>
</ExternalSystemSettings>
<ExternalSystemDebugServerProcess>false</ExternalSystemDebugServerProcess>
<ExternalSystemReattachDebugProcess>true</ExternalSystemReattachDebugProcess>
<DebugAllEnabled>false</DebugAllEnabled>
<RunAsTest>false</RunAsTest>
<method v="2">
<option name="Gradle.BeforeRunTask" enabled="true" tasks="clean build" externalProjectPath="$PROJECT_DIR$"
vmOptions="" scriptParameters=""/>
</method>
</configuration>
</component>

View File

@@ -1,3 +1,7 @@
@file:OptIn(ExperimentalKotlinGradlePluginApi::class, ExperimentalDistributionDsl::class)
import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi
import org.jetbrains.kotlin.gradle.targets.js.dsl.ExperimentalDistributionDsl
import org.jetbrains.kotlin.gradle.targets.js.webpack.KotlinWebpackOutput
buildscript {
@@ -27,24 +31,18 @@ kotlin {
}
distribution {
outputDirectory.set(File("$projectDir/../web/"))
outputDirectory.set(File("$projectDir/../web2/"))
}
}
}
jvm()
sourceSets {
val commonMain by getting {
dependencies {
implementation(project(":common"))
implementation("nl.astraeus:vst-worklet-base:1.0.0-SNAPSHOT")
}
}
val jsMain by getting {
dependencies {
implementation(project(":common"))
implementation("nl.astraeus:vst-worklet-base:1.0.1")
implementation("nl.astraeus:midi-arrays:0.3.4")
}
}
val jsMain by getting
}
}

View File

@@ -0,0 +1,82 @@
package nl.astraeus.vst.midi
typealias MidiHandler = (Byte, Byte, Byte) -> Unit
val Byte.channel: Byte
get() {
return (this.toInt() and 0x0F).toByte()
}
val Byte.command: Byte
get() {
return ((this.toInt() and 0xF0) shr 4).toByte()
}
val Byte.channelCommand: Boolean
get() {
return this.command >= 8 && this.command <= 13
}
class MidiMessageHandler(
var channel: Byte = -1
) {
val singleByteHandlers = mutableMapOf<Byte, MidiHandler>()
val doubleByteHandlers = mutableMapOf<Byte, MutableMap<Byte, MidiHandler>>()
fun addHandler(
byte1: Byte,
byte2: Byte = 0,
handler: MidiHandler
) {
val b1 = if (byte1.channelCommand) {
(byte1.toInt() and 0xF0).toByte()
} else {
byte1
}
if (byte2 == 0.toByte()) {
singleByteHandlers[b1] = handler
} else {
val map = doubleByteHandlers.getOrPut(b1) {
mutableMapOf()
}
map[byte2] = handler
}
}
fun addHandler(
byte1: Int,
byte2: Int = 0,
handler: MidiHandler
) = addHandler(
byte1.toByte(),
byte2.toByte(),
handler
)
fun handle(
byte1: Byte,
byte2: Byte,
byte3: Byte
) {
if (
channel < 0 ||
!byte1.channelCommand ||
(byte1.channelCommand && channel == byte1.channel)
) {
val b1 = if (byte1.channelCommand) {
(byte1.toInt() and 0xF0).toByte()
} else {
byte1
}
if (doubleByteHandlers.containsKey(b1)) {
doubleByteHandlers[b1]?.get(byte2)?.invoke(byte1, byte2, byte3)
} else if (singleByteHandlers.containsKey(b1)) {
singleByteHandlers[b1]?.invoke(byte1, byte2, byte3)
} else {
println("Unhandled message: $byte1 $byte2 $byte3")
}
}
}
}

View File

@@ -2,51 +2,50 @@
package nl.astraeus.vst.chip
import nl.astraeus.midi.message.SortedTimedMidiMessageList
import nl.astraeus.midi.message.TimedMidiMessage
import nl.astraeus.tba.SlicedByteArray
import nl.astraeus.vst.ADSR
import nl.astraeus.vst.AudioWorkletProcessor
import nl.astraeus.vst.Note
import nl.astraeus.vst.currentTime
import nl.astraeus.vst.midi.MidiMessageHandler
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.min
import kotlin.math.sin
val POLYPHONICS = 10
val POLYPHONICS = 20
val PI2 = PI * 2
@ExperimentalJsExport
@JsExport
enum class NoteState {
ON,
RELEASED,
OFF
}
@ExperimentalJsExport
@JsExport
class PlayingNote(
val note: Int,
var velocity: Int = 0
) {
val noteObj = Note.fromMidi(note)
fun retrigger(velocity: Int) {
this.velocity = velocity
state = NoteState.ON
sample = 0
attackSamples = 2500
releaseSamples = 10000
noteStart = currentTime
noteRelease = null
for (i in 0 until combDelayBuffer.length) {
combDelayBuffer[i] = 0f
}
}
var state = NoteState.OFF
var noteStart = currentTime
var noteRelease: Double? = null
var cycleOffset = 0.0
var sample = 0
var attackSamples = 2500
var releaseSamples = 10000
var actualVolume = 0f
val combDelayBuffer = Float32Array((sampleRate / noteObj.freq).toInt())
}
enum class Waveform {
@@ -56,152 +55,230 @@ enum class Waveform {
SAWTOOTH
}
@ExperimentalJsExport
@JsExport
enum class RecordingState {
STOPPED,
WAITING_TO_START,
RECORDING
}
@ExperimentalJsExport
@JsExport
class VstChipProcessor : AudioWorkletProcessor() {
val notes = Array(POLYPHONICS) {
PlayingNote(
0
)
}
val midiMessageBuffer = SortedTimedMidiMessageList()
val midiMessageHandler = MidiMessageHandler()
val notes = Array<PlayingNote?>(POLYPHONICS) { null }
var waveform = Waveform.SINE.ordinal
var volume = 0.75f
var dutyCycle = 0.5
var fmFreq = 0.0
var fmFreq = 0.5
var fmAmp = 0.0
var amFreq = 0.0
var amAmp = 0.0
var attack = 0.1
var decay = 0.2
var sustain = 0.5
var release = 0.2
val recordingBuffer = Float32Array(sampleRate / 60)
var recordingState = RecordingState.STOPPED
var recordingSample = 0
var recordingStart = 0
val sampleLength = 1 / sampleRate.toDouble()
val rightDelayBuffer = Float32Array(sampleRate)
val leftDelayBuffer = Float32Array(sampleRate)
var delayIndex = 0
var delay = 0.0
var delayDepth = 0.0
var feedback = 0.0
init {
this.port.onmessage = ::handleMessage
Note.updateSampleRate(sampleRate)
with(midiMessageHandler) {
addHandler(0x90) { b1, b2, b3 ->
val note = b2.toInt() and 0xff
val velocity = b3.toInt() and 0xff
if (velocity > 0) {
console.log("Note on", note, velocity)
noteOn(note, velocity)
} else {
console.log("Note off", note)
noteOff(note)
}
}
addHandler(0x80) { b1, b2, b3 ->
val note = b2.toInt() and 0xff
console.log("Note off", note)
noteOff(note)
}
addHandler(0xc9) { b1, b2, b3 ->
waveform = b2.toInt() and 0xff
}
addHandler(0xb0, 7) { b1, b2, b3 ->
volume = b3 / 127f
}
addHandler(0xb0, 0x47) { b1, b2, b3 ->
dutyCycle = b3 / 127.0
}
addHandler(0xb0, 0x40) { b1, b2, b3 ->
fmFreq = b3 / 127.0
}
addHandler(0xb0, 0x41) { b1, b2, b3 ->
fmAmp = b3 / 127.0
}
addHandler(0xb0, 0x42) { b1, b2, b3 ->
amFreq = b3 / 127.0
}
addHandler(0xb0, 0x43) { b1, b2, b3 ->
amAmp = b3 / 127.0
}
addHandler(0xb0, 0x49) { b1, b2, b3 ->
attack = b3 / 127.0
}
addHandler(0xb0, 0x4b) { b1, b2, b3 ->
decay = b3 / 127.0
}
addHandler(0xb0, 0x46) { b1, b2, b3 ->
sustain = b3 / 127.0
}
addHandler(0xb0, 0x48) { b1, b2, b3 ->
release = b3 / 127.0
}
addHandler(0xb0, 0x4e) { b1, b2, b3 ->
delay = b3 / 127.0
}
addHandler(0xb0, 0x4f) { b1, b2, b3 ->
delayDepth = b3 / 127.0
}
addHandler(0xb0, 0x50) { b1, b2, b3 ->
feedback = b3 / 127.0
}
addHandler(0xb0, 123) { b1, b2, b3 ->
for (note in notes) {
note?.noteRelease = currentTime
}
}
addHandler(0xe0) { b1, b2, b3 ->
if (b2.toInt() and 0xff > 0) {
val lsb = b2.toInt() and 0xff
val msb = b3.toInt() and 0xff
amFreq = (((msb - 0x40) + (lsb / 127.0)) / 0x40) * 10.0
}
}
}
}
private fun handleMessage(message: MessageEvent) {
//console.log("VstChipProcessor: Received message", message)
//console.log("VstChipProcessor: Received message:", currentTime)
val data = message.data
when (data) {
"test_on" -> {
playMidi(Int32Array(arrayOf(0x90, 60, 64)))
try {
when (data) {
is String -> {
when {
data == "start_recording" -> {
port.postMessage(recordingBuffer)
if (recordingState == RecordingState.STOPPED) {
recordingState = RecordingState.WAITING_TO_START
recordingSample = 0
}
}
}
"test_off" -> {
playMidi(Int32Array(arrayOf(0x90, 60, 0)))
}
is String -> {
}
is ArrayBuffer -> {
}
is Uint8Array -> {
val data32 = Int32Array(data.length)
for (i in 0 until data.length) {
data32[i] = (data[i].toInt() and 0xff)
data.startsWith("set_channel") -> {
val parts = data.split('\n')
if (parts.size == 2) {
midiMessageHandler.channel = parts[1].toByte()
println("Setting channel: ${midiMessageHandler.channel}")
}
}
data.startsWith("waveform") -> {
val parts = data.split('\n')
if (parts.size == 2) {
waveform = parts[1].toInt()
println("Setting waveform: $waveform")
}
}
}
}
playMidi(data32)
is ByteArray -> {
val message1 = TimedMidiMessage(data)
midiMessageBuffer.add(message1)
playBuffer()
}
/*
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)
}
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) {
if (bytes.length > 0) {
//console.log("Received", bytes)
when(bytes[0]) {
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 playBuffer() {
while (
midiMessageBuffer.isNotEmpty() &&
(midiMessageBuffer.nextTimestamp() ?: 0.0) < currentTime
) {
val midi = midiMessageBuffer.read()
playMidi(midi.midi)
}
}
private fun playMidi(bytes: SlicedByteArray) {
var index = 0
while (index < bytes.size && bytes[index].toUByte() > 0u) {
val buffer = bytes.getBlob(index, 3)
playMidiFromBuffer(buffer)
index += 3
}
}
private fun playMidiFromBuffer(bytes: SlicedByteArray) {
midiMessageHandler.handle(bytes[0], bytes[1], bytes[2])
}
private fun noteOn(note: Int, velocity: Int) {
for (i in 0 until POLYPHONICS) {
if (notes[i].note == note) {
notes[i].retrigger(velocity)
if (notes[i]?.note == note) {
notes[i]?.retrigger(velocity)
return
}
}
for (i in 0 until POLYPHONICS) {
if (notes[i].state == NoteState.OFF) {
if (notes[i] == null) {
notes[i] = PlayingNote(
note,
velocity
)
notes[i].state = NoteState.ON
val n = Note.fromMidi(note)
//console.log("Playing note: ${n.sharp} (${n.freq})")
break
}
}
@@ -209,79 +286,168 @@ class VstChipProcessor : AudioWorkletProcessor() {
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
if (notes[i]?.note == note) {
notes[i]?.noteRelease = currentTime
break
}
}
}
override fun process (
inputs: Array<Array<Float32Array>>,
outputs: Array<Array<Float32Array>>,
parameters: dynamic
) : Boolean {
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 (note in notes) {
if (note.state != NoteState.OFF) {
val sampleDelta = Note.fromMidi(note.note).sampleDelta
if (note != null) {
lowestNote = min(lowestNote, note.note)
}
}
if (lowestNote == 200 && recordingState == RecordingState.WAITING_TO_START) {
recordingState = RecordingState.RECORDING
recordingSample = 0
recordingStart = 0
}
playBuffer()
for ((index, note) in notes.withIndex()) {
if (note != null) {
val midiNote = Note.fromMidi(note.note)
val sampleDelta = midiNote.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
var targetVolume = note.velocity / 127f * 1f
targetVolume *= ADSR.calculate(
attack,
decay,
sustain,
release,
note.noteStart,
currentTime,
note.noteRelease
).toFloat()
note.actualVolume += (targetVolume - note.actualVolume) * 0.01f
if (note.state == NoteState.RELEASED && note.actualVolume <= 0) {
note.state = NoteState.OFF
if (note.noteRelease != null && note.actualVolume <= 0.01) {
notes[index] = null
}
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 fmMult = sin(currentTime * fmFreq * midiNote.freq * 2f * PI2) * fmAmp
val fmModulation =
sampleDelta * fmMult //+ (sin(fmFreq * 1000f * PI2 * (note.sample / sampleRate.toDouble())).toFloat() * (100f * fmAmp * sampleDelta))
val amModulation =
1f + (sin(sampleLength * amFreq * 1000f * PI2 * note.sample) * amAmp).toFloat()
cycleOffset = if (cycleOffset < dutyCycle) {
cycleOffset / dutyCycle / 2.0
} else {
0.5 + ((cycleOffset - dutyCycle) / (1.0 - dutyCycle) / 2.0)
}
val waveValue: Float = when (waveform) {
0 -> {
sin(cycleOffset * PI2).toFloat()
}
1 -> {
if (cycleOffset < dutyCycle) { 1f } else { -1f }
if (cycleOffset < 0.5) {
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 }
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
left[i] = left[i] + waveValue * note.actualVolume * volume * amModulation
right[i] = right[i] + waveValue * note.actualVolume * volume * amModulation
note.cycleOffset += sampleDelta
if (cycleOffset > 1f) {
// comb filter delay
val delaySampleIndex =
(note.sample + note.combDelayBuffer.length) % note.combDelayBuffer.length
left[i] = left[i] + (note.combDelayBuffer[delaySampleIndex] * feedback.toFloat())
right[i] = right[i] + (note.combDelayBuffer[delaySampleIndex] * feedback.toFloat())
note.combDelayBuffer[delaySampleIndex] = (left[i] + right[i]) / 2f
// end - comb filter delay
note.cycleOffset += sampleDelta + fmModulation
if (note.cycleOffset > 1f) {
note.cycleOffset -= 1f
if (note.note == lowestNote && recordingState == RecordingState.WAITING_TO_START) {
recordingState = RecordingState.RECORDING
recordingSample = 0
recordingStart = i
}
}
note.sample++
}
}
}
// if sin enable
/*
for (i in 0 until samples) {
left[i] = sin(left[i] * PI2).toFloat()
right[i] = sin(right[i] * PI2).toFloat()
}
*/
val delaySamples = (delay * leftDelayBuffer.length).toInt()
for (i in 0 until samples) {
if (delaySamples > 0) {
val delaySampleIndex = (delayIndex + sampleRate - delaySamples) % sampleRate
left[i] = left[i] + (leftDelayBuffer[delaySampleIndex] * delayDepth.toFloat())
right[i] = right[i] + (rightDelayBuffer[delaySampleIndex] * delayDepth.toFloat())
}
leftDelayBuffer[delayIndex] = left[i]
rightDelayBuffer[delayIndex++] = right[i]
delayIndex %= sampleRate
}
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
}
}
@@ -289,5 +455,5 @@ class VstChipProcessor : AudioWorkletProcessor() {
fun main() {
registerProcessor("vst-chip-processor", VstChipProcessor::class.js)
println("VstChipProcessor registered!")
console.log("'vst-chip-processor' registered!", currentTime)
}

View File

@@ -0,0 +1,194 @@
package nl.astraeus.vst.chip
import nl.astraeus.vst.chip.Note.entries
import kotlin.math.max
import kotlin.math.min
import kotlin.math.pow
import kotlin.math.round
/**
* User: rnentjes
* Date: 14-11-15
* Time: 11:50
*/
enum class Note(
val sharp: String,
val flat: String
) {
NO01("C--", "C--"),
NO02("C#-", "Db-"),
NO03("D--", "D--"),
NO04("D#-", "Eb-"),
NO05("E--", "E--"),
NO06("F--", "F--"),
NO07("F#-", "Gb-"),
NO08("G--", "G--"),
NO09("G#-", "Ab-"),
NO10("A--", "A--"),
NO11("A#-", "Bb-"),
NO12("B--", "B--"),
C0("C-0", "C-0"),
C0s("C#0", "Db0"),
D0("D-0", "D-0"),
D0s("D#0", "Eb0"),
E0("E-0", "E-0"),
F0("F-0", "F-0"),
F0s("F#0", "Gb0"),
G0("G-0", "G-0"),
G0s("G#0", "Ab0"),
A0("A-0", "A-0"),
A0s("A#0", "Bb0"),
B0("B-0", "B-0"),
C1("C-1", "C-1"),
C1s("C#1", "Db1"),
D1("D-1", "D-1"),
D1s("D#1", "Eb1"),
E1("E-1", "E-1"),
F1("F-1", "F-1"),
F1s("F#1", "Gb1"),
G1("G-1", "G-1"),
G1s("G#1", "Ab1"),
A1("A-1", "A-1"),
A1s("A#1", "Bb1"),
B1("B-1", "B-1"),
C2("C-2", "C-2"),
C2s("C#2", "Db2"),
D2("D-2", "D-2"),
D2s("D#2", "Eb2"),
E2("E-2", "E-2"),
F2("F-2", "F-2"),
F2s("F#2", "Gb2"),
G2("G-2", "G-2"),
G2s("G#2", "Ab2"),
A2("A-2", "A-2"),
A2s("A#2", "Bb2"),
B2("B-2", "B-2"),
C3("C-3", "C-3"),
C3s("C#3", "Db3"),
D3("D-3", "D-3"),
D3s("D#3", "Eb3"),
E3("E-3", "E-3"),
F3("F-3", "F-3"),
F3s("F#3", "Gb3"),
G3("G-3", "G-3"),
G3s("G#3", "Ab3"),
A3("A-3", "A-3"),
A3s("A#3", "Bb3"),
B3("B-3", "B-3"),
C4("C-4", "C-4"),
C4s("C#4", "Db4"),
D4("D-4", "D-4"),
D4s("D#4", "Eb4"),
E4("E-4", "E-4"),
F4("F-4", "F-4"),
F4s("F#4", "Gb4"),
G4("G-4", "G-4"),
G4s("G#4", "Ab4"),
A4("A-4", "A-4"),
A4s("A#4", "Bb4"),
B4("B-4", "B-4"),
C5("C-5", "C-5"),
C5s("C#5", "Db5"),
D5("D-5", "D-5"),
D5s("D#5", "Eb5"),
E5("E-5", "E-5"),
F5("F-5", "F-5"),
F5s("F#5", "Gb5"),
G5("G-5", "G-5"),
G5s("G#5", "Ab5"),
A5("A-5", "A-5"),
A5s("A#5", "Bb5"),
B5("B-5", "B-5"),
C6("C-6", "C-6"),
C6s("C#6", "Db6"),
D6("D-6", "D-6"),
D6s("D#6", "Eb6"),
E6("E-6", "E-6"),
F6("F-6", "F-6"),
F6s("F#6", "Gb6"),
G6("G-6", "G-6"),
G6s("G#6", "Ab6"),
A6("A-6", "A-6"),
A6s("A#6", "Bb6"),
B6("B-6", "B-6"),
C7("C-7", "C-7"),
C7s("C#7", "Db7"),
D7("D-7", "D-7"),
D7s("D#7", "Eb7"),
E7("E-7", "E-7"),
F7("F-7", "F-7"),
F7s("F#7", "Gb7"),
G7("G-7", "G-7"),
G7s("G#7", "Ab7"),
A7("A-7", "A-7"),
A7s("A#7", "Bb7"),
B7("B-7", "B-7"),
C8("C-8", "C-8"),
C8s("C#8", "Db8"),
D8("D-8", "D-8"),
D8s("D#8", "Eb8"),
E8("E-8", "E-8"),
F8("F-8", "F-8"),
F8s("F#8", "Gb8"),
G8("G-8", "G-8"),
G8s("G#8", "Ab8"),
A8("A-8", "A-8"),
A8s("A#8", "Bb8"),
B8("B-8", "B-8"),
C9("C-9", "C-9"),
C9s("C#9", "Db9"),
D9("D-9", "D-9"),
D9s("D#9", "Eb9"),
E9("E-9", "E-9"),
F9("F-9", "F-9"),
F9s("F#9", "Gb9"),
G9("G-9", "G-9"),
// out of midi range
//G9s("G#9","Ab9"),
//A9("A-9","A-9"),
//A9s("A#9","Bb9"),
//B9("B-9","B-9"),
NONE("---", "---"),
UP("^^^", "^^^"),
END("XXX", "XXX"),
;
// 69 = A4.ordinal
val freq: Double = round(440.0 * 2.0.pow((ordinal - 69) / 12.0)) // * 10000.0) / 10000.0
val cycleLength: Double = 1.0 / freq
var sampleDelta: Double = 0.0
fun transpose(semiNotes: Int): Note = if (ordinal >= C0.ordinal && ordinal <= G9.ordinal) {
var result = this.ordinal + semiNotes
result = min(result, G9.ordinal)
result = max(result, C0.ordinal)
fromMidi(result)
} else {
this
}
companion object {
fun fromMidi(midi: Int): Note {
// todo: add check
return entries[midi]
}
fun updateSampleRate(rate: Int) {
println("Setting sample rate to $rate")
for (note in Note.entries) {
note.sampleDelta = (1.0 / rate.toDouble()) / note.cycleLength
}
}
}
}
// freq = 10Hz
// cycleLength = 0.1
// sampleRate = 48000
// sampleDelta = 4800
// (1.0 / freq) * sampleRate

View File

@@ -1,66 +1,168 @@
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 = "common.gradle.kts")
apply(from = "version.gradle.kts")
}
plugins {
kotlin("multiplatform")
kotlin("plugin.serialization")
id("maven-publish")
application
kotlin("multiplatform")
id("maven-publish")
application
}
kotlin {
js {
compilerOptions {
target.set("es2015")
}
//useEsModules()
//useCommonJs()
binaries.executable()
browser {
commonWebpackConfig {
outputFileName = "vst-chip-worklet-ui.js"
sourceMaps = true
}
distribution {
outputDirectory.set(File("$projectDir/web/"))
}
}
}
jvm{
withJava()
js {
compilerOptions {
target.set("es2015")
}
//useEsModules()
//useCommonJs()
sourceSets {
val commonMain by getting {
dependencies {
implementation(project(":common"))
//base
api("nl.astraeus:kotlin-css-generator:1.0.7")
}
}
val jsMain by getting {
dependencies {
//base
implementation("nl.astraeus:kotlin-komponent-js:1.2.2")
}
}
val jsTest by getting {
dependencies {
implementation(kotlin("test-js"))
}
}
val jvmMain by getting {
dependencies {
//base
binaries.executable()
browser {
commonWebpackConfig {
outputFileName = "vst-chip-worklet-ui.js"
sourceMaps = true
devtool = "inline-source-map"
}
implementation("io.undertow:undertow-core:2.3.13.Final")
implementation("org.jetbrains.kotlinx:kotlinx-html-jvm:0.11.0")
}
}
distribution {
outputDirectory.set(File("$projectDir/web1/"))
}
}
}
/*
@OptIn(ExperimentalWasmDsl::class)
wasmJs {
binaries.executable()
browser{
distribution {
outputDirectory.set(File("$projectDir/web/"))
}
}
mavenPublication {
groupId = group as String
pom { name = "${project.name}-wasm-js" }
}
}
*/
jvm {
withJava()
}
sourceSets {
val commonMain by getting {
dependencies {
api("nl.astraeus:vst-ui-base:2.0.0")
implementation("nl.astraeus:midi-arrays:0.3.4")
}
}
val jsMain by getting
val jsTest by getting {
dependencies {
implementation(kotlin("test-js"))
}
}
val jvmMain by getting
}
}
application {
mainClass.set("nl.astraeus.vst.chip.MainKt")
}
tasks.register<Copy>("buildJS") {
duplicatesStrategy = DuplicatesStrategy.INCLUDE
dependsOn("audio-worklet:jsBrowserDevelopmentExecutableDistribution")
dependsOn("jsBrowserDevelopmentExecutableDistribution")
from(layout.projectDirectory.dir("web1"))
into(layout.projectDirectory.dir("web"))
from(layout.projectDirectory.dir("web2"))
into(layout.projectDirectory.dir("web"))
}
/* Hardcoded deploy configuration */
val deployDirectory = "vst-chip.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,15 @@
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/api/packages/rnentjes/maven")
}
maven {
url = uri("https://gitea.astraeus.nl:8443/api/packages/rnentjes/maven")
}
mavenCentral()
mavenLocal()
}
}

View File

@@ -1,32 +0,0 @@
@file:OptIn(ExperimentalKotlinGradlePluginApi::class)
import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi
buildscript {
apply(from = "../common.gradle.kts")
}
plugins {
kotlin("multiplatform")
}
kotlin {
js {
compilerOptions {
target.set("es2015")
}
browser()
}
jvm()
sourceSets {
val commonMain by getting {
dependencies {
}
}
val jsMain by getting {
dependencies {
}
}
}
}

View File

@@ -1 +0,0 @@
apply(from = "../settings.common.gradle.kts")

View File

@@ -1,192 +0,0 @@
package nl.astraeus.vst
import kotlin.math.max
import kotlin.math.min
import kotlin.math.pow
import kotlin.math.round
/**
* User: rnentjes
* Date: 14-11-15
* Time: 11:50
*/
enum class Note(
val sharp: String,
val flat: String
) {
NO01("C--","C--"),
NO02("C#-","Db-"),
NO03("D--","D--"),
NO04("D#-","Eb-"),
NO05("E--","E--"),
NO06("F--","F--"),
NO07("F#-","Gb-"),
NO08("G--","G--"),
NO09("G#-","Ab-"),
NO10("A--","A--"),
NO11("A#-","Bb-"),
NO12("B--","B--"),
C0("C-0","C-0"),
C0s("C#0","Db0"),
D0("D-0","D-0"),
D0s("D#0","Eb0"),
E0("E-0","E-0"),
F0("F-0","F-0"),
F0s("F#0","Gb0"),
G0("G-0","G-0"),
G0s("G#0","Ab0"),
A0("A-0","A-0"),
A0s("A#0","Bb0"),
B0("B-0","B-0"),
C1("C-1","C-1"),
C1s("C#1","Db1"),
D1("D-1","D-1"),
D1s("D#1","Eb1"),
E1("E-1","E-1"),
F1("F-1","F-1"),
F1s("F#1","Gb1"),
G1("G-1","G-1"),
G1s("G#1","Ab1"),
A1("A-1","A-1"),
A1s("A#1","Bb1"),
B1("B-1","B-1"),
C2("C-2","C-2"),
C2s("C#2","Db2"),
D2("D-2","D-2"),
D2s("D#2","Eb2"),
E2("E-2","E-2"),
F2("F-2","F-2"),
F2s("F#2","Gb2"),
G2("G-2","G-2"),
G2s("G#2","Ab2"),
A2("A-2","A-2"),
A2s("A#2","Bb2"),
B2("B-2","B-2"),
C3("C-3","C-3"),
C3s("C#3","Db3"),
D3("D-3","D-3"),
D3s("D#3","Eb3"),
E3("E-3","E-3"),
F3("F-3","F-3"),
F3s("F#3","Gb3"),
G3("G-3","G-3"),
G3s("G#3","Ab3"),
A3("A-3","A-3"),
A3s("A#3","Bb3"),
B3("B-3","B-3"),
C4("C-4","C-4"),
C4s("C#4","Db4"),
D4("D-4","D-4"),
D4s("D#4","Eb4"),
E4("E-4","E-4"),
F4("F-4","F-4"),
F4s("F#4","Gb4"),
G4("G-4","G-4"),
G4s("G#4","Ab4"),
A4("A-4","A-4"),
A4s("A#4","Bb4"),
B4("B-4","B-4"),
C5("C-5","C-5"),
C5s("C#5","Db5"),
D5("D-5","D-5"),
D5s("D#5","Eb5"),
E5("E-5","E-5"),
F5("F-5","F-5"),
F5s("F#5","Gb5"),
G5("G-5","G-5"),
G5s("G#5","Ab5"),
A5("A-5","A-5"),
A5s("A#5","Bb5"),
B5("B-5","B-5"),
C6("C-6","C-6"),
C6s("C#6","Db6"),
D6("D-6","D-6"),
D6s("D#6","Eb6"),
E6("E-6","E-6"),
F6("F-6","F-6"),
F6s("F#6","Gb6"),
G6("G-6","G-6"),
G6s("G#6","Ab6"),
A6("A-6","A-6"),
A6s("A#6","Bb6"),
B6("B-6","B-6"),
C7("C-7","C-7"),
C7s("C#7","Db7"),
D7("D-7","D-7"),
D7s("D#7","Eb7"),
E7("E-7","E-7"),
F7("F-7","F-7"),
F7s("F#7","Gb7"),
G7("G-7","G-7"),
G7s("G#7","Ab7"),
A7("A-7","A-7"),
A7s("A#7","Bb7"),
B7("B-7","B-7"),
C8("C-8","C-8"),
C8s("C#8","Db8"),
D8("D-8","D-8"),
D8s("D#8","Eb8"),
E8("E-8","E-8"),
F8("F-8","F-8"),
F8s("F#8","Gb8"),
G8("G-8","G-8"),
G8s("G#8","Ab8"),
A8("A-8","A-8"),
A8s("A#8","Bb8"),
B8("B-8","B-8"),
C9("C-9","C-9"),
C9s("C#9","Db9"),
D9("D-9","D-9"),
D9s("D#9","Eb9"),
E9("E-9","E-9"),
F9("F-9","F-9"),
F9s("F#9","Gb9"),
G9("G-9","G-9"),
// out of midi range
//G9s("G#9","Ab9"),
//A9("A-9","A-9"),
//A9s("A#9","Bb9"),
//B9("B-9","B-9"),
NONE("---", "---"),
UP("^^^","^^^"),
END("XXX","XXX"),
;
// 69 = A4.ordinal
val freq: Double = round(440.0 * 2.0.pow((ordinal - 69)/12.0)) // * 10000.0) / 10000.0
val cycleLength: Double = 1.0 / freq
var sampleDelta: Double = 0.0
fun transpose(semiNotes: Int): Note = if (ordinal >= C0.ordinal && ordinal <= G9.ordinal) {
var result = this.ordinal + semiNotes
result = min(result, G9.ordinal)
result = max(result, C0.ordinal)
fromMidi(result)
} else {
this
}
companion object {
fun fromMidi(midi: Int): Note {
// todo: add check
return entries[midi]
}
fun updateSampleRate(rate: Int) {
println("Setting sample rate to $rate")
for (note in Note.entries) {
note.sampleDelta = (1.0 / rate.toDouble()) / note.cycleLength
}
}
}
}
// freq = 10Hz
// cycleLength = 0.1
// sampleRate = 48000
// sampleDelta = 4800
// (1.0 / freq) * sampleRate

1
data/readme.md Normal file
View File

@@ -0,0 +1 @@
Data directory for the db

View File

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

View File

@@ -2,5 +2,4 @@ apply(from = "settings.common.gradle.kts")
rootProject.name = "vst-chip"
include(":common")
include(":audio-worklet")

View File

@@ -0,0 +1,44 @@
package nl.astraeus.vst.chip
import kotlin.js.ExperimentalJsExport
import kotlin.js.JsExport
import kotlin.js.JsName
@ExperimentalJsExport
@JsExport
data class PatchDTO(
@JsName("waveform")
val waveform: Int = 0,
@JsName("midiId")
val midiId: String = "",
@JsName("midiName")
val midiName: String = "",
@JsName("midiChannel")
var midiChannel: Int = 0,
@JsName("volume")
var volume: Double = 0.75,
@JsName("dutyCycle")
var dutyCycle: Double = 0.5,
@JsName("fmModFreq")
var fmModFreq: Double = 0.0,
@JsName("fmModAmp")
var fmModAmp: Double = 0.0,
@JsName("amModFreq")
var amModFreq: Double = 0.0,
@JsName("amModAmp")
var amModAmp: Double = 0.0,
@JsName("attack")
var attack: Double = 0.1,
@JsName("decay")
var decay: Double = 0.2,
@JsName("sustain")
var sustain: Double = 0.5,
@JsName("release")
var release: Double = 0.2,
@JsName("delay")
var delay: Double = 0.0,
@JsName("delayDepth")
var delayDepth: Double = 0.0,
@JsName("feedback")
var feedback: Double = 0.0,
)

View File

@@ -0,0 +1,53 @@
package nl.astraeus.vst.chip.logger
val log = Logger
enum class LogLevel {
TRACE,
DEBUG,
INFO,
WARN,
ERROR,
FATAL
}
object Logger {
var level: LogLevel = LogLevel.INFO
fun trace(message: () -> String?) {
if (level.ordinal <= LogLevel.TRACE.ordinal) {
println("TRACE: ${message()}")
}
}
fun debug(message: () -> String?) {
if (level.ordinal <= LogLevel.DEBUG.ordinal) {
println("DEBUG: ${message()}")
}
}
fun info(message: () -> String?) {
if (level.ordinal <= LogLevel.INFO.ordinal) {
println("INFO: ${message()}")
}
}
fun warn(e: Throwable? = null, message: () -> String?) {
if (level.ordinal <= LogLevel.WARN.ordinal) {
println("WARN: ${message()}")
e?.printStackTrace()
}
}
fun error(e: Throwable? = null, message: () -> String?) {
if (level.ordinal <= LogLevel.ERROR.ordinal) {
println("ERROR: ${message()}")
e?.printStackTrace()
}
}
fun fatal(e: Throwable, message: () -> String?) {
println("FATAL: ${message()}")
e.printStackTrace()
}
}

View File

@@ -1,29 +1,32 @@
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.komp.UnsafeMode
import nl.astraeus.vst.chip.logger.log
import nl.astraeus.vst.chip.midi.Midi
import nl.astraeus.vst.chip.view.MainView
import org.khronos.webgl.Uint8Array
import nl.astraeus.vst.chip.ws.WebsocketClient
import nl.astraeus.vst.ui.css.CssSettings
object Views {
val mainView by lazy {
MainView()
}
init {
CssSettings.shortId = false
CssSettings.preFix = "vst"
}
}
fun main() {
Komponent.create(document.body!!, MainView)
Komponent.unsafeMode = UnsafeMode.UNSAFE_SVG_ONLY
Komponent.create(document.body!!, Views.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)
WebsocketClient.connect {
log.debug { "Connected to server" }
}
}

View File

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

View File

@@ -1,5 +1,6 @@
package nl.astraeus.vst.chip.audio
import nl.astraeus.midi.message.TimedMidiMessage
import nl.astraeus.vst.chip.AudioWorkletNode
import nl.astraeus.vst.chip.AudioWorkletNodeParameters
import nl.astraeus.vst.chip.audio.AudioContextHandler.audioContext
@@ -53,8 +54,27 @@ abstract class AudioNode(
abstract fun onMessage(message: MessageEvent)
open fun postMessage(vararg data: Int) {
if (port == null) {
console.log("postMessage port is NULL!")
}
val array = ByteArray(data.size) { data[it].toByte() }
port?.postMessage(
TimedMidiMessage(
audioContext.currentTime,
*array
).data.buffer.toByteArray()
)
}
open fun postMessage(msg: Any) {
if (port == null) {
console.log("postMessage port is NULL!")
}
port?.postMessage(msg)
//console.log("Posted message", audioContext.currentTime)
}
// call from user gesture
@@ -80,6 +100,7 @@ abstract class AudioNode(
port = node.port as? MessagePort
created = true
console.log("Created node: ${audioContext.currentTime}")
done(node)
}

View File

@@ -1,14 +1,239 @@
@file:OptIn(ExperimentalJsExport::class)
package nl.astraeus.vst.chip.audio
import nl.astraeus.midi.message.TimedMidiMessage
import nl.astraeus.vst.chip.PatchDTO
import nl.astraeus.vst.chip.Views
import nl.astraeus.vst.chip.view.WaveformView
import org.khronos.webgl.Float32Array
import org.w3c.dom.MessageEvent
object VstChipWorklet : AudioNode(
"vst-chip-worklet.js",
"/vst-chip-worklet.js",
"vst-chip-processor"
) {
var waveform: Int = 0
set(value) {
field = value
postMessage("waveform\n$value")
}
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(
0xb0 + midiChannel, 7, (value * 127).toInt()
)
}
var dutyCycle = 0.5
set(value) {
field = value
super.postMessage(
0xb0 + midiChannel, 0x47, (value * 127).toInt()
)
}
var fmModFreq = 1.0
set(value) {
field = value
super.postMessage(
0xb0 + midiChannel, 0x40, (value * 127).toInt()
)
}
var fmModAmp = 0.0
set(value) {
field = value
super.postMessage(
0xb0 + midiChannel, 0x41, (value * 127).toInt()
)
}
var amModFreq = 0.0
set(value) {
field = value
super.postMessage(
0xb0 + midiChannel, 0x42, (value * 127).toInt()
)
}
var amModAmp = 0.0
set(value) {
field = value
super.postMessage(
0xb0 + midiChannel, 0x43, (value * 127).toInt()
)
}
var feedback = 0.0
set(value) {
field = value
super.postMessage(
0xb0 + midiChannel, 0x50, (value * 127).toInt()
)
}
var delay = 0.0
set(value) {
field = value
super.postMessage(
0xb0 + midiChannel, 0x4e, (value * 127).toInt()
)
}
var delayDepth = 0.0
set(value) {
field = value
super.postMessage(
0xb0 + midiChannel, 0x4f, (value * 127).toInt()
)
}
var attack = 0.1
set(value) {
field = value
super.postMessage(
0xb0 + midiChannel, 0x49, (value * 127).toInt()
)
}
var decay = 0.2
set(value) {
field = value
super.postMessage(
0xb0 + midiChannel, 0x4b, (value * 127).toInt()
)
}
var sustain = 0.5
set(value) {
field = value
super.postMessage(
0xb0 + midiChannel, 0x46, (value * 127).toInt()
)
}
var release = 0.2
set(value) {
field = value
super.postMessage(
0xb0 + midiChannel, 0x48, (value * 127).toInt()
)
}
var recording: Float32Array? = null
override fun onMessage(message: MessageEvent) {
console.log("Message from worklet: ", message)
//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 ByteArray) {
val tmm = TimedMidiMessage(msg)
val byte1 = tmm.midi[0]
if (byte1.toInt() and 0xf0 == 0xb0) {
handleIncomingMidi(tmm.midi[1], tmm.midi[2])
}
}
super.postMessage(msg)
}
override fun postMessage(vararg msg: Int) {
println("postMessage ${msg.size} bytes")
if (
msg.size == 3
&& (msg[0] and 0xf == midiChannel)
&& (msg[0] and 0xf0 == 0xb0)
) {
val knob = msg[1]
val value = msg[2]
handleIncomingMidi(knob.toByte(), value.toByte())
}
super.postMessage(msg)
}
private fun handleIncomingMidi(knob: Byte, value: Byte) {
println("Incoming knob: $knob, value: $value")
when (knob) {
0x46.toByte() -> {
volume = value / 127.0
Views.mainView.requestUpdate()
}
0x4a.toByte() -> {
dutyCycle = value / 127.0
Views.mainView.requestUpdate()
}
0x40.toByte() -> {
fmModFreq = value / 127.0
Views.mainView.requestUpdate()
}
0x41.toByte() -> {
fmModAmp = value / 127.0
Views.mainView.requestUpdate()
}
0x42.toByte() -> {
amModFreq = value / 127.0
Views.mainView.requestUpdate()
}
0x43.toByte() -> {
amModAmp = value / 127.0
Views.mainView.requestUpdate()
}
}
}
fun load(patch: PatchDTO) {
waveform = patch.waveform
midiChannel = patch.midiChannel
volume = patch.volume
dutyCycle = patch.dutyCycle
fmModFreq = patch.fmModFreq
fmModAmp = patch.fmModAmp
amModFreq = patch.amModFreq
amModAmp = patch.amModAmp
attack = patch.attack
decay = patch.decay
sustain = patch.sustain
release = patch.release
delay = patch.delay
delayDepth = patch.delayDepth
feedback = patch.feedback
}
fun save(): PatchDTO {
return PatchDTO(
waveform = waveform,
midiChannel = midiChannel,
volume = volume,
dutyCycle = dutyCycle,
fmModFreq = fmModFreq,
fmModAmp = fmModAmp,
amModFreq = amModFreq,
amModAmp = amModAmp,
attack = attack,
decay = decay,
sustain = sustain,
release = release,
delay = delay,
delayDepth = delayDepth,
feedback = feedback
)
}
}

View File

@@ -1,8 +1,10 @@
package nl.astraeus.vst.chip.midi
import kotlinx.browser.window
import nl.astraeus.midi.message.TimedMidiMessage
import nl.astraeus.vst.chip.Views
import nl.astraeus.vst.chip.audio.AudioContextHandler
import nl.astraeus.vst.chip.audio.VstChipWorklet
import nl.astraeus.vst.chip.view.MainView
import org.khronos.webgl.Uint8Array
import org.khronos.webgl.get
@@ -37,7 +39,6 @@ external class MIDIOutput {
}
object Midi {
var inputChannel: Int = -1
var outputChannel: Int = -1
var inputs = mutableListOf<MIDIInput>()
@@ -67,7 +68,7 @@ object Midi {
outputs.add(output)
}
MainView.requestUpdate()
Views.mainView.requestUpdate()
},
{ e ->
println("Failed to get MIDI access - $e")
@@ -75,6 +76,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()
@@ -92,9 +126,14 @@ object Midi {
hex.append(data[index].toString(16))
hex.append(" ")
}
console.log("Midi message:", hex)
console.log("Midi message:", hex, message)
val midiData = ByteArray(message.data.length) { data[it].toByte() }
val timeMessage = TimedMidiMessage(
AudioContextHandler.audioContext.currentTime,
*midiData
)
VstChipWorklet.postMessage(
message.data
timeMessage.data.buffer.toByteArray()
)
}
@@ -110,7 +149,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

@@ -1,23 +1,26 @@
@file:OptIn(ExperimentalJsExport::class)
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.InputType
import kotlinx.html.canvas
import kotlinx.html.classes
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.Position
import nl.astraeus.css.properties.Transform
import nl.astraeus.css.properties.em
@@ -27,21 +30,81 @@ import nl.astraeus.css.properties.px
import nl.astraeus.css.properties.rem
import nl.astraeus.css.properties.vh
import nl.astraeus.css.properties.vw
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.komp.currentElement
import nl.astraeus.midi.message.TimedMidiMessage
import nl.astraeus.midi.message.getCurrentTime
import nl.astraeus.vst.chip.Views
import nl.astraeus.vst.chip.audio.VstChipWorklet
import nl.astraeus.vst.chip.audio.VstChipWorklet.midiChannel
import nl.astraeus.vst.chip.midi.Midi
import org.khronos.webgl.Uint8Array
import nl.astraeus.vst.chip.ws.WebsocketClient
import nl.astraeus.vst.ui.components.ExpKnobComponent
import nl.astraeus.vst.ui.components.KnobComponent
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 org.khronos.webgl.get
import org.w3c.dom.CanvasRenderingContext2D
import org.w3c.dom.HTMLCanvasElement
import org.w3c.dom.HTMLInputElement
import org.w3c.dom.HTMLSelectElement
object MainView : Komponent() {
private var messages: MutableList<String> = ArrayList()
private var started = false
object WaveformView: Komponent() {
init {
MainViewCss
window.requestAnimationFrame(::onAnimationFrame)
}
fun onAnimationFrame(time: Double) {
if (Views.mainView.started) {
VstChipWorklet.postMessage("start_recording")
}
window.requestAnimationFrame(::onAnimationFrame)
}
override fun HtmlBuilder.render() {
div {
if (VstChipWorklet.recording != null) {
canvas {
width = "1000"
height = "400"
val ctx = (currentElement() as? HTMLCanvasElement)?.getContext("2d") as? CanvasRenderingContext2D
val data = VstChipWorklet.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()
}
}
}
}
}
}
class MainView : Komponent() {
private var messages: MutableList<String> = ArrayList()
var started = false
init {
css()
}
fun addMessage(message: String) {
@@ -52,19 +115,25 @@ object MainView : Komponent() {
requestUpdate()
}
override fun renderUpdate() {
println("Rendering MainView")
super.renderUpdate()
}
override fun HtmlBuilder.render() {
div(MainViewCss.MainDivCss.name) {
div(MainDivCss.name) {
if (!started) {
div(MainViewCss.StartSplashCss.name) {
div(MainViewCss.StartBoxCss.name) {
div(MainViewCss.StartButtonCss.name) {
div(StartSplashCss.name) {
div(StartBoxCss.name) {
div(StartButtonCss.name) {
+"START"
onClickFunction = {
started = true
VstChipWorklet.create {
requestUpdate()
}
}
}
}
onClickFunction = {
VstChipWorklet.create {
started = true
requestUpdate()
WebsocketClient.send("LOAD\n")
}
}
}
@@ -78,30 +147,22 @@ object MainView : Komponent() {
select {
option {
+"None"
value = ""
}
option {
+"Midi over Broadcast"
value = "midi-broadcast"
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 == "") {
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") {
//
}
Midi.setInput(target.value)
}
}
}
@@ -110,98 +171,269 @@ object MainView : Komponent() {
+"channel:"
input {
type = InputType.number
value = Midi.inputChannel.toString()
onChangeFunction = { event ->
value = VstChipWorklet.midiChannel.toString()
onInputFunction = { event ->
val target = event.target as HTMLInputElement
Midi.inputChannel = target.value.toInt()
println("onInput channel: $target")
VstChipWorklet.midiChannel = target.value.toInt()
}
}
}
}
div {
span {
+"Midi output: "
select {
option {
+"None"
value = ""
}
option {
+"Midi over Broadcast"
value = "midi-broadcast"
}
for (mi in Midi.outputs) {
option {
+mi.name
value = mi.id
}
}
span(ButtonBarCss.name) {
+"SAVE"
onClickFunction = {
val patch = VstChipWorklet.save().copy(
midiId = Midi.currentInput?.id ?: "",
midiName = Midi.currentInput?.name ?: ""
)
onChangeFunction = { event ->
val target = event.target as HTMLSelectElement
if (target.value == "") {
Midi.setOutput(null)
} else {
val selected = Midi.outputs.find { it.id == target.value }
if (selected != null) {
Midi.setOutput(selected)
}
}
}
WebsocketClient.send("SAVE\n${JSON.stringify(patch)}")
}
}
span {
+"channel:"
input {
type = InputType.number
value = Midi.outputChannel.toString()
onChangeFunction = { event ->
val target = event.target as HTMLInputElement
Midi.outputChannel = target.value.toInt()
}
span(ButtonBarCss.name) {
+"STOP"
onClickFunction = {
VstChipWorklet.postDirectlyToWorklet(
TimedMidiMessage(getCurrentTime(), (0xb0 + midiChannel).toByte(), 123, 0)
.data.buffer.data
)
}
}
}
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 {
span(ButtonBarCss.name) {
+"Sine"
if (VstChipWorklet.waveform == 0) {
classes += SelectedCss.name
}
onClickFunction = {
VstChipWorklet.waveform = 0
requestUpdate()
}
}
span(ButtonBarCss.name) {
+"Square"
if (VstChipWorklet.waveform == 1) {
classes += SelectedCss.name
}
onClickFunction = {
VstChipWorklet.waveform = 1
requestUpdate()
}
}
span(ButtonBarCss.name) {
+"Triangle"
if (VstChipWorklet.waveform == 2) {
classes += SelectedCss.name
}
onClickFunction = {
VstChipWorklet.waveform = 2
requestUpdate()
}
}
span(ButtonBarCss.name) {
+"Sawtooth"
if (VstChipWorklet.waveform == 3) {
classes += SelectedCss.name
}
onClickFunction = {
VstChipWorklet.waveform = 3
requestUpdate()
}
}
}
div(MainViewCss.ButtonCss.name) {
+"Send note off to output"
onClickFunction = {
val data = Uint8Array(
arrayOf(
0x90.toByte(),
0x3c.toByte(),
0x0.toByte(),
)
)
Midi.send(data)
}
div(ControlsCss.name) {
include(
ExpKnobComponent(
value = VstChipWorklet.volume,
label = "Volume",
minValue = 0.0,
maxValue = 1.0,
step = 5.0 / 127.0,
width = 100,
height = 120,
) { value ->
VstChipWorklet.volume = value
}
)
include(
KnobComponent(
value = VstChipWorklet.dutyCycle,
label = "Duty cycle",
minValue = 0.0,
maxValue = 1.0,
step = 2.0 / 127.0,
width = 100,
height = 120,
) { value ->
VstChipWorklet.dutyCycle = value
}
)
include(
ExpKnobComponent(
value = VstChipWorklet.fmModFreq,
label = "FM Freq",
minValue = 0.0,
maxValue = 2.0,
step = 5.0 / 127.0,
width = 100,
height = 120,
) { value ->
VstChipWorklet.fmModFreq = value
}
)
include(
ExpKnobComponent(
value = VstChipWorklet.fmModAmp,
label = "FM Ampl",
minValue = 0.0,
maxValue = 1.0,
step = 5.0 / 127.0,
width = 100,
height = 120,
) { value ->
VstChipWorklet.fmModAmp = value
}
)
include(
ExpKnobComponent(
value = VstChipWorklet.amModFreq,
label = "AM Freq",
minValue = 0.0,
maxValue = 1.0,
step = 5.0 / 127.0,
width = 100,
height = 120,
) { value ->
VstChipWorklet.amModFreq = value
}
)
include(
ExpKnobComponent(
value = VstChipWorklet.amModAmp,
label = "AM Ampl",
minValue = 0.0,
maxValue = 1.0,
step = 5.0 / 127.0,
width = 100,
height = 120,
) { value ->
VstChipWorklet.amModAmp = value
}
)
include(
ExpKnobComponent(
value = VstChipWorklet.feedback,
label = "Feedback",
minValue = 0.0,
maxValue = 1.0,
step = 5.0 / 127.0,
width = 100,
height = 120,
) { value ->
VstChipWorklet.feedback = value
}
)
include(
ExpKnobComponent(
value = VstChipWorklet.delay,
label = "Delay",
minValue = 0.0,
maxValue = 1.0,
step = 5.0 / 127.0,
width = 100,
height = 120,
) { value ->
VstChipWorklet.delay = value
}
)
include(
ExpKnobComponent(
value = VstChipWorklet.delayDepth,
label = "Delay depth",
minValue = 0.0,
maxValue = 1.0,
step = 5.0 / 127.0,
width = 100,
height = 120,
) { value ->
VstChipWorklet.delayDepth = value
}
)
}
div(ControlsCss.name) {
include(
ExpKnobComponent(
value = VstChipWorklet.attack,
label = "Attack",
minValue = 0.0,
maxValue = 5.0,
step = 25.0 / 127.0,
width = 100,
height = 120,
) { value ->
VstChipWorklet.attack = value / 5.0
}
)
include(
ExpKnobComponent(
value = VstChipWorklet.decay,
label = "Decay",
minValue = 0.0,
maxValue = 5.0,
step = 25.0 / 127.0,
width = 100,
height = 120,
) { value ->
VstChipWorklet.decay = value / 5.0
}
)
include(
KnobComponent(
value = VstChipWorklet.sustain,
label = "Sustain",
minValue = 0.0,
maxValue = 1.0,
step = 2.0 / 127.0,
width = 100,
height = 120,
) { value ->
VstChipWorklet.sustain = value
}
)
include(
ExpKnobComponent(
value = VstChipWorklet.release,
label = "Release",
minValue = 0.0,
maxValue = 5.0,
step = 25.0 / 127.0,
width = 100,
height = 120,
) { value ->
VstChipWorklet.release = value / 5.0
}
)
}
include(WaveformView)
}
}
object MainViewCss : CssId("main") {
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 {
private fun css() {
defineCss {
select("*") {
select("*:before") {
@@ -226,15 +458,18 @@ object MainView : Komponent() {
//transition()
noTextSelect()
}
select("input", "textarea") {
backgroundColor(Css.currentStyle.inputBackgroundColor)
color(Css.currentStyle.mainFontColor)
border("none")
}
select(cls(ButtonCss)) {
margin(1.rem)
padding(1.rem)
backgroundColor(Css.currentStyle.buttonBackgroundColor)
color(Css.currentStyle.mainFontColor)
hover {
backgroundColor(Css.currentStyle.buttonBackgroundColor.hover())
}
commonButton()
}
select(cls(ButtonBarCss)) {
margin(1.rem, 0.px)
commonButton()
}
select(cls(ActiveCss)) {
//backgroundColor(Css.currentStyle.selectedBackgroundColor)
@@ -254,7 +489,7 @@ object MainView : Komponent() {
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)
color(Css.currentStyle.mainFontColor)
borderRadius(0.25.em)
}
select(cls(StartSplashCss)) {
@@ -264,7 +499,7 @@ object MainView : Komponent() {
width(100.vw)
height(100.vh)
zIndex(100)
backgroundColor(hsla(32, 0, 50, 0.6))
backgroundColor(hsla(32, 0, 5, 0.65))
select(cls(StartBoxCss)) {
position(Position.relative)
@@ -272,7 +507,9 @@ object MainView : Komponent() {
top(25.vh)
width(50.vw)
height(50.vh)
backgroundColor(hsla(0, 0, 50, 0.25))
backgroundColor(hsla(239, 50, 10, 1.0))
borderColor(Css.currentStyle.mainFontColor)
borderWidth(2.px)
select(cls(StartButtonCss)) {
position(Position.absolute)
@@ -285,6 +522,31 @@ object MainView : Komponent() {
}
}
}
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

@@ -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 = 2.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

@@ -0,0 +1,122 @@
@file:OptIn(ExperimentalJsExport::class)
package nl.astraeus.vst.chip.ws
import kotlinx.browser.window
import nl.astraeus.vst.chip.PatchDTO
import nl.astraeus.vst.chip.Views
import nl.astraeus.vst.chip.audio.VstChipWorklet
import nl.astraeus.vst.chip.midi.Midi
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)
VstChipWorklet.load(patch)
Views.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)
}
}

View File

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

View File

@@ -1,20 +1,25 @@
package nl.astraeus.vst.chip
import io.undertow.Undertow
import io.undertow.UndertowOptions
import nl.astraeus.vst.base.Settings
import nl.astraeus.vst.base.db.Database
import nl.astraeus.vst.base.web.UndertowServer
import nl.astraeus.vst.chip.logger.LogLevel
import nl.astraeus.vst.chip.logger.Logger
fun main() {
Logger.level = LogLevel.DEBUG
Thread.setDefaultUncaughtExceptionHandler { _, e ->
e.printStackTrace()
}
val server = Undertow.builder()
.addHttpListener(Settings.port, "localhost")
.setIoThreads(4)
.setHandler(RequestHandler)
.setServerOption(UndertowOptions.SHUTDOWN_TIMEOUT, 1000)
.build()
Settings.port = 9005
Settings.jdbcStatsPort = 6005
println("Starting server at port ${Settings.port}...")
server?.start()
Database.start()
UndertowServer.start(
"Vst Chip",
"/vst-chip-worklet-ui.js"
)
}

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)
}
}

20
version.gradle.kts Normal file
View File

@@ -0,0 +1,20 @@
import java.util.*
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")
}
}
}