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.Note
import nl.astraeus.vst.registerProcessor import nl.astraeus.vst.registerProcessor
import nl.astraeus.vst.sampleRate import nl.astraeus.vst.sampleRate
import org.khronos.webgl.ArrayBuffer
import org.khronos.webgl.Float32Array import org.khronos.webgl.Float32Array
import org.khronos.webgl.Int32Array import org.khronos.webgl.Int32Array
import org.khronos.webgl.Uint8Array import org.khronos.webgl.Uint8Array
@@ -14,6 +13,7 @@ import org.khronos.webgl.get
import org.khronos.webgl.set import org.khronos.webgl.set
import org.w3c.dom.MessageEvent import org.w3c.dom.MessageEvent
import kotlin.math.PI import kotlin.math.PI
import kotlin.math.min
import kotlin.math.sin import kotlin.math.sin
val POLYPHONICS = 10 val POLYPHONICS = 10
@@ -56,6 +56,14 @@ enum class Waveform {
SAWTOOTH SAWTOOTH
} }
@ExperimentalJsExport
@JsExport
enum class RecordingState {
STOPPED,
WAITING_TO_START,
RECORDING
}
@ExperimentalJsExport @ExperimentalJsExport
@JsExport @JsExport
class VstChipProcessor : AudioWorkletProcessor() { class VstChipProcessor : AudioWorkletProcessor() {
@@ -66,6 +74,7 @@ class VstChipProcessor : AudioWorkletProcessor() {
) )
} }
var waveform = Waveform.SINE.ordinal var waveform = Waveform.SINE.ordinal
var volume = 0.75f
var dutyCycle = 0.5 var dutyCycle = 0.5
var fmFreq = 0.0 var fmFreq = 0.0
var fmAmp = 0.0 var fmAmp = 0.0
@@ -73,25 +82,46 @@ class VstChipProcessor : AudioWorkletProcessor() {
var amAmp = 0.0 var amAmp = 0.0
val sampleLength = 1 / sampleRate.toDouble() val sampleLength = 1 / sampleRate.toDouble()
val recordingBuffer = Float32Array(sampleRate / 60)
var recordingState = RecordingState.STOPPED
var recordingSample = 0
var recordingStart = 0
init { init {
this.port.onmessage = ::handleMessage this.port.onmessage = ::handleMessage
Note.updateSampleRate(sampleRate) Note.updateSampleRate(sampleRate)
} }
private fun handleMessage(message: MessageEvent) { private fun handleMessage(message: MessageEvent) {
console.log("VstChipProcessor: Received message:", message.data) //console.log("VstChipProcessor: Received message:", message.data)
val data = message.data val data = message.data
try { try {
when (data) { when (data) {
is String -> { is String -> {
if (data.startsWith("set_channel")) { when(data) {
val parts = data.split('\n') "start_recording" -> {
if (parts.size == 2) { port.postMessage(recordingBuffer)
midiChannel = parts[1].toInt() if (recordingState == RecordingState.STOPPED) {
println("Setting channel: $midiChannel") 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] val value = bytes[2]
when (knob) { when (knob) {
0x46 -> {
volume = value / 127f
}
0x4a -> { 0x4a -> {
dutyCycle = value / 127.0 dutyCycle = value / 127.0
} }
@@ -244,6 +277,18 @@ class VstChipProcessor : AudioWorkletProcessor() {
val left = outputs[0][0] val left = outputs[0][0]
val right = outputs[0][1] 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) { for (note in notes) {
if (note.state != NoteState.OFF) { if (note.state != NoteState.OFF) {
val sampleDelta = Note.fromMidi(note.note).sampleDelta val sampleDelta = Note.fromMidi(note.note).sampleDelta
@@ -264,16 +309,21 @@ class VstChipProcessor : AudioWorkletProcessor() {
} }
var cycleOffset = note.cycleOffset 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() 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) { val waveValue: Float = when (waveform) {
0 -> { 0 -> {
sin(cycleOffset * PI2).toFloat() sin(cycleOffset * PI2).toFloat()
} }
1 -> { 1 -> {
if (cycleOffset < dutyCycle) { 1f } else { -1f } if (cycleOffset < 0.5) { 1f } else { -1f }
} }
2 -> when { 2 -> when {
cycleOffset < 0.25 -> 4 * cycleOffset cycleOffset < 0.25 -> 4 * cycleOffset
@@ -288,18 +338,35 @@ class VstChipProcessor : AudioWorkletProcessor() {
} }
} }
left[i] = left[i] + waveValue * note.actualVolume * 0.3f * amModulation left[i] = left[i] + waveValue * note.actualVolume * volume * amModulation
right[i] = right[i] + waveValue * note.actualVolume * 0.3f * amModulation right[i] = right[i] + waveValue * note.actualVolume * volume * amModulation
note.cycleOffset += sampleDelta note.cycleOffset += sampleDelta + fmModulation
if (cycleOffset > 1f) { if (note.cycleOffset > 1f) {
note.cycleOffset -= 1f note.cycleOffset -= 1f
if (note.note == lowestNote && recordingState == RecordingState.WAITING_TO_START) {
recordingState = RecordingState.RECORDING
recordingSample = 0
recordingStart = i
}
} }
note.sample++ 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 return true
} }
} }

View File

@@ -1,30 +1,134 @@
package nl.astraeus.vst.chip.audio 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.Uint8Array
import org.khronos.webgl.get
import org.w3c.dom.MessageEvent import org.w3c.dom.MessageEvent
import kotlin.experimental.and
object VstChipWorklet : AudioNode( object VstChipWorklet : AudioNode(
"vst-chip-worklet.js", "vst-chip-worklet.js",
"vst-chip-processor" "vst-chip-processor"
) { ) {
var waveform: Int = 0
set(value) {
field = value
postMessage("waveform\n$value")
}
var midiChannel = 0
var volume = 0.75 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 var fmModFreq = 0.0
set(value) { set(value) {
field = value field = value
postMessage( super.postMessage(
Uint8Array(arrayOf(0xb0.toByte(), 0x4b.toByte(), (value * 127).toInt().toByte())) Uint8Array(arrayOf(0xb0.toByte(), 0x4b.toByte(), (value * 127).toInt().toByte()))
) )
} }
var fmModAmp = 0.0 var fmModAmp = 0.0
set(value) { set(value) {
field = value field = value
postMessage( super.postMessage(
Uint8Array(arrayOf(0xb0.toByte(), 0x4c.toByte(), (value * 127).toInt().toByte())) 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) { 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 { object Midi {
var inputChannel: Int = -1
var outputChannel: Int = -1 var outputChannel: Int = -1
var inputs = mutableListOf<MIDIInput>() var inputs = mutableListOf<MIDIInput>()
@@ -110,7 +109,7 @@ object Midi {
currentOutput?.open() currentOutput?.open()
} }
fun send(data: Uint8Array, timestamp: dynamic? = null) { fun send(data: Uint8Array, timestamp: dynamic = null) {
currentOutput?.send(data, timestamp) currentOutput?.send(data, timestamp)
} }

View File

@@ -1,14 +1,37 @@
package nl.astraeus.vst.chip.view package nl.astraeus.vst.chip.view
import kotlinx.browser.window 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.onChangeFunction
import kotlinx.html.js.onClickFunction import kotlinx.html.js.onClickFunction
import kotlinx.html.js.onInputFunction 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.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.komp.currentElement
import nl.astraeus.vst.chip.audio.VstChipWorklet import nl.astraeus.vst.chip.audio.VstChipWorklet
import nl.astraeus.vst.chip.midi.Midi import nl.astraeus.vst.chip.midi.Midi
import nl.astraeus.vst.ui.components.KnobComponent 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.Css.noTextSelect
import nl.astraeus.vst.ui.css.CssName import nl.astraeus.vst.ui.css.CssName
import nl.astraeus.vst.ui.css.hover import nl.astraeus.vst.ui.css.hover
import nl.astraeus.vst.util.formatDouble
import org.khronos.webgl.Uint8Array 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.HTMLInputElement
import org.w3c.dom.HTMLSelectElement 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 { object MainView : Komponent(), CssName {
private var messages: MutableList<String> = ArrayList() private var messages: MutableList<String> = ArrayList()
private var started = false var started = false
init { init {
css() css()
@@ -46,8 +115,8 @@ object MainView : Komponent(), CssName {
div(StartButtonCss.name) { div(StartButtonCss.name) {
+"START" +"START"
onClickFunction = { onClickFunction = {
started = true
VstChipWorklet.create { VstChipWorklet.create {
started = true
requestUpdate() requestUpdate()
} }
} }
@@ -92,12 +161,11 @@ object MainView : Komponent(), CssName {
+"channel:" +"channel:"
input { input {
type = InputType.number type = InputType.number
value = Midi.inputChannel.toString() value = VstChipWorklet.midiChannel.toString()
onInputFunction = { event -> onInputFunction = { event ->
val target = event.target as HTMLInputElement val target = event.target as HTMLInputElement
Midi.inputChannel = target.value.toInt() println("onInput channel: $target")
println("onInput channel: ${Midi.inputChannel}") VstChipWorklet.setChannel(target.value.toInt())
VstChipWorklet.postMessage("set_channel\n${Midi.inputChannel}")
} }
} }
} }
@@ -142,31 +210,75 @@ object MainView : Komponent(), CssName {
} }
} }
} }
div(ButtonCss.name) { div {
+"Send note on to output" span(ButtonCss.name) {
onClickFunction = { +"Send note on to output"
val data = Uint8Array( onClickFunction = {
arrayOf( val data = Uint8Array(
0x90.toByte(), arrayOf(
0x3c.toByte(), 0x90.toByte(),
0x70.toByte() 0x3c.toByte(),
0x70.toByte()
)
) )
) Midi.send(data, window.performance.now() + 1000)
Midi.send(data, window.performance.now() + 1000) Midi.send(data, window.performance.now() + 2000)
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) { div {
+"Send note off to output" span(ButtonCss.name) {
onClickFunction = { +"Sine"
val data = Uint8Array( if (VstChipWorklet.waveform == 0) {
arrayOf( classes += SelectedCss.name
0x90.toByte(), }
0x3c.toByte(), onClickFunction = {
0x0.toByte(), VstChipWorklet.waveform = 0
) requestUpdate()
) }
Midi.send(data) }
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) { div(ControlsCss.name) {
@@ -176,19 +288,35 @@ object MainView : Komponent(), CssName {
label = "Volume", label = "Volume",
minValue = 0.0, minValue = 0.0,
maxValue = 1.0, maxValue = 1.0,
step = 2.0 / 127.0 step = 2.0 / 127.0,
width = 100,
height = 120,
) { value -> ) { value ->
println("Value changed: ${formatDouble(value, 2)}")
VstChipWorklet.volume = value 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( include(
KnobComponent( KnobComponent(
value = VstChipWorklet.fmModFreq, value = VstChipWorklet.fmModFreq,
label = "FM Freq", label = "FM Freq",
minValue = 0.0, minValue = 0.0,
maxValue = 1.0, maxValue = 1.0,
step = 2.0 / 127.0 step = 2.0 / 127.0,
width = 100,
height = 120,
) { value -> ) { value ->
VstChipWorklet.fmModFreq = value VstChipWorklet.fmModFreq = value
} }
@@ -199,18 +327,48 @@ object MainView : Komponent(), CssName {
label = "FM Ampl", label = "FM Ampl",
minValue = 0.0, minValue = 0.0,
maxValue = 1.0, maxValue = 1.0,
step = 2.0 / 127.0 step = 2.0 / 127.0,
width = 100,
height = 120,
) { value -> ) { value ->
VstChipWorklet.fmModAmp = 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 MainDivCss : CssName
object ActiveCss : CssName object ActiveCss : CssName
object ButtonCss : CssName object ButtonCss : CssName
object SelectedCss : CssName
object NoteBarCss : CssName object NoteBarCss : CssName
object StartSplashCss : CssName object StartSplashCss : CssName
object StartBoxCss : CssName object StartBoxCss : CssName
@@ -242,7 +400,12 @@ object MainView : Komponent(), CssName {
//transition() //transition()
noTextSelect() noTextSelect()
} }
select("select", "input", "textarea") {
backgroundColor(Css.currentStyle.mainBackgroundColor)
color(Css.currentStyle.mainFontColor)
}
select(cls(ButtonCss)) { select(cls(ButtonCss)) {
display(Display.inlineBlock)
margin(1.rem) margin(1.rem)
padding(1.rem) padding(1.rem)
backgroundColor(Css.currentStyle.buttonBackgroundColor) backgroundColor(Css.currentStyle.buttonBackgroundColor)
@@ -253,6 +416,9 @@ object MainView : Komponent(), CssName {
hover { hover {
backgroundColor(Css.currentStyle.buttonBackgroundColor.hover()) backgroundColor(Css.currentStyle.buttonBackgroundColor.hover())
} }
and(SelectedCss.cls()) {
backgroundColor(Css.currentStyle.buttonBackgroundColor.hover().hover().hover())
}
} }
select(cls(ActiveCss)) { select(cls(ActiveCss)) {
//backgroundColor(Css.currentStyle.selectedBackgroundColor) //backgroundColor(Css.currentStyle.selectedBackgroundColor)
@@ -305,8 +471,8 @@ object MainView : Komponent(), CssName {
} }
select(ControlsCss.cls()) { select(ControlsCss.cls()) {
display(Display.flex) display(Display.flex)
flexDirection(FlexDirection.column) flexDirection(FlexDirection.row)
justifyContent(JustifyContent.center) justifyContent(JustifyContent.flexStart)
alignItems(AlignItems.center) alignItems(AlignItems.center)
margin(1.rem) margin(1.rem)
padding(1.rem) padding(1.rem)