This commit is contained in:
2024-06-20 18:57:20 +02:00
parent 945f4bb016
commit f4a5d0a75b
9 changed files with 350 additions and 139 deletions

View File

@@ -37,6 +37,8 @@ kotlin {
val commonMain by getting { val commonMain by getting {
dependencies { dependencies {
implementation(project(":common")) implementation(project(":common"))
implementation("nl.astraeus:vst-worklet-base:1.0.0-SNAPSHOT")
} }
} }
val jsMain by getting { val jsMain by getting {

View File

@@ -1,43 +0,0 @@
package nl.astraeus.vst
import org.khronos.webgl.Float32Array
import org.w3c.dom.MessagePort
enum class AutomationRate(
val rate: String
) {
A_RATE("a-rate"),
K_RATE("k-rate")
}
interface AudioParam {
var value: Double
var automationRate: AutomationRate
val defaultValue: Double
val minValue: Double
val maxValue: Double
}
interface AudioParamMap {
operator fun get(name: String): AudioParam
}
abstract external class AudioWorkletProcessor {
/** [MDN Reference](https://developer.mozilla.org/docs/Web/API/AudioWorkletNode/parameters) */
//val parameters: AudioParamMap;
/** [MDN Reference](https://developer.mozilla.org/docs/Web/API/AudioWorkletNode/port) */
@JsName("port")
val port: MessagePort
@JsName("process")
open fun process (
inputs: Array<Array<Float32Array>>,
outputs: Array<Array<Float32Array>>,
parameters: dynamic
) : Boolean { definedExternally }
}
external fun registerProcessor(name: String, processorCtor: JsClass<*>)
external val sampleRate: Int
external val currentTime: Double

View File

@@ -66,6 +66,11 @@ class VstChipProcessor : AudioWorkletProcessor() {
} }
var waveform = Waveform.SINE.ordinal var waveform = Waveform.SINE.ordinal
var dutyCycle = 0.5 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 { init {
this.port.onmessage = ::handleMessage this.port.onmessage = ::handleMessage
@@ -106,6 +111,7 @@ class VstChipProcessor : AudioWorkletProcessor() {
private fun playMidi(bytes: Int32Array) { private fun playMidi(bytes: Int32Array) {
if (bytes.length > 0) { if (bytes.length > 0) {
//console.log("Received", bytes)
when(bytes[0]) { when(bytes[0]) {
0x90 -> { 0x90 -> {
if (bytes.length == 3) { if (bytes.length == 3) {
@@ -119,6 +125,7 @@ class VstChipProcessor : AudioWorkletProcessor() {
} }
} }
} }
0x80 -> { 0x80 -> {
if (bytes.length >= 2) { if (bytes.length >= 2) {
val note = bytes[1] val note = bytes[1]
@@ -126,6 +133,7 @@ class VstChipProcessor : AudioWorkletProcessor() {
noteOff(note) noteOff(note)
} }
} }
0xc9 -> { 0xc9 -> {
if (bytes.length >= 1) { if (bytes.length >= 1) {
val waveform = bytes[1] val waveform = bytes[1]
@@ -135,18 +143,44 @@ class VstChipProcessor : AudioWorkletProcessor() {
} }
} }
} }
0xb0 -> { 0xb0 -> {
if (bytes.length == 3) { if (bytes.length == 3) {
val knob = bytes[1] val knob = bytes[1]
val value = bytes[2] val value = bytes[2]
when(knob) { when (knob) {
0x4a -> { 0x4a -> {
dutyCycle = value / 127.0 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
}
}
} }
} }
} }
@@ -167,7 +201,7 @@ class VstChipProcessor : AudioWorkletProcessor() {
notes[i].state = NoteState.ON notes[i].state = NoteState.ON
val n = Note.fromMidi(note) val n = Note.fromMidi(note)
console.log("Playing note: ${n.sharp} (${n.freq})") //console.log("Playing note: ${n.sharp} (${n.freq})")
break break
} }
} }
@@ -205,13 +239,16 @@ class VstChipProcessor : AudioWorkletProcessor() {
note.releaseSamples-- note.releaseSamples--
targetVolume *= (note.releaseSamples / 10000f) targetVolume *= (note.releaseSamples / 10000f)
} }
note.actualVolume += (targetVolume - note.actualVolume) * 0.001f note.actualVolume += (targetVolume - note.actualVolume) * 0.0001f
if (note.state == NoteState.RELEASED && note.actualVolume <= 0) { if (note.state == NoteState.RELEASED && note.actualVolume <= 0) {
note.state = NoteState.OFF note.state = NoteState.OFF
} }
val cycleOffset = note.cycleOffset 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) { val waveValue: Float = when (waveform) {
0 -> { 0 -> {
@@ -233,13 +270,14 @@ class VstChipProcessor : AudioWorkletProcessor() {
} }
} }
left[i] = left[i] + waveValue * note.actualVolume * 0.3f left[i] = left[i] + waveValue * note.actualVolume * 0.3f * amModulation
right[i] = right[i] + waveValue * note.actualVolume * 0.3f right[i] = right[i] + waveValue * note.actualVolume * 0.3f * amModulation
note.cycleOffset += sampleDelta note.cycleOffset += sampleDelta
if (cycleOffset > 1f) { if (cycleOffset > 1f) {
note.cycleOffset -= 1f note.cycleOffset -= 1f
} }
note.sample++
} }
} }
} }

