From ccc7e9a4e961d066f8c7672a23fc6dfc31d711dd Mon Sep 17 00:00:00 2001 From: rnentjes Date: Fri, 28 Jun 2024 17:07:58 +0200 Subject: [PATCH] Modulation, waveforms --- .../nl/astraeus/vst/chip/ChipProcessor.kt | 95 ++++++- .../astraeus/vst/chip/audio/VstChipWorklet.kt | 110 +++++++- .../kotlin/nl/astraeus/vst/chip/midi/Midi.kt | 3 +- .../nl/astraeus/vst/chip/view/MainView.kt | 240 +++++++++++++++--- 4 files changed, 392 insertions(+), 56 deletions(-) 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 a65de46..2e94977 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 @@ -6,7 +6,6 @@ import nl.astraeus.vst.AudioWorkletProcessor import nl.astraeus.vst.Note import nl.astraeus.vst.registerProcessor import nl.astraeus.vst.sampleRate -import org.khronos.webgl.ArrayBuffer import org.khronos.webgl.Float32Array import org.khronos.webgl.Int32Array import org.khronos.webgl.Uint8Array @@ -14,6 +13,7 @@ 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 @@ -56,6 +56,14 @@ enum class Waveform { SAWTOOTH } +@ExperimentalJsExport +@JsExport +enum class RecordingState { + STOPPED, + WAITING_TO_START, + RECORDING +} + @ExperimentalJsExport @JsExport class VstChipProcessor : AudioWorkletProcessor() { @@ -66,6 +74,7 @@ class VstChipProcessor : AudioWorkletProcessor() { ) } var waveform = Waveform.SINE.ordinal + var volume = 0.75f var dutyCycle = 0.5 var fmFreq = 0.0 var fmAmp = 0.0 @@ -73,25 +82,46 @@ class VstChipProcessor : AudioWorkletProcessor() { var amAmp = 0.0 val sampleLength = 1 / sampleRate.toDouble() + val recordingBuffer = Float32Array(sampleRate / 60) + var recordingState = RecordingState.STOPPED + var recordingSample = 0 + var recordingStart = 0 + init { this.port.onmessage = ::handleMessage Note.updateSampleRate(sampleRate) } private fun handleMessage(message: MessageEvent) { - console.log("VstChipProcessor: Received message:", message.data) + //console.log("VstChipProcessor: Received message:", message.data) val data = message.data try { when (data) { is String -> { - if (data.startsWith("set_channel")) { - val parts = data.split('\n') - if (parts.size == 2) { - midiChannel = parts[1].toInt() - println("Setting channel: $midiChannel") + when(data) { + "start_recording" -> { + port.postMessage(recordingBuffer) + if (recordingState == RecordingState.STOPPED) { + recordingState = RecordingState.WAITING_TO_START + recordingSample = 0 + } } + else -> + if (data.startsWith("set_channel")) { + val parts = data.split('\n') + if (parts.size == 2) { + midiChannel = parts[1].toInt() + println("Setting channel: $midiChannel") + } + } else if (data.startsWith("waveform")) { + val parts = data.split('\n') + if (parts.size == 2) { + waveform =parts[1].toInt() + println("Setting waveform: $waveform") + } + } } } @@ -168,6 +198,9 @@ class VstChipProcessor : AudioWorkletProcessor() { val value = bytes[2] when (knob) { + 0x46 -> { + volume = value / 127f + } 0x4a -> { dutyCycle = value / 127.0 } @@ -244,6 +277,18 @@ class VstChipProcessor : AudioWorkletProcessor() { val left = outputs[0][0] val right = outputs[0][1] + var lowestNote = 200 + for (note in notes) { + if (note.state != NoteState.OFF) { + lowestNote = min(lowestNote, note.note) + } + } + if (lowestNote == 200 && recordingState == RecordingState.WAITING_TO_START) { + recordingState = RecordingState.RECORDING + recordingSample = 0 + recordingStart = 0 + } + for (note in notes) { if (note.state != NoteState.OFF) { val sampleDelta = Note.fromMidi(note.note).sampleDelta @@ -264,16 +309,21 @@ class VstChipProcessor : AudioWorkletProcessor() { } var cycleOffset = note.cycleOffset - val fmModulation = sin(sampleLength * fmFreq * 10f * PI2 * note.sample).toFloat() * fmAmp * 5f + val fmModulation = sampleDelta * sin( fmFreq * 20f * PI2 * (note.sample / sampleRate.toDouble())).toFloat() * fmAmp val amModulation = 1f + (sin(sampleLength * amFreq * 10f * PI2 * note.sample) * amAmp).toFloat() - cycleOffset += fmModulation + + 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 @@ -288,18 +338,35 @@ class VstChipProcessor : AudioWorkletProcessor() { } } - 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) { + 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 (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 } } diff --git a/src/jsMain/kotlin/nl/astraeus/vst/chip/audio/VstChipWorklet.kt b/src/jsMain/kotlin/nl/astraeus/vst/chip/audio/VstChipWorklet.kt index f2f72c3..3611f17 100644 --- a/src/jsMain/kotlin/nl/astraeus/vst/chip/audio/VstChipWorklet.kt +++ b/src/jsMain/kotlin/nl/astraeus/vst/chip/audio/VstChipWorklet.kt @@ -1,30 +1,134 @@ package nl.astraeus.vst.chip.audio +import nl.astraeus.vst.chip.view.MainView +import nl.astraeus.vst.chip.view.WaveformView +import org.khronos.webgl.Float32Array import org.khronos.webgl.Uint8Array +import org.khronos.webgl.get import org.w3c.dom.MessageEvent +import kotlin.experimental.and object VstChipWorklet : AudioNode( "vst-chip-worklet.js", "vst-chip-processor" ) { + var waveform: Int = 0 + set(value) { + field = value + postMessage("waveform\n$value") + } + var midiChannel = 0 var volume = 0.75 + set(value) { + field = value + super.postMessage( + Uint8Array(arrayOf(0xb0.toByte(), 0x46.toByte(), (value * 127).toInt().toByte())) + ) + } + var dutyCycle = 0.5 + set(value) { + field = value + super.postMessage( + Uint8Array(arrayOf(0xb0.toByte(), 0x4a.toByte(), (value * 127).toInt().toByte())) + ) + } var fmModFreq = 0.0 set(value) { field = value - postMessage( + super.postMessage( Uint8Array(arrayOf(0xb0.toByte(), 0x4b.toByte(), (value * 127).toInt().toByte())) ) } var fmModAmp = 0.0 set(value) { field = value - postMessage( + super.postMessage( Uint8Array(arrayOf(0xb0.toByte(), 0x4c.toByte(), (value * 127).toInt().toByte())) ) } + var amModFreq = 0.0 + set(value) { + field = value + super.postMessage( + Uint8Array(arrayOf(0xb0.toByte(), 0x47.toByte(), (value * 127).toInt().toByte())) + ) + } + var amModAmp = 0.0 + set(value) { + field = value + super.postMessage( + Uint8Array(arrayOf(0xb0.toByte(), 0x48.toByte(), (value * 127).toInt().toByte())) + ) + } + 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() + } + } + + override fun postMessage(msg: Any) { + if (msg is Uint8Array) { + if ( + msg.length == 3 + && (msg[0] and 0xf == midiChannel.toByte()) + && (msg[0] and 0xf0.toByte() == 0xb0.toByte()) + ) { + val knob = msg[1] + val value = msg[2] + + handleIncomingMidi(knob, value) + } else { + super.postMessage(msg) + } + } else { + super.postMessage(msg) + } + } + + private fun handleIncomingMidi(knob: Byte, value: Byte) { + when (knob) { + 0x46.toByte() -> { + volume = value / 127.0 + MainView.requestUpdate() + } + + 0x4a.toByte() -> { + dutyCycle = value / 127.0 + MainView.requestUpdate() + } + + 0x4b.toByte() -> { + fmModFreq = value / 127.0 + MainView.requestUpdate() + } + + 0x4c.toByte() -> { + fmModAmp = value / 127.0 + MainView.requestUpdate() + } + + 0x47.toByte() -> { + amModFreq = value / 127.0 + MainView.requestUpdate() + } + + 0x48.toByte() -> { + amModAmp = value / 127.0 + MainView.requestUpdate() + } + } + } + + fun setChannel(channel: Int) { + midiChannel = channel + + postMessage("set_channel\n${midiChannel}") } } 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 d3486db..2afaf99 100644 --- a/src/jsMain/kotlin/nl/astraeus/vst/chip/midi/Midi.kt +++ b/src/jsMain/kotlin/nl/astraeus/vst/chip/midi/Midi.kt @@ -37,7 +37,6 @@ external class MIDIOutput { } object Midi { - var inputChannel: Int = -1 var outputChannel: Int = -1 var inputs = mutableListOf() @@ -110,7 +109,7 @@ object Midi { currentOutput?.open() } - fun send(data: Uint8Array, timestamp: dynamic? = null) { + 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 4873d18..6de3724 100644 --- a/src/jsMain/kotlin/nl/astraeus/vst/chip/view/MainView.kt +++ b/src/jsMain/kotlin/nl/astraeus/vst/chip/view/MainView.kt @@ -1,14 +1,37 @@ package nl.astraeus.vst.chip.view import kotlinx.browser.window -import kotlinx.html.* +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 nl.astraeus.css.properties.* +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 +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.komp.currentElement import nl.astraeus.vst.chip.audio.VstChipWorklet import nl.astraeus.vst.chip.midi.Midi import nl.astraeus.vst.ui.components.KnobComponent @@ -17,14 +40,60 @@ import nl.astraeus.vst.ui.css.Css.defineCss import nl.astraeus.vst.ui.css.Css.noTextSelect import nl.astraeus.vst.ui.css.CssName import nl.astraeus.vst.ui.css.hover -import nl.astraeus.vst.util.formatDouble import org.khronos.webgl.Uint8Array +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 WaveformView: Komponent() { + + init { + window.requestAnimationFrame(::onAnimationFrame) + } + + fun onAnimationFrame(time: Double) { + if (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.clearRect(0.0, 0.0, width, height) + val step = 1000.0 / data.length + ctx.beginPath() + ctx.strokeStyle = "rgba(255, 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() + } + } + } + } + } + +} + object MainView : Komponent(), CssName { private var messages: MutableList = ArrayList() - private var started = false + var started = false init { css() @@ -46,8 +115,8 @@ object MainView : Komponent(), CssName { div(StartButtonCss.name) { +"START" onClickFunction = { - started = true VstChipWorklet.create { + started = true requestUpdate() } } @@ -92,12 +161,11 @@ object MainView : Komponent(), CssName { +"channel:" input { type = InputType.number - value = Midi.inputChannel.toString() + value = VstChipWorklet.midiChannel.toString() onInputFunction = { event -> val target = event.target as HTMLInputElement - Midi.inputChannel = target.value.toInt() - println("onInput channel: ${Midi.inputChannel}") - VstChipWorklet.postMessage("set_channel\n${Midi.inputChannel}") + println("onInput channel: $target") + VstChipWorklet.setChannel(target.value.toInt()) } } } @@ -142,31 +210,75 @@ object MainView : Komponent(), CssName { } } } - div(ButtonCss.name) { - +"Send note on to output" - onClickFunction = { - val data = Uint8Array( - arrayOf( - 0x90.toByte(), - 0x3c.toByte(), - 0x70.toByte() + div { + span(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) + Midi.send(data, window.performance.now() + 1000) + Midi.send(data, window.performance.now() + 2000) + } + } + span(ButtonCss.name) { + +"Send note off to output" + onClickFunction = { + val data = Uint8Array( + arrayOf( + 0x90.toByte(), + 0x3c.toByte(), + 0x0.toByte(), + ) + ) + Midi.send(data) + } } } - div(ButtonCss.name) { - +"Send note off to output" - onClickFunction = { - val data = Uint8Array( - arrayOf( - 0x90.toByte(), - 0x3c.toByte(), - 0x0.toByte(), - ) - ) - Midi.send(data) + div { + span(ButtonCss.name) { + +"Sine" + if (VstChipWorklet.waveform == 0) { + classes += SelectedCss.name + } + onClickFunction = { + VstChipWorklet.waveform = 0 + requestUpdate() + } + } + span(ButtonCss.name) { + +"Square" + if (VstChipWorklet.waveform == 1) { + classes += SelectedCss.name + } + onClickFunction = { + VstChipWorklet.waveform = 1 + requestUpdate() + } + } + span(ButtonCss.name) { + +"Triangle" + if (VstChipWorklet.waveform == 2) { + classes += SelectedCss.name + } + onClickFunction = { + VstChipWorklet.waveform = 2 + requestUpdate() + } + } + span(ButtonCss.name) { + +"Sawtooth" + if (VstChipWorklet.waveform == 3) { + classes += SelectedCss.name + } + onClickFunction = { + VstChipWorklet.waveform = 3 + requestUpdate() + } } } div(ControlsCss.name) { @@ -176,19 +288,35 @@ object MainView : Komponent(), CssName { label = "Volume", minValue = 0.0, maxValue = 1.0, - step = 2.0 / 127.0 + step = 2.0 / 127.0, + width = 100, + height = 120, ) { value -> - println("Value changed: ${formatDouble(value, 2)}") 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( KnobComponent( value = VstChipWorklet.fmModFreq, label = "FM Freq", minValue = 0.0, maxValue = 1.0, - step = 2.0 / 127.0 + step = 2.0 / 127.0, + width = 100, + height = 120, ) { value -> VstChipWorklet.fmModFreq = value } @@ -199,18 +327,48 @@ object MainView : Komponent(), CssName { label = "FM Ampl", minValue = 0.0, maxValue = 1.0, - step = 2.0 / 127.0 + step = 2.0 / 127.0, + width = 100, + height = 120, ) { value -> VstChipWorklet.fmModAmp = value } ) + include( + KnobComponent( + value = VstChipWorklet.amModFreq, + label = "AM Freq", + minValue = 0.0, + maxValue = 1.0, + step = 2.0 / 127.0, + width = 100, + height = 120, + ) { value -> + VstChipWorklet.amModFreq = value + } + ) + include( + KnobComponent( + value = VstChipWorklet.amModAmp, + label = "AM Ampl", + minValue = 0.0, + maxValue = 1.0, + step = 2.0 / 127.0, + width = 100, + height = 120, + ) { value -> + VstChipWorklet.amModAmp = value + } + ) } + include(WaveformView) } } object MainDivCss : CssName object ActiveCss : CssName object ButtonCss : CssName + object SelectedCss : CssName object NoteBarCss : CssName object StartSplashCss : CssName object StartBoxCss : CssName @@ -242,7 +400,12 @@ object MainView : Komponent(), CssName { //transition() noTextSelect() } + select("select", "input", "textarea") { + backgroundColor(Css.currentStyle.mainBackgroundColor) + color(Css.currentStyle.mainFontColor) + } select(cls(ButtonCss)) { + display(Display.inlineBlock) margin(1.rem) padding(1.rem) backgroundColor(Css.currentStyle.buttonBackgroundColor) @@ -253,6 +416,9 @@ object MainView : Komponent(), CssName { hover { backgroundColor(Css.currentStyle.buttonBackgroundColor.hover()) } + and(SelectedCss.cls()) { + backgroundColor(Css.currentStyle.buttonBackgroundColor.hover().hover().hover()) + } } select(cls(ActiveCss)) { //backgroundColor(Css.currentStyle.selectedBackgroundColor) @@ -305,8 +471,8 @@ object MainView : Komponent(), CssName { } select(ControlsCss.cls()) { display(Display.flex) - flexDirection(FlexDirection.column) - justifyContent(JustifyContent.center) + flexDirection(FlexDirection.row) + justifyContent(JustifyContent.flexStart) alignItems(AlignItems.center) margin(1.rem) padding(1.rem)