Modulation, waveforms

This commit is contained in:
2024-06-28 17:07:58 +02:00
parent b02c7733b0
commit ccc7e9a4e9
4 changed files with 392 additions and 56 deletions

View File

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

View File

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

View File

@@ -37,7 +37,6 @@ external class MIDIOutput {
}
object Midi {
var inputChannel: Int = -1
var outputChannel: Int = -1
var inputs = mutableListOf<MIDIInput>()
@@ -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)
}

View File

@@ -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<String> = 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)