4 Commits
main ... iframe

Author SHA1 Message Date
b7449c86fd Kotlin v. 2.0.0 2024-06-10 21:32:31 +02:00
223ff91dde Fixes 2024-06-10 21:20:57 +02:00
6f03f71b15 Communication iframe -> audio worklet etc. 2023-09-10 15:35:46 +02:00
a9e631055b Connect multiple audio worklets 2023-09-08 15:49:48 +02:00
19 changed files with 973 additions and 568 deletions

View File

@@ -1,10 +1,9 @@
import org.jetbrains.kotlin.gradle.targets.js.webpack.KotlinWebpackOutput.Target.VAR
import org.jetbrains.kotlin.gradle.plugin.KotlinJsCompilerType.LEGACY
import org.jetbrains.kotlin.gradle.dsl.KotlinJsCompile
import org.jetbrains.kotlin.gradle.plugin.KotlinJsCompilerType.IR
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
import org.jetbrains.kotlin.gradle.targets.js.webpack.KotlinWebpackOutput.Target.VAR
plugins {
kotlin("multiplatform") version "1.8.10"
kotlin("multiplatform") version "2.0.0"
application
}
@@ -18,6 +17,13 @@ repositories {
val jsMode = IR
tasks.withType<KotlinJsCompile>().configureEach {
kotlinOptions {
moduleKind = "es"
useEsClasses = true
}
}
kotlin {
jvm {
compilations.all {
@@ -32,6 +38,15 @@ kotlin {
binaries.executable()
browser()
}
js("jsWorklet", jsMode) {
binaries.executable()
browser {
commonWebpackConfig {
outputFileName = "html-worklet.js"
}
}
}
js("jsAudioWorklet", jsMode) {
binaries.executable()
@@ -66,9 +81,10 @@ application {
}
tasks.named<Copy>("jvmProcessResources") {
val jsBrowserDistribution = tasks.named("jsBrowserDevelopmentWebpack")
val jsBrowserDistribution = tasks.named("jsBrowserDistribution")
from(jsBrowserDistribution)
from(tasks.named("jsAudioWorkletBrowserDistribution"))
from(tasks.named("jsWorkletBrowserDistribution"))
}
tasks.named<JavaExec>("run") {

View File

@@ -1,162 +0,0 @@
import kotlin.math.max
import kotlin.math.min
import kotlin.math.pow
/**
* User: rnentjes
* Date: 14-11-15
* Time: 11:50
*/
var sampleRate: Int = 44100
enum class Note(
val description: String
) {
C0("C-0"),
C0s("C#0"),
D0("D-0"),
D0s("D#0"),
E0("E-0"),
F0("F-0"),
F0s("F#0"),
G0("G-0"),
G0s("G#0"),
A0("A-0"),
A0s("A#0"),
B0("B-0"),
C1("C-1"),
C1s("C#1"),
D1("D-1"),
D1s("D#1"),
E1("E-1"),
F1("F-1"),
F1s("F#1"),
G1("G-1"),
G1s("G#1"),
A1("A-1"),
A1s("A#1"),
B1("B-1"),
C2("C-2"),
C2s("C#2"),
D2("D-2"),
D2s("D#2"),
E2("E-2"),
F2("F-2"),
F2s("F#2"),
G2("G-2"),
G2s("G#2"),
A2("A-2"),
A2s("A#2"),
B2("B-2"),
C3("C-3"),
C3s("C#3"),
D3("D-3"),
D3s("D#3"),
E3("E-3"),
F3("F-3"),
F3s("F#3"),
G3("G-3"),
G3s("G#3"),
A3("A-3"),
A3s("A#3"),
B3("B-3"),
C4("C-4"),
C4s("C#4"),
D4("D-4"),
D4s("D#4"),
E4("E-4"),
F4("F-4"),
F4s("F#4"),
G4("G-4"),
G4s("G#4"),
A4("A-4"),
A4s("A#4"),
B4("B-4"),
C5("C-5"),
C5s("C#5"),
D5("D-5"),
D5s("D#5"),
E5("E-5"),
F5("F-5"),
F5s("F#5"),
G5("G-5"),
G5s("G#5"),
A5("A-5"),
A5s("A#5"),
B5("B-5"),
C6("C-6"),
C6s("C#6"),
D6("D-6"),
D6s("D#6"),
E6("E-6"),
F6("F-6"),
F6s("F#6"),
G6("G-6"),
G6s("G#6"),
A6("A-6"),
A6s("A#6"),
B6("B-6"),
C7("C-7"),
C7s("C#7"),
D7("D-7"),
D7s("D#7"),
E7("E-7"),
F7("F-7"),
F7s("F#7"),
G7("G-7"),
G7s("G#7"),
A7("A-7"),
A7s("A#7"),
B7("B-7"),
C8("C-8"),
C8s("C#8"),
D8("D-8"),
D8s("D#8"),
E8("E-8"),
F8("F-8"),
F8s("F#8"),
G8("G-8"),
G8s("G#8"),
A8("A-8"),
A8s("A#8"),
B8("B-8"),
NONE("---"),
END("XXX"),
UP("^^^"),
;
val freq: Double by lazy {
val ordinal = ordinal
val relNote = ordinal - A4.ordinal
440.0 * 2.0.pow(relNote/12.0)
}
val cycleLength: Double by lazy { 1.0 / freq }
val sampleDelta: Double by lazy { (1.0 / sampleRate.toDouble()) / cycleLength }
fun transpose(semiNotes: Int): Note = if (ordinal >= C0.ordinal && ordinal <= B8.ordinal) {
var result = this.ordinal + semiNotes
result = min(result, B8.ordinal)
result = max(result, C0.ordinal)
values().firstOrNull { it.ordinal == result } ?: this
} else {
this
}
/*
* Amount of one cycle to advance per sample
*/
/*
fun sampleDelta(): Double {
// 44100
val sampleRate = sampleRate
val time = 1f / sampleRate.toDouble()
return time / cycleLength
}
*/
}

View File

@@ -0,0 +1,179 @@
package common
import kotlin.math.max
import kotlin.math.min
import kotlin.math.pow
/**
* User: rnentjes
* Date: 14-11-15
* Time: 11:50
*/
var sampleRate = 44100
set(value) {
field = value
for (note in Note.values()) {
note.sampleDelta = (1.0 / sampleRate.toDouble()) / note.cycleLength
}
}
//var samplesPerEntry = 5512
enum class Note(
val sharp: String,
val flat: String
) {
NONE("---", "---"),
NO02("",""),
NO03("",""),
NO04("",""),
NO05("",""),
NO06("",""),
NO07("",""),
NO08("",""),
NO09("",""),
NO10("",""),
NO11("",""),
NO12("",""),
C0("C-0","C-0"),
C0s("C#0","Db0"),
D0("D-0","D-0"),
D0s("D#0","Eb0"),
E0("E-0","E-0"),
F0("F-0","F-0"),
F0s("F#0","Gb0"),
G0("G-0","G-0"),
G0s("G#0","Ab0"),
A0("A-0","A-0"),
A0s("A#0","Bb0"),
B0("B-0","B-0"),
C1("C-1","C-1"),
C1s("C#1","Db1"),
D1("D-1","D-1"),
D1s("D#1","Eb1"),
E1("E-1","E-1"),
F1("F-1","F-1"),
F1s("F#1","Gb1"),
G1("G-1","G-1"),
G1s("G#1","Ab1"),
A1("A-1","A-1"),
A1s("A#1","Bb1"),
B1("B-1","B-1"),
C2("C-2","C-2"),
C2s("C#2","Db2"),
D2("D-2","D-2"),
D2s("D#2","Eb2"),
E2("E-2","E-2"),
F2("F-2","F-2"),
F2s("F#2","Gb2"),
G2("G-2","G-2"),
G2s("G#2","Ab2"),
A2("A-2","A-2"),
A2s("A#2","Bb2"),
B2("B-2","B-2"),
C3("C-3","C-3"),
C3s("C#3","Db3"),
D3("D-3","D-3"),
D3s("D#3","Eb3"),
E3("E-3","E-3"),
F3("F-3","F-3"),
F3s("F#3","Gb3"),
G3("G-3","G-3"),
G3s("G#3","Ab3"),
A3("A-3","A-3"),
A3s("A#3","Bb3"),
B3("B-3","B-3"),
C4("C-4","C-4"),
C4s("C#4","Db4"),
D4("D-4","D-4"),
D4s("D#4","Eb4"),
E4("E-4","E-4"),
F4("F-4","F-4"),
F4s("F#4","Gb4"),
G4("G-4","G-4"),
G4s("G#4","Ab4"),
A4("A-4","A-4"),
A4s("A#4","Bb4"),
B4("B-4","B-4"),
C5("C-5","C-5"),
C5s("C#5","Db5"),
D5("D-5","D-5"),
D5s("D#5","Eb5"),
E5("E-5","E-5"),
F5("F-5","F-5"),
F5s("F#5","Gb5"),
G5("G-5","G-5"),
G5s("G#5","Ab5"),
A5("A-5","A-5"),
A5s("A#5","Bb5"),
B5("B-5","B-5"),
C6("C-6","C-6"),
C6s("C#6","Db6"),
D6("D-6","D-6"),
D6s("D#6","Eb6"),
E6("E-6","E-6"),
F6("F-6","F-6"),
F6s("F#6","Gb6"),
G6("G-6","G-6"),
G6s("G#6","Ab6"),
A6("A-6","A-6"),
A6s("A#6","Bb6"),
B6("B-6","B-6"),
C7("C-7","C-7"),
C7s("C#7","Db7"),
D7("D-7","D-7"),
D7s("D#7","Eb7"),
E7("E-7","E-7"),
F7("F-7","F-7"),
F7s("F#7","Gb7"),
G7("G-7","G-7"),
G7s("G#7","Ab7"),
A7("A-7","A-7"),
A7s("A#7","Bb7"),
B7("B-7","B-7"),
C8("C-8","C-8"),
C8s("C#8","Db8"),
D8("D-8","D-8"),
D8s("D#8","Eb8"),
E8("E-8","E-8"),
F8("F-8","F-8"),
F8s("F#8","Gb8"),
G8("G-8","G-8"),
G8s("G#8","Ab8"),
A8("A-8","A-8"),
A8s("A#8","Bb8"),
B8("B-8","B-8"),
C9("C-9","C-9"),
C9s("C#9","Db9"),
D9("D-9","D-9"),
D9s("D#9","Eb9"),
E9("E-9","E-9"),
F9("F-9","F-9"),
F9s("F#9","Gb9"),
G9("G-9","G-9"),
// out of midi range
//G9s("G#9","Ab9"),
//A9("A-9","A-9"),
//A9s("A#9","Bb9"),
//B9("B-9","B-9"),
UP("^^^","^^^"),
END("XXX","XXX"),
;
// 69 = A4.ordinal
val freq: Double = 440.0 * 2.0.pow((ordinal - 69)/12.0)
val cycleLength: Double = 1.0 / freq
var sampleDelta: Double = (1.0 / sampleRate.toDouble()) / cycleLength
fun transpose(semiNotes: Int): Note = if (ordinal >= C0.ordinal && ordinal <= G9.ordinal) {
var result = this.ordinal + semiNotes
result = min(result, G9.ordinal)
result = max(result, C0.ordinal)
values().firstOrNull { it.ordinal == result } ?: this
} else {
this
}
}

View File

@@ -0,0 +1,130 @@
import common.Note
import org.khronos.webgl.Float32Array
import org.khronos.webgl.set
import org.w3c.dom.MessageEvent
import org.w3c.dom.MessagePort
import kotlin.math.PI
import kotlin.math.sin
const val PI2 = PI * 2
class NoteState(
note: Note,
counter: Int
)
@ExperimentalJsExport
@JsExport
class AudioProcessor : AudioWorkletProcessor() {
private var started = true
private var counter: Int = 0
private var note: Note? = null
private var offset = 0.0
private var note_length = 2500
private var harmonics = 3
private var transpose = 0
private var workletPort: MessagePort? = null
init {
this.port.onmessage = ::handleMessage
common.sampleRate = sampleRate
}
private fun handleMessage(message: MessageEvent) {
console.log("AudioProcessor: Received message", message)
val data: Any? = message.data
if (data is String) {
val parts = data.split("\n")
when (parts[0]) {
"start" -> {
println("Start worklet!")
common.sampleRate = sampleRate
started = true
}
"stop" -> {
println("Stop worklet!")
started = false
}
"set_note_length" -> {
note_length = parts[1].toInt()
}
"harmonics" -> {
harmonics = parts[1].toInt()
}
"transpose" -> {
transpose = parts[1].toInt()
}
"play" -> {
note = Note.valueOf(parts[1])
counter=1
offset = 0.0
}
else ->
console.error("Don't kow how to handle message", message)
}
} else {
val dynData: dynamic = message.data
if (dynData.command == "audio-iframe-message-port") {
workletPort = dynData.port
workletPort?.onmessage = {
console.log("AudioProcessor: Received message from iframe", it)
handleMessage(it)
}
}
}
}
override fun process(
inputs: Array<Array<Float32Array>>,
outputs: Array<Array<Float32Array>>,
parameters: dynamic
) : Boolean {
if (started) {
check(outputs.size == 1) {
"Expected 1 output got ${outputs.size}"
}
check(outputs[0].size == 2) {
"Expected 2 output channels, got ${outputs.size}"
}
note?.also { activeNote ->
val delta = activeNote.sampleDelta
val samples = outputs[0][0].length
val left = outputs[0][0]
val right = outputs[0][1]
for (sample in 0..<samples) {
var value = sin(offset * PI2)
for (index in 0..<harmonics) {
value += sin(offset * (index + 2) * PI2) * (3.0 / (index + 2))
}
offset += delta
// new note every NOTE_LENGTH samples
val noteProgress = counter % note_length
// simple envelop from max to 0 every note
value *= (1.0 - noteProgress / note_length.toDouble())
left[sample] = value.toFloat()
right[sample] = value.toFloat()
if (counter >= note_length) {
note = null
}
counter++
}
}
}
return true
}
}

View File

@@ -0,0 +1,42 @@
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<Array<Float32Array>>,
outputs: Array<Array<Float32Array>>,
parameters: dynamic
) : Boolean
}
external fun registerProcessor(name: String, processorCtor: JsClass<*>)
external val sampleRate: Int
external val currentTime: Double

View File

@@ -0,0 +1,7 @@
@OptIn(ExperimentalJsExport::class)
fun main() {
registerProcessor("audio-processor", AudioProcessor::class.js)
registerProcessor("mixer-processor", MixerProcessor::class.js)
}

View File

@@ -0,0 +1,66 @@
import org.khronos.webgl.Float32Array
import org.khronos.webgl.get
import org.khronos.webgl.set
import org.w3c.dom.MessageEvent
@ExperimentalJsExport
@JsExport
class MixerProcessor : AudioWorkletProcessor() {
var started = false
val leftBuffer = Float32Array(sampleRate * 2)
val rightBuffer = Float32Array(sampleRate * 2)
init {
this.port.onmessage = ::handleMessage
}
private fun handleMessage(message: MessageEvent) {
console.log("MixerProcessor: Received message", message)
val data = message.data
if (data is String) {
val parts = data.split("\n")
when (parts[0]) {
"start" -> {
println("Start worklet!")
started = true
}
"stop" -> {
println("Stop worklet!")
started = false
}
else ->
console.error("Don't kow how to handle message", message)
}
}
}
override fun process(
inputs: Array<Array<Float32Array>>,
outputs: Array<Array<Float32Array>>,
parameters: dynamic
) : Boolean {
if (started) {
check(inputs.size == 2) {
"Expected 2 inputs, got ${inputs.size}"
}
check(outputs.size == 1) {
"Expected 1 output, got ${outputs.size}"
}
val left = outputs[0][0]
val right = outputs[0][1]
for (input in inputs.indices) {
for (index in 0 ..< left.length) {
left[index] = left[index] + inputs[input][0][index]
right[index] = right[index] + inputs[input][1][index]
}
}
}
return true
}
}

View File

@@ -1,96 +0,0 @@
@file:OptIn(ExperimentalJsExport::class)
import org.khronos.webgl.Float64Array
import org.khronos.webgl.set
import org.w3c.dom.MessageEvent
import org.w3c.dom.MessagePort
import kotlin.math.PI
import kotlin.math.sin
const val PI2 = PI * 2
@ExperimentalJsExport
@JsExport
object WorkletProcessor {
private var port: MessagePort? = null
private var counter: Int = 0
private var note = Note.C2
private var offset = 0.0
private var started = false
private var note_length = 2500
private var harmonics = 3
@JsName("setPort")
fun setPort(port: MessagePort) {
WorkletProcessor.port = port
WorkletProcessor.port?.onmessage = WorkletProcessor::onMessage
}
@JsName("onMessage")
fun onMessage(message: MessageEvent) {
console.log("WorkletProcessor: Received message", message)
val data = message.data
if (data is String) {
val parts = data.split("\n")
when (parts[0]) {
"start" -> {
println("Start worklet!")
started = true
}
"stop" -> {
println("Stop worklet!")
started = false
}
"set_note_length" -> {
note_length = parts[1].toInt()
}
"harmonics" -> {
harmonics = parts[1].toInt()
}
else ->
console.error("Don't kow how to handle message", message)
}
}
}
@JsName("process")
fun process(samples: Int, left: Float64Array, right: Float64Array) {
if (started) {
var tmpCounter = counter
var delta = note.sampleDelta
for (sample in 0 until samples) {
var value = sin(offset * PI2);
for(index in 0 until harmonics) {
value += sin(offset * (index + 2) * PI2) * (1.0 / (index+2))
}
offset += delta
// new note every NOTE_LENGTH samples
val noteProgress = tmpCounter % note_length
if (noteProgress == 0) {
note = note.transpose(1)
if (note == Note.C7) {
note = Note.C2
}
delta = note.sampleDelta
}
// simple envelop from max to 0 every note
value *= (1.0 - noteProgress / note_length.toDouble())
left[sample] = value
right[sample] = value
tmpCounter++
}
counter += samples
}
}
}

View File

@@ -1,54 +0,0 @@
; // make sure kotlin js code last statement is ended
class WorkletProcessor extends AudioWorkletProcessor {
// Static getter to define AudioParam objects in this custom processor.
static get parameterDescriptors() {
return [{
name: 'volume',
defaultValue: 0.75
}];
}
constructor() {
super();
console.log("worklet-processor.constructor", this, audioWorklet);
audioWorklet.WorkletProcessor.setPort(this.port);
console.log("STARTED worklet-processor.js");
}
process(inputs, outputs, parameters) {
let result = true;
let samplesToProcess = 0;
if (outputs.length !== 1) {
result = false;
console.log("Unexpected number of outputs!", outputs)
} else {
let channels = outputs[0].length;
if (channels !== 2) {
result = false;
console.log("Unexpected number of channels!", outputs[0]);
} else if (outputs[0][0].length !== outputs[0][1].length) {
result = false;
console.log("Channels have different lengths!!", outputs[0]);
} else {
samplesToProcess = outputs[0][0].length;
audioWorklet.WorkletProcessor.process(
samplesToProcess,
outputs[0][0],
outputs[0][1]
);
}
}
return result;
}
}
registerProcessor('worklet-processor', WorkletProcessor);

View File

@@ -0,0 +1,71 @@
package nl.astraeus
import common.Note
import nl.astraeus.handler.AudioNode
import org.w3c.dom.MessageEvent
import org.w3c.dom.MessagePort
class AudioProcessorNode(
audioModule: dynamic,
destination: dynamic,
outputIndex: Int = 0,
inputIndex: Int = 0
) : AudioNode(
audioModule,
"audio-processor",
0,
arrayOf(2),
destination,
outputIndex,
inputIndex
) {
override fun onMessage(message: MessageEvent) {
console.log("Got message from audio worklet", message)
}
fun start() {
if (!created) {
create {
start()
}
} else {
node?.port.postMessage("start")
}
}
fun stop() {
if (!created) {
create {
stop()
}
} else {
node?.port.postMessage("stop")
}
}
fun harmonic(i: Int) {
node?.port.postMessage("harmonics\n$i")
}
fun transpose(i: Int) {
node?.port.postMessage("transpose\n$i")
}
fun length(i: Int) {
node?.port.postMessage("set_note_length\n$i")
}
fun play(note: Note) {
node?.port.postMessage("play\n$note")
}
fun setWorkletPort(port2: MessagePort) {
node?.port.postMessage(
createWorkletSetPortMessage(
"audio-iframe-message-port",
port2
),
arrayOf(port2)
)
}
}

View File

@@ -1,31 +1,81 @@
package nl.astraeus
import common.Note
import kotlinx.browser.document
import nl.astraeus.handler.AudioWorkletHandler
import kotlinx.browser.window
import nl.astraeus.handler.AudioModule
import org.w3c.dom.HTMLIFrameElement
import org.w3c.dom.HTMLInputElement
import org.w3c.dom.MessageChannel
import org.w3c.dom.MessagePort
fun createWorkletSetPortMessage(
command: String,
port: MessagePort
): dynamic {
val result = js("{}")
result.command = command
result.port = port
return result
}
fun main() {
AudioWorkletHandler.loadCode()
val audioModule = AudioModule("static/audio-worklet.js")
val mixer = MixerProcessorNode(audioModule)
var node1: AudioProcessorNode? = null
var node2: AudioProcessorNode? = null
val iframeWorkletChannel = MessageChannel()
println("Ok")
window.addEventListener("message", { event ->
console.log("Main Received message: ", event)
}, "")
//window.postMessage("Hello from Main 1", window.location.origin + "/worklet.html")
document.getElementById("iframe")?.also {
val iframe = it as? HTMLIFrameElement
iframe?.also {
it.contentWindow?.postMessage("Hello from Main 2", window.location.origin + "/worklet.html")
it.contentWindow?.postMessage(
createWorkletSetPortMessage(
"audio-processor-message-port",
iframeWorkletChannel.port1
),
window.location.origin + "/worklet.html",
arrayOf(iframeWorkletChannel.port1)
)
}
}
document.getElementById("createButton")?.also {
it.addEventListener("click", {
mixer.create {
node1 = AudioProcessorNode(audioModule, mixer.node)
node2 = AudioProcessorNode(audioModule, mixer.node, 0,1)
node1?.create {
println("node 1 created")
node1?.setWorkletPort(iframeWorkletChannel.port2)
}
node2?.create {
println("node 2 created")
node2?.transpose(7)
}
}
}, "")
}
document.getElementById("startButton")?.also {
it.addEventListener("click", {
if (AudioWorkletHandler.audioContext == null) {
AudioWorkletHandler.createContext {
println("Created context")
AudioWorkletHandler.start()
}
} else {
AudioWorkletHandler.start()
}
mixer.start()
}, "")
}
document.getElementById("stopButton")?.also {
it.addEventListener("click", {
AudioWorkletHandler.stop()
mixer.stop()
}, "")
}
@@ -33,17 +83,49 @@ fun main() {
it.addEventListener("change", {
val target = it.target
if (target is HTMLInputElement) {
AudioWorkletHandler.setNoteLength(target.value.toInt())
}
}, "")
}
document.getElementById("harmonics")?.also {
it.addEventListener("change", {
val target = it.target
if (target is HTMLInputElement) {
AudioWorkletHandler.setHarmonics(target.value.toInt())
node1?.length(target.value.toInt())
node2?.length(target.value.toInt())
}
}, "")
}
document.getElementById("harmonics")?.also {
it.addEventListener("change", {
val target = it.target
if (target is HTMLInputElement) {
node1?.harmonic(target.value.toInt())
}
}, "")
}
document.getElementById("note_c3_1")?.also {
it.addEventListener("click", {
node1?.play(Note.C3)
}, "")
}
document.getElementById("note_e3_1")?.also {
it.addEventListener("click", {
node1?.play(Note.E3)
}, "")
}
document.getElementById("note_g3_1")?.also {
it.addEventListener("click", {
node1?.play(Note.G3)
}, "")
}
document.getElementById("note_c3_2")?.also {
it.addEventListener("click", {
node2?.play(Note.C3)
}, "")
}
document.getElementById("note_e3_2")?.also {
it.addEventListener("click", {
node2?.play(Note.E3)
}, "")
}
document.getElementById("note_g3_2")?.also {
it.addEventListener("click", {
node2?.play(Note.G3)
}, "")
}
}

View File

@@ -0,0 +1,37 @@
package nl.astraeus
import nl.astraeus.handler.AudioNode
import org.w3c.dom.MessageEvent
class MixerProcessorNode(
audioModule: dynamic
) : AudioNode(
audioModule,
"mixer-processor",
2,
arrayOf(2)
) {
override fun onMessage(message: MessageEvent) {
console.log("Got message from audio worklet", message)
}
fun start() {
if (!created) {
create {
start()
}
} else {
node?.port.postMessage("start")
}
}
fun stop() {
if (!created) {
create {
stop()
}
} else {
node?.port.postMessage("stop")
}
}
}

View File

@@ -0,0 +1,83 @@
package nl.astraeus.handler
import org.w3c.dom.MessageEvent
import org.w3c.dom.MessagePort
enum class ModuleStatus {
INIT,
LOADING,
READY
}
class AudioModule(
val jsFile: String
) {
var status = ModuleStatus.INIT
var audioContext: dynamic = null
var module: dynamic = null
// call from user gesture
fun doAction(action: () -> Unit) {
if (module == null && status == ModuleStatus.INIT) {
status = ModuleStatus.LOADING
if (audioContext == null) {
audioContext = AudioContext()
}
module = audioContext.audioWorklet.addModule(
jsFile
)
module.then {
status = ModuleStatus.READY
action()
}
} else if (status == ModuleStatus.READY) {
action()
} else {
console.log("Module not yet loaded")
}
}
}
abstract class AudioNode(
val module: AudioModule,
val processorName: String,
val numberOfInputs: Int = 0,
val outputChannelCount: Array<Int> = arrayOf(2),
val destination: dynamic = null,
val outputIndex: Int = 0,
val inputIndex: Int = 0
) {
var created = false
var node: dynamic = null
var port: MessagePort? = null
abstract fun onMessage(message: MessageEvent)
// call from user gesture
fun create(done: (node: dynamic) -> Unit) {
module.doAction {
node = AudioWorkletNode(
module.audioContext,
processorName,
AudioWorkletNodeParameters(
numberOfInputs,
outputChannelCount
)
)
if (destination == null) {
node.connect(module.audioContext.destination)
} else {
node.connect(destination, outputIndex, inputIndex)
}
node.port.onmessage = ::onMessage
port = node.port as? MessagePort
created = true
done(node)
}
}
}

View File

@@ -1,173 +0,0 @@
package nl.astraeus.handler
import kotlinx.browser.window
import org.w3c.dom.MessageEvent
import org.w3c.dom.MessagePort
import org.w3c.dom.url.URL
import org.w3c.files.Blob
import org.w3c.files.FilePropertyBag
enum class WorkletState {
INIT,
LOADING,
LOADED,
READY
}
abstract class AudioWorklet(
val jsCodeFile: String,
val kotlinCodeFile: String
) {
var audioContext: dynamic = null
var state = WorkletState.INIT
var processingCode: Blob? = null
var sampleRate: Int = 44100
var audioWorkletMessagePort: MessagePort? = null
abstract fun createNode(audioContext: dynamic): dynamic
abstract fun onAudioWorkletMessage(message: MessageEvent)
abstract fun onCodeLoaded()
fun loadCode() {
// hack
// concat kotlin js and note-processor.js because
// audio worklet es6 is not supported in kotlin yet
state = WorkletState.LOADING
window.fetch(jsCodeFile).then { daResponse ->
if (daResponse.ok) {
daResponse.text().then { daText ->
window.fetch(kotlinCodeFile).then { npResponse ->
if (npResponse.ok) {
npResponse.text().then { npText ->
processingCode = Blob(
arrayOf(daText, npText),
FilePropertyBag(type = "application/javascript")
)
state = WorkletState.LOADED
println("Loaded $this code")
onCodeLoaded()
}
}
}
}
}
}
}
fun createContext(callback: () -> Unit) {
js("window.AudioContext = window.AudioContext || window.webkitAudioContext")
audioContext = js("new window.AudioContext()")
sampleRate = audioContext.sampleRate as Int
check(state == WorkletState.LOADED) {
"Can not createContext when code is not yet loaded, call loadCode first"
}
val module = audioContext.audioWorklet.addModule(
URL.createObjectURL(processingCode!!)
)
module.then {
val node: dynamic = createNode(audioContext)
node.connect(audioContext.destination)
node.port.onmessage = ::onAudioWorkletMessage
audioWorkletMessagePort = node.port as? MessagePort
state = WorkletState.READY
//postBatchedRequests()
callback()
"dynamic"
}
}
fun isResumed(): Boolean = audioContext?.state == "running"
fun resume() {
check(state == WorkletState.READY) {
"Unable to resume, state is not READY [$state]"
}
audioContext?.resume()
}
/* fun postRequest(request: WorkerRequest) {
batchedRequests.add(request)
postBatchedRequests()
}
private fun postBatchedRequests() {
val port = audioWorkletMessagePort
if (port != null) {
for (request in batchedRequests) {
val message = when (serializer) {
is StringFormat -> {
serializer.encodeToString(WorkerRequest.serializer(), request)
}
is BinaryFormat -> {
serializer.encodeToByteArray(WorkerRequest.serializer(), request)
}
else -> {
error("Unknown serializer format ${serializer::class.simpleName}")
}
}
port.postMessage(message)
}
batchedRequests.clear()
}
}*/
}
object AudioWorkletHandler : AudioWorklet(
"static/worklet-processor.js",
"static/audio-worklet.js"
) {
override fun createNode(audioContext: dynamic): dynamic = js(
// worklet-processor as defined in de javascript:
// registerProcessor('worklet-processor', WorkletProcessor);
"new AudioWorkletNode(audioContext, 'worklet-processor', { numberOfInputs: 0, outputChannelCount: [2] })"
)
override fun onAudioWorkletMessage(message: MessageEvent) {
console.log("Received message from audio worklet: ", message)
}
override fun onCodeLoaded() {
println("Audio worklet code is loaded.")
}
fun start() {
audioWorkletMessagePort?.postMessage("start")
}
fun stop() {
audioWorkletMessagePort?.postMessage("stop")
}
fun setNoteLength(length: Int) {
audioWorkletMessagePort?.postMessage("set_note_length\n$length")
}
fun setHarmonics(length: Int) {
audioWorkletMessagePort?.postMessage("harmonics\n$length")
}
}

View File

@@ -0,0 +1,18 @@
package nl.astraeus.handler
external class AudioContext {
var sampleRate: Int
}
external class AudioWorkletNode(
audioContext: dynamic,
name: String,
options: dynamic
)
class AudioWorkletNodeParameters(
@JsName("numberOfInputs")
val numberOfInputs: Int,
@JsName("outputChannelCount")
val outputChannelCount: Array<Int>
)

View File

@@ -0,0 +1,43 @@
package nl.astraeus.worklet
import kotlinx.browser.document
import kotlinx.browser.window
import org.w3c.dom.HTMLInputElement
import org.w3c.dom.MessageEvent
import org.w3c.dom.MessagePort
fun main() {
var port: MessagePort? = null
console.log("Worklet", document.location)
window.addEventListener("message", { event ->
console.log("Worklet xxx Received message: ", event)
if (event is MessageEvent) {
val data: dynamic = event.data
console.log("WL DATA: ", data, data?.command == "audio-processor-message-port")
if (data?.command == "audio-processor-message-port") {
port = data.port
port?.onmessage = {
console.log("Worklet Received message from audio worklet: ", it)
}
}
}
}, "")
window.parent.postMessage(
"Hello from Worklet",
window.location.origin + "/index.html"
)
document.getElementById("harmonics")?.also {
it.addEventListener("change", {
val target = it.target
if (target is HTMLInputElement) {
console.log("Sending: ", target.value, port)
port?.postMessage("harmonics\n${target.value}")
}
}, "")
}
}

View File

@@ -0,0 +1,125 @@
import kotlinx.html.HTML
import kotlinx.html.InputType
import kotlinx.html.body
import kotlinx.html.div
import kotlinx.html.h3
import kotlinx.html.head
import kotlinx.html.id
import kotlinx.html.iframe
import kotlinx.html.input
import kotlinx.html.label
import kotlinx.html.link
import kotlinx.html.script
import kotlinx.html.span
import kotlinx.html.style
import kotlinx.html.title
fun HTML.index() {
head {
title("Hello from Ktor!")
link("static/worklet.css", "stylesheet" ,"text/css")
}
body {
div {
+"We need a button to start because we can only start audio from a user event:"
}
div("button_div") {
span("button") {
id = "createButton"
+"Create"
}
span("button") {
id = "startButton"
+"Start"
}
span("button") {
id = "stopButton"
+"Stop"
}
}
div {
+ "An example of how to interact with the audioworklet:"
}
div {
label {
htmlFor = "noteLength"
+"Note length (in samples):"
}
input {
id = "noteLength"
type = InputType.number
value = "2500"
min = "1"
max = "100000"
step = "100"
}
}
div {
label {
htmlFor = "harmonics"
+"Number of harmonics:"
}
input {
id = "harmonics"
type = InputType.number
value = "3"
min = "0"
max = "10"
}
}
h3 {
+ "Node 1"
}
div {
input {
id = "note_c3_1"
type = InputType.button
value = "C3"
}
input {
id = "note_e3_1"
type = InputType.button
value = "E3"
}
input {
id = "note_g3_1"
type = InputType.button
value = "G3"
}
}
h3 {
+ "Node 2"
}
div {
input {
id = "note_c3_2"
type = InputType.button
value = "C3"
}
input {
id = "note_e3_2"
type = InputType.button
value = "E3"
}
input {
id = "note_g3_2"
type = InputType.button
value = "G3"
}
}
iframe {
id = "iframe"
src = "/worklet.html"
style = "width: 100%; height: 500px; background-color: #ddf;"
}
script(src = "/static/kotlin-audioworklet.js") {}
}
}

View File

@@ -4,71 +4,22 @@ import io.ktor.server.engine.embeddedServer
import io.ktor.server.html.*
import io.ktor.server.http.content.*
import io.ktor.server.netty.Netty
import io.ktor.server.response.*
import io.ktor.server.routing.*
import kotlinx.html.*
fun HTML.index() {
head {
title("Hello from Ktor!")
link("static/worklet.css", "stylesheet" ,"text/css")
}
body {
div {
+"We need a button to start because we can only start audio from a user event:"
}
div("button_div") {
span("button") {
id = "startButton"
+"Start"
}
span("button") {
id = "stopButton"
+"Stop"
}
}
div {
+ "An example of how to interact with the audioworklet:"
}
div {
label {
htmlFor = "noteLength"
+"Note length (in samples):"
}
input {
id = "noteLength"
type = InputType.number
value = "2500"
min = "1"
max = "100000"
step = "100"
}
}
div {
label {
htmlFor = "harmonics"
+"Number of harmonics:"
}
input {
id = "harmonics"
type = InputType.number
value = "3"
min = "1"
max = "10"
}
}
script(src = "/static/kotlin-audioworklet.js") {}
}
}
fun main() {
embeddedServer(Netty, port = 8080, host = "127.0.0.1") {
routing {
get("/") {
call.respondRedirect("/index.html")
}
get("/index.html") {
call.respondHtml(HttpStatusCode.OK, HTML::index)
}
get("/worklet.html") {
call.respondHtml(HttpStatusCode.OK, HTML::worklet)
}
static("/static") {
resources()
}

View File

@@ -0,0 +1,40 @@
import kotlinx.html.HTML
import kotlinx.html.InputType
import kotlinx.html.body
import kotlinx.html.div
import kotlinx.html.head
import kotlinx.html.id
import kotlinx.html.iframe
import kotlinx.html.input
import kotlinx.html.label
import kotlinx.html.link
import kotlinx.html.script
import kotlinx.html.span
import kotlinx.html.title
fun HTML.worklet() {
head {
title("Hello from Ktor!")
link("static/worklet.css", "stylesheet" ,"text/css")
}
body {
div {
+"IFrame js set worklet parameters:"
}
div {
label {
htmlFor = "harmonics"
+"Number of harmonics:"
}
input {
id = "harmonics"
type = InputType.number
value = "3"
min = "0"
max = "10"
}
}
script(src = "/static/html-worklet.js") {}
}
}