Replaced `MainView` object with a `Views` singleton for better modularity and lazy initialization. Adjusted CSS structure, updated dependencies, and improved FM/AM modulation logic for greater flexibility. Additionally, upgraded Kotlin multiplatform version and added inline source mapping.
555 lines
15 KiB
Kotlin
555 lines
15 KiB
Kotlin
@file:OptIn(ExperimentalJsExport::class)
|
|
|
|
package nl.astraeus.vst.chip.view
|
|
|
|
import kotlinx.browser.window
|
|
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 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.Style
|
|
import nl.astraeus.css.style.cls
|
|
import nl.astraeus.komp.HtmlBuilder
|
|
import nl.astraeus.komp.Komponent
|
|
import nl.astraeus.komp.currentElement
|
|
import nl.astraeus.midi.message.TimedMidiMessage
|
|
import nl.astraeus.midi.message.getCurrentTime
|
|
import nl.astraeus.vst.chip.Views
|
|
import nl.astraeus.vst.chip.audio.VstChipWorklet
|
|
import nl.astraeus.vst.chip.audio.VstChipWorklet.midiChannel
|
|
import nl.astraeus.vst.chip.midi.Midi
|
|
import nl.astraeus.vst.chip.ws.WebsocketClient
|
|
import nl.astraeus.vst.ui.components.ExpKnobComponent
|
|
import nl.astraeus.vst.ui.components.KnobComponent
|
|
import nl.astraeus.vst.ui.css.Css
|
|
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 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 (Views.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.lineWidth = 2.0
|
|
ctx.clearRect(0.0, 0.0, width, height)
|
|
val step = 1000.0 / data.length
|
|
ctx.beginPath()
|
|
ctx.strokeStyle = "rgba(0, 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()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
class MainView : Komponent() {
|
|
private var messages: MutableList<String> = ArrayList()
|
|
var started = false
|
|
|
|
init {
|
|
css()
|
|
}
|
|
|
|
fun addMessage(message: String) {
|
|
messages.add(message)
|
|
while (messages.size > 10) {
|
|
messages.removeAt(0)
|
|
}
|
|
requestUpdate()
|
|
}
|
|
|
|
override fun renderUpdate() {
|
|
println("Rendering MainView")
|
|
super.renderUpdate()
|
|
}
|
|
|
|
override fun HtmlBuilder.render() {
|
|
div(MainDivCss.name) {
|
|
if (!started) {
|
|
div(StartSplashCss.name) {
|
|
div(StartBoxCss.name) {
|
|
div(StartButtonCss.name) {
|
|
+"START"
|
|
}
|
|
}
|
|
onClickFunction = {
|
|
VstChipWorklet.create {
|
|
started = true
|
|
requestUpdate()
|
|
WebsocketClient.send("LOAD\n")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
h1 {
|
|
+"VST Chip"
|
|
}
|
|
div {
|
|
span {
|
|
+"Midi input: "
|
|
select {
|
|
option {
|
|
+"None"
|
|
value = "none"
|
|
}
|
|
for (mi in Midi.inputs) {
|
|
option {
|
|
+mi.name
|
|
value = mi.id
|
|
selected = mi.id == Midi.currentInput?.id
|
|
}
|
|
}
|
|
|
|
onChangeFunction = { event ->
|
|
val target = event.target as HTMLSelectElement
|
|
if (target.value == "none") {
|
|
Midi.setInput(null)
|
|
} else {
|
|
Midi.setInput(target.value)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
span {
|
|
+"channel:"
|
|
input {
|
|
type = InputType.number
|
|
value = VstChipWorklet.midiChannel.toString()
|
|
onInputFunction = { event ->
|
|
val target = event.target as HTMLInputElement
|
|
println("onInput channel: $target")
|
|
VstChipWorklet.midiChannel = target.value.toInt()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
div {
|
|
span(ButtonBarCss.name) {
|
|
+"SAVE"
|
|
onClickFunction = {
|
|
val patch = VstChipWorklet.save().copy(
|
|
midiId = Midi.currentInput?.id ?: "",
|
|
midiName = Midi.currentInput?.name ?: ""
|
|
)
|
|
|
|
WebsocketClient.send("SAVE\n${JSON.stringify(patch)}")
|
|
}
|
|
}
|
|
span(ButtonBarCss.name) {
|
|
+"STOP"
|
|
onClickFunction = {
|
|
VstChipWorklet.postDirectlyToWorklet(
|
|
TimedMidiMessage(getCurrentTime(), (0xb0 + midiChannel).toByte(), 123, 0)
|
|
.data.buffer.data
|
|
)
|
|
}
|
|
}
|
|
}
|
|
div {
|
|
span(ButtonBarCss.name) {
|
|
+"Sine"
|
|
if (VstChipWorklet.waveform == 0) {
|
|
classes += SelectedCss.name
|
|
}
|
|
onClickFunction = {
|
|
VstChipWorklet.waveform = 0
|
|
requestUpdate()
|
|
}
|
|
}
|
|
span(ButtonBarCss.name) {
|
|
+"Square"
|
|
if (VstChipWorklet.waveform == 1) {
|
|
classes += SelectedCss.name
|
|
}
|
|
onClickFunction = {
|
|
VstChipWorklet.waveform = 1
|
|
requestUpdate()
|
|
}
|
|
}
|
|
span(ButtonBarCss.name) {
|
|
+"Triangle"
|
|
if (VstChipWorklet.waveform == 2) {
|
|
classes += SelectedCss.name
|
|
}
|
|
onClickFunction = {
|
|
VstChipWorklet.waveform = 2
|
|
requestUpdate()
|
|
}
|
|
}
|
|
span(ButtonBarCss.name) {
|
|
+"Sawtooth"
|
|
if (VstChipWorklet.waveform == 3) {
|
|
classes += SelectedCss.name
|
|
}
|
|
onClickFunction = {
|
|
VstChipWorklet.waveform = 3
|
|
requestUpdate()
|
|
}
|
|
}
|
|
}
|
|
div(ControlsCss.name) {
|
|
include(
|
|
ExpKnobComponent(
|
|
value = VstChipWorklet.volume,
|
|
label = "Volume",
|
|
minValue = 0.0,
|
|
maxValue = 1.0,
|
|
step = 5.0 / 127.0,
|
|
width = 100,
|
|
height = 120,
|
|
) { 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(
|
|
ExpKnobComponent(
|
|
value = VstChipWorklet.fmModFreq,
|
|
label = "FM Freq",
|
|
minValue = 0.0,
|
|
maxValue = 2.0,
|
|
step = 5.0 / 127.0,
|
|
width = 100,
|
|
height = 120,
|
|
) { value ->
|
|
VstChipWorklet.fmModFreq = value
|
|
}
|
|
)
|
|
include(
|
|
ExpKnobComponent(
|
|
value = VstChipWorklet.fmModAmp,
|
|
label = "FM Ampl",
|
|
minValue = 0.0,
|
|
maxValue = 1.0,
|
|
step = 5.0 / 127.0,
|
|
width = 100,
|
|
height = 120,
|
|
) { value ->
|
|
VstChipWorklet.fmModAmp = value
|
|
}
|
|
)
|
|
include(
|
|
ExpKnobComponent(
|
|
value = VstChipWorklet.amModFreq,
|
|
label = "AM Freq",
|
|
minValue = 0.0,
|
|
maxValue = 1.0,
|
|
step = 5.0 / 127.0,
|
|
width = 100,
|
|
height = 120,
|
|
) { value ->
|
|
VstChipWorklet.amModFreq = value
|
|
}
|
|
)
|
|
include(
|
|
ExpKnobComponent(
|
|
value = VstChipWorklet.amModAmp,
|
|
label = "AM Ampl",
|
|
minValue = 0.0,
|
|
maxValue = 1.0,
|
|
step = 5.0 / 127.0,
|
|
width = 100,
|
|
height = 120,
|
|
) { value ->
|
|
VstChipWorklet.amModAmp = value
|
|
}
|
|
)
|
|
include(
|
|
ExpKnobComponent(
|
|
value = VstChipWorklet.feedback,
|
|
label = "Feedback",
|
|
minValue = 0.0,
|
|
maxValue = 1.0,
|
|
step = 5.0 / 127.0,
|
|
width = 100,
|
|
height = 120,
|
|
) { value ->
|
|
VstChipWorklet.feedback = value
|
|
}
|
|
)
|
|
include(
|
|
ExpKnobComponent(
|
|
value = VstChipWorklet.delay,
|
|
label = "Delay",
|
|
minValue = 0.0,
|
|
maxValue = 1.0,
|
|
step = 5.0 / 127.0,
|
|
width = 100,
|
|
height = 120,
|
|
) { value ->
|
|
VstChipWorklet.delay = value
|
|
}
|
|
)
|
|
include(
|
|
ExpKnobComponent(
|
|
value = VstChipWorklet.delayDepth,
|
|
label = "Delay depth",
|
|
minValue = 0.0,
|
|
maxValue = 1.0,
|
|
step = 5.0 / 127.0,
|
|
width = 100,
|
|
height = 120,
|
|
) { value ->
|
|
VstChipWorklet.delayDepth = value
|
|
}
|
|
)
|
|
}
|
|
div(ControlsCss.name) {
|
|
include(
|
|
ExpKnobComponent(
|
|
value = VstChipWorklet.attack,
|
|
label = "Attack",
|
|
minValue = 0.0,
|
|
maxValue = 5.0,
|
|
step = 25.0 / 127.0,
|
|
width = 100,
|
|
height = 120,
|
|
) { value ->
|
|
VstChipWorklet.attack = value / 5.0
|
|
}
|
|
)
|
|
include(
|
|
ExpKnobComponent(
|
|
value = VstChipWorklet.decay,
|
|
label = "Decay",
|
|
minValue = 0.0,
|
|
maxValue = 5.0,
|
|
step = 25.0 / 127.0,
|
|
width = 100,
|
|
height = 120,
|
|
) { value ->
|
|
VstChipWorklet.decay = value / 5.0
|
|
}
|
|
)
|
|
include(
|
|
KnobComponent(
|
|
value = VstChipWorklet.sustain,
|
|
label = "Sustain",
|
|
minValue = 0.0,
|
|
maxValue = 1.0,
|
|
step = 2.0 / 127.0,
|
|
width = 100,
|
|
height = 120,
|
|
) { value ->
|
|
VstChipWorklet.sustain = value
|
|
}
|
|
)
|
|
include(
|
|
ExpKnobComponent(
|
|
value = VstChipWorklet.release,
|
|
label = "Release",
|
|
minValue = 0.0,
|
|
maxValue = 5.0,
|
|
step = 25.0 / 127.0,
|
|
width = 100,
|
|
height = 120,
|
|
) { value ->
|
|
VstChipWorklet.release = value / 5.0
|
|
}
|
|
)
|
|
}
|
|
include(WaveformView)
|
|
}
|
|
}
|
|
|
|
companion object MainViewCss : CssName() {
|
|
object MainDivCss : CssName()
|
|
object ActiveCss : CssName()
|
|
object ButtonCss : CssName()
|
|
object ButtonBarCss : CssName()
|
|
object SelectedCss : CssName()
|
|
object NoteBarCss : CssName()
|
|
object StartSplashCss : CssName()
|
|
object StartBoxCss : CssName()
|
|
object StartButtonCss : CssName()
|
|
object ControlsCss : CssName()
|
|
|
|
private fun css() {
|
|
defineCss {
|
|
select("*") {
|
|
select("*:before") {
|
|
select("*:after") {
|
|
boxSizing(BoxSizing.borderBox)
|
|
}
|
|
}
|
|
}
|
|
select("html", "body") {
|
|
margin(0.px)
|
|
padding(0.px)
|
|
height(100.prc)
|
|
}
|
|
select("html", "body") {
|
|
backgroundColor(Css.currentStyle.mainBackgroundColor)
|
|
color(Css.currentStyle.mainFontColor)
|
|
|
|
fontFamily("JetbrainsMono, monospace")
|
|
fontSize(14.px)
|
|
fontWeight(FontWeight.bold)
|
|
|
|
//transition()
|
|
noTextSelect()
|
|
}
|
|
select("input", "textarea") {
|
|
backgroundColor(Css.currentStyle.inputBackgroundColor)
|
|
color(Css.currentStyle.mainFontColor)
|
|
border("none")
|
|
}
|
|
select(cls(ButtonCss)) {
|
|
margin(1.rem)
|
|
commonButton()
|
|
}
|
|
select(cls(ButtonBarCss)) {
|
|
margin(1.rem, 0.px)
|
|
commonButton()
|
|
}
|
|
select(cls(ActiveCss)) {
|
|
//backgroundColor(Css.currentStyle.selectedBackgroundColor)
|
|
}
|
|
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.mainFontColor)
|
|
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, 5, 0.65))
|
|
|
|
select(cls(StartBoxCss)) {
|
|
position(Position.relative)
|
|
left(25.vw)
|
|
top(25.vh)
|
|
width(50.vw)
|
|
height(50.vh)
|
|
backgroundColor(hsla(239, 50, 10, 1.0))
|
|
borderColor(Css.currentStyle.mainFontColor)
|
|
borderWidth(2.px)
|
|
|
|
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")
|
|
}
|
|
}
|
|
}
|
|
select(ControlsCss.cls()) {
|
|
display(Display.flex)
|
|
flexDirection(FlexDirection.row)
|
|
justifyContent(JustifyContent.flexStart)
|
|
alignItems(AlignItems.center)
|
|
margin(1.rem)
|
|
padding(1.rem)
|
|
backgroundColor(Css.currentStyle.mainBackgroundColor)
|
|
}
|
|
}
|
|
}
|
|
|
|
private fun Style.commonButton() {
|
|
display(Display.inlineBlock)
|
|
padding(1.rem)
|
|
backgroundColor(Css.currentStyle.buttonBackgroundColor)
|
|
borderColor(Css.currentStyle.buttonBorderColor)
|
|
borderWidth(Css.currentStyle.buttonBorderWidth)
|
|
color(Css.currentStyle.mainFontColor)
|
|
|
|
hover {
|
|
backgroundColor(Css.currentStyle.buttonBackgroundColor.hover())
|
|
}
|
|
and(SelectedCss.cls()) {
|
|
backgroundColor(Css.currentStyle.buttonBackgroundColor.hover().hover().hover())
|
|
}
|
|
}
|
|
}
|
|
|
|
}
|