View File

@@ -154,7 +154,7 @@ enum class Note(
; ;
// 69 = A4.ordinal // 69 = A4.ordinal
val freq: Double = round(440.0 * 2.0.pow((ordinal - 69)/12.0) * 10000.0) / 10000.0 val freq: Double = round(440.0 * 2.0.pow((ordinal - 69)/12.0)) // * 10000.0) / 10000.0
val cycleLength: Double = 1.0 / freq val cycleLength: Double = 1.0 / freq
var sampleDelta: Double = 0.0 var sampleDelta: Double = 0.0

View File

@@ -1,15 +1,29 @@
package nl.astraeus.vst.chip package nl.astraeus.vst.chip
import kotlinx.browser.document import kotlinx.browser.document
import kotlinx.browser.window
import nl.astraeus.komp.Komponent import nl.astraeus.komp.Komponent
import nl.astraeus.vst.chip.channel.Broadcaster import nl.astraeus.vst.chip.channel.Broadcaster
import nl.astraeus.vst.chip.channel.MidiMessage
import nl.astraeus.vst.chip.midi.Midi import nl.astraeus.vst.chip.midi.Midi
import nl.astraeus.vst.chip.view.MainView import nl.astraeus.vst.chip.view.MainView
import org.khronos.webgl.Uint8Array
fun main() { fun main() {
Komponent.create(document.body!!, MainView) Komponent.create(document.body!!, MainView)
Broadcaster.start()
Midi.start() 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

@@ -35,7 +35,6 @@ class AudioModule(
console.log("Module not yet loaded") console.log("Module not yet loaded")
} }
} }
} }
abstract class AudioNode( abstract class AudioNode(

View File

@@ -1,23 +1,104 @@
@file:OptIn(ExperimentalJsExport::class)
package nl.astraeus.vst.chip.channel package nl.astraeus.vst.chip.channel
import nl.astraeus.vst.chip.view.MainView import kotlinx.browser.window
import org.khronos.webgl.Uint8Array
import org.w3c.dom.BroadcastChannel import org.w3c.dom.BroadcastChannel
import org.w3c.dom.MessageEvent import org.w3c.dom.MessageEvent
import kotlin.js.Date import kotlin.math.min
@JsExport
enum class MessageType {
SYNC,
MIDI
}
@JsExport
class SyncMessage(
@JsName("timeOrigin")
val timeOrigin: Double = window.performance.asDynamic().timeOrigin,
@JsName("now")
val now: Double = window.performance.now()
) {
@JsName("type")
val type: String = MessageType.SYNC.name
}
// time -> syncOrigin
// receive syn message
// syncOrigin = my timeOrigin - sync.timeOrigin
// - sync.timeOrigin = 50
// - my.timeOrigin = 100
// - syncOrigin = -50
// - sync.timeOrigin = 49
// - my.timeOrigin = 100
// - syncOrigin = update to -51
@JsExport
class MidiMessage(
@JsName("data")
val data: Uint8Array,
@JsName("timestamp")
val timestamp: dynamic = null,
@JsName("timeOrigin")
val timeOrigin: dynamic = window.performance.asDynamic().timeOrigin
) {
@JsName("type")
val type = MessageType.MIDI.name
}
object Sync {
var syncOrigin = 0.0
fun update(sync: SyncMessage) {
syncOrigin = min(syncOrigin, window.performance.asDynamic().timeOrigin - sync.timeOrigin)
}
fun now(): Double = window.performance.now() + syncOrigin
}
object Broadcaster { object Broadcaster {
val channels = mutableMapOf<Int, BroadcastChannel>()
val channel = BroadcastChannel("audio-worklet") fun getChannel(channel: Int): BroadcastChannel = channels.getOrPut(channel) {
val bcChannel = BroadcastChannel("audio-worklet-$channel")
fun start() { bcChannel.onmessage = { event ->
channel.onmessage = ::onMessage onMessage(channel, event)
}
bcChannel
} }
fun onMessage(event: MessageEvent) { private fun onMessage(channel: Int, event: MessageEvent) {
MainView.addMessage("Received message ${event.data} time ${Date().getTime()}") val data: dynamic = event.data.asDynamic()
if (data.type == MessageType.SYNC.name) {
val syncMessage = SyncMessage(
data.timeOrigin,
data.now
)
Sync.update(syncMessage)
} else {
console.log(
"Received broadcast message on channel $channel",
event,
window.performance,
window.performance.now() - event.timeStamp.toDouble()
)
}
} }
fun send(message: String) { fun send(channel: Int, message: Any) {
channel.postMessage(message) getChannel(channel).postMessage(message)
} }
fun sync() {
for (channel in channels.values) {
channel.postMessage(SyncMessage())
}
}
} }

View File

@@ -30,13 +30,20 @@ external class MIDIOutput {
val type: String val type: String
val version: String val version: String
fun send(message: dynamic) fun send(message: dynamic, timestamp: dynamic)
fun open()
fun close()
} }
object Midi { object Midi {
var inputChannel: Int = -1
var outputChannel: Int = -1
var inputs = mutableListOf<MIDIInput>() var inputs = mutableListOf<MIDIInput>()
var outputs = mutableListOf<MIDIInput>() var outputs = mutableListOf<MIDIOutput>()
var currentInput: MIDIInput? = null var currentInput: MIDIInput? = null
var currentOutput: MIDIOutput? = null
fun start() { fun start() {
val navigator = window.navigator.asDynamic() val navigator = window.navigator.asDynamic()
@@ -68,7 +75,7 @@ object Midi {
) )
} }
fun setInput(input: MIDIInput) { fun setInput(input: MIDIInput?) {
console.log("Setting input", input) console.log("Setting input", input)
currentInput?.close() currentInput?.close()
@@ -94,4 +101,17 @@ object Midi {
currentInput?.open() currentInput?.open()
} }
fun setOutput(output: MIDIOutput?) {
console.log("Setting output", output)
currentOutput?.close()
currentOutput = output
currentOutput?.open()
}
fun send(data: Uint8Array, timestamp: dynamic? = null) {
currentOutput?.send(data, timestamp)
}
} }

View File

@@ -6,38 +6,39 @@ import daw.style.Css.noTextSelect
import daw.style.CssId import daw.style.CssId
import daw.style.CssName import daw.style.CssName
import daw.style.hover import daw.style.hover
import kotlinx.html.FlowContent import kotlinx.browser.window
import kotlinx.html.P import kotlinx.html.InputType
import kotlinx.html.a
import kotlinx.html.br
import kotlinx.html.classes
import kotlinx.html.div import kotlinx.html.div
import kotlinx.html.h1 import kotlinx.html.h1
import kotlinx.html.hr import kotlinx.html.input
import kotlinx.html.js.onChangeFunction import kotlinx.html.js.onChangeFunction
import kotlinx.html.js.onClickFunction import kotlinx.html.js.onClickFunction
import kotlinx.html.js.onMouseDownFunction
import kotlinx.html.js.onMouseUpFunction
import kotlinx.html.option import kotlinx.html.option
import kotlinx.html.select import kotlinx.html.select
import kotlinx.html.span import kotlinx.html.span
import nl.astraeus.css.properties.BoxSizing import nl.astraeus.css.properties.BoxSizing
import nl.astraeus.css.properties.FontWeight import nl.astraeus.css.properties.FontWeight
import nl.astraeus.css.properties.Position
import nl.astraeus.css.properties.Transform
import nl.astraeus.css.properties.em
import nl.astraeus.css.properties.hsla
import nl.astraeus.css.properties.prc import nl.astraeus.css.properties.prc
import nl.astraeus.css.properties.px import nl.astraeus.css.properties.px
import nl.astraeus.css.properties.rem import nl.astraeus.css.properties.rem
import nl.astraeus.css.properties.vh
import nl.astraeus.css.properties.vw
import nl.astraeus.css.style.cls import nl.astraeus.css.style.cls
import nl.astraeus.komp.HtmlBuilder import nl.astraeus.komp.HtmlBuilder
import nl.astraeus.komp.Komponent import nl.astraeus.komp.Komponent
import nl.astraeus.vst.Note
import nl.astraeus.vst.chip.audio.VstChipWorklet import nl.astraeus.vst.chip.audio.VstChipWorklet
import nl.astraeus.vst.chip.channel.Broadcaster
import nl.astraeus.vst.chip.midi.Midi import nl.astraeus.vst.chip.midi.Midi
import org.khronos.webgl.Int32Array import org.khronos.webgl.Uint8Array
import org.w3c.dom.HTMLSelectElement import org.w3c.dom.HTMLSelectElement
import org.w3c.performance.Performance
object MainView : Komponent() { object MainView : Komponent() {
private var messages: MutableList<String> = ArrayList() private var messages: MutableList<String> = ArrayList()
private var started = false
init { init {
MainViewCss MainViewCss
@@ -52,94 +53,153 @@ object MainView : Komponent() {
} }
override fun HtmlBuilder.render() { override fun HtmlBuilder.render() {
div { div(MainViewCss.MainDivCss.name) {
h1 { if (!started) {
+"VST Chip" div(MainViewCss.StartSplashCss.name) {
} div(MainViewCss.StartBoxCss.name) {
div { div(MainViewCss.StartButtonCss.name) {
+"Hello, World!" +"START"
} onClickFunction = {
div { started = true
if (VstChipWorklet.created) { VstChipWorklet.create {
+"Worklet created" requestUpdate()
} else { }
a {
href = "#"
+"Create worklet"
onClickFunction = {
VstChipWorklet.create {
requestUpdate()
} }
} }
} }
} }
} }
h1 {
+"VST Chip"
}
div { div {
+ "Midi input: " span {
select { +"Midi input: "
for (mi in Midi.inputs) { select {
option { option {
+mi.name +"None"
value = mi.id value = ""
}
option {
+"Midi over Broadcast"
value = "midi-broadcast"
}
for (mi in Midi.inputs) {
option {
+mi.name
value = mi.id
}
}
onChangeFunction = { event ->
val target = event.target as HTMLSelectElement
if (target.value == "") {
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") {
//
}
}
} }
} }
}
onChangeFunction = { event -> span {
val target = event.target as HTMLSelectElement +"channel:"
val selected = Midi.inputs.find { it.id == target.value } input {
if (selected != null) { type = InputType.number
Midi.setInput(selected) value = Midi.inputChannel.toString()
onChangeFunction = { event ->
val target = event.target as HTMLSelectElement
Midi.inputChannel = 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
}
}
br {} onChangeFunction = { event ->
val target = event.target as HTMLSelectElement
hr {} if (target.value == "") {
Midi.setOutput(null)
repeat(9) { } else {
div(classes = MainViewCss.NoteBarCss.name) { val selected = Midi.outputs.find { it.id == target.value }
for (index in it*12+12..it*12+23) { if (selected != null) {
notePlayer(Note.entries[index]) Midi.setOutput(selected)
}
}
}
}
}
span {
+"channel:"
input {
type = InputType.number
value = Midi.outputChannel.toString()
onChangeFunction = { event ->
val target = event.target as HTMLSelectElement
Midi.outputChannel = target.value.toInt()
}
} }
} }
} }
div {
hr {} +"Send note on to output"
onClickFunction = {
for (message in messages) { val data = Uint8Array(
div { arrayOf(
+message 0x90.toByte(),
0x3c.toByte(),
0x70.toByte()
)
)
Midi.send(data, window.performance.now() + 1000)
Midi.send(data, window.performance.now() + 2000)
} }
} }
} div {
} +"Send note off to output"
onClickFunction = {
private fun FlowContent.notePlayer(note: Note) { val data = Uint8Array(
span { arrayOf(
a(classes = MainViewCss.ButtonCss.name) { 0x90.toByte(),
href = "#" 0x3c.toByte(),
+note.sharp 0x0.toByte(),
onMouseDownFunction = { )
VstChipWorklet.postMessage(Int32Array(arrayOf(0x90, note.ordinal, 32))) )
Midi.send(data)
} }
onMouseUpFunction = {
VstChipWorklet.postMessage(Int32Array(arrayOf(0x90, note.ordinal, 0)))
}
/*
onMouseOutFunction = {
VstChipWorklet.postMessage(Int32Array(arrayOf(0x90, note.ordinal, 0)))
}
*/
} }
} }
} }
object MainViewCss : CssId("main") { object MainViewCss : CssId("main") {
object MainDivCss : CssName()
object ActiveCss : CssName() object ActiveCss : CssName()
object ButtonCss : CssName() object ButtonCss : CssName()
object NoteBarCss : CssName() object NoteBarCss : CssName()
object StartSplashCss : CssName()
object StartBoxCss : CssName()
object StartButtonCss : CssName()
init { init {
defineCss { defineCss {
@@ -155,9 +215,6 @@ object MainView : Komponent() {
padding(0.px) padding(0.px)
height(100.prc) height(100.prc)
color(Css.currentStyle.mainFontColor)
backgroundColor(Css.currentStyle.mainBackgroundColor)
fontFamily("JetbrainsMono, monospace") fontFamily("JetbrainsMono, monospace")
fontSize(14.px) fontSize(14.px)
fontWeight(FontWeight.bold) fontWeight(FontWeight.bold)
@@ -180,6 +237,49 @@ object MainView : Komponent() {
select(cls(NoteBarCss)) { select(cls(NoteBarCss)) {
minHeight(4.rem) 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")
}
}
}
} }
} }
} }