diff --git a/audio-worklet/build.gradle.kts b/audio-worklet/build.gradle.kts index fbd9ae0..132f5a1 100644 --- a/audio-worklet/build.gradle.kts +++ b/audio-worklet/build.gradle.kts @@ -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 { diff --git a/audio-worklet/src/jsMain/kotlin/nl/astraeus/vst/Externals.kt b/audio-worklet/src/jsMain/kotlin/nl/astraeus/vst/Externals.kt deleted file mode 100644 index ccbe28c..0000000 --- a/audio-worklet/src/jsMain/kotlin/nl/astraeus/vst/Externals.kt +++ /dev/null @@ -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>, - outputs: Array>, - parameters: dynamic - ) : Boolean { definedExternally } - -} - -external fun registerProcessor(name: String, processorCtor: JsClass<*>) -external val sampleRate: Int -external val currentTime: Double diff --git a/audio-worklet/src/jsMain/kotlin/nl/astraeus/vst/chip/ChipProcessor.kt b/audio-worklet/src/jsMain/kotlin/nl/astraeus/vst/chip/ChipProcessor.kt index 18daa7a..a76f2a8 100644 --- a/audio-worklet/src/jsMain/kotlin/nl/astraeus/vst/chip/ChipProcessor.kt +++ b/audio-worklet/src/jsMain/kotlin/nl/astraeus/vst/chip/ChipProcessor.kt @@ -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++ } } } diff --git a/common/src/commonMain/kotlin/nl/astraeus/vst/Note.kt b/common/src/commonMain/kotlin/nl/astraeus/vst/Note.kt index 6b5f639..ca08cde 100644 --- a/common/src/commonMain/kotlin/nl/astraeus/vst/Note.kt +++ b/common/src/commonMain/kotlin/nl/astraeus/vst/Note.kt @@ -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 diff --git a/src/jsMain/kotlin/nl/astraeus/vst/chip/Main.kt b/src/jsMain/kotlin/nl/astraeus/vst/chip/Main.kt index 6bee45c..a673959 100644 --- a/src/jsMain/kotlin/nl/astraeus/vst/chip/Main.kt +++ b/src/jsMain/kotlin/nl/astraeus/vst/chip/Main.kt @@ -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) + } diff --git a/src/jsMain/kotlin/nl/astraeus/vst/chip/audio/AudioModule.kt b/src/jsMain/kotlin/nl/astraeus/vst/chip/audio/AudioModule.kt index 95cbe95..82f582a 100644 --- a/src/jsMain/kotlin/nl/astraeus/vst/chip/audio/AudioModule.kt +++ b/src/jsMain/kotlin/nl/astraeus/vst/chip/audio/AudioModule.kt @@ -35,7 +35,6 @@ class AudioModule( console.log("Module not yet loaded") } } - } abstract class AudioNode( diff --git a/src/jsMain/kotlin/nl/astraeus/vst/chip/channel/Broadcaster.kt b/src/jsMain/kotlin/nl/astraeus/vst/chip/channel/Broadcaster.kt index 08bbc35..f4bfca3 100644 --- a/src/jsMain/kotlin/nl/astraeus/vst/chip/channel/Broadcaster.kt +++ b/src/jsMain/kotlin/nl/astraeus/vst/chip/channel/Broadcaster.kt @@ -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() - 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()) + } + } + } diff --git a/src/jsMain/kotlin/nl/astraeus/vst/chip/midi/Midi.kt b/src/jsMain/kotlin/nl/astraeus/vst/chip/midi/Midi.kt index 02ce022..d3486db 100644 --- a/src/jsMain/kotlin/nl/astraeus/vst/chip/midi/Midi.kt +++ b/src/jsMain/kotlin/nl/astraeus/vst/chip/midi/Midi.kt @@ -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() - var outputs = mutableListOf() + var outputs = mutableListOf() 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) + } + } diff --git a/src/jsMain/kotlin/nl/astraeus/vst/chip/view/MainView.kt b/src/jsMain/kotlin/nl/astraeus/vst/chip/view/MainView.kt index 9459376..686e439 100644 --- a/src/jsMain/kotlin/nl/astraeus/vst/chip/view/MainView.kt +++ b/src/jsMain/kotlin/nl/astraeus/vst/chip/view/MainView.kt @@ -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 = 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") + } + } + } } } }