diff --git a/build.gradle b/build.gradle index 4dfd17563..7f4fa656e 100644 --- a/build.gradle +++ b/build.gradle @@ -13,7 +13,7 @@ plugins { allprojects { group = 'dev.atsushieno' - version = '0.9.2' + version = '0.9.3' } apply from: "${rootDir}/publish-root.gradle" diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 930cba60f..07eaffbb0 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -3,15 +3,16 @@ agp = "8.2.2" android-compileSdk = "35" android-minSdk = "23" android-targetSdk = "35" -androidx-activityCompose = "1.9.0" +androidx-activityCompose = "1.9.1" androidx-appcompat = "1.7.0" androidx-core-ktx = "1.13.1" androidx-espresso-core = "3.6.1" androidx-test-junit = "1.2.1" -compose = "1.6.8" -compose-plugin = "1.6.10" +compose-components-resources = "1.7.0-alpha02" +compose = "1.7.0-beta07" +compose-plugin = "1.7.0-alpha02" junit = "4.13.2" -kotlin = "2.0.0" +kotlin = "2.0.10" dokka = "1.9.20" binary-compatibility-validator = "0.14.0" junit-jupiter-api = "5.10.2" @@ -22,6 +23,8 @@ kotlinx-coroutines-core = "1.9.0-RC" kotlinx-datetime = "0.6.0" kotlinxSerialization = "1.7.0-RC" ktor-io = "2.3.11" +filekitCore = "0.7.0" +js-synthesizer = "1.10.0" mpfilepicker = "3.1.0" gradle-javacpp = "1.5.10" @@ -30,6 +33,7 @@ alsa-javacpp = "0.1.0" rtmidi-javacpp = "0.1.5" [libraries] +compose-components-resources = { module = "org.jetbrains.compose.components:components-resources", version.ref = "compose-components-resources" } kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } kotlin-test-junit = { module = "org.jetbrains.kotlin:kotlin-test-junit", version.ref = "kotlin" } junit = { group = "junit", name = "junit", version.ref = "junit" } @@ -41,6 +45,8 @@ androidx-activity-compose = { module = "androidx.activity:activity-compose", ver compose-ui = { module = "androidx.compose.ui:ui", version.ref = "compose" } compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling", version.ref = "compose" } compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview", version.ref = "compose" } +filekit-compose = { module = "io.github.vinceglb:filekit-compose", version.ref = "filekitCore" } +filekit-core = { module = "io.github.vinceglb:filekit-core", version.ref = "filekitCore" } alsa-javacpp = { module = "dev.atsushieno:alsa-javacpp", version.ref = "alsa-javacpp" } junit-jupiter-api = { module = "org.junit.jupiter:junit-jupiter-api", version.ref = "junit-jupiter-api" } diff --git a/kotlin-js-store/yarn.lock b/kotlin-js-store/yarn.lock index cafc40126..c2f58d455 100644 --- a/kotlin-js-store/yarn.lock +++ b/kotlin-js-store/yarn.lock @@ -1657,6 +1657,11 @@ jest-worker@^27.4.5: merge-stream "^2.0.0" supports-color "^8.0.0" +js-synthesizer@1.10.0: + version "1.10.0" + resolved "https://registry.yarnpkg.com/js-synthesizer/-/js-synthesizer-1.10.0.tgz#b13fd5589697cd4f8a3bea221586e9c5919e3bf7" + integrity sha512-OR8j8QcWgAPvVcJ8LOtCx3Ypdm7lfyumYGQmxLUWIpZmnpzoUlb/HDYagHMv79je1D/rS6mEkq27F48MS87LMw== + js-yaml@4.1.0: version "4.1.0" resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.1.0.tgz#c1fb65f8f5017901cdd2c951864ba18458a10602" diff --git a/ktmidi-wasm-ext/build.gradle.kts b/ktmidi-wasm-ext/build.gradle.kts new file mode 100644 index 000000000..4491cd3dd --- /dev/null +++ b/ktmidi-wasm-ext/build.gradle.kts @@ -0,0 +1,112 @@ +import org.jetbrains.compose.compose +import org.jetbrains.kotlin.gradle.targets.js.dsl.ExperimentalWasmDsl + +plugins { + kotlin("multiplatform") + id("maven-publish") + id("signing") + id("org.jetbrains.dokka") +} + +kotlin { + @OptIn(ExperimentalWasmDsl::class) + wasmJs { + browser {} + } + sourceSets { + val wasmJsMain by getting { + dependencies { + implementation(libs.compose.components.resources) + implementation(project(":ktmidi")) + implementation(npm("js-synthesizer", libs.versions.js.synthesizer.get())) + } + } + } +} + +tasks.withType().configureEach { + val signingTasks = tasks.withType() + mustRunAfter(signingTasks) +} + +tasks { + val dokkaOutputDir = "${layout.buildDirectory}/dokka" + + dokkaHtml { + outputDirectory.set(file(dokkaOutputDir)) + } + + val deleteDokkaOutputDir by registering(Delete::class) { + delete(dokkaOutputDir) + } + + register("javadocJar") { + dependsOn(deleteDokkaOutputDir, dokkaHtml) + archiveClassifier.set("javadoc") + from(dokkaOutputDir) + } +} + +val repositoryId: String? = System.getenv("OSSRH_STAGING_REPOSITORY_ID") +val moduleDescription = "Kotlin Multiplatform library for MIDI 1.0 and MIDI 2.0 - Native specific" +// copypasting +afterEvaluate { + publishing { + publications { + publications.withType{ + // https://github.com/gradle/gradle/issues/26091#issuecomment-1681343496 + val dokkaJar = project.tasks.register("${name}DokkaJar", Jar::class) { + group = JavaBasePlugin.DOCUMENTATION_GROUP + description = "Assembles Kotlin docs with Dokka into a Javadoc jar" + archiveClassifier.set("javadoc") + from(tasks.named("dokkaHtml")) + + // Each archive name should be distinct, to avoid implicit dependency issues. + // We use the same format as the sources Jar tasks. + // https://youtrack.jetbrains.com/issue/KT-46466 + archiveBaseName.set("${archiveBaseName.get()}-${name}") + } + artifact(dokkaJar) + + pom { + name.set("$name") + description.set(moduleDescription) + url.set("https://github.com/atsushieno/ktmidi") + scm { + url.set("https://github.com/atsushieno/ktmidi") + } + licenses { + license { + name.set("the MIT License") + url.set("https://github.com/atsushieno/ktmidi/blob/main/LICENSE") + } + } + developers { + developer { + id.set("atsushieno") + name.set("Atsushi Eno") + email.set("atsushieno@gmail.com") + } + } + } + } + } + + repositories { + maven { + name = "OSSRH" + //url = uri("https://s01.oss.sonatype.org/service/local/staging/deployByRepositoryId/$repositoryId/") + url = uri("https://s01.oss.sonatype.org/service/local/staging/deploy/maven2/") + credentials { + username = System.getenv("OSSRH_USERNAME") + password = System.getenv("OSSRH_PASSWORD") + } + } + } + } + + // keep it as is. It is replaced by CI release builds + signing {} +} + + diff --git a/ktmidi-wasm-ext/src/wasmJsMain/kotlin/dev/atsushieno/ktmidi/ISynthesizer.kt b/ktmidi-wasm-ext/src/wasmJsMain/kotlin/dev/atsushieno/ktmidi/ISynthesizer.kt new file mode 100644 index 000000000..9fb4dad04 --- /dev/null +++ b/ktmidi-wasm-ext/src/wasmJsMain/kotlin/dev/atsushieno/ktmidi/ISynthesizer.kt @@ -0,0 +1,66 @@ +@file:JsModule("js-synthesizer") + +package dev.atsushieno.ktmidi + +import org.khronos.webgl.ArrayBuffer +import org.khronos.webgl.Uint8Array +import kotlin.js.Promise + +external interface SynthesizerSettings : JsAny +external interface InterpolationValues : JsAny +external interface PlayerSetTempoType : JsAny + +external interface ISynthesizer : JsAny { + fun isInitialized(): Boolean + fun init(sampleRate: Double, settings: SynthesizerSettings? = definedExternally) + fun close() + fun isPlaying(): Boolean + fun setInterpolation(value: InterpolationValues, channel: Double?) + fun getGain(): Double + fun setGain(gain: Double) + fun setChannelType(channel: Double, isDrum: Boolean) + fun waitForVoicesStopped(): Promise // void + + fun loadSFont(bin: ArrayBuffer): Promise // number + fun unloadSFont(id: Double) + fun unloadSFontAsync(id: Double): Promise // void + fun getSFontBankOffset(id: Double): Promise // number + fun setSFontBankOffset(id: Double, offset: Double) + + fun render(outBuffer: JsAny?) // AudioBuffer | Float32Array[] + + fun midiNoteOn(chan: Byte, key: Byte, vel: Byte) + fun midiNoteOff(chan: Byte, key: Byte) + fun midiKeyPressure(chan: Byte, key: Byte, value: Byte) + fun midiControl(chan: Byte, ctrl: Byte, value: Byte) + fun midiProgramChange(chan: Byte, prognum: Byte) + fun midiChannelPressure(chan: Byte, value: Byte) + fun midiPitchBend(chan: Byte, value: Short) + fun midiSysEx(data: Uint8Array) + + fun midiPitchWheelSensitivity(chan: Byte, value: Byte) + fun midiBankSelect(chan: Byte, bank: Byte) + fun midiSFontSelect(chan: Byte, sfontId: Int) + fun midiProgramSelect(chan: Byte, sfontId: Int, bank: Byte, presetNum: Byte) + fun midiUnsetProgram(chan: Byte) + fun midiProgramReset() + fun midiSystemReset() + fun midiAllNotesOff(chan: Byte?) + fun midiAllSoundsOff(chan: Byte?) + fun midiSetChannelType(chan: Byte, isDrum: Boolean) + + fun resetPlayer(): Promise // void + fun closePlayer() + fun isPlayerPlaying(): Boolean + fun addSMFDataToPlayer(bin: ArrayBuffer): Promise // void + fun playPlayer(): Promise // void + fun stopPlayer() + fun retrievePlayerCurrentTick(): Promise // number + fun retrievePlayerTotalTicks(): Promise // number + fun retrievePlayerBpm(): Promise // number + fun retrievePlayerMIDITempo(): Promise // number + fun seekPlayer(ticks: Double) + fun setPlayerLoop(loopTimes: Double) + fun setPlayerTempo(tempoType: PlayerSetTempoType, tempo: Double) + fun waitForPlayerStopped(): Promise // void +} diff --git a/ktmidi-wasm-ext/src/wasmJsMain/kotlin/dev/atsushieno/ktmidi/JsSynthesizerMidiAccess.kt b/ktmidi-wasm-ext/src/wasmJsMain/kotlin/dev/atsushieno/ktmidi/JsSynthesizerMidiAccess.kt new file mode 100644 index 000000000..fc8d7a4d8 --- /dev/null +++ b/ktmidi-wasm-ext/src/wasmJsMain/kotlin/dev/atsushieno/ktmidi/JsSynthesizerMidiAccess.kt @@ -0,0 +1,104 @@ +package dev.atsushieno.ktmidi + +//import org.jetbrains.compose.resources.InternalResourceApi +//import org.jetbrains.compose.resources.readResourceBytes +import org.khronos.webgl.ArrayBuffer +import org.khronos.webgl.Uint8Array + +private fun createSynthesizer(): ISynthesizer = js("new JSSynth.Synthesizer()") +private fun waitForReady(runnable: ()->Unit): Nothing = js("JSSynth.waitForReady().then(runnable)") + +class JsSynthesizerMidiAccess(private val sfBinary: ArrayBuffer, sfName: String) : MidiAccess() { + companion object { + + /* + @Suppress("CAST_NEVER_SUCCEEDS") + @OptIn(InternalResourceApi::class) + suspend fun createDefault() = + create(readResourceBytes("FluidR3_GM.sf2") as ArrayBuffer, "FluidR3_GM.sf2") + */ + + fun create(sfBinary: ArrayBuffer, sfName: String): JsSynthesizerMidiAccess { + var synth: JsSynthesizerMidiAccess? = null + println("!!! JsSynthesizerMidiAccess.create() start.") + try { + waitForReady { + println("!!! in waitForReady().") + synth = JsSynthesizerMidiAccess(sfBinary, sfName) + println("!!! waitForReady() done.") + } + println("!!! JsSynthesizerMidiAccess.create() done.") + } catch (ex: Exception) { + println(ex) + throw ex + } + return synth!! + } + } + + private val details = JsSynthesizerMidiOutputDetails(sfName) + + override val name: String + get() = "JsSynthesizer" + override val inputs: Iterable + get() = listOf() + override val outputs: Iterable + get() = listOf(details) + + override suspend fun openInput(portId: String): MidiInput { + throw UnsupportedOperationException() + } + + override suspend fun openOutput(portId: String): MidiOutput { + val synth = createSynthesizer() + synth.init(44100.0) + if (sfBinary.byteLength > 0) + synth.loadSFont(sfBinary) + return JsSynthesizerMidiOutput(details, synth) + } + + internal class JsSynthesizerMidiOutputDetails( + private val soundFontName: String, + override val version: String = "1.0" + ) : MidiPortDetails { + override val id: String = soundFontName + override val manufacturer: String + get() = "JsSynthesizerMidiAccess" + override val name: String + get() = "js-synthesizer: $soundFontName" + override val midiTransportProtocol: Int + get() = MidiTransportProtocol.MIDI1 + } + + internal class JsSynthesizerMidiOutput( + override val details: MidiPortDetails, + private val synth: ISynthesizer + ) : MidiOutput { + private var state = MidiPortConnectionState.OPEN + + override fun send(mevent: ByteArray, offset: Int, length: Int, timestampInNanoseconds: Long) { + Midi1Message.convert(mevent, offset, length, Midi1SysExChunkProcessor()).onEach { + when(it.statusCode.toInt()) { + MidiChannelStatus.NOTE_OFF -> synth.midiNoteOff(it.channel, it.msb) + MidiChannelStatus.NOTE_ON -> synth.midiNoteOn(it.channel, it.msb, it.lsb) + MidiChannelStatus.PAF -> synth.midiKeyPressure(it.channel, it.msb, it.lsb) + MidiChannelStatus.CC -> synth.midiControl(it.channel, it.msb, it.lsb) + MidiChannelStatus.PROGRAM -> synth.midiProgramChange(it.channel, it.msb) + MidiChannelStatus.CAF -> synth.midiChannelPressure(it.channel, it.msb) + MidiChannelStatus.PITCH_BEND -> synth.midiPitchBend(it.channel, (it.msb * 128 + it.lsb).toShort()) + else -> + if (it.statusByte.toInt() == Midi1Status.SYSEX) + synth.midiSysEx((it as Midi1CompoundMessage).extraData!! as Uint8Array) + } + } + } + + override val connectionState: MidiPortConnectionState + get() = state + + override fun close() { + synth.close() + state = MidiPortConnectionState.CLOSED + } + } +} diff --git a/ktmidi/api/android/ktmidi.api b/ktmidi/api/android/ktmidi.api index 767a34a3b..8c1e1b68b 100644 --- a/ktmidi/api/android/ktmidi.api +++ b/ktmidi/api/android/ktmidi.api @@ -570,6 +570,15 @@ public final class dev/atsushieno/ktmidi/GeneralMidi2$Percussions { public static final field VIBRASLAP I } +public final class dev/atsushieno/ktmidi/MergedMidiAccess : dev/atsushieno/ktmidi/MidiAccess { + public fun (Ljava/lang/String;Ljava/util/List;)V + public fun getInputs ()Ljava/lang/Iterable; + public fun getName ()Ljava/lang/String; + public fun getOutputs ()Ljava/lang/Iterable; + public fun openInput (Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun openOutput (Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + public final class dev/atsushieno/ktmidi/MergedMidiModuleDatabase : dev/atsushieno/ktmidi/MidiModuleDatabase { public fun (Ljava/lang/Iterable;)V public fun all ()Ljava/lang/Iterable; diff --git a/ktmidi/api/jvm/ktmidi.api b/ktmidi/api/jvm/ktmidi.api index caca3aafb..bf9052aef 100644 --- a/ktmidi/api/jvm/ktmidi.api +++ b/ktmidi/api/jvm/ktmidi.api @@ -565,6 +565,15 @@ public final class dev/atsushieno/ktmidi/JvmMidiAccess : dev/atsushieno/ktmidi/M public fun openOutput (Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; } +public final class dev/atsushieno/ktmidi/MergedMidiAccess : dev/atsushieno/ktmidi/MidiAccess { + public fun (Ljava/lang/String;Ljava/util/List;)V + public fun getInputs ()Ljava/lang/Iterable; + public fun getName ()Ljava/lang/String; + public fun getOutputs ()Ljava/lang/Iterable; + public fun openInput (Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun openOutput (Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + public final class dev/atsushieno/ktmidi/MergedMidiModuleDatabase : dev/atsushieno/ktmidi/MidiModuleDatabase { public fun (Ljava/lang/Iterable;)V public fun all ()Ljava/lang/Iterable; diff --git a/settings.gradle b/settings.gradle index 1c048933a..e6388411c 100644 --- a/settings.gradle +++ b/settings.gradle @@ -23,6 +23,7 @@ include(":ktmidi") include(":ktmidi-ci") include(":ktmidi-jvm-desktop") include(":ktmidi-native-ext") +include(":ktmidi-wasm-ext") include(":input-sample") include(":player-sample") include(':ktmidi-ci-tool')