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 {
dependencies {
implementation(project(":common"))
implementation("nl.astraeus:vst-worklet-base:1.0.0-SNAPSHOT")
}
}
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 dutyCycle = 0.5
var fmFreq = 0.0
var fmAmp = 0.0
var amFreq = 0.0
var amAmp = 0.0
val sampleLength = 1 / sampleRate.toDouble()
init {
this.port.onmessage = ::handleMessage
@@ -106,6 +111,7 @@ class VstChipProcessor : AudioWorkletProcessor() {
private fun playMidi(bytes: Int32Array) {
if (bytes.length > 0) {
//console.log("Received", bytes)
when(bytes[0]) {
0x90 -> {
if (bytes.length == 3) {
@@ -119,6 +125,7 @@ class VstChipProcessor : AudioWorkletProcessor() {
}
}
}
0x80 -> {
if (bytes.length >= 2) {
val note = bytes[1]
@@ -126,6 +133,7 @@ class VstChipProcessor : AudioWorkletProcessor() {
noteOff(note)
}
}
0xc9 -> {
if (bytes.length >= 1) {
val waveform = bytes[1]
@@ -135,18 +143,44 @@ class VstChipProcessor : AudioWorkletProcessor() {
}
}
}
0xb0 -> {
if (bytes.length == 3) {
val knob = bytes[1]
val value = bytes[2]
when(knob) {
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
}
}
}
}
}
@@ -167,7 +201,7 @@ class VstChipProcessor : AudioWorkletProcessor() {
notes[i].state = NoteState.ON
val n = Note.fromMidi(note)
console.log("Playing note: ${n.sharp} (${n.freq})")
//console.log("Playing note: ${n.sharp} (${n.freq})")
break
}
}
@@ -205,13 +239,16 @@ class VstChipProcessor : AudioWorkletProcessor() {
note.releaseSamples--
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) {
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) {
0 -> {
@@ -233,13 +270,14 @@ class VstChipProcessor : AudioWorkletProcessor() {
}
}
left[i] = left[i] + waveValue * note.actualVolume * 0.3f
right[i] = right[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 * amModulation
note.cycleOffset += sampleDelta
if (cycleOffset > 1f) {
note.cycleOffset -= 1f
}
note.sample++
}
}
}

View File

@@ -154,7 +154,7 @@ enum class Note(
;
// 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
var sampleDelta: Double = 0.0

View File

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

View File

@@ -1,23 +1,104 @@
@file:OptIn(ExperimentalJsExport::class)
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.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 {
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() {
channel.onmessage = ::onMessage
bcChannel.onmessage = { event ->
onMessage(channel, event)
}
bcChannel
}
fun onMessage(event: MessageEvent) {
MainView.addMessage("Received message ${event.data} time ${Date().getTime()}")
private fun onMessage(channel: Int, event: MessageEvent) {
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) {
channel.postMessage(message)
fun send(channel: Int, message: Any) {
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 version: String
fun send(message: dynamic)
fun send(message: dynamic, timestamp: dynamic)
fun open()
fun close()
}
object Midi {
var inputChannel: Int = -1
var outputChannel: Int = -1
var inputs = mutableListOf<MIDIInput>()
var outputs = mutableListOf<MIDIInput>()
var outputs = mutableListOf<MIDIOutput>()
var currentInput: MIDIInput? = null
var currentOutput: MIDIOutput? = null
fun start() {
val navigator = window.navigator.asDynamic()
@@ -68,7 +75,7 @@ object Midi {
)
}
fun setInput(input: MIDIInput) {
fun setInput(input: MIDIInput?) {
console.log("Setting input", input)
currentInput?.close()
@@ -94,4 +101,17 @@ object Midi {
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.CssName
import daw.style.hover
import kotlinx.html.FlowContent
import kotlinx.html.P
import kotlinx.html.a
import kotlinx.html.br
import kotlinx.html.classes
import kotlinx.browser.window
import kotlinx.html.InputType
import kotlinx.html.div
import kotlinx.html.h1
import kotlinx.html.hr
import kotlinx.html.input
import kotlinx.html.js.onChangeFunction
import kotlinx.html.js.onClickFunction
import kotlinx.html.js.onMouseDownFunction
import kotlinx.html.js.onMouseUpFunction
import kotlinx.html.option
import kotlinx.html.select
import kotlinx.html.span
import nl.astraeus.css.properties.BoxSizing
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.px
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.komp.HtmlBuilder
import nl.astraeus.komp.Komponent
import nl.astraeus.vst.Note
import nl.astraeus.vst.chip.audio.VstChipWorklet
import nl.astraeus.vst.chip.channel.Broadcaster
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.performance.Performance
object MainView : Komponent() {
private var messages: MutableList<String> = ArrayList()
private var started = false
init {
MainViewCss
@@ -52,94 +53,153 @@ object MainView : Komponent() {
}
override fun HtmlBuilder.render() {
div {
h1 {
+"VST Chip"
}
div {
+"Hello, World!"
}
div {
if (VstChipWorklet.created) {
+"Worklet created"
} else {
a {
href = "#"
+"Create worklet"
onClickFunction = {
VstChipWorklet.create {
requestUpdate()
div(MainViewCss.MainDivCss.name) {
if (!started) {
div(MainViewCss.StartSplashCss.name) {
div(MainViewCss.StartBoxCss.name) {
div(MainViewCss.StartButtonCss.name) {
+"START"
onClickFunction = {
started = true
VstChipWorklet.create {
requestUpdate()
}
}
}
}
}
}
h1 {
+"VST Chip"
}
div {
+ "Midi input: "
select {
for (mi in Midi.inputs) {
span {
+"Midi input: "
select {
option {
+mi.name
value = mi.id
+"None"
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 ->
val target = event.target as HTMLSelectElement
val selected = Midi.inputs.find { it.id == target.value }
if (selected != null) {
Midi.setInput(selected)
}
span {
+"channel:"
input {
type = InputType.number
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 {}
hr {}
repeat(9) {
div(classes = MainViewCss.NoteBarCss.name) {
for (index in it*12+12..it*12+23) {
notePlayer(Note.entries[index])
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)
}
}
}
}
}
span {
+"channel:"
input {
type = InputType.number
value = Midi.outputChannel.toString()
onChangeFunction = { event ->
val target = event.target as HTMLSelectElement
Midi.outputChannel = target.value.toInt()
}
}
}
}
hr {}
for (message in messages) {
div {
+message
div {
+"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)
}
}
}
}
private fun FlowContent.notePlayer(note: Note) {
span {
a(classes = MainViewCss.ButtonCss.name) {
href = "#"
+note.sharp
onMouseDownFunction = {
VstChipWorklet.postMessage(Int32Array(arrayOf(0x90, note.ordinal, 32)))
div {
+"Send note off to output"
onClickFunction = {
val data = Uint8Array(
arrayOf(
0x90.toByte(),
0x3c.toByte(),
0x0.toByte(),
)
)
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 MainDivCss : CssName()
object ActiveCss : CssName()
object ButtonCss : CssName()
object NoteBarCss : CssName()
object StartSplashCss : CssName()
object StartBoxCss : CssName()
object StartButtonCss : CssName()
init {
defineCss {
@@ -155,9 +215,6 @@ object MainView : Komponent() {
padding(0.px)
height(100.prc)
color(Css.currentStyle.mainFontColor)
backgroundColor(Css.currentStyle.mainBackgroundColor)
fontFamily("JetbrainsMono, monospace")
fontSize(14.px)
fontWeight(FontWeight.bold)
@@ -180,6 +237,49 @@ object MainView : Komponent() {
select(cls(NoteBarCss)) {
minHeight(4.rem)
}
select(cls(MainDivCss)) {
margin(1.rem)
}
select("select") {
plain("appearance", "none")
border("0")
outline("0")
width(20.rem)
padding(0.5.rem, 2.rem, 0.5.rem, 0.5.rem)
backgroundImage("url('https://upload.wikimedia.org/wikipedia/commons/9/9d/Caret_down_font_awesome_whitevariation.svg')")
background("right 0.8em center/1.4em")
backgroundColor(Css.currentStyle.inputBackgroundColor)
color(Css.currentStyle.entryFontColor)
borderRadius(0.25.em)
}
select(cls(StartSplashCss)) {
position(Position.fixed)
left(0.px)
top(0.px)
width(100.vw)
height(100.vh)
zIndex(100)
backgroundColor(hsla(32, 0, 50, 0.6))
select(cls(StartBoxCss)) {
position(Position.relative)
left(25.vw)
top(25.vh)
width(50.vw)
height(50.vh)
backgroundColor(hsla(0, 0, 50, 0.25))
select(cls(StartButtonCss)) {
position(Position.absolute)
left(50.prc)
top(50.prc)
transform(Transform("translate(-50%, -50%)"))
padding(1.rem)
backgroundColor(Css.currentStyle.buttonBackgroundColor)
cursor("pointer")
}
}
}
}
}
}