├── .circleci ├── config-github-pages.yml └── config.yml ├── .github └── dependabot.yml ├── .gitignore ├── LICENSE ├── README.md ├── android ├── .gitignore ├── build.gradle.kts ├── proguard-rules.pro └── src │ ├── androidTest │ └── kotlin │ │ └── it │ │ └── krzeminski │ │ └── fsynth │ │ └── ExampleInstrumentedTest.kt │ ├── main │ ├── AndroidManifest.xml │ ├── kotlin │ │ └── it │ │ │ └── krzeminski │ │ │ └── fsynth │ │ │ └── MainActivity.kt │ └── res │ │ ├── drawable-v24 │ │ └── ic_launcher_foreground.xml │ │ ├── drawable │ │ └── ic_launcher_background.xml │ │ ├── layout │ │ └── activity_main.xml │ │ ├── mipmap-anydpi-v26 │ │ ├── ic_launcher.xml │ │ └── ic_launcher_round.xml │ │ ├── mipmap-hdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ ├── mipmap-mdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ ├── mipmap-xhdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ ├── mipmap-xxhdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ ├── mipmap-xxxhdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ └── values │ │ ├── colors.xml │ │ ├── strings.xml │ │ └── styles.xml │ └── test │ └── kotlin │ └── it │ └── krzeminski │ └── fsynth │ └── ExampleUnitTest.kt ├── build.gradle.kts ├── cli ├── build.gradle.kts └── src │ ├── main │ └── kotlin │ │ └── it │ │ └── krzeminski │ │ └── fsynth │ │ ├── ConsoleEntryPoint.kt │ │ ├── JvmSoundPlayback.kt │ │ └── SoundOutputRendering.kt │ └── test │ └── kotlin │ └── performance │ └── RenderingTimeAndNumberOfSegments.kt ├── core ├── build.gradle.kts └── src │ ├── commonMain │ └── kotlin │ │ └── it │ │ └── krzeminski │ │ ├── fsynth │ │ ├── PrimitiveWaveGenerators.kt │ │ ├── Rendering.kt │ │ ├── development │ │ │ └── Metronome.kt │ │ ├── effects │ │ │ └── envelope │ │ │ │ └── AdsrEnvelope.kt │ │ ├── instruments │ │ │ ├── Instrument.kt │ │ │ ├── Organs.kt │ │ │ ├── Percussion.kt │ │ │ └── Synthesizer.kt │ │ ├── postprocessing │ │ │ └── QualityReduction.kt │ │ ├── songs │ │ │ ├── PinkPantherThemeIntro.kt │ │ │ ├── SimpleDemoSong.kt │ │ │ ├── SongRepository.kt │ │ │ └── VanHalenJumpIntro.kt │ │ ├── synthesis │ │ │ ├── Preprocessing.kt │ │ │ ├── Synthesis.kt │ │ │ ├── caching │ │ │ │ └── bucketing │ │ │ │ │ ├── BucketedTrack.kt │ │ │ │ │ └── BucketedTrackBuilding.kt │ │ │ └── types │ │ │ │ └── SongForSynthesis.kt │ │ ├── types │ │ │ ├── BoundedWaveform.kt │ │ │ ├── MusicNote.kt │ │ │ ├── PositionedBoundedWaveform.kt │ │ │ ├── Song.kt │ │ │ ├── SongBuildingDsl.kt │ │ │ ├── SynthesisParameters.kt │ │ │ └── Waveform.kt │ │ └── worker │ │ │ ├── README.md │ │ │ └── SynthesisWorker.kt │ │ └── gitinfo │ │ └── types │ │ └── GitInfo.kt │ └── commonTest │ └── kotlin │ └── it │ └── krzeminski │ └── fsynth │ ├── PrimitiveWaveGeneratorsTest.kt │ ├── RenderingTest.kt │ ├── effects │ └── envelope │ │ └── AdsrEnvelopeTest.kt │ ├── postprocessing │ └── QualityReductionTest.kt │ ├── synthesis │ ├── PreprocessingTest.kt │ ├── SynthesisTest.kt │ └── caching │ │ └── bucketing │ │ └── BucketedTrackBuildingTest.kt │ ├── testutils │ └── BoundedWaveformComparison.kt │ └── types │ ├── BoundedWaveformTest.kt │ ├── MusicNoteTest.kt │ ├── SongBuildingDslTest.kt │ └── WaveformTest.kt ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── ktlint.gradle.kts ├── settings.gradle.kts └── web ├── build.gradle.kts ├── serviceworker ├── build.gradle.kts └── src │ └── main │ └── kotlin │ └── it │ └── krzeminski │ └── fsynth │ └── web │ └── serviceworker │ └── main.kt ├── src └── main │ ├── kotlin │ └── it │ │ └── krzeminski │ │ ├── fsynth │ │ ├── PlaybackCustomization.kt │ │ ├── Player.kt │ │ ├── SynthesisWorkerProxy.kt │ │ ├── VersionInfo.kt │ │ ├── Wavesurfer.kt │ │ ├── main.kt │ │ └── typings │ │ │ ├── AudioBufferToWav.kt │ │ │ ├── AudioRelatedClasses.kt │ │ │ └── WaveSurfer.kt │ │ └── testutils │ │ └── TimeMeasurement.kt │ └── resources │ ├── Logo.svg │ ├── index.html │ └── manifest.webmanifest ├── webpack.config.d └── hot-reload-fix.js └── worker ├── build.gradle.kts └── src └── main └── kotlin └── it └── krzeminski └── fsynth └── web └── worker ├── WebSoundRendering.kt ├── main.kt ├── testutils └── TimeMeasurement.kt └── typings └── AudioRelatedClasses.kt /.circleci/config-github-pages.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | jobs: 3 | build: 4 | docker: 5 | - image: circleci/openjdk:8-jdk 6 | steps: 7 | - run: echo "No-op" 8 | -------------------------------------------------------------------------------- /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | jobs: 3 | core: 4 | docker: 5 | - image: circleci/openjdk:8-jdk-node-browsers 6 | working_directory: ~/repo 7 | steps: 8 | - checkout 9 | - run: ./gradlew --version 10 | - run: ./gradlew :core:build 11 | android: 12 | docker: 13 | - image: circleci/android:api-29 14 | working_directory: ~/repo 15 | steps: 16 | - checkout 17 | - run: ./gradlew --version 18 | - run: ./gradlew :android:build 19 | cli: 20 | docker: 21 | - image: circleci/openjdk:8-jdk 22 | working_directory: ~/repo 23 | steps: 24 | - checkout 25 | - run: ./gradlew --version 26 | - run: ./gradlew :cli:build 27 | - run: 28 | name: Publish code coverage info 29 | command: bash <(curl -s https://codecov.io/bash) 30 | web: 31 | docker: 32 | - image: circleci/openjdk:8-jdk-node-browsers 33 | working_directory: ~/repo 34 | environment: 35 | CHROME_BIN: "/usr/bin/google-chrome" 36 | steps: 37 | - checkout 38 | - run: ./gradlew --version 39 | - run: ./gradlew :web:build 40 | - run: 41 | name: Publish to GitHub Pages 42 | command: | 43 | set -e 44 | 45 | if [ $CIRCLE_BRANCH != "master" ] 46 | then 47 | echo "Not on master branch - skipping" 48 | exit 0 49 | fi 50 | 51 | git config --global user.email "builds@circleci.com" 52 | git config --global user.name "CircleCI" 53 | 54 | git clone --single-branch --branch gh-pages https://${GITHUB_TOKEN}@github.com/${CIRCLE_PROJECT_USERNAME}/${CIRCLE_PROJECT_REPONAME}.git gh-pages 55 | cd gh-pages 56 | git checkout --orphan gh-pages-new 57 | git rm -rf . 58 | mkdir .circleci 59 | 60 | cd .. 61 | cp -av web/build/distributions gh-pages 62 | cp .circleci/config-github-pages.yml gh-pages/.circleci/config.yml 63 | cd gh-pages 64 | mv distributions/* . 65 | 66 | git add -A 67 | git commit -m "Circle CI deployment to GitHub Pages: ${CIRCLE_SHA1}" --allow-empty 68 | git branch -D gh-pages 69 | git branch -m gh-pages-new gh-pages 70 | git push --force origin gh-pages 71 | 72 | workflows: 73 | version: 2 74 | Tests: 75 | jobs: 76 | - core 77 | - android 78 | - cli 79 | - web 80 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "gradle" 9 | directory: "/" 10 | schedule: 11 | interval: "daily" 12 | - package-ecosystem: "gradle" 13 | directory: "/core" 14 | schedule: 15 | interval: "daily" 16 | - package-ecosystem: "gradle" 17 | directory: "/web" 18 | schedule: 19 | interval: "daily" 20 | - package-ecosystem: "gradle" 21 | directory: "/android" 22 | schedule: 23 | interval: "daily" 24 | - package-ecosystem: "gradle" 25 | directory: "/cli" 26 | schedule: 27 | interval: "daily" 28 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .idea/shelf 3 | /android.tests.dependencies 4 | /confluence/target 5 | /dependencies 6 | /dist 7 | /local 8 | /gh-pages 9 | /ideaSDK 10 | /clionSDK 11 | /android-studio/sdk 12 | out/ 13 | /tmp 14 | workspace.xml 15 | *.versionsBackup 16 | /idea/testData/debugger/tinyApp/classes* 17 | /jps-plugin/testData/kannotator 18 | /ultimate/dependencies 19 | /ultimate/ideaSDK 20 | /ultimate/out 21 | /ultimate/tmp 22 | /js/js.translator/testData/out/ 23 | /js/js.translator/testData/out-min/ 24 | .gradle/ 25 | build/ 26 | !**/src/**/build 27 | !**/test/**/build 28 | *.iml 29 | !**/testData/**/*.iml 30 | .idea/ 31 | target/ 32 | local.properties 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Piotr Krzemiński 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Build Status](https://circleci.com/gh/krzema12/fsynth.svg?style=svg)](https://app.circleci.com/pipelines/github/krzema12/fsynth) [![codecov](https://codecov.io/gh/krzema12/fsynth/branch/master/graph/badge.svg)](https://codecov.io/gh/krzema12/fsynth) [![Awesome Kotlin Badge](https://kotlin.link/awesome-kotlin.svg)](https://github.com/KotlinBy/awesome-kotlin) 2 | 3 | 4 | 5 | # What is fsynth? 6 | 7 | It's a simple music synthesizer. It focuses on generating the waveforms from scratch, no samples are used. You can listen to it via the Web player, or using the cross-platform Java CLI. Read on to learn more. 8 | 9 | Secondly, this project is my playground for learning Kotlin, functional programming (hence the "f" in "fsynth"), front-end development, Gradle, multiplatform projects, and other. It's also a place where I can focus more on quality than I normally could afford in a professional environment, because here I don't have a pressure to deliver on time. That's why my aim here is also to have as little technical debt as possible, and have as clean code as I can write. 10 | 11 | Songs are described with such DSL: 12 | 13 | ```kotlin 14 | val simpleDemoSong = song( 15 | name = "Simple demo song", 16 | beatsPerMinute = 120) { 17 | track(instrument = organs, volume = 0.3f) { 18 | note(1 by 8, D4) 19 | note(1 by 16, Csharp4) 20 | note(1 by 16, D4) 21 | note(1 by 8, E4) 22 | note(1 by 8, D4) 23 | 24 | pause(1 by 8) 25 | chord(1 by 8, A3, D4, Fsharp4) 26 | chord(1 by 4, B3, D4, G4) 27 | } 28 | } 29 | ``` 30 | 31 | # How to listen 32 | 33 | ## Web 34 | 35 | https://fsynthlib.github.io/fsynth/ 36 | 37 | ## Java CLI 38 | 39 | The CLI uses system sound output to play music. 40 | 41 | You can use the CLI from the distribution package: 42 | 43 | ``` 44 | ./gradlew :cli:installDist 45 | cli/build/install/fsynth/bin/fsynth --song 'Van Halen - Jump (intro)' 46 | ``` 47 | 48 | or during development, you can call the CLI through Gradle: 49 | 50 | ``` 51 | ./gradlew :cli:run --args="--song 'Van Halen - Jump (intro)'" 52 | ``` 53 | 54 | To see a list of available songs, call the CLI without arguments. 55 | 56 | # Talk on [Tricity Kotlin User Group](https://www.facebook.com/groups/tricity.kotlin.user.group) 57 | 58 | [![[TKUG #1] Piotr Krzemiński, 'Project fsynth: show and tell'](http://img.youtube.com/vi/mQEIn7Eqsio/0.jpg)](http://www.youtube.com/watch?v=mQEIn7Eqsio "[TKUG #1] Piotr Krzemiński, 'Project fsynth: show and tell'") 59 | 60 | # Build prerequisites 61 | 62 | The below dependencies won't be installed by Gradle: 63 | 64 | * JDK + path to it in `JAVA_HOME` 65 | (warning: use Oracle's JDK for now, there are some issue with OpenJDK; see #31 for details) 66 | * Android SDK + path to it in `ANDROID_HOME` 67 | -------------------------------------------------------------------------------- /android/.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | -------------------------------------------------------------------------------- /android/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | id("com.android.application") 3 | id("kotlin-android") 4 | id("kotlin-android-extensions") 5 | } 6 | 7 | android { 8 | compileSdkVersion(29) 9 | buildToolsVersion("29.0.2") 10 | defaultConfig { 11 | applicationId = "it.krzeminski.fsynth" 12 | minSdkVersion(26) 13 | targetSdkVersion(29) 14 | versionCode = 1 15 | versionName = "1.0" 16 | testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" 17 | } 18 | buildTypes { 19 | getByName("release") { 20 | isMinifyEnabled = false 21 | proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") 22 | } 23 | } 24 | kotlinOptions { 25 | jvmTarget = JavaVersion.VERSION_1_8.toString() 26 | } 27 | } 28 | 29 | repositories { 30 | google() 31 | jcenter() 32 | } 33 | 34 | val kotlinVersion: String by rootProject.extra 35 | 36 | dependencies { 37 | implementation(fileTree(Pair("dir", "libs"), Pair("include", listOf("*.jar")))) 38 | implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlinVersion") 39 | implementation("androidx.appcompat:appcompat:1.2.0") 40 | implementation("androidx.core:core-ktx:1.3.2") 41 | implementation("androidx.constraintlayout:constraintlayout:2.0.4") 42 | implementation("com.google.android.material:material:1.3.0") 43 | implementation(project(":core")) 44 | testImplementation("junit:junit:4.13.2") 45 | androidTestImplementation("androidx.test:runner:1.3.0") 46 | androidTestImplementation("androidx.test.espresso:espresso-core:3.3.0") 47 | } 48 | -------------------------------------------------------------------------------- /android/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | # Add project specific ProGuard rules here. 2 | # You can control the set of applied configuration files using the 3 | # proguardFiles setting in build.gradle.kts. 4 | # 5 | # For more details, see 6 | # http://developer.android.com/guide/developing/tools/proguard.html 7 | 8 | # If your project uses WebView with JS, uncomment the following 9 | # and specify the fully qualified class name to the JavaScript interface 10 | # class: 11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview { 12 | # public *; 13 | #} 14 | 15 | # Uncomment this to preserve the line number information for 16 | # debugging stack traces. 17 | #-keepattributes SourceFile,LineNumberTable 18 | 19 | # If you keep the line number information, uncomment this to 20 | # hide the original source file name. 21 | #-renamesourcefileattribute SourceFile 22 | -------------------------------------------------------------------------------- /android/src/androidTest/kotlin/it/krzeminski/fsynth/ExampleInstrumentedTest.kt: -------------------------------------------------------------------------------- 1 | package it.krzeminski.fsynth 2 | 3 | import androidx.test.platform.app.InstrumentationRegistry 4 | import androidx.test.ext.junit.runners.AndroidJUnit4 5 | 6 | import org.junit.Test 7 | import org.junit.runner.RunWith 8 | 9 | import org.junit.Assert.assertEquals 10 | 11 | /** 12 | * Instrumented test, which will execute on an Android device. 13 | * 14 | * See [testing documentation](http://d.android.com/tools/testing). 15 | */ 16 | @RunWith(AndroidJUnit4::class) 17 | class ExampleInstrumentedTest { 18 | @Test 19 | fun useAppContext() { 20 | // Context of the app under test. 21 | val appContext = InstrumentationRegistry.getInstrumentation().targetContext 22 | assertEquals("it.krzeminski.fsynth", appContext.packageName) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /android/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /android/src/main/kotlin/it/krzeminski/fsynth/MainActivity.kt: -------------------------------------------------------------------------------- 1 | package it.krzeminski.fsynth 2 | 3 | import android.media.AudioFormat 4 | import android.media.AudioManager 5 | import android.media.AudioTrack 6 | import android.media.AudioTrack.WRITE_BLOCKING 7 | import androidx.appcompat.app.AppCompatActivity 8 | import android.os.Bundle 9 | import androidx.core.view.plusAssign 10 | import com.google.android.material.button.MaterialButton 11 | import it.krzeminski.fsynth.songs.allSongs 12 | import it.krzeminski.fsynth.types.Song 13 | import kotlinx.android.synthetic.main.activity_main.* 14 | 15 | class MainActivity : AppCompatActivity() { 16 | 17 | override fun onCreate(savedInstanceState: Bundle?) { 18 | super.onCreate(savedInstanceState) 19 | setContentView(R.layout.activity_main) 20 | 21 | allSongs.forEach { song -> 22 | buttons += MaterialButton(this, null, R.attr.materialButtonOutlinedStyle).apply { 23 | text = song.name 24 | setOnClickListener { 25 | song.playOnAndroid(sampleRate = 44100) 26 | } 27 | } 28 | } 29 | } 30 | } 31 | 32 | private fun Song.playOnAndroid(sampleRate: Int) { 33 | val samples = renderWithSampleRate(sampleRate).toList() 34 | val audioTrack = AudioTrack( 35 | AudioManager.STREAM_MUSIC, 36 | sampleRate, 37 | AudioFormat.CHANNEL_OUT_MONO, 38 | AudioFormat.ENCODING_PCM_FLOAT, 39 | samples.size*java.lang.Float.BYTES, 40 | AudioTrack.MODE_STATIC) 41 | with(audioTrack) { 42 | write(samples.toFloatArray(), 0, samples.size, WRITE_BLOCKING) 43 | play() 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /android/src/main/res/drawable-v24/ic_launcher_foreground.xml: -------------------------------------------------------------------------------- 1 | 7 | 12 | 13 | 19 | 22 | 25 | 26 | 27 | 28 | 34 | 35 | -------------------------------------------------------------------------------- /android/src/main/res/drawable/ic_launcher_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 8 | 10 | 12 | 14 | 16 | 18 | 20 | 22 | 24 | 26 | 28 | 30 | 32 | 34 | 36 | 38 | 40 | 42 | 44 | 46 | 48 | 50 | 52 | 54 | 56 | 58 | 60 | 62 | 64 | 66 | 68 | 70 | 72 | 74 | 75 | -------------------------------------------------------------------------------- /android/src/main/res/layout/activity_main.xml: -------------------------------------------------------------------------------- 1 | 2 | 9 | 10 | 14 | 15 | -------------------------------------------------------------------------------- /android/src/main/res/mipmap-anydpi-v26/ic_launcher.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /android/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /android/src/main/res/mipmap-hdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fsynthlib/fsynth/1f138bf5c5d46a80b23eca8bde589da1b865662a/android/src/main/res/mipmap-hdpi/ic_launcher.png -------------------------------------------------------------------------------- /android/src/main/res/mipmap-hdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fsynthlib/fsynth/1f138bf5c5d46a80b23eca8bde589da1b865662a/android/src/main/res/mipmap-hdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /android/src/main/res/mipmap-mdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fsynthlib/fsynth/1f138bf5c5d46a80b23eca8bde589da1b865662a/android/src/main/res/mipmap-mdpi/ic_launcher.png -------------------------------------------------------------------------------- /android/src/main/res/mipmap-mdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fsynthlib/fsynth/1f138bf5c5d46a80b23eca8bde589da1b865662a/android/src/main/res/mipmap-mdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /android/src/main/res/mipmap-xhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fsynthlib/fsynth/1f138bf5c5d46a80b23eca8bde589da1b865662a/android/src/main/res/mipmap-xhdpi/ic_launcher.png -------------------------------------------------------------------------------- /android/src/main/res/mipmap-xhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fsynthlib/fsynth/1f138bf5c5d46a80b23eca8bde589da1b865662a/android/src/main/res/mipmap-xhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /android/src/main/res/mipmap-xxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fsynthlib/fsynth/1f138bf5c5d46a80b23eca8bde589da1b865662a/android/src/main/res/mipmap-xxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /android/src/main/res/mipmap-xxhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fsynthlib/fsynth/1f138bf5c5d46a80b23eca8bde589da1b865662a/android/src/main/res/mipmap-xxhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /android/src/main/res/mipmap-xxxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fsynthlib/fsynth/1f138bf5c5d46a80b23eca8bde589da1b865662a/android/src/main/res/mipmap-xxxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /android/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fsynthlib/fsynth/1f138bf5c5d46a80b23eca8bde589da1b865662a/android/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /android/src/main/res/values/colors.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #008577 4 | #00574B 5 | #D81B60 6 | 7 | -------------------------------------------------------------------------------- /android/src/main/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | fsynth 3 | 4 | -------------------------------------------------------------------------------- /android/src/main/res/values/styles.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /android/src/test/kotlin/it/krzeminski/fsynth/ExampleUnitTest.kt: -------------------------------------------------------------------------------- 1 | package it.krzeminski.fsynth 2 | 3 | import org.junit.Test 4 | 5 | import org.junit.Assert.assertEquals 6 | 7 | /** 8 | * Example local unit test, which will execute on the development machine (host). 9 | * 10 | * See [testing documentation](http://d.android.com/tools/testing). 11 | */ 12 | class ExampleUnitTest { 13 | @Test 14 | fun addition_isCorrect() { 15 | assertEquals(4, 2 + 2) 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /build.gradle.kts: -------------------------------------------------------------------------------- 1 | buildscript { 2 | val kotlinVersion by extra { "1.4.30" } 3 | 4 | repositories { 5 | google() 6 | mavenCentral() 7 | maven("https://plugins.gradle.org/m2/") 8 | } 9 | 10 | dependencies { 11 | classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion") 12 | // As a workaround, the root project knows about the Android project. 13 | // Otherwise, there are some issues with building the Android project. 14 | classpath("com.android.tools.build:gradle:3.5.0") 15 | } 16 | } 17 | 18 | allprojects { 19 | repositories { 20 | mavenCentral() 21 | } 22 | 23 | tasks.withType> { 24 | kotlinOptions { 25 | freeCompilerArgs = freeCompilerArgs + "-progressive" 26 | } 27 | } 28 | } 29 | 30 | subprojects { 31 | apply(from = "$rootDir/ktlint.gradle.kts") 32 | } 33 | -------------------------------------------------------------------------------- /cli/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | kotlin("jvm") 3 | application 4 | jacoco 5 | } 6 | 7 | 8 | val kotlinVersion: String by rootProject.extra 9 | 10 | dependencies { 11 | implementation("org.jetbrains.kotlin:kotlin-stdlib:$kotlinVersion") 12 | implementation("com.github.ajalt:clikt:1.7.0") 13 | implementation(project(":core")) 14 | 15 | testImplementation("junit:junit:4.13.2") 16 | testImplementation("org.jetbrains.kotlin:kotlin-test") 17 | testImplementation("org.jetbrains.kotlin:kotlin-test-junit") 18 | } 19 | 20 | application { 21 | mainClassName = "it.krzeminski.fsynth.ConsoleEntryPointKt" 22 | applicationName = "fsynth" 23 | } 24 | 25 | val compileKotlin: org.jetbrains.kotlin.gradle.tasks.KotlinCompile by tasks 26 | compileKotlin.kotlinOptions.jvmTarget = "1.8" 27 | 28 | jacoco { 29 | toolVersion = "0.8.3" 30 | } 31 | 32 | val jacocoTestReport = tasks.getByName("jacocoTestReport") { 33 | reports { 34 | xml.isEnabled = true 35 | html.isEnabled = true 36 | } 37 | 38 | afterEvaluate { 39 | classDirectories.setFrom(files(classDirectories.files.map { 40 | fileTree(it) { 41 | exclude( 42 | // Excluding songs and instruments from code coverage. 43 | // Rationale: testing songs would be like testing MIDI files - some pieces of data. 44 | // Regarding instruments, they are defined as math operations on simpler waveforms and testing them 45 | // would bring no benefit, however operations alone (addition, multiplication) are still tested. 46 | "it/krzeminski/fsynth/songs/**", 47 | "it/krzeminski/fsynth/instruments/**") 48 | } 49 | })) 50 | } 51 | } 52 | 53 | tasks.getByName("check").dependsOn(jacocoTestReport) 54 | -------------------------------------------------------------------------------- /cli/src/main/kotlin/it/krzeminski/fsynth/ConsoleEntryPoint.kt: -------------------------------------------------------------------------------- 1 | package it.krzeminski.fsynth 2 | 3 | import com.github.ajalt.clikt.core.CliktCommand 4 | import com.github.ajalt.clikt.parameters.options.default 5 | import com.github.ajalt.clikt.parameters.options.option 6 | import com.github.ajalt.clikt.parameters.options.required 7 | import com.github.ajalt.clikt.parameters.options.validate 8 | import com.github.ajalt.clikt.parameters.types.choice 9 | import com.github.ajalt.clikt.parameters.types.float 10 | import com.github.ajalt.clikt.parameters.types.path 11 | import it.krzeminski.fsynth.generated.gitInfo 12 | import it.krzeminski.fsynth.songs.allSongs 13 | import it.krzeminski.fsynth.types.Song 14 | import java.nio.file.Path 15 | import java.time.Instant 16 | 17 | fun main(args: Array) = Synthesize().main(args) 18 | 19 | private class Synthesize : CliktCommand(name = "fsynth") { 20 | val song: Song by option( 21 | "--song", 22 | help = "Name of the song to play", 23 | metavar = "NAME") 24 | .choice(allSongs.map { it.name to it }.toMap()) 25 | .required() 26 | 27 | val startTime: Float by option( 28 | help = "Optional. Starts the payback omitting the given amount of seconds", 29 | metavar = "SECONDS") 30 | .float() 31 | .default(0.0f) 32 | .validate { 33 | require(it >= 0.0f) { 34 | "Start time should be positive!" 35 | } 36 | } 37 | 38 | val outputFile: Path? by option( 39 | help = "Optional. If given, the song will not be played, but instead, written as a WAVE file", 40 | metavar = "PATH") 41 | .path() 42 | 43 | override fun run() { 44 | printIntroduction() 45 | 46 | val audioStream = song.asAudioStream(samplesPerSecond = 44100, sampleSizeInBits = 8, startTime = startTime) 47 | 48 | val outputFileFinal = outputFile 49 | if (outputFileFinal != null) { 50 | audioStream?.saveAsWaveFile(outputFileFinal) 51 | } else { 52 | audioStream?.playAndBlockUntilFinishes() 53 | } 54 | } 55 | } 56 | 57 | private fun printIntroduction() { 58 | println("fsynth by Piotr Krzemiński") 59 | println("Version ${gitInfo.latestCommit.sha1.substring(0, 8)} " + 60 | "from ${Instant.ofEpochSecond(gitInfo.latestCommit.timeUnixTimestamp)}") 61 | } 62 | -------------------------------------------------------------------------------- /cli/src/main/kotlin/it/krzeminski/fsynth/JvmSoundPlayback.kt: -------------------------------------------------------------------------------- 1 | package it.krzeminski.fsynth 2 | 3 | import it.krzeminski.fsynth.types.Song 4 | import java.io.ByteArrayInputStream 5 | import java.nio.file.Path 6 | import javax.sound.sampled.AudioFileFormat 7 | import javax.sound.sampled.AudioFormat 8 | import javax.sound.sampled.AudioInputStream 9 | import javax.sound.sampled.AudioSystem 10 | import javax.sound.sampled.Clip 11 | import javax.sound.sampled.LineEvent 12 | import kotlin.system.measureTimeMillis 13 | 14 | fun Song.asAudioStream(samplesPerSecond: Int, sampleSizeInBits: Int, startTime: Float): AudioInputStream? { 15 | require(sampleSizeInBits == 8) { "Only 8-bit samples are supported now!" } 16 | 17 | lateinit var rawData: ByteArray 18 | val synthesisTimeInMilliseconds = measureTimeMillis { 19 | rawData = render8bit(song = this, sampleRate = samplesPerSecond, startTime = startTime) 20 | } 21 | 22 | if (rawData.isEmpty()) { 23 | println("No song data to play, returning a null AudioInputStream") 24 | return null 25 | } 26 | 27 | println("Synthesized in ${synthesisTimeInMilliseconds.toFloat() / 1000.0f} s") 28 | val audioFormat = buildAudioFormat(samplesPerSecond, sampleSizeInBits) 29 | return prepareAudioInputStream(rawData, audioFormat) 30 | } 31 | 32 | fun AudioInputStream.playAndBlockUntilFinishes() { 33 | println("Playing...") 34 | val audioInputStream = this 35 | with(AudioSystem.getClip()) { 36 | open(audioInputStream) 37 | start() 38 | sleepUntilPlaybackFinishes() 39 | } 40 | } 41 | 42 | fun AudioInputStream.saveAsWaveFile(path: Path) { 43 | AudioSystem.write(this, AudioFileFormat.Type.WAVE, path.toFile()) 44 | } 45 | 46 | private object AudioFormatConstants { 47 | const val MONO = 1 48 | const val UNSIGNED = false 49 | const val LITTLE_ENDIAN = false 50 | } 51 | 52 | private fun buildAudioFormat(samplesPerSecond: Int, sampleSizeInBits: Int): AudioFormat { 53 | return AudioFormat( 54 | samplesPerSecond.toFloat(), 55 | sampleSizeInBits, 56 | AudioFormatConstants.MONO, 57 | AudioFormatConstants.UNSIGNED, 58 | AudioFormatConstants.LITTLE_ENDIAN) 59 | } 60 | 61 | private fun prepareAudioInputStream(rawData: ByteArray, audioFormat: AudioFormat): AudioInputStream { 62 | return AudioInputStream( 63 | ByteArrayInputStream(rawData), 64 | audioFormat, 65 | rawData.size.toLong()) 66 | } 67 | 68 | private fun Clip.sleepUntilPlaybackFinishes() { 69 | var continuePlayback = true 70 | addLineListener { event -> 71 | if (event.type == LineEvent.Type.STOP) { 72 | continuePlayback = false 73 | } 74 | } 75 | 76 | while (continuePlayback) { 77 | Thread.sleep(100) 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /cli/src/main/kotlin/it/krzeminski/fsynth/SoundOutputRendering.kt: -------------------------------------------------------------------------------- 1 | package it.krzeminski.fsynth 2 | 3 | import it.krzeminski.fsynth.types.Song 4 | 5 | fun render8bit(song: Song, sampleRate: Int, startTime: Float): ByteArray = 6 | song.renderWithSampleRate(sampleRate, startTime) 7 | .map { waveformValue -> normalizedWaveValueToByte(waveformValue) } 8 | .toList() 9 | .toByteArray() 10 | 11 | /** 12 | * Takes [value] of the sound wave, between -1.0 and 1.0, and returns an 8-bit sample (e.g. 0 for -1.0, 255 for 1.0). 13 | */ 14 | private fun normalizedWaveValueToByte(value: Float): Byte = 15 | (((value + 1.0f) / 2.0f) * 255.0f).toByte() 16 | -------------------------------------------------------------------------------- /cli/src/test/kotlin/performance/RenderingTimeAndNumberOfSegments.kt: -------------------------------------------------------------------------------- 1 | package performance 2 | 3 | import it.krzeminski.fsynth.instruments.synthesizer 4 | import it.krzeminski.fsynth.renderWithSampleRate 5 | import it.krzeminski.fsynth.types.MusicNote 6 | import it.krzeminski.fsynth.types.by 7 | import it.krzeminski.fsynth.types.song 8 | import kotlin.system.measureTimeMillis 9 | 10 | fun main(args: Array) { 11 | (1..1000).forEach { numberOfSegments -> 12 | val testSong = song(name = "Test song", beatsPerMinute = 100) { 13 | track(instrument = synthesizer, volume = 1.0f) { 14 | repeat(numberOfSegments) { 15 | note(1 by 4, MusicNote.A4) 16 | } 17 | } 18 | } 19 | val timeMillis = measureTimeMillis { 20 | testSong.renderWithSampleRate(44100).forEach { } 21 | } 22 | println("Segments: $numberOfSegments, time in ms: $timeMillis") 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /core/build.gradle.kts: -------------------------------------------------------------------------------- 1 | import org.ajoberstar.grgit.Grgit 2 | 3 | plugins { 4 | kotlin("multiplatform") 5 | id("org.ajoberstar.grgit") version "4.1.0" 6 | } 7 | 8 | kotlin { 9 | jvm { 10 | } 11 | js { 12 | browser() 13 | } 14 | sourceSets { 15 | val commonMain by getting { 16 | dependencies { 17 | implementation("org.jetbrains.kotlin:kotlin-stdlib-common") 18 | } 19 | kotlin.srcDirs(kotlin.srcDirs, "$buildDir/generated/") 20 | } 21 | val commonTest by getting { 22 | dependencies { 23 | implementation("org.jetbrains.kotlin:kotlin-test-common") 24 | implementation("org.jetbrains.kotlin:kotlin-test-annotations-common") 25 | implementation("it.krzeminski.vis-assert:vis-assert:0.4.1-beta") 26 | } 27 | } 28 | val jvmMain by getting { 29 | dependencies { 30 | implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8") 31 | } 32 | } 33 | val jvmTest by getting { 34 | dependencies { 35 | implementation("org.jetbrains.kotlin:kotlin-test") 36 | implementation("org.jetbrains.kotlin:kotlin-test-junit") 37 | } 38 | } 39 | val jsMain by getting { 40 | dependencies { 41 | implementation("org.jetbrains.kotlin:kotlin-stdlib-js") 42 | } 43 | } 44 | val jsTest by getting { 45 | dependencies { 46 | implementation("org.jetbrains.kotlin:kotlin-test-js") 47 | } 48 | } 49 | } 50 | } 51 | 52 | val generateGitInfo = tasks.register("generateGitInfo") { 53 | val grgit = Grgit.open(mapOf("currentDir" to project.rootDir)) 54 | val fileDirectory = "$buildDir/generated/it/krzeminski/fsynth/generated" 55 | val fileName = "GitInfo.kt" 56 | 57 | val sha1 = grgit.head().id 58 | val timeUnixTimestamp = grgit.head().dateTime.toEpochSecond() 59 | val containsUncommittedChanges = !grgit.status().isClean 60 | 61 | val contents = """ 62 | package it.krzeminski.fsynth.generated 63 | 64 | import it.krzeminski.gitinfo.types.CommitMetadata 65 | import it.krzeminski.gitinfo.types.GitInfo 66 | 67 | val gitInfo = GitInfo( 68 | latestCommit = CommitMetadata( 69 | sha1 = "$sha1", 70 | timeUnixTimestamp = $timeUnixTimestamp), 71 | containsUncommittedChanges = $containsUncommittedChanges) 72 | """.trimIndent() 73 | doLast { 74 | file(fileDirectory).mkdirs() 75 | file("$fileDirectory/$fileName").writeText(contents) 76 | } 77 | } 78 | 79 | // There's no easy way to express that 'generateGitInfo' task needs to be executed before any code is compiled. 80 | // That's why a dependency on this task is added to all known tasks that deal with compiling code, so that this file 81 | // already exists when code for any platform is about to be compiled. 82 | tasks.getByName("compileKotlinJs").dependsOn(generateGitInfo) 83 | tasks.getByName("compileKotlinJvm").dependsOn(generateGitInfo) 84 | tasks.getByName("compileKotlinMetadata").dependsOn(generateGitInfo) 85 | -------------------------------------------------------------------------------- /core/src/commonMain/kotlin/it/krzeminski/fsynth/PrimitiveWaveGenerators.kt: -------------------------------------------------------------------------------- 1 | package it.krzeminski.fsynth 2 | 3 | import it.krzeminski.fsynth.types.Waveform 4 | import kotlin.math.PI 5 | import kotlin.math.sin 6 | 7 | val silence = { _: Float -> 0.0f } 8 | 9 | fun sineWave(frequency: Float): Waveform = { t -> sin(frequency*t*2.0*PI).toFloat() } 10 | 11 | fun squareWave(frequency: Float): Waveform = { t -> if (sineWave(frequency)(t) >= 0.0f) 1.0f else -1.0f } 12 | -------------------------------------------------------------------------------- /core/src/commonMain/kotlin/it/krzeminski/fsynth/Rendering.kt: -------------------------------------------------------------------------------- 1 | package it.krzeminski.fsynth 2 | 3 | import it.krzeminski.fsynth.synthesis.buildSongEvaluationFunction 4 | import it.krzeminski.fsynth.synthesis.durationInSeconds 5 | import it.krzeminski.fsynth.synthesis.preprocessForSynthesis 6 | import it.krzeminski.fsynth.synthesis.types.SongForSynthesis 7 | import it.krzeminski.fsynth.types.Song 8 | 9 | fun Song.renderWithSampleRate(sampleRate: Int, startTime: Float = 0.0f, onProgressChange: (Int) -> Unit = {}) = 10 | this.preprocessForSynthesis() 11 | .renderWithSampleRate(sampleRate, startTime, onProgressChange) 12 | 13 | fun SongForSynthesis.renderWithSampleRate( 14 | sampleRate: Int, 15 | startTime: Float, 16 | onProgressChange: (Int) -> Unit = {} 17 | ): Sequence { 18 | val startSample = (sampleRate.toFloat() * startTime).toInt() 19 | val endSample = (sampleRate.toFloat() * durationInSeconds).toInt() 20 | val samplesToRender = endSample - startSample 21 | val songEvaluationFunction = buildSongEvaluationFunction() 22 | return (startSample..endSample).asSequence().chunked((samplesToRender / 10).coerceAtLeast(1)).map { chunk -> 23 | onProgressChange((chunk[0] - startSample)*100 / samplesToRender) 24 | chunk 25 | .map(sampleIndexToTimeInSeconds(sampleRate)) 26 | .map(songEvaluationFunction) 27 | }.flatten() 28 | } 29 | 30 | private fun sampleIndexToTimeInSeconds(sampleRate: Int) = { sampleIndex: Int -> sampleIndex.toFloat() / sampleRate.toFloat() } 31 | -------------------------------------------------------------------------------- /core/src/commonMain/kotlin/it/krzeminski/fsynth/development/Metronome.kt: -------------------------------------------------------------------------------- 1 | package it.krzeminski.fsynth.development 2 | 3 | import it.krzeminski.fsynth.instruments.synthesizer 4 | import it.krzeminski.fsynth.types.MusicNote.C4 5 | import it.krzeminski.fsynth.types.MusicNote.C5 6 | import it.krzeminski.fsynth.types.NoteValue 7 | import it.krzeminski.fsynth.types.Song 8 | import it.krzeminski.fsynth.types.Track 9 | import it.krzeminski.fsynth.types.TrackSegment 10 | 11 | fun Song.add4by4MetronomeTrackForDevelopment(trackSegments: Int) = 12 | this.copy(tracks = tracks + Track( 13 | name = "Metronome", 14 | instrument = synthesizer, 15 | volume = 0.15f, 16 | segments = generateTrackSegments(trackSegments))) 17 | 18 | private fun generateTrackSegments(trackSegments: Int) = 19 | (0..(trackSegments - 1)).asSequence() 20 | .flatMap { 21 | listOf(C5, C4, C4, C4) 22 | .flatMap { note -> 23 | listOf( 24 | TrackSegment.SingleNote(NoteValue(1, 32), note), 25 | TrackSegment.Pause(NoteValue(7, 32))) 26 | } 27 | .asSequence() 28 | } 29 | .toList() 30 | -------------------------------------------------------------------------------- /core/src/commonMain/kotlin/it/krzeminski/fsynth/effects/envelope/AdsrEnvelope.kt: -------------------------------------------------------------------------------- 1 | package it.krzeminski.fsynth.effects.envelope 2 | 3 | import it.krzeminski.fsynth.types.BoundedWaveform 4 | 5 | /** 6 | * Describes parameters of an Attack-Decay-Sustain-Release envelope. 7 | * All time parameters are given in seconds. 8 | * @see Wikipedia, Envelope (music)#ADSR. 9 | */ 10 | data class AdsrEnvelopeDefinition( 11 | val attackTime: Float, 12 | val decayTime: Float, 13 | val sustainLevel: Float, 14 | val releaseTime: Float 15 | ) 16 | 17 | fun buildEnvelopeFunction(definition: AdsrEnvelopeDefinition) = { keyPressDuration: Float -> adsrEnvelope(keyPressDuration, definition) } 18 | 19 | fun adsrEnvelope(keyPressDuration: Float, definition: AdsrEnvelopeDefinition): BoundedWaveform { 20 | with(definition) { 21 | require(keyPressDuration >= 0.0f) { "Key press duration must not be negative!" } 22 | require(attackTime >= 0.0f) { "Attack time must not be negative!" } 23 | require(decayTime >= 0.0f) { "Decay time must not be negative!" } 24 | require(sustainLevel >= 0.0f) { "Sustain level must not be negative!" } 25 | require(releaseTime >= 0.0f) { "Release time must not be negative!" } 26 | 27 | fun envelopeForPressedKey(t: Float): Float { 28 | return when (t) { 29 | in 0.0f..attackTime -> when { 30 | attackTime != 0.0f -> t / attackTime 31 | decayTime != 0.0f -> 1.0f 32 | else -> sustainLevel 33 | } 34 | in attackTime..(attackTime + decayTime) -> 1.0f - (1.0f - sustainLevel)*(t - attackTime) / decayTime 35 | else -> sustainLevel 36 | } 37 | } 38 | val envelopeAtKeyRelease = envelopeForPressedKey(keyPressDuration) 39 | val actualReleaseTime = if (sustainLevel != 0.0f) releaseTime * envelopeAtKeyRelease / sustainLevel else 0.0f 40 | val waveform = { t: Float -> 41 | when (t) { 42 | in 0.0f..keyPressDuration -> envelopeForPressedKey(t) 43 | else -> envelopeAtKeyRelease - sustainLevel*(t - keyPressDuration) / releaseTime 44 | } 45 | } 46 | return BoundedWaveform(waveform, keyPressDuration + actualReleaseTime) 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /core/src/commonMain/kotlin/it/krzeminski/fsynth/instruments/Instrument.kt: -------------------------------------------------------------------------------- 1 | package it.krzeminski.fsynth.instruments 2 | 3 | import it.krzeminski.fsynth.types.BoundedWaveform 4 | import it.krzeminski.fsynth.types.Waveform 5 | 6 | data class Instrument( 7 | val waveform: (Frequency) -> Waveform, 8 | val envelope: (KeyPressDuration) -> BoundedWaveform 9 | ) 10 | 11 | typealias Frequency = Float 12 | typealias KeyPressDuration = Float 13 | -------------------------------------------------------------------------------- /core/src/commonMain/kotlin/it/krzeminski/fsynth/instruments/Organs.kt: -------------------------------------------------------------------------------- 1 | package it.krzeminski.fsynth.instruments 2 | 3 | import it.krzeminski.fsynth.effects.envelope.AdsrEnvelopeDefinition 4 | import it.krzeminski.fsynth.effects.envelope.buildEnvelopeFunction 5 | import it.krzeminski.fsynth.sineWave 6 | import it.krzeminski.fsynth.types.plus 7 | import it.krzeminski.fsynth.types.times 8 | 9 | val organs = Instrument( 10 | waveform = { frequency: Frequency -> 11 | 0.3 * sineWave(2 * frequency) + 12 | 0.7 * sineWave(frequency) + 13 | 0.2 * sineWave(0.5f * frequency) 14 | }, 15 | envelope = buildEnvelopeFunction( 16 | AdsrEnvelopeDefinition( 17 | attackTime = 0.05f, 18 | decayTime = 0.0f, 19 | sustainLevel = 1.0f, 20 | releaseTime = 0.3f)) 21 | ) 22 | -------------------------------------------------------------------------------- /core/src/commonMain/kotlin/it/krzeminski/fsynth/instruments/Percussion.kt: -------------------------------------------------------------------------------- 1 | package it.krzeminski.fsynth.instruments 2 | 3 | import it.krzeminski.fsynth.effects.envelope.AdsrEnvelopeDefinition 4 | import it.krzeminski.fsynth.effects.envelope.buildEnvelopeFunction 5 | import kotlin.random.Random 6 | 7 | val cymbals = Instrument( 8 | waveform = { _: Frequency -> 9 | with(Random(0)) { 10 | { _ -> this.nextFloat() } 11 | } 12 | }, 13 | envelope = buildEnvelopeFunction( 14 | AdsrEnvelopeDefinition( 15 | attackTime = 0.001f, 16 | decayTime = 0.0f, 17 | sustainLevel = 1.0f, 18 | releaseTime = 0.05f)) 19 | ) 20 | -------------------------------------------------------------------------------- /core/src/commonMain/kotlin/it/krzeminski/fsynth/instruments/Synthesizer.kt: -------------------------------------------------------------------------------- 1 | package it.krzeminski.fsynth.instruments 2 | 3 | import it.krzeminski.fsynth.effects.envelope.AdsrEnvelopeDefinition 4 | import it.krzeminski.fsynth.effects.envelope.buildEnvelopeFunction 5 | import it.krzeminski.fsynth.sineWave 6 | import it.krzeminski.fsynth.squareWave 7 | import it.krzeminski.fsynth.types.plus 8 | import it.krzeminski.fsynth.types.times 9 | 10 | val synthesizer = Instrument( 11 | waveform = { frequency: Frequency -> 12 | 0.3 * squareWave(frequency) + 13 | 0.2 * squareWave(frequency*2) + 14 | 0.5 * sineWave(frequency) 15 | }, 16 | envelope = buildEnvelopeFunction( 17 | AdsrEnvelopeDefinition( 18 | attackTime = 0.05f, 19 | decayTime = 0.0f, 20 | sustainLevel = 1.0f, 21 | releaseTime = 0.01f)) 22 | ) 23 | 24 | val simpleDecayEnvelopeSynthesizer = synthesizer.copy( 25 | envelope = buildEnvelopeFunction( 26 | AdsrEnvelopeDefinition( 27 | attackTime = 0.05f, 28 | decayTime = 5.0f, 29 | sustainLevel = 0.2f, 30 | releaseTime = 0.005f)) 31 | ) 32 | -------------------------------------------------------------------------------- /core/src/commonMain/kotlin/it/krzeminski/fsynth/postprocessing/QualityReduction.kt: -------------------------------------------------------------------------------- 1 | package it.krzeminski.fsynth.postprocessing 2 | 3 | import kotlin.math.roundToInt 4 | 5 | /** 6 | * Given some sample value as [Float] (that is, 32-bit precision), converts the value so that the whole <-1.0; 1.0> 7 | * range is divided into [targetLevels] buckets. For example, if [targetLevels] is 2, all values below 0 will become -1, 8 | * and above 0 will become 1. 9 | * 10 | * This function is useful to simulate what would happen if there was less bits per sample e.g. in some data 11 | * transmission, when considering sound. It allows checking what is the quality degradation given the amount of data 12 | * designated for the number of levels per sample. 13 | */ 14 | fun Float.reduceLevelsPerSample(targetLevels: Int): Float { 15 | val maxValue = targetLevels - 1 16 | val normalizedInput = (this + 1.0f) / 2.0f 17 | val valueScaledToMax = normalizedInput * maxValue 18 | val rounded = valueScaledToMax.roundToInt().toFloat() 19 | return rounded * 2.0f / maxValue - 1.0f 20 | } 21 | -------------------------------------------------------------------------------- /core/src/commonMain/kotlin/it/krzeminski/fsynth/songs/PinkPantherThemeIntro.kt: -------------------------------------------------------------------------------- 1 | package it.krzeminski.fsynth.songs 2 | 3 | import it.krzeminski.fsynth.instruments.cymbals 4 | import it.krzeminski.fsynth.instruments.simpleDecayEnvelopeSynthesizer 5 | import it.krzeminski.fsynth.instruments.synthesizer 6 | import it.krzeminski.fsynth.types.MusicNote.* // ktlint-disable no-wildcard-imports 7 | import it.krzeminski.fsynth.types.by 8 | import it.krzeminski.fsynth.types.to 9 | import it.krzeminski.fsynth.types.song 10 | 11 | val pinkPantherThemeIntro = song(name = "Pink Panther Theme (intro)", beatsPerMinute = 120) { 12 | track(name = "Main melody", instrument = synthesizer, volume = 0.3f) { 13 | pause(1 by 1) 14 | pause(1 by 1) 15 | pause(1 by 1) 16 | pause(1 by 1) 17 | 18 | pause(11 by 12) 19 | note(1 by 12, Dsharp4) 20 | 21 | note(5 by 12, E4) 22 | note(1 by 12, Fsharp4) 23 | note(5 by 12, G4) 24 | note(1 by 12, Dsharp4) 25 | 26 | note(2 by 12, E4) 27 | note(1 by 12, Fsharp4) 28 | note(2 by 12, G4) 29 | note(1 by 12, C5) 30 | note(2 by 12, B4) 31 | note(1 by 12, E4) 32 | note(2 by 12, G4) 33 | note(1 by 12, B4) 34 | 35 | glissando(7 by 12, A4 to Asharp4) 36 | glissando(1 by 12, G4 to A4) 37 | note(1 by 12, G4) 38 | note(1 by 12, E4) 39 | note(1 by 12, D4) 40 | note(10 by 12, E4) 41 | pause(2 by 12) 42 | note(1 by 12, Dsharp4) 43 | 44 | note(5 by 12, E4) 45 | note(1 by 12, Fsharp4) 46 | note(5 by 12, G4) 47 | note(1 by 12, Dsharp4) 48 | 49 | note(2 by 12, E4) 50 | note(1 by 12, Fsharp4) 51 | note(2 by 12, G4) 52 | note(1 by 12, C5) 53 | note(2 by 12, B4) 54 | note(1 by 12, G4) 55 | note(2 by 12, B4) 56 | note(1 by 12, E5) 57 | 58 | note(1 by 1, Dsharp5) 59 | 60 | pause(1 by 1) 61 | 62 | pause(3 by 12) 63 | glissando(2 by 12, D5 to E5) 64 | note(1 by 12, D5) 65 | glissando(2 by 12, A4 to B4) 66 | note(1 by 12, A4) 67 | note(2 by 12, G4) 68 | note(1 by 12, E4) 69 | 70 | glissando(3 by 12, Asharp4 to A4) 71 | glissando(3 by 12, Asharp4 to A4) 72 | glissando(3 by 12, Asharp4 to A4) 73 | glissando(3 by 12, Asharp4 to A4) 74 | 75 | note(1 by 12, G4) 76 | note(1 by 12, E4) 77 | note(1 by 12, D4) 78 | note(1 by 12, E4) 79 | pause(1 by 12) 80 | note(7 by 12, E4) 81 | } 82 | 83 | track(name = "Chord background", instrument = synthesizer, volume = 0.1f) { 84 | repeat(2) { 85 | pause(8 by 12) 86 | chord(1 by 12, Csharp3, Gsharp3) 87 | chord(2 by 12, D3, A3) 88 | chord(1 by 12, Dsharp3, Asharp3) 89 | 90 | chord(1 by 1, E3, B3) 91 | } 92 | 93 | repeat(2) { 94 | pause(8 by 12) 95 | chord(1 by 12, Csharp3, Gsharp3) 96 | chord(2 by 12, D3, A3) 97 | chord(1 by 12, Dsharp3, Asharp3) 98 | 99 | chord(1 by 1, E3, B3) 100 | pause(8 by 12) 101 | chord(1 by 12, E3, B3) 102 | chord(2 by 12, Dsharp3, Asharp3) 103 | chord(1 by 12, D3, A3) 104 | 105 | chord(1 by 1, Csharp3, Gsharp3) 106 | } 107 | 108 | pause(8 by 12) 109 | chord(1 by 12, Csharp3, Gsharp3) 110 | chord(2 by 12, D3, A3) 111 | chord(1 by 12, Dsharp3, Asharp3) 112 | 113 | chord(1 by 4, E3, B3) 114 | pause(3 by 4) 115 | 116 | pause(1 by 1) 117 | 118 | chord(1 by 1, E3, B3) 119 | } 120 | 121 | track(name = "Bass", instrument = simpleDecayEnvelopeSynthesizer, volume = 0.1f) { 122 | pause(1 by 1) 123 | 124 | repeat(2) { 125 | note(1 by 2, E3) 126 | note(1 by 2, B2) 127 | note(1 by 2, E2) 128 | pause(1 by 2) 129 | } 130 | 131 | repeat(2) { 132 | note(1 by 2, E3) 133 | note(1 by 2, B2) 134 | note(1 by 2, E2) 135 | pause(1 by 2) 136 | 137 | note(1 by 2, Csharp3) 138 | note(1 by 2, Gsharp2) 139 | note(1 by 2, Csharp2) 140 | pause(1 by 2) 141 | } 142 | } 143 | 144 | track(name = "Percussion", instrument = cymbals, volume = 0.1f) { 145 | fun repeatingPattern() { 146 | note(1 by 12, A4) 147 | pause(2 by 12) 148 | note(1 by 12, A4) 149 | pause(1 by 12) 150 | note(1 by 24, A4) 151 | pause(1 by 24) 152 | } 153 | 154 | pause(1 by 1) 155 | 156 | repeat(24) { 157 | repeatingPattern() 158 | } 159 | 160 | note(1 by 12, A4) 161 | pause(11 by 12) 162 | pause(1 by 1) 163 | 164 | repeat(2) { 165 | repeatingPattern() 166 | } 167 | 168 | note(1 by 12, A4) 169 | } 170 | } 171 | -------------------------------------------------------------------------------- /core/src/commonMain/kotlin/it/krzeminski/fsynth/songs/SimpleDemoSong.kt: -------------------------------------------------------------------------------- 1 | package it.krzeminski.fsynth.songs 2 | 3 | import it.krzeminski.fsynth.instruments.organs 4 | import it.krzeminski.fsynth.types.MusicNote.* // ktlint-disable no-wildcard-imports 5 | import it.krzeminski.fsynth.types.by 6 | import it.krzeminski.fsynth.types.song 7 | 8 | // The "volume" parameter is needed to avoid generating values from outside <-1.0; 1.0>. 9 | // TODO: Handle this issue properly, maybe compute maximum volume automatically and scale appropriately. 10 | val simpleDemoSong = song( 11 | name = "Simple demo song", 12 | beatsPerMinute = 120) { 13 | track(instrument = organs, volume = 0.3f) { 14 | note(1 by 8, D4) 15 | note(1 by 16, Csharp4) 16 | note(1 by 16, D4) 17 | note(1 by 8, E4) 18 | note(1 by 8, D4) 19 | 20 | pause(1 by 8) 21 | chord(1 by 8, A3, D4, Fsharp4) 22 | chord(1 by 4, B3, D4, G4) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /core/src/commonMain/kotlin/it/krzeminski/fsynth/songs/SongRepository.kt: -------------------------------------------------------------------------------- 1 | package it.krzeminski.fsynth.songs 2 | 3 | // It has to be defined as a lazily initialized structure, not just a list. 4 | // There's a bug in Kotlin JS that for a regular list, can generate an incorrect JavaScript for initializing the list. 5 | // The order which Kotlin JS takes into account is defined by the names of the files where the objects are defined, 6 | // and in what order they appear when sorted alphabetically. Because of this, this very file appears before 7 | // 'VanHalenJumpIntro.kt', so 'vanHalenJumpIntro' constant was not yet defined, and in 'allSongs' list, the list 8 | // contained 'simpleDemoSong' and 'undefined'. 9 | // Tracked as https://youtrack.jetbrains.com/issue/KT-25796 10 | val allSongs by lazy { 11 | listOf( 12 | simpleDemoSong, 13 | vanHalenJumpIntro, 14 | pinkPantherThemeIntro) 15 | } 16 | -------------------------------------------------------------------------------- /core/src/commonMain/kotlin/it/krzeminski/fsynth/songs/VanHalenJumpIntro.kt: -------------------------------------------------------------------------------- 1 | package it.krzeminski.fsynth.songs 2 | 3 | import it.krzeminski.fsynth.instruments.simpleDecayEnvelopeSynthesizer 4 | import it.krzeminski.fsynth.instruments.synthesizer 5 | import it.krzeminski.fsynth.types.MusicNote.* // ktlint-disable no-wildcard-imports 6 | import it.krzeminski.fsynth.types.by 7 | import it.krzeminski.fsynth.types.song 8 | 9 | val vanHalenJumpIntro = song( 10 | name = "Van Halen - Jump (intro)", 11 | beatsPerMinute = 133) { 12 | track(name = "Main", instrument = synthesizer, volume = 0.2f) { 13 | repeat(3) { 14 | pause(1 by 4) 15 | chord(1 by 8, G4, B4, D5) 16 | pause(1 by 4) 17 | chord(1 by 8, G4, C5, E5) 18 | pause(1 by 4) 19 | chord(1 by 8, F4, A4, C5) 20 | pause(1 by 4) 21 | chord(1 by 8, F4, A4, C5) 22 | pause(1 by 8) 23 | chord(1 by 8, G4, B4, D5) 24 | pause(1 by 8) 25 | chord(3 by 8, G4, B4, D5) 26 | chord(1 by 8, G4, C5, E5) 27 | pause(1 by 4) 28 | chord(1 by 8, F4, A4, C5) 29 | pause(1 by 8) 30 | chord(1 by 4, C4, F4, A4) 31 | chord(1 by 4, C4, E4, G4) 32 | chord(5 by 8, C4, D4, G4) 33 | } 34 | 35 | pause(1 by 4) 36 | repeat(4) { 37 | chord(1 by 8, F5, A5, C6) 38 | pause(1 by 4) 39 | } 40 | chord(1 by 4, C5, E5, G5) 41 | pause(1 by 4) 42 | chord(1 by 8, F4, A4, C5) 43 | pause(1 by 4) 44 | chord(1 by 8, F4, A4, C5) 45 | pause(1 by 8) 46 | chord(1 by 4, C4, F4, A4) 47 | chord(1 by 8, E4, G4) 48 | pause(1 by 8) 49 | chord(5 by 8, C4, D4, G4) 50 | } 51 | 52 | track(name = "Bass", instrument = simpleDecayEnvelopeSynthesizer, volume = 0.2f) { 53 | repeat(2) { 54 | chord(23 by 8, C2, C3) 55 | chord(1 by 2, F1, F2) 56 | chord(1 by 2, G1, G2) 57 | pause(1 by 8) 58 | } 59 | 60 | repeat(2) { 61 | repeat(23) { 62 | chord(1 by 16, C2, C3) 63 | pause(1 by 16) 64 | } 65 | 66 | repeat(4) { 67 | chord(1 by 16, F1, F2) 68 | pause(1 by 16) 69 | } 70 | repeat(5) { 71 | chord(1 by 16, G1, G2) 72 | pause(1 by 16) 73 | } 74 | } 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /core/src/commonMain/kotlin/it/krzeminski/fsynth/synthesis/Preprocessing.kt: -------------------------------------------------------------------------------- 1 | package it.krzeminski.fsynth.synthesis 2 | 3 | import it.krzeminski.fsynth.silence 4 | import it.krzeminski.fsynth.synthesis.types.SongForSynthesis 5 | import it.krzeminski.fsynth.synthesis.types.TrackForSynthesis 6 | import it.krzeminski.fsynth.types.BoundedWaveform 7 | import it.krzeminski.fsynth.types.NoteValue 8 | import it.krzeminski.fsynth.types.PositionedBoundedWaveform 9 | import it.krzeminski.fsynth.types.Song 10 | import it.krzeminski.fsynth.types.Track 11 | import it.krzeminski.fsynth.types.TrackSegment 12 | import it.krzeminski.fsynth.types.plus 13 | import it.krzeminski.fsynth.types.times 14 | import kotlin.math.ln 15 | import kotlin.math.pow 16 | 17 | fun Song.preprocessForSynthesis() = SongForSynthesis(tracks = tracks.preprocess(this)) 18 | 19 | private fun List.preprocess(song: Song) = map { it.preprocess(song) } 20 | 21 | private fun Track.preprocess(song: Song) = 22 | TrackForSynthesis(segments = segments.preprocess(song, this), volume = this.volume) 23 | 24 | private fun List.preprocess(song: Song, track: Track) = 25 | preprocess(song, track, segmentsProcessed = emptyList(), segmentsToProcess = this, filledDuration = 0.0f) 26 | 27 | private tailrec fun preprocess( 28 | song: Song, 29 | track: Track, 30 | segmentsProcessed: List, 31 | segmentsToProcess: List, 32 | filledDuration: Float 33 | ): List { 34 | if (segmentsToProcess.isEmpty()) 35 | return segmentsProcessed 36 | 37 | // This implementation has memory complexity of O(n^2). 38 | // TODO: use data structures that allow O(n) complexity, in scope of #42. 39 | val (newBoundedWaveform, noteDuration) = 40 | segmentsToProcess.first().toBoundedWaveformWithNoteDuration(song, track) 41 | val newPositionedBoundedWaveform = PositionedBoundedWaveform(newBoundedWaveform, startTime = filledDuration) 42 | return preprocess(song, track, segmentsProcessed + newPositionedBoundedWaveform, 43 | segmentsToProcess.drop(1), filledDuration + noteDuration) 44 | } 45 | 46 | private fun TrackSegment.toBoundedWaveformWithNoteDuration(song: Song, track: Track): Pair { 47 | when (this) { 48 | is TrackSegment.SingleNote -> { 49 | val noteDuration = value.toSeconds(song.beatsPerMinute) 50 | return Pair(track.instrument.waveform(pitch.frequency) * 51 | track.instrument.envelope(noteDuration), noteDuration) 52 | } 53 | is TrackSegment.Glissando -> { 54 | val noteDuration = value.toSeconds(song.beatsPerMinute) 55 | return Pair( 56 | { t: Float -> 57 | val stretchedTime = stretchTimeForGlissando( 58 | transition.startPitch.midiNoteNumber, 59 | transition.endPitch.midiNoteNumber, 60 | value.toSeconds(song.beatsPerMinute), 61 | t) 62 | track.instrument.waveform(1.0f)(stretchedTime) 63 | } * track.instrument.envelope(noteDuration), 64 | noteDuration) 65 | } 66 | is TrackSegment.Chord -> { 67 | val noteDuration = value.toSeconds(song.beatsPerMinute) 68 | return Pair( 69 | (pitches 70 | .map { it.frequency } 71 | .map(track.instrument.waveform) 72 | .reduce { accumulator, current -> accumulator + current } 73 | ) * track.instrument.envelope(noteDuration), 74 | noteDuration) 75 | } 76 | is TrackSegment.Pause -> { 77 | val noteDuration = value.toSeconds(song.beatsPerMinute) 78 | return Pair(BoundedWaveform(silence, noteDuration), noteDuration) 79 | } 80 | } 81 | } 82 | 83 | /** 84 | * This function calculates what time should be given to a 1-hertz instrument function to sound like glissando with the 85 | * given parameters. For example, if glissando should start at A4 and end at A5 (double the frequency of A4), the time 86 | * at the end of glissando (when [t] is near [durationInSeconds]) will flow twice as fast comparing to [t] being near 0. 87 | * 88 | * A detailed explanation of this formula can be found at 89 | * https://github.com/krzema12/fsynth/wiki/Glissando:-explanation-of-implementation 90 | */ 91 | private fun stretchTimeForGlissando(startNoteIndex: Int, endNoteIndex: Int, durationInSeconds: Float, t: Float): Float { 92 | val a = (endNoteIndex - startNoteIndex).toFloat() / (12.0f * durationInSeconds) 93 | val b = (startNoteIndex - 69).toFloat() / 12.0f 94 | val c = 440.0f 95 | return c * (2.0f.pow(a * t + b) - 2.0f.pow(b)) / (a * ln(2.0f)) 96 | } 97 | 98 | private fun NoteValue.toSeconds(beatsPerMinute: Int) = 99 | (this.numerator * BEATS_PER_BAR * SECONDS_IN_MINUTE).toFloat() / (beatsPerMinute * this.denominator).toFloat() 100 | 101 | // Constant for now. 102 | // TODO: generalize it (may be not always 4). 103 | private const val BEATS_PER_BAR = 4 104 | private const val SECONDS_IN_MINUTE = 60 105 | -------------------------------------------------------------------------------- /core/src/commonMain/kotlin/it/krzeminski/fsynth/synthesis/Synthesis.kt: -------------------------------------------------------------------------------- 1 | package it.krzeminski.fsynth.synthesis 2 | 3 | import it.krzeminski.fsynth.synthesis.caching.bucketing.BucketedTrack 4 | import it.krzeminski.fsynth.synthesis.caching.bucketing.buildBucketedTrack 5 | import it.krzeminski.fsynth.synthesis.types.SongForSynthesis 6 | import it.krzeminski.fsynth.synthesis.types.TrackForSynthesis 7 | import it.krzeminski.fsynth.types.PositionedBoundedWaveform 8 | import it.krzeminski.fsynth.types.Song 9 | import it.krzeminski.fsynth.types.Waveform 10 | import it.krzeminski.fsynth.types.endTime 11 | 12 | fun SongForSynthesis.buildSongEvaluationFunction(): Waveform { 13 | val bucketedTracks = tracks.buildBucketedTracks() 14 | return { t -> 15 | getSongWaveformValueCached(bucketedTracks, t) 16 | } 17 | } 18 | 19 | val Song.durationInSeconds: Float 20 | get() = this.preprocessForSynthesis().durationInSeconds 21 | 22 | val SongForSynthesis.durationInSeconds: Float 23 | get() = this.tracks.map { it.durationInSeconds }.maxOrNull() ?: 0.0f 24 | 25 | val TrackForSynthesis.durationInSeconds: Float 26 | get() = this.segments.map { it.endTime }.maxOrNull() ?: 0.0f 27 | 28 | private fun List.buildBucketedTracks(): List = 29 | this.map { track -> 30 | buildCachedTrack(track) 31 | } 32 | 33 | /** 34 | * This value is a trade-off between performance and memory consumption. 35 | * The larger the buckets are, the longer it takes to search through it for the right track segment. 36 | * The smaller the buckets are, the more memory they require. 37 | * 38 | * It has been empirically proven that going lower with this value (smaller buckets) doesn't help with synthesis 39 | * performance for generic songs. For some special cases, this value may have to be adjusted. 40 | */ 41 | private const val bucketSizeInSeconds = 1.0f 42 | 43 | private fun buildCachedTrack(trackForSynthesis: TrackForSynthesis): BucketedTrack { 44 | return trackForSynthesis.buildBucketedTrack(bucketSizeInSeconds = bucketSizeInSeconds) 45 | } 46 | 47 | private fun getSongWaveformValueCached(bucketedTracks: List, time: Float): Float = 48 | bucketedTracks.map { it.getWaveformValue(time) }.sum() 49 | 50 | private fun BucketedTrack.getWaveformValue(time: Float): Float { 51 | val whichBucket = (time / bucketSizeInSeconds).toInt() 52 | 53 | if (!containsBucketWithIndex(whichBucket)) 54 | return 0.0f 55 | 56 | return buckets[whichBucket] 57 | .filter { it playsFor time } 58 | .map { it.boundedWaveform.waveform(time - it.startTime) } 59 | .sum() * volume 60 | } 61 | 62 | private infix fun PositionedBoundedWaveform.playsFor(time: Float) = 63 | time >= startTime && time <= (startTime + boundedWaveform.duration) 64 | 65 | private fun BucketedTrack.containsBucketWithIndex(whichBucket: Int) = 66 | whichBucket < buckets.size 67 | -------------------------------------------------------------------------------- /core/src/commonMain/kotlin/it/krzeminski/fsynth/synthesis/caching/bucketing/BucketedTrack.kt: -------------------------------------------------------------------------------- 1 | package it.krzeminski.fsynth.synthesis.caching.bucketing 2 | 3 | import it.krzeminski.fsynth.types.PositionedBoundedWaveform 4 | 5 | data class BucketedTrack( 6 | /** 7 | * 0th element of this list contains track segments that exist in the song in the time range 8 | * [0 s; [bucketSizeInSeconds]), 1st element: [[bucketSizeInSeconds], 2*[bucketSizeInSeconds]), and so on. 9 | * 10 | * In order for this bucketing strategy to make sense, this list must have random access in O(1), e.g. be an 11 | * [ArrayList]. [List] is used here only to improve immutability ([ArrayList] is mutable). 12 | */ 13 | val buckets: List, 14 | val bucketSizeInSeconds: Float, 15 | val volume: Float 16 | ) 17 | 18 | typealias TrackBucket = List 19 | -------------------------------------------------------------------------------- /core/src/commonMain/kotlin/it/krzeminski/fsynth/synthesis/caching/bucketing/BucketedTrackBuilding.kt: -------------------------------------------------------------------------------- 1 | package it.krzeminski.fsynth.synthesis.caching.bucketing 2 | 3 | import it.krzeminski.fsynth.synthesis.durationInSeconds 4 | import it.krzeminski.fsynth.synthesis.types.TrackForSynthesis 5 | import it.krzeminski.fsynth.types.PositionedBoundedWaveform 6 | import it.krzeminski.fsynth.types.endTime 7 | import kotlin.math.ceil 8 | 9 | fun TrackForSynthesis.buildBucketedTrack(bucketSizeInSeconds: Float): BucketedTrack { 10 | val numberOfBuckets = calculateNumberOfBuckets(bucketSizeInSeconds) 11 | 12 | val buckets = ArrayList( 13 | (0 until numberOfBuckets).asSequence() 14 | .map { bucketIndex -> segmentsForBucket(segments, bucketIndex, bucketSizeInSeconds) } 15 | .toList()) 16 | 17 | return BucketedTrack(buckets, bucketSizeInSeconds, volume) 18 | } 19 | 20 | private fun TrackForSynthesis.calculateNumberOfBuckets(bucketSizeInSeconds: Float) = 21 | ceil(durationInSeconds / bucketSizeInSeconds).toInt() 22 | 23 | private fun segmentsForBucket( 24 | positionedBoundedWaveforms: List, 25 | bucketIndex: Int, 26 | bucketSizeInSeconds: Float 27 | ): List { 28 | return positionedBoundedWaveforms 29 | .filter { segment -> segment.belongsToBucket(bucketIndex, bucketSizeInSeconds) } 30 | } 31 | 32 | private fun PositionedBoundedWaveform.belongsToBucket(bucketIndex: Int, bucketSizeInSeconds: Float): Boolean { 33 | val bucketBounds = makeBucketBounds(bucketIndex, bucketSizeInSeconds) 34 | return startTime inBoundsOf bucketBounds || 35 | endTime inBoundsOf bucketBounds || 36 | this spansOverWhole bucketBounds 37 | } 38 | 39 | private data class BucketBounds( 40 | val startTimeInSeconds: Float, 41 | val endTimeInSeconds: Float 42 | ) 43 | 44 | private fun makeBucketBounds(bucketIndex: Int, bucketSizeInSeconds: Float): BucketBounds { 45 | val startTimeInSeconds = bucketSizeInSeconds * bucketIndex.toFloat() 46 | return BucketBounds( 47 | startTimeInSeconds = startTimeInSeconds, 48 | endTimeInSeconds = startTimeInSeconds + bucketSizeInSeconds) 49 | } 50 | 51 | private infix fun Float.inBoundsOf(bucketBounds: BucketBounds) = 52 | this >= bucketBounds.startTimeInSeconds && this <= bucketBounds.endTimeInSeconds 53 | 54 | private infix fun PositionedBoundedWaveform.spansOverWhole(bucketBounds: BucketBounds) = 55 | startTime <= bucketBounds.startTimeInSeconds && endTime >= bucketBounds.endTimeInSeconds 56 | -------------------------------------------------------------------------------- /core/src/commonMain/kotlin/it/krzeminski/fsynth/synthesis/types/SongForSynthesis.kt: -------------------------------------------------------------------------------- 1 | package it.krzeminski.fsynth.synthesis.types 2 | 3 | import it.krzeminski.fsynth.types.PositionedBoundedWaveform 4 | 5 | data class SongForSynthesis(val tracks: List) 6 | 7 | data class TrackForSynthesis(val segments: List, val volume: Float) 8 | -------------------------------------------------------------------------------- /core/src/commonMain/kotlin/it/krzeminski/fsynth/types/BoundedWaveform.kt: -------------------------------------------------------------------------------- 1 | package it.krzeminski.fsynth.types 2 | 3 | data class BoundedWaveform( 4 | val waveform: Waveform, 5 | val duration: Float 6 | ) 7 | 8 | operator fun BoundedWaveform.plus(otherBoundedWaveform: BoundedWaveform): BoundedWaveform { 9 | require(this.duration == otherBoundedWaveform.duration) { 10 | "Adding waveforms with different durations is not supported!" 11 | } 12 | return BoundedWaveform( 13 | waveform = { time -> this.waveform(time) + otherBoundedWaveform.waveform(time) }, 14 | duration = this.duration) 15 | } 16 | -------------------------------------------------------------------------------- /core/src/commonMain/kotlin/it/krzeminski/fsynth/types/MusicNote.kt: -------------------------------------------------------------------------------- 1 | package it.krzeminski.fsynth.types 2 | 3 | import kotlin.math.pow 4 | 5 | enum class MusicNote(val midiNoteNumber: Int) { 6 | VeryLowForTesting(0), 7 | A0(21), 8 | Asharp0(22), 9 | B0(23), 10 | C1(24), 11 | Csharp1(25), 12 | D1(26), 13 | Dsharp1(27), 14 | E1(28), 15 | F1(29), 16 | Fsharp1(30), 17 | G1(31), 18 | Gsharp1(32), 19 | A1(33), 20 | Asharp1(34), 21 | B1(35), 22 | C2(36), 23 | Csharp2(37), 24 | D2(38), 25 | Dsharp2(39), 26 | E2(40), 27 | F2(41), 28 | Fsharp2(42), 29 | G2(43), 30 | Gsharp2(44), 31 | A2(45), 32 | Asharp2(46), 33 | B2(47), 34 | C3(48), 35 | Csharp3(49), 36 | D3(50), 37 | Dsharp3(51), 38 | E3(52), 39 | F3(53), 40 | Fsharp3(54), 41 | G3(55), 42 | Gsharp3(56), 43 | A3(57), 44 | Asharp3(58), 45 | B3(59), 46 | C4(60), 47 | Csharp4(61), 48 | D4(62), 49 | Dsharp4(63), 50 | E4(64), 51 | F4(65), 52 | Fsharp4(66), 53 | G4(67), 54 | Gsharp4(68), 55 | A4(69), 56 | Asharp4(70), 57 | B4(71), 58 | C5(72), 59 | Csharp5(73), 60 | D5(74), 61 | Dsharp5(75), 62 | E5(76), 63 | F5(77), 64 | Fsharp5(78), 65 | G5(79), 66 | Gsharp5(80), 67 | A5(81), 68 | Asharp5(82), 69 | B5(83), 70 | C6(84), 71 | Csharp6(85), 72 | D6(86), 73 | Dsharp6(87), 74 | E6(88), 75 | F6(89), 76 | Fsharp6(90), 77 | G6(91), 78 | Gsharp6(92), 79 | A6(93), 80 | Asharp6(94), 81 | B6(95), 82 | C7(96), 83 | Csharp7(97), 84 | D7(98), 85 | Dsharp7(99), 86 | E7(100), 87 | F7(101), 88 | Fsharp7(102), 89 | G7(103), 90 | Gsharp7(104), 91 | A7(105), 92 | Asharp7(106), 93 | B7(107), 94 | C8(108); 95 | 96 | val frequency: Float 97 | get() = 98 | 2.0f.pow((midiNoteNumber - CONCERT_A_NOTE_MIDI_NOTE_NUMBER).toFloat() / SEMITONES_IN_OCTAVE) * 99 | CONCERT_A_NOTE_FREQUENCY 100 | 101 | companion object { 102 | const val CONCERT_A_NOTE_MIDI_NOTE_NUMBER = 69 103 | const val CONCERT_A_NOTE_FREQUENCY = 440.0f 104 | const val SEMITONES_IN_OCTAVE = 12 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /core/src/commonMain/kotlin/it/krzeminski/fsynth/types/PositionedBoundedWaveform.kt: -------------------------------------------------------------------------------- 1 | package it.krzeminski.fsynth.types 2 | 3 | data class PositionedBoundedWaveform( 4 | val boundedWaveform: BoundedWaveform, 5 | val startTime: Float 6 | ) 7 | 8 | val PositionedBoundedWaveform.endTime: Float 9 | get() = startTime + boundedWaveform.duration 10 | -------------------------------------------------------------------------------- /core/src/commonMain/kotlin/it/krzeminski/fsynth/types/Song.kt: -------------------------------------------------------------------------------- 1 | package it.krzeminski.fsynth.types 2 | 3 | import it.krzeminski.fsynth.instruments.Instrument 4 | 5 | data class Song(val name: String, val beatsPerMinute: Int, val tracks: List) 6 | 7 | data class Track( 8 | val name: String?, 9 | val instrument: Instrument, 10 | val volume: Float, 11 | val segments: List 12 | ) 13 | 14 | sealed class TrackSegment { 15 | data class SingleNote(val value: NoteValue, val pitch: MusicNote) : TrackSegment() 16 | data class Glissando(val value: NoteValue, val transition: MusicNoteTransition) : TrackSegment() 17 | data class Chord(val value: NoteValue, val pitches: List) : TrackSegment() 18 | data class Pause(val value: NoteValue) : TrackSegment() 19 | } 20 | 21 | data class NoteValue(val numerator: Int, val denominator: Int) 22 | 23 | data class MusicNoteTransition(val startPitch: MusicNote, val endPitch: MusicNote) 24 | -------------------------------------------------------------------------------- /core/src/commonMain/kotlin/it/krzeminski/fsynth/types/SongBuildingDsl.kt: -------------------------------------------------------------------------------- 1 | package it.krzeminski.fsynth.types 2 | 3 | import it.krzeminski.fsynth.instruments.Instrument 4 | 5 | fun song(name: String, beatsPerMinute: Int, init: SongBuilder.() -> Unit): Song { 6 | val songBuilder = SongBuilder(name, beatsPerMinute) 7 | songBuilder.init() 8 | return songBuilder.build() 9 | } 10 | 11 | @DslMarker 12 | annotation class SongDslMarker 13 | 14 | @SongDslMarker 15 | class SongBuilder( 16 | name: String, 17 | private val beatsPerMinute: Int 18 | ) { 19 | private var song = Song(name = name, beatsPerMinute = beatsPerMinute, tracks = emptyList()) 20 | 21 | fun track( 22 | name: String? = null, 23 | instrument: Instrument, 24 | volume: Float, 25 | init: TrackBuilder.() -> Unit 26 | ) { 27 | val trackBuilder = TrackBuilder(instrument, volume, name) 28 | initAndAppendTrack(trackBuilder, init) 29 | } 30 | 31 | private fun initAndAppendTrack(trackBuilder: TrackBuilder, init: TrackBuilder.() -> Unit) { 32 | trackBuilder.init() 33 | song = song.copy(tracks = song.tracks + trackBuilder.build()) 34 | } 35 | 36 | fun build(): Song { 37 | return song 38 | } 39 | } 40 | 41 | @SongDslMarker 42 | class TrackBuilder( 43 | private val instrument: Instrument, 44 | private val volume: Float, 45 | private val name: String? 46 | ) { 47 | private var track = Track(name = name, instrument = instrument, volume = volume, segments = emptyList()) 48 | 49 | fun note(value: NoteValue, pitch: MusicNote) { 50 | track = track.copy(segments = track.segments + TrackSegment.SingleNote(value, pitch)) 51 | } 52 | 53 | fun glissando(value: NoteValue, transition: MusicNoteTransition) { 54 | track = track.copy(segments = track.segments + TrackSegment.Glissando(value, transition)) 55 | } 56 | 57 | fun chord(value: NoteValue, vararg pitches: MusicNote) { 58 | track = track.copy(segments = track.segments + TrackSegment.Chord(value, pitches.toList())) 59 | } 60 | 61 | fun pause(value: NoteValue) { 62 | track = track.copy(segments = track.segments + TrackSegment.Pause(value)) 63 | } 64 | 65 | fun build(): Track { 66 | return track 67 | } 68 | } 69 | 70 | infix fun Int.by(denominator: Int): NoteValue = NoteValue(this, denominator) 71 | 72 | infix fun MusicNote.to(endPitch: MusicNote): MusicNoteTransition = MusicNoteTransition(this, endPitch) 73 | -------------------------------------------------------------------------------- /core/src/commonMain/kotlin/it/krzeminski/fsynth/types/SynthesisParameters.kt: -------------------------------------------------------------------------------- 1 | package it.krzeminski.fsynth.types 2 | 3 | data class SynthesisParameters( 4 | /** 5 | * Null means downcasting is disabled. 6 | */ 7 | val downcastToBitsPerSample: Int?, 8 | val tempoOffset: Int, 9 | val synthesisSamplesPerSecondMultiplier: Float, 10 | val playbackSamplesPerSecondMultiplier: Float 11 | ) 12 | -------------------------------------------------------------------------------- /core/src/commonMain/kotlin/it/krzeminski/fsynth/types/Waveform.kt: -------------------------------------------------------------------------------- 1 | package it.krzeminski.fsynth.types 2 | 3 | typealias Waveform = (Float) -> Float 4 | 5 | operator fun Waveform.plus(otherWaveform: Waveform): Waveform = { time -> this(time) + otherWaveform(time) } 6 | 7 | operator fun Waveform.times(boundedWaveform: BoundedWaveform) = 8 | BoundedWaveform( 9 | waveform = { time -> this(time) * boundedWaveform.waveform(time) }, 10 | duration = boundedWaveform.duration) 11 | 12 | operator fun Double.times(waveform: Waveform): Waveform = { time -> this.toFloat() * waveform(time) } 13 | -------------------------------------------------------------------------------- /core/src/commonMain/kotlin/it/krzeminski/fsynth/worker/README.md: -------------------------------------------------------------------------------- 1 | This package contains code that is used to define a contract for asynchronous synthesis. 2 | For example, for Web workers, when the app posts a synthesis request to the worker, and receives some response. 3 | -------------------------------------------------------------------------------- /core/src/commonMain/kotlin/it/krzeminski/fsynth/worker/SynthesisWorker.kt: -------------------------------------------------------------------------------- 1 | package it.krzeminski.fsynth.worker 2 | 3 | import it.krzeminski.fsynth.types.SynthesisParameters 4 | 5 | interface SynthesisWorker { 6 | suspend fun synthesize(synthesisRequest: SynthesisRequest, progressHandler: (Int) -> Unit): Array 7 | } 8 | 9 | data class SynthesisRequest( 10 | // Song type cannot be used here because it cannot be serialized when sending through the Web Workers API. 11 | // It cannot be serialized probably because it contains an 'instrument' field which is a function, which is not 12 | // serializable. It also means that only songs defined in 'allSongs' constant in the 'core' project can be 13 | // synthesized now. 14 | // See https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Structured_clone_algorithm 15 | val songName: String, 16 | val synthesisParameters: SynthesisParameters 17 | ) 18 | 19 | data class SynthesisResponse( 20 | val type: String, 21 | val songData: Array? = null, 22 | val progress: Int? = null 23 | ) 24 | -------------------------------------------------------------------------------- /core/src/commonMain/kotlin/it/krzeminski/gitinfo/types/GitInfo.kt: -------------------------------------------------------------------------------- 1 | package it.krzeminski.gitinfo.types 2 | 3 | data class CommitMetadata( 4 | val sha1: String, 5 | val timeUnixTimestamp: Long 6 | ) 7 | 8 | data class GitInfo( 9 | val latestCommit: CommitMetadata, 10 | val containsUncommittedChanges: Boolean 11 | ) 12 | -------------------------------------------------------------------------------- /core/src/commonTest/kotlin/it/krzeminski/fsynth/PrimitiveWaveGeneratorsTest.kt: -------------------------------------------------------------------------------- 1 | package it.krzeminski.fsynth 2 | 3 | import it.krzeminski.visassert.assertFunctionConformsTo 4 | import kotlin.test.Test 5 | 6 | /* ktlint-disable no-multi-spaces paren-spacing */ 7 | 8 | class PrimitiveWaveGeneratorsTest { 9 | @Test 10 | fun sineWaveFor1Hz() { 11 | assertFunctionConformsTo( 12 | functionUnderTest = sineWave(1.0f), 13 | visualisation = { 14 | row(1.0f, " IIIIIXIIIII ") 15 | row( " IIIII IIIII ") 16 | row( " III III ") 17 | row( " III III ") 18 | row( " II II ") 19 | row(0.0f, "XI III II") 20 | row( " II II ") 21 | row( " III III ") 22 | row( " III III ") 23 | row( " IIIII IIIII ") 24 | row(-1.0f, " IIIIIXIIIII ") 25 | xAxis { 26 | markers("| | | | |") 27 | values( 0.0f, 0.25f, 0.5f, 0.75f, 1.0f) 28 | } 29 | }) 30 | } 31 | 32 | @Test 33 | fun sineWaveFor2Hz() { 34 | assertFunctionConformsTo( 35 | functionUnderTest = sineWave(2.0f), 36 | visualisation = { 37 | row(1.0f, " IIXII IIIII ") 38 | row( " III III III III ") 39 | row( " I I I I ") 40 | row( " II II II II ") 41 | row( " I I I I ") 42 | row(0.0f, "X I I I I") 43 | row( " I I I I ") 44 | row( " II II II II ") 45 | row( " I I I I ") 46 | row( " III III III III ") 47 | row(-1.0f, " IIIII IIXII ") 48 | xAxis { 49 | markers("| | | | |") 50 | values( 0.0f, 0.25f, 0.5f, 0.75f, 1.0f) 51 | } 52 | }) 53 | } 54 | 55 | @Test 56 | fun squareWaveFor1Hz() { 57 | assertFunctionConformsTo( 58 | functionUnderTest = squareWave(1.0f), 59 | visualisation = { 60 | row(1.0f, "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXi ") 61 | row( " i ") 62 | row( " i ") 63 | row( " i ") 64 | row( " i ") 65 | row(0.0f, " i ") 66 | row( " i ") 67 | row( " i ") 68 | row( " i ") 69 | row( " i ") 70 | row(-1.0f, " iXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX") 71 | xAxis { 72 | markers("| | | | |") 73 | values( 0.0f, 0.25f, 0.5f, 0.75f, 1.0f) 74 | } 75 | }) 76 | } 77 | 78 | @Test 79 | fun squareWaveFor2Hz() { 80 | assertFunctionConformsTo( 81 | functionUnderTest = squareWave(2.0f), 82 | visualisation = { 83 | row(1.0f, "XXXXXXXXXXXXXXXXXXXXi iXXXXXXXXXXXXXXXXXXXi ") 84 | row( " i i i ") 85 | row( " i i i ") 86 | row( " i i i ") 87 | row( " i i i ") 88 | row(0.0f, " i i i ") 89 | row( " i i i ") 90 | row( " i i i ") 91 | row( " i i i ") 92 | row( " i i i ") 93 | row(-1.0f, " iXXXXXXXXXXXXXXXXXXXi iXXXXXXXXXXXXXXXXXXXX") 94 | xAxis { 95 | markers("| | | | |") 96 | values( 0.0f, 0.25f, 0.5f, 0.75f, 1.0f) 97 | } 98 | }) 99 | } 100 | } 101 | 102 | /* ktlint-disable no-multi-spaces paren-spacing */ 103 | -------------------------------------------------------------------------------- /core/src/commonTest/kotlin/it/krzeminski/fsynth/RenderingTest.kt: -------------------------------------------------------------------------------- 1 | package it.krzeminski.fsynth 2 | 3 | import it.krzeminski.fsynth.synthesis.types.SongForSynthesis 4 | import it.krzeminski.fsynth.synthesis.types.TrackForSynthesis 5 | import it.krzeminski.fsynth.types.BoundedWaveform 6 | import it.krzeminski.fsynth.types.PositionedBoundedWaveform 7 | import it.krzeminski.visassert.assertFunctionConformsTo 8 | import kotlin.test.Test 9 | import kotlin.test.assertEquals 10 | 11 | /* ktlint-disable no-multi-spaces paren-spacing */ 12 | 13 | class RenderingTest { 14 | @Test 15 | fun rendersSongCorrectly() { 16 | val testSong = SongForSynthesis( 17 | tracks = listOf( 18 | TrackForSynthesis( 19 | segments = listOf( 20 | PositionedBoundedWaveform(BoundedWaveform(sineWave(1.0f), 3.0f), 0.0f)), 21 | volume = 1.0f))) 22 | 23 | val songSamples = testSong.renderWithSampleRate(8, 0.0f).toList().toFloatArray() 24 | 25 | assertEquals(25, songSamples.size) 26 | val songSamplesAsAssertableFunction = { t: Float -> songSamples[t.toInt()] } 27 | assertFunctionConformsTo( 28 | functionUnderTest = songSamplesAsAssertableFunction, 29 | visualisation = { 30 | row(1.0f, " I I I ") 31 | row( " I I I I I I ") 32 | row( " ") 33 | row( " ") 34 | row( " ") 35 | row(0.0f, "X I I I I I I") 36 | row( " ") 37 | row( " ") 38 | row( " ") 39 | row( " I I I I I I ") 40 | row(-1.0f, " I I I ") 41 | xAxis { 42 | markers("| | | |") 43 | values( 0.0f, 10.0f, 20.0f, 24.0f) 44 | } 45 | }) 46 | } 47 | 48 | @Test 49 | fun rendersSongCorrectlyWhenStartTimeGiven() { 50 | val testSong = SongForSynthesis( 51 | tracks = listOf( 52 | TrackForSynthesis( 53 | segments = listOf( 54 | PositionedBoundedWaveform(BoundedWaveform(sineWave(1.0f), 3.0f), 0.0f)), 55 | volume = 1.0f))) 56 | 57 | val songSamples = testSong.renderWithSampleRate(8, 2.0f).toList().toFloatArray() 58 | 59 | assertEquals(9, songSamples.size) 60 | val songSamplesAsAssertableFunction = { t: Float -> songSamples[t.toInt()] } 61 | assertFunctionConformsTo( 62 | functionUnderTest = songSamplesAsAssertableFunction, 63 | visualisation = { 64 | row(1.0f, " I ") 65 | row( " I I ") 66 | row( " ") 67 | row( " ") 68 | row( " ") 69 | row(0.0f, "I I I") 70 | row( " ") 71 | row( " ") 72 | row( " ") 73 | row( " I I ") 74 | row(-1.0f, " I ") 75 | xAxis { 76 | markers("| |") 77 | values( 0.0f, 8.0f) 78 | } 79 | }) 80 | } 81 | 82 | @Test 83 | fun rendersSongCorrectlyWhenStartTimeGivenAndLargerThanSongLength() { 84 | val testSong = SongForSynthesis( 85 | tracks = listOf( 86 | TrackForSynthesis( 87 | segments = listOf( 88 | PositionedBoundedWaveform(BoundedWaveform(sineWave(1.0f), 3.0f), 0.0f)), 89 | volume = 1.0f))) 90 | 91 | val songSamples = testSong.renderWithSampleRate(8, 10.0f).toList().toFloatArray() 92 | 93 | assertEquals(0, songSamples.size) 94 | } 95 | } 96 | 97 | /* ktlint-disable no-multi-spaces paren-spacing */ 98 | -------------------------------------------------------------------------------- /core/src/commonTest/kotlin/it/krzeminski/fsynth/effects/envelope/AdsrEnvelopeTest.kt: -------------------------------------------------------------------------------- 1 | package it.krzeminski.fsynth.effects.envelope 2 | 3 | import it.krzeminski.visassert.assertFunctionConformsTo 4 | import kotlin.math.abs 5 | import kotlin.test.Test 6 | import kotlin.test.assertEquals 7 | import kotlin.test.assertFailsWith 8 | import kotlin.test.assertTrue 9 | 10 | /* ktlint-disable no-multi-spaces paren-spacing */ 11 | 12 | class AdsrEnvelopeTest { 13 | @Test 14 | fun genericEnvelope() { 15 | val envelopeDefinition = AdsrEnvelopeDefinition( 16 | attackTime = 0.5f, 17 | decayTime = 2.5f, 18 | sustainLevel = 0.25f, 19 | releaseTime = 1.5f 20 | ) 21 | 22 | val envelope = adsrEnvelope(keyPressDuration = 4.0f, definition = envelopeDefinition) 23 | 24 | assertEquals(actual = envelope.duration, expected = 5.5f) 25 | assertFunctionConformsTo(envelope.waveform) { 26 | row(1.0f, " XI ") 27 | row( " IIII ") 28 | row( " I III ") 29 | row(0.75f, " III ") 30 | row( " I IIII ") 31 | row( " III ") 32 | row(0.5f, " I III ") 33 | row( " IIIi ") 34 | row( " I iIII ") 35 | row(0.25f, " IXXXXXXXXXXXXXIII ") 36 | row( " I IIIII ") 37 | row( " IIIIII ") 38 | row(0.0f, "X IIIX") 39 | xAxis { 40 | markers("| | | | | | | | | | | |") 41 | values( 0.0f, 0.5f, 1.0f, 1.5f, 2.0f, 2.5f, 3.0f, 3.5f, 4.0f, 4.5f, 5.0f, 5.5f) 42 | } 43 | } 44 | } 45 | 46 | @Test 47 | fun attackTimeIsZero() { 48 | val envelopeDefinition = AdsrEnvelopeDefinition( 49 | attackTime = 0.0f, 50 | decayTime = 2.5f, 51 | sustainLevel = 0.25f, 52 | releaseTime = 1.5f 53 | ) 54 | 55 | val envelope = adsrEnvelope(keyPressDuration = 3.5f, definition = envelopeDefinition) 56 | 57 | assertEquals(actual = envelope.duration, expected = 5.0f) 58 | assertFunctionConformsTo(envelope.waveform) { 59 | row(1.0f, "XI ") 60 | row( " IIII ") 61 | row( " III ") 62 | row(0.75f, " III ") 63 | row( " IIII ") 64 | row( " III ") 65 | row(0.5f, " III ") 66 | row( " IIIi ") 67 | row( " iIII ") 68 | row(0.25f, " IXXXXXXXXXXXXXIII ") 69 | row( " IIIII ") 70 | row( " IIIIII ") 71 | row(0.0f, " IIIX") 72 | xAxis { 73 | markers("| | | | | | | | | | |") 74 | values( 0.0f, 0.5f, 1.0f, 1.5f, 2.0f, 2.5f, 3.0f, 3.5f, 4.0f, 4.5f, 5.0f) 75 | } 76 | } 77 | } 78 | 79 | @Test 80 | fun decayTimeIsZero() { 81 | val envelopeDefinition = AdsrEnvelopeDefinition( 82 | attackTime = 0.5f, 83 | decayTime = 0.0f, 84 | sustainLevel = 0.25f, 85 | releaseTime = 1.5f 86 | ) 87 | 88 | val envelope = adsrEnvelope(keyPressDuration = 4.0f, definition = envelopeDefinition) 89 | 90 | assertEquals(actual = envelope.duration, expected = 5.5f) 91 | assertFunctionConformsTo(envelope.waveform) { 92 | row(1.0f, " X ") 93 | row( " ") 94 | row( " I ") 95 | row(0.75f, " ") 96 | row( " I ") 97 | row( " ") 98 | row(0.5f, " I ") 99 | row( " ") 100 | row( " I ") 101 | row(0.25f, " XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXIII ") 102 | row( " I IIIII ") 103 | row( " IIIIII ") 104 | row(0.0f, "X IIIX") 105 | xAxis { 106 | markers("| | | | | | | | | | | |") 107 | values( 0.0f, 0.5f, 1.0f, 1.5f, 2.0f, 2.5f, 3.0f, 3.5f, 4.0f, 4.5f, 5.0f, 5.5f) 108 | } 109 | } 110 | } 111 | 112 | @Test 113 | fun sustainLevelIsZero() { 114 | val envelopeDefinition = AdsrEnvelopeDefinition( 115 | attackTime = 0.5f, 116 | decayTime = 2.5f, 117 | sustainLevel = 0.0f, 118 | releaseTime = 1.5f 119 | ) 120 | 121 | val envelope = adsrEnvelope(keyPressDuration = 4.0f, definition = envelopeDefinition) 122 | 123 | assertEquals(actual = envelope.duration, expected = 4.0f) 124 | assertFunctionConformsTo(envelope.waveform) { 125 | row(1.0f, " XI ") 126 | row( " II ") 127 | row( " I III ") 128 | row(0.75f, " II ") 129 | row( " I III ") 130 | row( " II ") 131 | row(0.5f, " I III ") 132 | row( " II ") 133 | row( " I III ") 134 | row(0.25f, " II ") 135 | row( " I III ") 136 | row( " II ") 137 | row(0.0f, "X IXXXXXXXXXXXXX") 138 | xAxis { 139 | markers("| | | | | | | | |") 140 | values( 0.0f, 0.5f, 1.0f, 1.5f, 2.0f, 2.5f, 3.0f, 3.5f, 4.0f) 141 | } 142 | } 143 | } 144 | 145 | @Test 146 | fun releaseTimeIsZero() { 147 | val envelopeDefinition = AdsrEnvelopeDefinition( 148 | attackTime = 0.5f, 149 | decayTime = 2.5f, 150 | sustainLevel = 0.25f, 151 | releaseTime = 0.0f 152 | ) 153 | 154 | val envelope = adsrEnvelope(keyPressDuration = 4.0f, definition = envelopeDefinition) 155 | 156 | assertEquals(actual = envelope.duration, expected = 4.0f) 157 | assertFunctionConformsTo(envelope.waveform) { 158 | row(1.0f, " XI ") 159 | row( " IIII ") 160 | row( " I III ") 161 | row(0.75f, " III ") 162 | row( " I IIII ") 163 | row( " III ") 164 | row(0.5f, " I III ") 165 | row( " IIIi ") 166 | row( " I iIII ") 167 | row(0.25f, " IXXXXXXXXXXXXX") 168 | row( " I ") 169 | row( " ") 170 | row(0.0f, "X ") 171 | xAxis { 172 | markers("| | | | | | | | |") 173 | values( 0.0f, 0.5f, 1.0f, 1.5f, 2.0f, 2.5f, 3.0f, 3.5f, 4.0f) 174 | } 175 | } 176 | } 177 | 178 | @Test 179 | fun attackAndDecayAreZero() { 180 | val envelopeDefinition = AdsrEnvelopeDefinition( 181 | attackTime = 0.0f, 182 | decayTime = 0.0f, 183 | sustainLevel = 0.25f, 184 | releaseTime = 1.5f 185 | ) 186 | 187 | val envelope = adsrEnvelope(keyPressDuration = 4.0f, definition = envelopeDefinition) 188 | 189 | assertEquals(actual = envelope.duration, expected = 5.5f) 190 | assertFunctionConformsTo(envelope.waveform) { 191 | row(1.0f, " ") 192 | row( " ") 193 | row( " ") 194 | row(0.75f, " ") 195 | row( " ") 196 | row( " ") 197 | row(0.5f, " ") 198 | row( " ") 199 | row( " ") 200 | row(0.25f, "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXIII ") 201 | row( " IIIII ") 202 | row( " IIIIII ") 203 | row(0.0f, " IIIX") 204 | xAxis { 205 | markers("| | | | | | | | | | | |") 206 | values( 0.0f, 0.5f, 1.0f, 1.5f, 2.0f, 2.5f, 3.0f, 3.5f, 4.0f, 4.5f, 5.0f, 5.5f) 207 | } 208 | } 209 | } 210 | 211 | @Test 212 | fun decayAndSustainAreZero() { 213 | val envelopeDefinition = AdsrEnvelopeDefinition( 214 | attackTime = 0.5f, 215 | decayTime = 0.0f, 216 | sustainLevel = 0.0f, 217 | releaseTime = 1.5f 218 | ) 219 | 220 | val envelope = adsrEnvelope(keyPressDuration = 4.0f, definition = envelopeDefinition) 221 | 222 | assertEquals(actual = envelope.duration, expected = 4.0f) 223 | assertFunctionConformsTo(envelope.waveform) { 224 | row(1.0f, " X ") 225 | row( " ") 226 | row( " I ") 227 | row(0.75f, " ") 228 | row( " I ") 229 | row( " ") 230 | row(0.5f, " I ") 231 | row( " ") 232 | row( " I ") 233 | row(0.25f, " ") 234 | row( " I ") 235 | row( " ") 236 | row(0.0f, "X XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX") 237 | xAxis { 238 | markers("| | | | | | | | |") 239 | values( 0.0f, 0.5f, 1.0f, 1.5f, 2.0f, 2.5f, 3.0f, 3.5f, 4.0f) 240 | } 241 | } 242 | } 243 | 244 | @Test 245 | fun sustainAndReleaseAreZero() { 246 | val envelopeDefinition = AdsrEnvelopeDefinition( 247 | attackTime = 0.5f, 248 | decayTime = 2.5f, 249 | sustainLevel = 0.0f, 250 | releaseTime = 0.0f 251 | ) 252 | 253 | val envelope = adsrEnvelope(keyPressDuration = 4.0f, definition = envelopeDefinition) 254 | 255 | assertEquals(actual = envelope.duration, expected = 4.0f) 256 | assertFunctionConformsTo(envelope.waveform) { 257 | row(1.0f, " XI ") 258 | row( " II ") 259 | row( " I III ") 260 | row(0.75f, " II ") 261 | row( " I III ") 262 | row( " II ") 263 | row(0.5f, " I III ") 264 | row( " II ") 265 | row( " I III ") 266 | row(0.25f, " II ") 267 | row( " I III ") 268 | row( " II ") 269 | row(0.0f, "X IXXXXXXXXXXXXX") 270 | xAxis { 271 | markers("| | | | | | | | |") 272 | values( 0.0f, 0.5f, 1.0f, 1.5f, 2.0f, 2.5f, 3.0f, 3.5f, 4.0f) 273 | } 274 | } 275 | } 276 | 277 | @Test 278 | fun attackAndSustainAreZero() { 279 | val envelopeDefinition = AdsrEnvelopeDefinition( 280 | attackTime = 0.0f, 281 | decayTime = 2.5f, 282 | sustainLevel = 0.0f, 283 | releaseTime = 1.5f 284 | ) 285 | 286 | val envelope = adsrEnvelope(keyPressDuration = 4.0f, definition = envelopeDefinition) 287 | 288 | assertEquals(actual = envelope.duration, expected = 4.0f) 289 | assertFunctionConformsTo(envelope.waveform) { 290 | row(1.0f, "XI ") 291 | row( " II ") 292 | row( " III ") 293 | row(0.75f, " II ") 294 | row( " III ") 295 | row( " II ") 296 | row(0.5f, " III ") 297 | row( " II ") 298 | row( " III ") 299 | row(0.25f, " II ") 300 | row( " III ") 301 | row( " II ") 302 | row(0.0f, " IXXXXXXXXXXXXXXXXXXX") 303 | xAxis { 304 | markers("| | | | | | | | |") 305 | values( 0.0f, 0.5f, 1.0f, 1.5f, 2.0f, 2.5f, 3.0f, 3.5f, 4.0f) 306 | } 307 | } 308 | } 309 | 310 | @Test 311 | fun attackAndReleaseAreZero() { 312 | val envelopeDefinition = AdsrEnvelopeDefinition( 313 | attackTime = 0.0f, 314 | decayTime = 2.5f, 315 | sustainLevel = 0.25f, 316 | releaseTime = 0.0f 317 | ) 318 | 319 | val envelope = adsrEnvelope(keyPressDuration = 4.0f, definition = envelopeDefinition) 320 | 321 | assertEquals(actual = envelope.duration, expected = 4.0f) 322 | assertFunctionConformsTo(envelope.waveform) { 323 | row(1.0f, "XI ") 324 | row( " IIII ") 325 | row( " III ") 326 | row(0.75f, " III ") 327 | row( " IIII ") 328 | row( " III ") 329 | row(0.5f, " III ") 330 | row( " IIIi ") 331 | row( " iIII ") 332 | row(0.25f, " IXXXXXXXXXXXXXXXXXXX") 333 | row( " ") 334 | row( " ") 335 | row(0.0f, " ") 336 | xAxis { 337 | markers("| | | | | | | | |") 338 | values( 0.0f, 0.5f, 1.0f, 1.5f, 2.0f, 2.5f, 3.0f, 3.5f, 4.0f) 339 | } 340 | } 341 | } 342 | 343 | @Test 344 | fun decayAndReleaseAreZero() { 345 | val envelopeDefinition = AdsrEnvelopeDefinition( 346 | attackTime = 0.5f, 347 | decayTime = 0.0f, 348 | sustainLevel = 0.25f, 349 | releaseTime = 0.0f 350 | ) 351 | 352 | val envelope = adsrEnvelope(keyPressDuration = 4.0f, definition = envelopeDefinition) 353 | 354 | assertEquals(actual = envelope.duration, expected = 4.0f) 355 | assertFunctionConformsTo(envelope.waveform) { 356 | row(1.0f, " X ") 357 | row( " ") 358 | row( " I ") 359 | row(0.75f, " ") 360 | row( " I ") 361 | row( " ") 362 | row(0.5f, " I ") 363 | row( " ") 364 | row( " I ") 365 | row(0.25f, " XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX") 366 | row( " I ") 367 | row( " ") 368 | row(0.0f, "X ") 369 | xAxis { 370 | markers("| | | | | | | | |") 371 | values( 0.0f, 0.5f, 1.0f, 1.5f, 2.0f, 2.5f, 3.0f, 3.5f, 4.0f) 372 | } 373 | } 374 | } 375 | 376 | @Test 377 | fun attackDecayAndSustainAreZero() { 378 | val envelopeDefinition = AdsrEnvelopeDefinition( 379 | attackTime = 0.0f, 380 | decayTime = 0.0f, 381 | sustainLevel = 0.0f, 382 | releaseTime = 1.5f 383 | ) 384 | 385 | val envelope = adsrEnvelope(keyPressDuration = 4.0f, definition = envelopeDefinition) 386 | 387 | assertEquals(actual = envelope.duration, expected = 4.0f) 388 | assertFunctionConformsTo(envelope.waveform) { 389 | row(1.0f, " ") 390 | row( " ") 391 | row( " ") 392 | row(0.75f, " ") 393 | row( " ") 394 | row( " ") 395 | row(0.5f, " ") 396 | row( " ") 397 | row( " ") 398 | row(0.25f, " ") 399 | row( " ") 400 | row( " ") 401 | row(0.0f, "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX") 402 | xAxis { 403 | markers("| | | | | | | | |") 404 | values( 0.0f, 0.5f, 1.0f, 1.5f, 2.0f, 2.5f, 3.0f, 3.5f, 4.0f) 405 | } 406 | } 407 | } 408 | 409 | @Test 410 | fun attackDecayAndReleaseAreZero() { 411 | val envelopeDefinition = AdsrEnvelopeDefinition( 412 | attackTime = 0.0f, 413 | decayTime = 0.0f, 414 | sustainLevel = 0.25f, 415 | releaseTime = 0.0f 416 | ) 417 | 418 | val envelope = adsrEnvelope(keyPressDuration = 4.0f, definition = envelopeDefinition) 419 | 420 | assertEquals(actual = envelope.duration, expected = 4.0f) 421 | assertFunctionConformsTo(envelope.waveform) { 422 | row(1.0f, " ") 423 | row( " ") 424 | row( " ") 425 | row(0.75f, " ") 426 | row( " ") 427 | row( " ") 428 | row(0.5f, " ") 429 | row( " ") 430 | row( " ") 431 | row(0.25f, "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX") 432 | row( " ") 433 | row( " ") 434 | row(0.0f, " ") 435 | xAxis { 436 | markers("| | | | | | | | |") 437 | values( 0.0f, 0.5f, 1.0f, 1.5f, 2.0f, 2.5f, 3.0f, 3.5f, 4.0f) 438 | } 439 | } 440 | } 441 | 442 | @Test 443 | fun attackSustainAndReleaseAreZero() { 444 | val envelopeDefinition = AdsrEnvelopeDefinition( 445 | attackTime = 0.0f, 446 | decayTime = 2.5f, 447 | sustainLevel = 0.0f, 448 | releaseTime = 0.0f 449 | ) 450 | 451 | val envelope = adsrEnvelope(keyPressDuration = 4.0f, definition = envelopeDefinition) 452 | 453 | assertEquals(actual = envelope.duration, expected = 4.0f) 454 | assertFunctionConformsTo(envelope.waveform) { 455 | row(1.0f, "XI ") 456 | row( " II ") 457 | row( " III ") 458 | row(0.75f, " II ") 459 | row( " III ") 460 | row( " II ") 461 | row(0.5f, " III ") 462 | row( " II ") 463 | row( " III ") 464 | row(0.25f, " II ") 465 | row( " III ") 466 | row( " II ") 467 | row(0.0f, " IXXXXXXXXXXXXXXXXXXX") 468 | xAxis { 469 | markers("| | | | | | | | |") 470 | values( 0.0f, 0.5f, 1.0f, 1.5f, 2.0f, 2.5f, 3.0f, 3.5f, 4.0f) 471 | } 472 | } 473 | } 474 | 475 | @Test 476 | fun decaySustainAndReleaseAreZero() { 477 | val envelopeDefinition = AdsrEnvelopeDefinition( 478 | attackTime = 0.5f, 479 | decayTime = 0.0f, 480 | sustainLevel = 0.0f, 481 | releaseTime = 0.0f 482 | ) 483 | 484 | val envelope = adsrEnvelope(keyPressDuration = 4.0f, definition = envelopeDefinition) 485 | 486 | assertEquals(actual = envelope.duration, expected = 4.0f) 487 | assertFunctionConformsTo(envelope.waveform) { 488 | row(1.0f, " X ") 489 | row( " ") 490 | row( " I ") 491 | row(0.75f, " ") 492 | row( " I ") 493 | row( " ") 494 | row(0.5f, " I ") 495 | row( " ") 496 | row( " I ") 497 | row(0.25f, " ") 498 | row( " I ") 499 | row( " ") 500 | row(0.0f, "X XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX") 501 | xAxis { 502 | markers("| | | | | | | | |") 503 | values( 0.0f, 0.5f, 1.0f, 1.5f, 2.0f, 2.5f, 3.0f, 3.5f, 4.0f) 504 | } 505 | } 506 | } 507 | 508 | @Test 509 | fun attackDecaySustainAndReleaseAreZero() { 510 | val envelopeDefinition = AdsrEnvelopeDefinition( 511 | attackTime = 0.0f, 512 | decayTime = 0.0f, 513 | sustainLevel = 0.0f, 514 | releaseTime = 0.0f 515 | ) 516 | 517 | val envelope = adsrEnvelope(keyPressDuration = 4.0f, definition = envelopeDefinition) 518 | 519 | assertEquals(actual = envelope.duration, expected = 4.0f) 520 | assertFunctionConformsTo(envelope.waveform) { 521 | row(1.0f, " ") 522 | row( " ") 523 | row( " ") 524 | row(0.75f, " ") 525 | row( " ") 526 | row( " ") 527 | row(0.5f, " ") 528 | row( " ") 529 | row( " ") 530 | row(0.25f, " ") 531 | row( " ") 532 | row( " ") 533 | row(0.0f, "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX") 534 | xAxis { 535 | markers("| | | | | | | | |") 536 | values( 0.0f, 0.5f, 1.0f, 1.5f, 2.0f, 2.5f, 3.0f, 3.5f, 4.0f) 537 | } 538 | } 539 | } 540 | 541 | @Test 542 | fun keyPressDurationShorterThanAttackTime() { 543 | val envelopeDefinition = AdsrEnvelopeDefinition( 544 | attackTime = 0.5f, 545 | decayTime = 2.5f, 546 | sustainLevel = 0.25f, 547 | releaseTime = 1.5f 548 | ) 549 | 550 | val envelope = adsrEnvelope(keyPressDuration = 0.25f, definition = envelopeDefinition) 551 | 552 | assertEquals(actual = envelope.duration, expected = 3.25f) 553 | assertFunctionConformsTo(envelope.waveform) { 554 | row(0.5f, " IIII ") 555 | row( " IIIIII ") 556 | row( " I IIIII ") 557 | row(0.25f, " IIIIIIi ") 558 | row( " I iIIIII ") 559 | row( " IIIIIIi ") 560 | row(0.0f, "X iII ") 561 | xAxis { 562 | markers("| | | | | | | |") 563 | values( 0.0f, 0.5f, 1.0f, 1.5f, 2.0f, 2.5f, 3.0f, 3.5f) 564 | } 565 | } 566 | } 567 | 568 | @Test 569 | fun keyPressDurationShorterThanAttackPlusDecayTime() { 570 | val envelopeDefinition = AdsrEnvelopeDefinition( 571 | attackTime = 0.5f, 572 | decayTime = 2.5f, 573 | sustainLevel = 0.25f, 574 | releaseTime = 1.5f 575 | ) 576 | 577 | val envelope = adsrEnvelope(keyPressDuration = 2.0f, definition = envelopeDefinition) 578 | 579 | assertTrue(abs(envelope.duration - 5.3f) < 0.000001f, message = "Was: ${envelope.duration}") 580 | assertFunctionConformsTo(envelope.waveform) { 581 | row(1.0f, " XI ") 582 | row( " IIII ") 583 | row( " I III ") 584 | row(0.75f, " III ") 585 | row( " I IIII ") 586 | row( " III ") 587 | row(0.5f, " I IIIIII ") 588 | row( " IIIIII ") 589 | row( " I IIIIII ") 590 | row(0.25f, " IIIIII ") 591 | row( " I IIIIII ") 592 | row( " IIIIII ") 593 | row(0.0f, "X IIIIII") 594 | xAxis { 595 | markers("| | | | | | | | | | | |") 596 | values( 0.0f, 0.5f, 1.0f, 1.5f, 2.0f, 2.5f, 3.0f, 3.5f, 4.0f, 4.5f, 5.0f, 5.5f) 597 | } 598 | } 599 | } 600 | 601 | @Test 602 | fun negativeKeyPressDuration() { 603 | val envelopeDefinition = AdsrEnvelopeDefinition( 604 | attackTime = 0.5f, 605 | decayTime = 2.5f, 606 | sustainLevel = 0.25f, 607 | releaseTime = 1.5f 608 | ) 609 | 610 | assertFailsWith { 611 | adsrEnvelope(keyPressDuration = -2.0f, definition = envelopeDefinition) 612 | }.let { e -> 613 | assertEquals(actual = e.message, expected = "Key press duration must not be negative!") 614 | } 615 | } 616 | 617 | @Test 618 | fun negativeEnvelopeParameters() { 619 | listOf( 620 | Pair(AdsrEnvelopeDefinition(-0.5f, 2.5f, 0.25f, 1.5f), "Attack time must not be negative!"), 621 | Pair(AdsrEnvelopeDefinition(0.5f, -2.5f, 0.25f, 1.5f), "Decay time must not be negative!"), 622 | Pair(AdsrEnvelopeDefinition(0.5f, 2.5f, -0.25f, 1.5f), "Sustain level must not be negative!"), 623 | Pair(AdsrEnvelopeDefinition(0.5f, 2.5f, 0.25f, -1.5f), "Release time must not be negative!") 624 | ).forEach { testcase -> 625 | assertFailsWith { 626 | adsrEnvelope(keyPressDuration = 2.0f, definition = testcase.first) 627 | }.let { e -> 628 | assertEquals(actual = e.message, expected = testcase.second) 629 | } 630 | } 631 | } 632 | } 633 | -------------------------------------------------------------------------------- /core/src/commonTest/kotlin/it/krzeminski/fsynth/postprocessing/QualityReductionTest.kt: -------------------------------------------------------------------------------- 1 | package it.krzeminski.fsynth.postprocessing 2 | 3 | import kotlin.test.Test 4 | import kotlin.test.assertEquals 5 | 6 | class QualityReductionTest { 7 | @Test 8 | fun toTwoLevels() { 9 | listOf( 10 | Pair(-1.0f, -1.0f), 11 | Pair(-0.75f, -1.0f), 12 | Pair(-0.1f, -1.0f), 13 | Pair(0.1f, 1.0f), 14 | Pair(0.75f, 1.0f), 15 | Pair(1.0f, 1.0f)).forEach { (before, after) -> 16 | assertEquals(after, before.reduceLevelsPerSample(2)) 17 | } 18 | } 19 | 20 | @Test 21 | fun toThreeLevels() { 22 | listOf( 23 | Pair(-1.0f, -1.0f), 24 | Pair(-0.75f, -1.0f), 25 | Pair(-0.1f, 0.0f), 26 | Pair(0.1f, 0.0f), 27 | Pair(0.75f, 1.0f), 28 | Pair(1.0f, 1.0f)).forEach { (before, after) -> 29 | assertEquals(after, before.reduceLevelsPerSample(3)) 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /core/src/commonTest/kotlin/it/krzeminski/fsynth/synthesis/PreprocessingTest.kt: -------------------------------------------------------------------------------- 1 | package it.krzeminski.fsynth.synthesis 2 | 3 | import it.krzeminski.fsynth.effects.envelope.AdsrEnvelopeDefinition 4 | import it.krzeminski.fsynth.effects.envelope.buildEnvelopeFunction 5 | import it.krzeminski.fsynth.instruments.Instrument 6 | import it.krzeminski.fsynth.silence 7 | import it.krzeminski.fsynth.sineWave 8 | import it.krzeminski.fsynth.testutils.assertValuesEqual 9 | import it.krzeminski.fsynth.types.BoundedWaveform 10 | import it.krzeminski.fsynth.types.MusicNote.* // ktlint-disable no-wildcard-imports 11 | import it.krzeminski.fsynth.types.MusicNoteTransition 12 | import it.krzeminski.fsynth.types.NoteValue 13 | import it.krzeminski.fsynth.types.Song 14 | import it.krzeminski.fsynth.types.Track 15 | import it.krzeminski.fsynth.types.TrackSegment 16 | import it.krzeminski.fsynth.types.times 17 | import it.krzeminski.visassert.assertFunctionConformsTo 18 | import kotlin.test.Test 19 | import kotlin.test.assertEquals 20 | 21 | class PreprocessingTest { 22 | companion object { 23 | val testInstrumentForNoteC4 = { _: Float -> 123.0f } 24 | val testInstrumentForNoteE4 = { _: Float -> 456.0f } 25 | val testInstrumentForNoteG4 = { _: Float -> 789.0f } 26 | val testInstrument = Instrument( 27 | waveform = { frequency: Float -> 28 | when (frequency) { 29 | 1.0f -> sineWave(1.0f) 30 | C4.frequency -> testInstrumentForNoteC4 31 | E4.frequency -> testInstrumentForNoteE4 32 | G4.frequency -> testInstrumentForNoteG4 33 | else -> throw IllegalStateException("Only the tree above notes should be used in this test!") 34 | } 35 | }, 36 | envelope = buildEnvelopeFunction( 37 | AdsrEnvelopeDefinition( 38 | attackTime = 0.0f, 39 | decayTime = 0.0f, 40 | sustainLevel = 1.0f, 41 | releaseTime = 0.0f))) 42 | val testInstrumentWithRelease = testInstrument.copy( 43 | envelope = buildEnvelopeFunction( 44 | AdsrEnvelopeDefinition( 45 | attackTime = 0.0f, 46 | decayTime = 0.0f, 47 | sustainLevel = 1.0f, 48 | releaseTime = 0.5f))) 49 | } 50 | 51 | @Test 52 | fun severalSimpleNotes() { 53 | val testSong = Song( 54 | name = "Test song", 55 | beatsPerMinute = 240, 56 | tracks = listOf( 57 | Track( 58 | name = "Test track", 59 | instrument = testInstrument, 60 | segments = listOf( 61 | TrackSegment.SingleNote(NoteValue(1, 4), C4), 62 | TrackSegment.SingleNote(NoteValue(1, 8), E4), 63 | TrackSegment.SingleNote(NoteValue(1, 2), G4) 64 | ), 65 | volume = 1.0f 66 | ) 67 | ) 68 | ) 69 | 70 | val preprocessedTestSong = testSong.preprocessForSynthesis() 71 | 72 | assertEquals(preprocessedTestSong.tracks.size, 1) 73 | assertEquals(preprocessedTestSong.tracks[0].volume, 1.0f) 74 | assertEquals(preprocessedTestSong.tracks[0].segments.size, 3) 75 | with(preprocessedTestSong.tracks[0]) { 76 | with(segments[0]) { 77 | assertEquals(startTime, 0.0f) 78 | assertValuesEqual(boundedWaveform, BoundedWaveform(testInstrumentForNoteC4, 0.25f), delta = 0.001f) 79 | } 80 | with(segments[1]) { 81 | assertEquals(startTime, 0.25f) 82 | assertValuesEqual(boundedWaveform, BoundedWaveform(testInstrumentForNoteE4, 0.125f), delta = 0.001f) 83 | } 84 | with(segments[2]) { 85 | assertEquals(startTime, 0.375f) 86 | assertValuesEqual(boundedWaveform, BoundedWaveform(testInstrumentForNoteG4, 0.5f), delta = 0.001f) 87 | } 88 | } 89 | } 90 | 91 | @Test 92 | fun severalOverlappingNotes() { 93 | val testSong = Song( 94 | name = "Test song", 95 | beatsPerMinute = 240, 96 | tracks = listOf( 97 | Track( 98 | name = "Test track", 99 | instrument = testInstrumentWithRelease, 100 | segments = listOf( 101 | TrackSegment.SingleNote(NoteValue(1, 4), C4), 102 | TrackSegment.SingleNote(NoteValue(1, 8), E4), 103 | TrackSegment.SingleNote(NoteValue(1, 2), G4) 104 | ), 105 | volume = 1.0f 106 | ) 107 | ) 108 | ) 109 | 110 | val preprocessedTestSong = testSong.preprocessForSynthesis() 111 | 112 | assertEquals(preprocessedTestSong.tracks.size, 1) 113 | assertEquals(preprocessedTestSong.tracks[0].volume, 1.0f) 114 | assertEquals(preprocessedTestSong.tracks[0].segments.size, 3) 115 | with(preprocessedTestSong.tracks[0]) { 116 | with(segments[0]) { 117 | assertEquals(startTime, 0.0f) 118 | assertValuesEqual(boundedWaveform, 119 | testInstrumentForNoteC4 * testInstrumentWithRelease.envelope(0.25f), 120 | delta = 0.001f) 121 | } 122 | with(segments[1]) { 123 | assertEquals(startTime, 0.25f) 124 | assertValuesEqual(boundedWaveform, 125 | testInstrumentForNoteE4 * testInstrumentWithRelease.envelope(0.125f), 126 | delta = 0.001f) 127 | } 128 | with(segments[2]) { 129 | assertEquals(startTime, 0.375f) 130 | assertValuesEqual(boundedWaveform, 131 | testInstrumentForNoteG4 * testInstrumentWithRelease.envelope(0.5f), 132 | delta = 0.001f) 133 | } 134 | } 135 | } 136 | 137 | /* ktlint-disable no-multi-spaces paren-spacing */ 138 | 139 | @Test 140 | fun glissando() { 141 | val testSong = Song( 142 | name = "Test song", 143 | beatsPerMinute = 240, 144 | tracks = listOf( 145 | Track( 146 | name = "Test track", 147 | instrument = testInstrument, 148 | segments = listOf( 149 | TrackSegment.Glissando(NoteValue(1, 4), 150 | MusicNoteTransition(VeryLowForTesting, A0)) 151 | ), 152 | volume = 1.0f 153 | ) 154 | ) 155 | ) 156 | 157 | val preprocessedTestSong = testSong.preprocessForSynthesis() 158 | 159 | with (preprocessedTestSong.tracks[0]) { 160 | assertEquals(1, segments.size) 161 | assertEquals(0.0f, segments[0].startTime) 162 | assertEquals(0.25f, segments[0].boundedWaveform.duration) 163 | assertFunctionConformsTo(segments[0].boundedWaveform.waveform) { 164 | row(1.0f, " IIII III II I ") 165 | row( " I II I I I I I ") 166 | row( " II I I I I I ") 167 | row( " I I I I ") 168 | row( " I I I I I I ") 169 | row( " I ") 170 | row( " I I I I I ") 171 | row( " I I I I ") 172 | row(0.0f, "X I I ") 173 | row( " I I I I I") 174 | row( " I I I I ") 175 | row( " ") 176 | row( " I I I I I ") 177 | row( " I I I I I ") 178 | row( " I I I I I ") 179 | row( " I I I I I I I ") 180 | row(-1.0f, " III II II I ") 181 | xAxis { 182 | markers("| |") 183 | values( 0.0f, 0.25f) 184 | } 185 | } 186 | } 187 | } 188 | 189 | /* ktlint-disable no-multi-spaces paren-spacing */ 190 | 191 | @Test 192 | fun chord() { 193 | val testSong = Song( 194 | name = "Test song", 195 | beatsPerMinute = 240, 196 | tracks = listOf( 197 | Track( 198 | name = "Test track", 199 | instrument = testInstrument, 200 | segments = listOf( 201 | TrackSegment.Chord(NoteValue(1, 4), listOf(C4, E4, G4)) 202 | ), 203 | volume = 1.0f 204 | ) 205 | ) 206 | ) 207 | 208 | val preprocessedTestSong = testSong.preprocessForSynthesis() 209 | 210 | with (preprocessedTestSong.tracks[0]) { 211 | assertEquals(1, segments.size) 212 | assertEquals(0.0f, segments[0].startTime) 213 | assertEquals(0.25f, segments[0].boundedWaveform.duration) 214 | assertEquals(123.0f + 456.0f + 789.0f, segments[0].boundedWaveform.waveform(0.0f)) 215 | } 216 | } 217 | 218 | @Test 219 | fun pause() { 220 | val testSong = Song( 221 | name = "Test song", 222 | beatsPerMinute = 240, 223 | tracks = listOf( 224 | Track( 225 | name = "Test track", 226 | instrument = testInstrument, 227 | segments = listOf( 228 | TrackSegment.Pause(NoteValue(1, 4)) 229 | ), 230 | volume = 1.0f 231 | ) 232 | ) 233 | ) 234 | 235 | val preprocessedTestSong = testSong.preprocessForSynthesis() 236 | 237 | assertEquals(preprocessedTestSong.tracks.size, 1) 238 | assertEquals(preprocessedTestSong.tracks[0].volume, 1.0f) 239 | assertEquals(preprocessedTestSong.tracks[0].segments.size, 1) 240 | with(preprocessedTestSong.tracks[0]) { 241 | with(segments[0]) { 242 | assertEquals(startTime, 0.0f) 243 | assertValuesEqual(boundedWaveform, BoundedWaveform(silence, 0.25f), delta = 0.001f) 244 | } 245 | } 246 | } 247 | 248 | @Test 249 | fun severalTracks() { 250 | val testSong = Song( 251 | name = "Test song", 252 | beatsPerMinute = 240, 253 | tracks = listOf( 254 | Track( 255 | name = "Test track", 256 | instrument = testInstrument, 257 | segments = listOf( 258 | TrackSegment.SingleNote(NoteValue(1, 4), C4) 259 | ), 260 | volume = 1.0f 261 | ), 262 | Track( 263 | name = "Test track 2", 264 | instrument = testInstrument, 265 | segments = listOf( 266 | TrackSegment.SingleNote(NoteValue(1, 8), E4) 267 | ), 268 | volume = 1.0f 269 | ) 270 | ) 271 | ) 272 | 273 | val preprocessedTestSong = testSong.preprocessForSynthesis() 274 | 275 | assertEquals(preprocessedTestSong.tracks.size, 2) 276 | with(preprocessedTestSong.tracks[0]) { 277 | assertEquals(volume, 1.0f) 278 | assertEquals(segments.size, 1) 279 | with(segments[0]) { 280 | assertEquals(startTime, 0.0f) 281 | assertValuesEqual(boundedWaveform, BoundedWaveform(testInstrumentForNoteC4, 0.25f), delta = 0.001f) 282 | } 283 | } 284 | with(preprocessedTestSong.tracks[1]) { 285 | assertEquals(volume, 1.0f) 286 | assertEquals(segments.size, 1) 287 | with(segments[0]) { 288 | assertEquals(startTime, 0.0f) 289 | assertValuesEqual(boundedWaveform, BoundedWaveform(testInstrumentForNoteE4, 0.125f), delta = 0.001f) 290 | } 291 | } 292 | } 293 | } 294 | -------------------------------------------------------------------------------- /core/src/commonTest/kotlin/it/krzeminski/fsynth/synthesis/SynthesisTest.kt: -------------------------------------------------------------------------------- 1 | package it.krzeminski.fsynth.synthesis 2 | 3 | import it.krzeminski.fsynth.squareWave 4 | import it.krzeminski.fsynth.synthesis.types.SongForSynthesis 5 | import it.krzeminski.fsynth.synthesis.types.TrackForSynthesis 6 | import it.krzeminski.fsynth.types.BoundedWaveform 7 | import it.krzeminski.fsynth.types.PositionedBoundedWaveform 8 | import it.krzeminski.visassert.assertFunctionConformsTo 9 | import kotlin.test.Test 10 | import kotlin.test.assertEquals 11 | 12 | /* ktlint-disable no-multi-spaces paren-spacing */ 13 | 14 | class SynthesisTest { 15 | @Test 16 | fun singleTrackWithSingleSegment() { 17 | val testSong = SongForSynthesis( 18 | tracks = listOf( 19 | TrackForSynthesis( 20 | segments = listOf( 21 | PositionedBoundedWaveform(BoundedWaveform(squareWave(4.0f), 0.5f), 0.0f)), 22 | volume = 1.0f))) 23 | 24 | val songEvaluationFunction = testSong.buildSongEvaluationFunction() 25 | assertEquals(0.5f, testSong.durationInSeconds) 26 | assertFunctionConformsTo(songEvaluationFunction) { 27 | row(1.0f, "XXXXXXXXi iXXXXXXXi ") 28 | row(0.0f, " i i i iXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX") 29 | row(-1.0f, " iXXXXXXi iXXXXXXi ") 30 | xAxis { 31 | markers("| | |") 32 | values( 0.0f, 0.5f, 1.0f) 33 | } 34 | } 35 | } 36 | 37 | @Test 38 | fun durationIsCorrectlyCalculatedAmongMultipleTracksForNonOverlappingSegments() { 39 | val testSong = SongForSynthesis( 40 | tracks = listOf( 41 | TrackForSynthesis( 42 | segments = listOf( 43 | PositionedBoundedWaveform(BoundedWaveform(squareWave(4.0f), 0.1f), 0.0f), 44 | PositionedBoundedWaveform(BoundedWaveform(squareWave(4.0f), 0.2f), 0.1f), 45 | PositionedBoundedWaveform(BoundedWaveform(squareWave(4.0f), 0.3f), 0.3f)), 46 | volume = 1.0f), 47 | TrackForSynthesis( 48 | segments = listOf( 49 | PositionedBoundedWaveform(BoundedWaveform(squareWave(4.0f), 0.5f), 0.0f), 50 | PositionedBoundedWaveform(BoundedWaveform(squareWave(4.0f), 2.5f), 0.5f), 51 | PositionedBoundedWaveform(BoundedWaveform(squareWave(4.0f), 1.2f), 3.0f)), 52 | volume = 1.0f), 53 | TrackForSynthesis( 54 | segments = listOf( 55 | PositionedBoundedWaveform(BoundedWaveform(squareWave(4.0f), 1.0f), 0.0f), 56 | PositionedBoundedWaveform(BoundedWaveform(squareWave(4.0f), 0.2f), 1.0f), 57 | PositionedBoundedWaveform(BoundedWaveform(squareWave(4.0f), 0.2f), 1.2f)), 58 | volume = 1.0f))) 59 | 60 | assertEquals(0.5f + 2.5f + 1.2f, testSong.durationInSeconds) 61 | } 62 | 63 | @Test 64 | fun durationIsCorrectlyCalculatedForOverlappingSegments() { 65 | val testSong = SongForSynthesis( 66 | tracks = listOf( 67 | TrackForSynthesis( 68 | segments = listOf( 69 | PositionedBoundedWaveform(BoundedWaveform(squareWave(4.0f), 0.5f), 0.0f), 70 | PositionedBoundedWaveform(BoundedWaveform(squareWave(4.0f), 2.0f), 0.25f), 71 | PositionedBoundedWaveform(BoundedWaveform(squareWave(4.0f), 0.5f), 0.5f)), 72 | volume = 1.0f))) 73 | 74 | assertEquals(2.0f + 0.25f, testSong.durationInSeconds) 75 | } 76 | 77 | @Test 78 | fun durationForEmptySongForSynthesis() { 79 | val testSong = SongForSynthesis( 80 | tracks = emptyList()) 81 | 82 | assertEquals(0.0f, testSong.durationInSeconds) 83 | } 84 | 85 | @Test 86 | fun multipleTracksWithEqualVolumesAreCorrectlySynthesized() { 87 | val testSong = SongForSynthesis( 88 | tracks = listOf( 89 | TrackForSynthesis( 90 | segments = listOf( 91 | PositionedBoundedWaveform(BoundedWaveform(squareWave(8.0f), 0.5f), 0.0f)), 92 | volume = 0.5f), 93 | TrackForSynthesis( 94 | segments = listOf( 95 | PositionedBoundedWaveform(BoundedWaveform(squareWave(2.0f), 1.0f), 0.0f)), 96 | volume = 0.5f))) 97 | 98 | val songEvaluationFunction = testSong.buildSongEvaluationFunction() 99 | assertFunctionConformsTo(songEvaluationFunction) { 100 | row(1.0f, "XXXXi iXXXi ") 101 | row(0.5f, " i i i iXXXXXXXXXXXXXXi ") 102 | row(0.0f, " iXXXi iXXXXXXi iXXXi i i i") 103 | row(-0.5f, " i i i i iXXXXXXXXXXXXXi") 104 | row(-1.0f, " iXXXi iXXXi ") 105 | xAxis { 106 | markers("| | |") 107 | values( 0.0f, 0.5f, 1.0f) 108 | } 109 | } 110 | } 111 | 112 | @Test 113 | fun overlappingSegmentsAreAreCorrectlySynthesized() { 114 | val testSong = SongForSynthesis( 115 | tracks = listOf( 116 | TrackForSynthesis( 117 | segments = listOf( 118 | PositionedBoundedWaveform(BoundedWaveform({ 1.0f }, 0.75f), 0.0f), 119 | PositionedBoundedWaveform(BoundedWaveform({ 0.5f }, 0.25f), 0.25f)), 120 | volume = 1.0f))) 121 | 122 | val songEvaluationFunction = testSong.buildSongEvaluationFunction() 123 | assertFunctionConformsTo(songEvaluationFunction) { 124 | row(1.5f, " XXXXXXXXXXXXXXXX ") 125 | row(1.0f, "XXXXXXXXXXXXXXX XXXXXXXXXXXXXXX") 126 | xAxis { 127 | markers("| | | |") 128 | values( 0.0f, 0.25f, 0.5f, 0.75f) 129 | } 130 | } 131 | } 132 | 133 | @Test 134 | fun multipleTracksWithDifferentVolumesAreCorrectlySynthesized() { 135 | val testSong = SongForSynthesis( 136 | tracks = listOf( 137 | TrackForSynthesis( 138 | segments = listOf( 139 | PositionedBoundedWaveform(BoundedWaveform(squareWave(8.0f), 0.5f), 0.0f)), 140 | volume = 0.5f), 141 | TrackForSynthesis( 142 | segments = listOf( 143 | PositionedBoundedWaveform(BoundedWaveform(squareWave(2.0f), 1.0f), 0.0f)), 144 | volume = 0.25f))) 145 | 146 | val songEvaluationFunction = testSong.buildSongEvaluationFunction() 147 | assertFunctionConformsTo(songEvaluationFunction) { 148 | row(1.0f, " ") 149 | row(0.75f, "XXXXi iXXXi ") 150 | row(0.5f, " i i i ") 151 | row(0.25f, " i i i iXXi iXXXi iXXXXXXXXXXXXXXi ") 152 | row(0.0f, " i i i i i i i i i i") 153 | row(-0.25f, " iXXXi iXXXi i i i i iXXXXXXXXXXXXXi") 154 | row(-0.5f, " i i i i ") 155 | row(-0.75f, " iXXXi iXXXi ") 156 | row(-1.0f, " ") 157 | xAxis { 158 | markers("| | |") 159 | values( 0.0f, 0.5f, 1.0f) 160 | } 161 | } 162 | } 163 | 164 | @Test 165 | fun multipleTracksAreCorrectlySynthesizedForSongLengthBeyondBucketSize() { 166 | val testSong = SongForSynthesis( 167 | tracks = listOf( 168 | TrackForSynthesis( 169 | segments = listOf( 170 | PositionedBoundedWaveform(BoundedWaveform(squareWave(4.0f), 1.0f), 0.0f)), 171 | volume = 0.5f), 172 | TrackForSynthesis( 173 | segments = listOf( 174 | PositionedBoundedWaveform(BoundedWaveform(squareWave(1.0f), 2.0f), 0.0f)), 175 | volume = 0.5f))) 176 | 177 | val songEvaluationFunction = testSong.buildSongEvaluationFunction() 178 | assertFunctionConformsTo(songEvaluationFunction) { 179 | row(1.0f, "XXXXi iXXXi ") 180 | row(0.5f, " i i i iXXXXXXXXXXXXXXXi ") 181 | row(0.0f, " iXXXi iXXXXXXi iXXXi i i i") 182 | row(-0.5f, " i i i i iXXXXXXXXXXXXXi") 183 | row(-1.0f, " iXXXi iXXi ") 184 | xAxis { 185 | markers("| | | | |") 186 | values( 0.0f, 0.5f, 1.0f, 1.5f, 2.0f) 187 | } 188 | } 189 | } 190 | } 191 | 192 | /* ktlint-disable no-multi-spaces paren-spacing */ 193 | -------------------------------------------------------------------------------- /core/src/commonTest/kotlin/it/krzeminski/fsynth/synthesis/caching/bucketing/BucketedTrackBuildingTest.kt: -------------------------------------------------------------------------------- 1 | package it.krzeminski.fsynth.synthesis.caching.bucketing 2 | 3 | import it.krzeminski.fsynth.silence 4 | import it.krzeminski.fsynth.synthesis.types.TrackForSynthesis 5 | import it.krzeminski.fsynth.types.BoundedWaveform 6 | import it.krzeminski.fsynth.types.PositionedBoundedWaveform 7 | import kotlin.test.Test 8 | import kotlin.test.assertEquals 9 | 10 | class BucketedTrackBuildingTest { 11 | @Test 12 | fun simpleTrack() { 13 | val segment1 = PositionedBoundedWaveform(BoundedWaveform(silence, 0.5f), 0.0f) 14 | val segment2 = PositionedBoundedWaveform(BoundedWaveform(silence, 0.5f), 0.5f) 15 | val segment3 = PositionedBoundedWaveform(BoundedWaveform(silence, 0.5f), 1.0f) 16 | val trackToBeCached = TrackForSynthesis( 17 | segments = listOf(segment1, segment2, segment3), 18 | volume = 1.0f) 19 | 20 | val bucketedTrack = trackToBeCached.buildBucketedTrack(bucketSizeInSeconds = 1.0f) 21 | 22 | assertEquals(2, bucketedTrack.buckets.size) 23 | assertEquals( 24 | listOf(segment1, segment2, segment3), 25 | bucketedTrack.buckets[0]) 26 | assertEquals( 27 | listOf(segment2, segment3), 28 | bucketedTrack.buckets[1]) 29 | } 30 | 31 | @Test 32 | fun segmentSpanningOverThreeBuckets() { 33 | val segment1 = PositionedBoundedWaveform(BoundedWaveform(silence, 0.5f), 0.0f) 34 | val segment2 = PositionedBoundedWaveform(BoundedWaveform(silence, 2.0f), 0.5f) 35 | val trackToBeCached = TrackForSynthesis( 36 | segments = listOf(segment1, segment2), 37 | volume = 1.0f) 38 | 39 | val bucketedTrack = trackToBeCached.buildBucketedTrack(bucketSizeInSeconds = 1.0f) 40 | 41 | assertEquals(3, bucketedTrack.buckets.size) 42 | assertEquals( 43 | listOf(segment1, segment2), 44 | bucketedTrack.buckets[0]) 45 | assertEquals( 46 | listOf(segment2), 47 | bucketedTrack.buckets[1]) 48 | assertEquals( 49 | listOf(segment2), 50 | bucketedTrack.buckets[2]) 51 | } 52 | 53 | @Test 54 | fun overlappingSegments() { 55 | val segment1 = PositionedBoundedWaveform(BoundedWaveform(silence, 0.5f), 0.0f) 56 | val segment2 = PositionedBoundedWaveform(BoundedWaveform(silence, 0.5f), 0.25f) 57 | val segment3 = PositionedBoundedWaveform(BoundedWaveform(silence, 0.5f), 0.5f) 58 | val segment4 = PositionedBoundedWaveform(BoundedWaveform(silence, 0.5f), 0.75f) 59 | val segment5 = PositionedBoundedWaveform(BoundedWaveform(silence, 0.5f), 1.0f) 60 | val trackToBeCached = TrackForSynthesis( 61 | segments = listOf(segment1, segment2, segment3, segment4, segment5), 62 | volume = 1.0f) 63 | 64 | val bucketedTrack = trackToBeCached.buildBucketedTrack(bucketSizeInSeconds = 1.0f) 65 | 66 | assertEquals(2, bucketedTrack.buckets.size) 67 | assertEquals( 68 | listOf(segment1, segment2, segment3, segment4, segment5), 69 | bucketedTrack.buckets[0]) 70 | assertEquals( 71 | listOf(segment3, segment4, segment5), 72 | bucketedTrack.buckets[1]) 73 | } 74 | 75 | @Test 76 | fun segmentLengthEqualToBucketLength() { 77 | val segment1 = PositionedBoundedWaveform(BoundedWaveform(silence, 1.0f), 0.0f) 78 | val trackToBeCached = TrackForSynthesis( 79 | segments = listOf(segment1), 80 | volume = 1.0f) 81 | 82 | val bucketedTrack = trackToBeCached.buildBucketedTrack(bucketSizeInSeconds = 1.0f) 83 | 84 | assertEquals(1, bucketedTrack.buckets.size) 85 | assertEquals( 86 | listOf(segment1), 87 | bucketedTrack.buckets[0]) 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /core/src/commonTest/kotlin/it/krzeminski/fsynth/testutils/BoundedWaveformComparison.kt: -------------------------------------------------------------------------------- 1 | package it.krzeminski.fsynth.testutils 2 | 3 | import it.krzeminski.fsynth.types.BoundedWaveform 4 | import kotlin.test.assertEquals 5 | 6 | fun assertValuesEqual(expected: BoundedWaveform, actual: BoundedWaveform, delta: Float) { 7 | assertEquals(expected.duration, actual.duration) 8 | val numberOfSamples = (expected.duration / delta).toInt() 9 | (0..numberOfSamples).asSequence() 10 | .forEach { 11 | val t = it.toFloat() * delta 12 | val expectedValue = expected.waveform(t) 13 | val actualValue = actual.waveform(t) 14 | assertEquals( 15 | expected = expectedValue, 16 | actual = actualValue, 17 | message = "Value mismatch for argument $t: expected $expectedValue, actual $actualValue") 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /core/src/commonTest/kotlin/it/krzeminski/fsynth/types/BoundedWaveformTest.kt: -------------------------------------------------------------------------------- 1 | package it.krzeminski.fsynth.types 2 | 3 | import it.krzeminski.fsynth.squareWave 4 | import it.krzeminski.visassert.assertFunctionConformsTo 5 | import kotlin.test.Test 6 | import kotlin.test.assertEquals 7 | import kotlin.test.assertFailsWith 8 | 9 | /* ktlint-disable no-multi-spaces paren-spacing */ 10 | 11 | class BoundedWaveformTest { 12 | @Test 13 | fun addingTwoBoundedWaveformsWithEqualDurations() { 14 | val waveform1 = squareWave(2.0f) 15 | assertFunctionConformsTo(waveform1) { 16 | row(1.0f, "XXXXXXXi iXXXXXXXi i") 17 | row(-1.0f, " iXXXXXXXi iXXXXXXXi") 18 | xAxis { 19 | markers("| |") 20 | values( 0.0f, 1.0f) 21 | } 22 | } 23 | val boundedWaveform1 = BoundedWaveform(waveform1, 1.0f) 24 | val waveform2 = squareWave(4.0f) 25 | assertFunctionConformsTo(waveform2) { 26 | row(1.0f, "XXXi iXXXi iXXXi iXXXi i") 27 | row(-1.0f, " iXXXi iXXXi iXXXi iXXXi") 28 | xAxis { 29 | markers("| |") 30 | values( 0.0f, 1.0f) 31 | } 32 | } 33 | val boundedWaveform2 = BoundedWaveform(waveform2, 1.0f) 34 | 35 | val aboveBoundedWaveformsAdded = boundedWaveform1 + boundedWaveform2 36 | 37 | assertEquals(1.0f, aboveBoundedWaveformsAdded.duration) 38 | assertFunctionConformsTo(aboveBoundedWaveformsAdded.waveform) { 39 | row(2.0f, "XXXi iXXXi i") 40 | row(0.0f, " iXXXXXXXi i iXXXXXXXi i") 41 | row(-2.0f, " iXXXi iXXXi") 42 | xAxis { 43 | markers("| |") 44 | values( 0.0f, 1.0f) 45 | } 46 | } 47 | } 48 | 49 | @Test 50 | fun addingTwoBoundedWaveformsWithDifferentDuration() { 51 | val waveform1 = squareWave(2.0f) 52 | assertFunctionConformsTo(waveform1) { 53 | row(1.0f, "XXXXXXXi iXXXXXXXi i") 54 | row(-1.0f, " iXXXXXXXi iXXXXXXXi") 55 | xAxis { 56 | markers("| |") 57 | values( 0.0f, 1.0f) 58 | } 59 | } 60 | val boundedWaveform1 = BoundedWaveform(waveform1, 0.5f) 61 | val waveform2 = squareWave(4.0f) 62 | assertFunctionConformsTo(waveform2) { 63 | row(1.0f, "XXXi iXXXi iXXXi iXXXi i") 64 | row(-1.0f, " iXXXi iXXXi iXXXi iXXXi") 65 | xAxis { 66 | markers("| |") 67 | values( 0.0f, 1.0f) 68 | } 69 | } 70 | val boundedWaveform2 = BoundedWaveform(waveform2, 1.0f) 71 | 72 | assertFailsWith { 73 | boundedWaveform1 + boundedWaveform2 74 | }.let { e -> 75 | assertEquals("Adding waveforms with different durations is not supported!", e.message) 76 | } 77 | } 78 | } 79 | 80 | /* ktlint-disable no-multi-spaces paren-spacing */ 81 | -------------------------------------------------------------------------------- /core/src/commonTest/kotlin/it/krzeminski/fsynth/types/MusicNoteTest.kt: -------------------------------------------------------------------------------- 1 | package it.krzeminski.fsynth.types 2 | 3 | import kotlin.test.Test 4 | import kotlin.test.assertEquals 5 | 6 | class MusicNoteTest { 7 | @Test fun testFrequencyForA4() = assertEquals(440.0f, MusicNote.A4.frequency) 8 | } -------------------------------------------------------------------------------- /core/src/commonTest/kotlin/it/krzeminski/fsynth/types/SongBuildingDslTest.kt: -------------------------------------------------------------------------------- 1 | package it.krzeminski.fsynth.types 2 | 3 | import it.krzeminski.fsynth.instruments.organs 4 | import kotlin.test.Test 5 | import it.krzeminski.fsynth.types.MusicNote.* // ktlint-disable no-wildcard-imports 6 | import kotlin.test.assertEquals 7 | 8 | class SongBuildingDslTest { 9 | companion object { 10 | val testInstrument = organs 11 | } 12 | 13 | @Test 14 | fun severalSimpleNotes() { 15 | val songCreatedWithDsl = song("Test song", 240) { 16 | track("Test track", testInstrument, 1.0f) { 17 | note(1 by 4, C4) 18 | note(1 by 8, E4) 19 | note(1 by 2, G4) 20 | } 21 | } 22 | 23 | val expectedSong = Song( 24 | name = "Test song", 25 | beatsPerMinute = 240, 26 | tracks = listOf( 27 | Track( 28 | name = "Test track", 29 | instrument = testInstrument, 30 | volume = 1.0f, 31 | segments = listOf( 32 | TrackSegment.SingleNote(NoteValue(1, 4), C4), 33 | TrackSegment.SingleNote(NoteValue(1, 8), E4), 34 | TrackSegment.SingleNote(NoteValue(1, 2), G4) 35 | ) 36 | ) 37 | ) 38 | ) 39 | 40 | assertEquals(expected = expectedSong, actual = songCreatedWithDsl) 41 | } 42 | 43 | @Test 44 | fun glissando() { 45 | val songCreatedWithDsl = song("Test song", 240) { 46 | track("Test track", testInstrument, 1.0f) { 47 | glissando(1 by 4, C4 to E4) 48 | } 49 | } 50 | 51 | val expectedSong = Song( 52 | name = "Test song", 53 | beatsPerMinute = 240, 54 | tracks = listOf( 55 | Track( 56 | name = "Test track", 57 | instrument = testInstrument, 58 | volume = 1.0f, 59 | segments = listOf( 60 | TrackSegment.Glissando(NoteValue(1, 4), MusicNoteTransition(C4, E4)) 61 | ) 62 | ) 63 | ) 64 | ) 65 | 66 | assertEquals(expected = expectedSong, actual = songCreatedWithDsl) 67 | } 68 | 69 | @Test 70 | fun chord() { 71 | val songCreatedWithDsl = song("Test song", 240) { 72 | track("Test track", testInstrument, 1.0f) { 73 | chord(1 by 4, C4, E4, G4) 74 | } 75 | } 76 | 77 | val expectedSong = Song( 78 | name = "Test song", 79 | beatsPerMinute = 240, 80 | tracks = listOf( 81 | Track( 82 | name = "Test track", 83 | instrument = testInstrument, 84 | volume = 1.0f, 85 | segments = listOf( 86 | TrackSegment.Chord(NoteValue(1, 4), listOf(C4, E4, G4)) 87 | ) 88 | ) 89 | ) 90 | ) 91 | 92 | assertEquals(expected = expectedSong, actual = songCreatedWithDsl) 93 | } 94 | 95 | @Test 96 | fun pause() { 97 | val songCreatedWithDsl = song("Test song", 240) { 98 | track("Test track", testInstrument, 1.0f) { 99 | pause(1 by 4) 100 | } 101 | } 102 | 103 | val expectedSong = Song( 104 | name = "Test song", 105 | beatsPerMinute = 240, 106 | tracks = listOf( 107 | Track( 108 | name = "Test track", 109 | instrument = testInstrument, 110 | volume = 1.0f, 111 | segments = listOf( 112 | TrackSegment.Pause(NoteValue(1, 4)) 113 | ) 114 | ) 115 | ) 116 | ) 117 | 118 | assertEquals(expected = expectedSong, actual = songCreatedWithDsl) 119 | } 120 | 121 | @Test 122 | fun severalTracks() { 123 | val songCreatedWithDsl = song("Test song", 240) { 124 | track("Test track", testInstrument, 0.123f) { 125 | note(1 by 4, C4) 126 | } 127 | track("Test track 2", testInstrument, 0.456f) { 128 | note(1 by 8, E4) 129 | } 130 | } 131 | 132 | val expectedSong = Song( 133 | name = "Test song", 134 | beatsPerMinute = 240, 135 | tracks = listOf( 136 | Track( 137 | name = "Test track", 138 | instrument = testInstrument, 139 | volume = 0.123f, 140 | segments = listOf( 141 | TrackSegment.SingleNote(NoteValue(1, 4), C4) 142 | ) 143 | ), 144 | Track( 145 | name = "Test track 2", 146 | instrument = testInstrument, 147 | volume = 0.456f, 148 | segments = listOf( 149 | TrackSegment.SingleNote(NoteValue(1, 8), E4) 150 | ) 151 | ) 152 | ) 153 | ) 154 | 155 | assertEquals(expected = expectedSong, actual = songCreatedWithDsl) 156 | } 157 | } 158 | -------------------------------------------------------------------------------- /core/src/commonTest/kotlin/it/krzeminski/fsynth/types/WaveformTest.kt: -------------------------------------------------------------------------------- 1 | package it.krzeminski.fsynth.types 2 | 3 | import it.krzeminski.fsynth.squareWave 4 | import it.krzeminski.visassert.assertFunctionConformsTo 5 | import kotlin.test.Test 6 | import kotlin.test.assertEquals 7 | 8 | /* ktlint-disable no-multi-spaces paren-spacing */ 9 | 10 | class WaveformTest { 11 | @Test 12 | fun addingTwoWaveforms() { 13 | val waveform1 = squareWave(2.0f) 14 | assertFunctionConformsTo(waveform1) { 15 | row(1.0f, "XXXXXXXi iXXXXXXXi i") 16 | row(-1.0f, " iXXXXXXXi iXXXXXXXi") 17 | xAxis { 18 | markers("| |") 19 | values( 0.0f, 1.0f) 20 | } 21 | } 22 | val waveform2 = squareWave(4.0f) 23 | assertFunctionConformsTo(waveform2) { 24 | row(1.0f, "XXXi iXXXi iXXXi iXXXi i") 25 | row(-1.0f, " iXXXi iXXXi iXXXi iXXXi") 26 | xAxis { 27 | markers("| |") 28 | values( 0.0f, 1.0f) 29 | } 30 | } 31 | val aboveWaveformsAdded = waveform1 + waveform2 32 | assertFunctionConformsTo(aboveWaveformsAdded) { 33 | row(2.0f, "XXXi iXXXi i") 34 | row(0.0f, " iXXXXXXXi i iXXXXXXXi i") 35 | row(-2.0f, " iXXXi iXXXi") 36 | xAxis { 37 | markers("| |") 38 | values( 0.0f, 1.0f) 39 | } 40 | } 41 | } 42 | 43 | @Test 44 | fun multiplyingWaveformByConstant() { 45 | val waveform = squareWave(2.0f) 46 | assertFunctionConformsTo(waveform) { 47 | row(1.0f, "XXXXXXXi iXXXXXXXi i") 48 | row(-1.0f, " iXXXXXXXi iXXXXXXXi") 49 | xAxis { 50 | markers("| |") 51 | values( 0.0f, 1.0f) 52 | } 53 | } 54 | val aboveWaveformMultiplied = 3.0 * waveform 55 | assertFunctionConformsTo(aboveWaveformMultiplied) { 56 | row(3.0f, "XXXXXXXi iXXXXXXXi i") 57 | row(-3.0f, " iXXXXXXXi iXXXXXXXi") 58 | xAxis { 59 | markers("| |") 60 | values( 0.0f, 1.0f) 61 | } 62 | } 63 | } 64 | 65 | @Test 66 | fun multiplyingWaveformByBoundedWaveform() { 67 | val waveform = squareWave(2.0f) 68 | assertFunctionConformsTo(waveform) { 69 | row(1.0f, "XXXXXXXi iXXXXXXXi i") 70 | row( " i i i i") 71 | row( " i i i i") 72 | row( " i i i i") 73 | row(0.0f, " i i i i") 74 | row( " i i i i") 75 | row( " i i i i") 76 | row( " i i i i") 77 | row(-1.0f, " iXXXXXXXi iXXXXXXXi") 78 | xAxis { 79 | markers("| | |") 80 | values( 0.0f, 0.5f, 1.0f) 81 | } 82 | } 83 | val waveformForBoundedWaveform = { x: Float -> -2.0f * x + 1.0f } 84 | assertFunctionConformsTo(waveformForBoundedWaveform) { 85 | row(1.0f, "X ") 86 | row( " II ") 87 | row( " II ") 88 | row( " II ") 89 | row( " II ") 90 | row( " II ") 91 | row( " II ") 92 | row( " II ") 93 | row(0.0f, " X") 94 | xAxis { 95 | markers("| |") 96 | values( 0.0f, 0.5f) 97 | } 98 | } 99 | val boundedWaveform = BoundedWaveform(waveformForBoundedWaveform, 0.5f) 100 | 101 | val aboveWaveformMultipliedByBoundedWaveform = waveform * boundedWaveform 102 | assertEquals(0.5f, aboveWaveformMultipliedByBoundedWaveform.duration) 103 | assertFunctionConformsTo(aboveWaveformMultipliedByBoundedWaveform.waveform) { 104 | row(1.0f, "XI ") 105 | row( " IIII ") 106 | row( " IIi ") 107 | row( " i ") 108 | row(0.0f, " i IX") 109 | row( " i IIII ") 110 | row( " iI ") 111 | row( " ") 112 | row(-1.0f, " ") 113 | xAxis { 114 | markers("| |") 115 | values( 0.0f, 0.5f) 116 | } 117 | } 118 | } 119 | } 120 | 121 | /* ktlint-disable no-multi-spaces paren-spacing */ 122 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fsynthlib/fsynth/1f138bf5c5d46a80b23eca8bde589da1b865662a/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-6.0-bin.zip 4 | zipStoreBase=GRADLE_USER_HOME 5 | zipStorePath=wrapper/dists 6 | -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | # 4 | # Copyright 2015 the original author or authors. 5 | # 6 | # Licensed under the Apache License, Version 2.0 (the "License"); 7 | # you may not use this file except in compliance with the License. 8 | # You may obtain a copy of the License at 9 | # 10 | # http://www.apache.org/licenses/LICENSE-2.0 11 | # 12 | # Unless required by applicable law or agreed to in writing, software 13 | # distributed under the License is distributed on an "AS IS" BASIS, 14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | # See the License for the specific language governing permissions and 16 | # limitations under the License. 17 | # 18 | 19 | ############################################################################## 20 | ## 21 | ## Gradle start up script for UN*X 22 | ## 23 | ############################################################################## 24 | 25 | # Attempt to set APP_HOME 26 | # Resolve links: $0 may be a link 27 | PRG="$0" 28 | # Need this for relative symlinks. 29 | while [ -h "$PRG" ] ; do 30 | ls=`ls -ld "$PRG"` 31 | link=`expr "$ls" : '.*-> \(.*\)$'` 32 | if expr "$link" : '/.*' > /dev/null; then 33 | PRG="$link" 34 | else 35 | PRG=`dirname "$PRG"`"/$link" 36 | fi 37 | done 38 | SAVED="`pwd`" 39 | cd "`dirname \"$PRG\"`/" >/dev/null 40 | APP_HOME="`pwd -P`" 41 | cd "$SAVED" >/dev/null 42 | 43 | APP_NAME="Gradle" 44 | APP_BASE_NAME=`basename "$0"` 45 | 46 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 47 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' 48 | 49 | # Use the maximum available, or set MAX_FD != -1 to use that value. 50 | MAX_FD="maximum" 51 | 52 | warn () { 53 | echo "$*" 54 | } 55 | 56 | die () { 57 | echo 58 | echo "$*" 59 | echo 60 | exit 1 61 | } 62 | 63 | # OS specific support (must be 'true' or 'false'). 64 | cygwin=false 65 | msys=false 66 | darwin=false 67 | nonstop=false 68 | case "`uname`" in 69 | CYGWIN* ) 70 | cygwin=true 71 | ;; 72 | Darwin* ) 73 | darwin=true 74 | ;; 75 | MINGW* ) 76 | msys=true 77 | ;; 78 | NONSTOP* ) 79 | nonstop=true 80 | ;; 81 | esac 82 | 83 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 84 | 85 | # Determine the Java command to use to start the JVM. 86 | if [ -n "$JAVA_HOME" ] ; then 87 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 88 | # IBM's JDK on AIX uses strange locations for the executables 89 | JAVACMD="$JAVA_HOME/jre/sh/java" 90 | else 91 | JAVACMD="$JAVA_HOME/bin/java" 92 | fi 93 | if [ ! -x "$JAVACMD" ] ; then 94 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 95 | 96 | Please set the JAVA_HOME variable in your environment to match the 97 | location of your Java installation." 98 | fi 99 | else 100 | JAVACMD="java" 101 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 102 | 103 | Please set the JAVA_HOME variable in your environment to match the 104 | location of your Java installation." 105 | fi 106 | 107 | # Increase the maximum file descriptors if we can. 108 | if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then 109 | MAX_FD_LIMIT=`ulimit -H -n` 110 | if [ $? -eq 0 ] ; then 111 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then 112 | MAX_FD="$MAX_FD_LIMIT" 113 | fi 114 | ulimit -n $MAX_FD 115 | if [ $? -ne 0 ] ; then 116 | warn "Could not set maximum file descriptor limit: $MAX_FD" 117 | fi 118 | else 119 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" 120 | fi 121 | fi 122 | 123 | # For Darwin, add options to specify how the application appears in the dock 124 | if $darwin; then 125 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" 126 | fi 127 | 128 | # For Cygwin, switch paths to Windows format before running java 129 | if $cygwin ; then 130 | APP_HOME=`cygpath --path --mixed "$APP_HOME"` 131 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` 132 | JAVACMD=`cygpath --unix "$JAVACMD"` 133 | 134 | # We build the pattern for arguments to be converted via cygpath 135 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` 136 | SEP="" 137 | for dir in $ROOTDIRSRAW ; do 138 | ROOTDIRS="$ROOTDIRS$SEP$dir" 139 | SEP="|" 140 | done 141 | OURCYGPATTERN="(^($ROOTDIRS))" 142 | # Add a user-defined pattern to the cygpath arguments 143 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then 144 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" 145 | fi 146 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 147 | i=0 148 | for arg in "$@" ; do 149 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` 150 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option 151 | 152 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition 153 | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` 154 | else 155 | eval `echo args$i`="\"$arg\"" 156 | fi 157 | i=$((i+1)) 158 | done 159 | case $i in 160 | (0) set -- ;; 161 | (1) set -- "$args0" ;; 162 | (2) set -- "$args0" "$args1" ;; 163 | (3) set -- "$args0" "$args1" "$args2" ;; 164 | (4) set -- "$args0" "$args1" "$args2" "$args3" ;; 165 | (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; 166 | (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; 167 | (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; 168 | (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; 169 | (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; 170 | esac 171 | fi 172 | 173 | # Escape application args 174 | save () { 175 | for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done 176 | echo " " 177 | } 178 | APP_ARGS=$(save "$@") 179 | 180 | # Collect all arguments for the java command, following the shell quoting and substitution rules 181 | eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" 182 | 183 | # by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong 184 | if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then 185 | cd "$(dirname "$0")" 186 | fi 187 | 188 | exec "$JAVACMD" "$@" 189 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem http://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | 17 | @if "%DEBUG%" == "" @echo off 18 | @rem ########################################################################## 19 | @rem 20 | @rem Gradle startup script for Windows 21 | @rem 22 | @rem ########################################################################## 23 | 24 | @rem Set local scope for the variables with windows NT shell 25 | if "%OS%"=="Windows_NT" setlocal 26 | 27 | set DIRNAME=%~dp0 28 | if "%DIRNAME%" == "" set DIRNAME=. 29 | set APP_BASE_NAME=%~n0 30 | set APP_HOME=%DIRNAME% 31 | 32 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 33 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 34 | 35 | @rem Find java.exe 36 | if defined JAVA_HOME goto findJavaFromJavaHome 37 | 38 | set JAVA_EXE=java.exe 39 | %JAVA_EXE% -version >NUL 2>&1 40 | if "%ERRORLEVEL%" == "0" goto init 41 | 42 | echo. 43 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 44 | echo. 45 | echo Please set the JAVA_HOME variable in your environment to match the 46 | echo location of your Java installation. 47 | 48 | goto fail 49 | 50 | :findJavaFromJavaHome 51 | set JAVA_HOME=%JAVA_HOME:"=% 52 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 53 | 54 | if exist "%JAVA_EXE%" goto init 55 | 56 | echo. 57 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 58 | echo. 59 | echo Please set the JAVA_HOME variable in your environment to match the 60 | echo location of your Java installation. 61 | 62 | goto fail 63 | 64 | :init 65 | @rem Get command-line arguments, handling Windows variants 66 | 67 | if not "%OS%" == "Windows_NT" goto win9xME_args 68 | 69 | :win9xME_args 70 | @rem Slurp the command line arguments. 71 | set CMD_LINE_ARGS= 72 | set _SKIP=2 73 | 74 | :win9xME_args_slurp 75 | if "x%~1" == "x" goto execute 76 | 77 | set CMD_LINE_ARGS=%* 78 | 79 | :execute 80 | @rem Setup the command line 81 | 82 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 83 | 84 | @rem Execute Gradle 85 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% 86 | 87 | :end 88 | @rem End local scope for the variables with windows NT shell 89 | if "%ERRORLEVEL%"=="0" goto mainEnd 90 | 91 | :fail 92 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 93 | rem the _cmd.exe /c_ return code! 94 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 95 | exit /b 1 96 | 97 | :mainEnd 98 | if "%OS%"=="Windows_NT" endlocal 99 | 100 | :omega 101 | -------------------------------------------------------------------------------- /ktlint.gradle.kts: -------------------------------------------------------------------------------- 1 | repositories { 2 | jcenter() 3 | } 4 | 5 | val ktlint by configurations.creating 6 | 7 | dependencies { 8 | ktlint("com.pinterest:ktlint:0.32.0") 9 | } 10 | 11 | val ktlintTask = tasks.register("ktlint") { 12 | group = "verification" 13 | description = "Check Kotlin code style." 14 | classpath = ktlint 15 | main = "com.pinterest.ktlint.Main" 16 | args("src/**/*.kt", "-v") 17 | } 18 | 19 | tasks.register("ktlintFormat") { 20 | group = "formatting" 21 | description = "Fix Kotlin code style deviations." 22 | classpath = ktlint 23 | main = "com.pinterest.ktlint.Main" 24 | args("-F", "src/**/*.kt") 25 | } 26 | 27 | afterEvaluate { 28 | project.tasks["check"].dependsOn(ktlintTask) 29 | } 30 | -------------------------------------------------------------------------------- /settings.gradle.kts: -------------------------------------------------------------------------------- 1 | rootProject.name = "fsynth" 2 | 3 | include("core", "cli", "web", ":web:worker", ":web:serviceworker", "android") 4 | 5 | enableFeaturePreview("GRADLE_METADATA") 6 | -------------------------------------------------------------------------------- /web/build.gradle.kts: -------------------------------------------------------------------------------- 1 | import com.android.build.gradle.internal.tasks.factory.dependsOn 2 | 3 | plugins { 4 | kotlin("js") 5 | } 6 | 7 | repositories { 8 | jcenter() 9 | maven("https://kotlin.bintray.com/kotlin-js-wrappers") 10 | maven("https://dl.bintray.com/cfraser/muirwik") 11 | } 12 | 13 | // The below versions cannot be freely changed independently. Only certain combinations are valid and map to the actual 14 | // existing versions in the repositories. 15 | val kotlinVersion: String by rootProject.extra 16 | val reactVersion = "17.0.1" 17 | val jsWrappersVersion = "pre.148" 18 | 19 | kotlin { 20 | target { 21 | useCommonJs() 22 | browser { 23 | webpackTask { 24 | runTask { 25 | } 26 | } 27 | } 28 | } 29 | 30 | sourceSets { 31 | val main by getting { 32 | dependencies { 33 | implementation("org.jetbrains.kotlin:kotlin-stdlib-js:$kotlinVersion") 34 | implementation("org.jetbrains:kotlin-react:$reactVersion-$jsWrappersVersion-kotlin-$kotlinVersion") 35 | implementation("org.jetbrains:kotlin-react-dom:$reactVersion-$jsWrappersVersion-kotlin-$kotlinVersion") 36 | implementation("org.jetbrains:kotlin-styled:5.2.1-$jsWrappersVersion-kotlin-$kotlinVersion") 37 | implementation("org.jetbrains:kotlin-css-js:1.0.0-$jsWrappersVersion-kotlin-$kotlinVersion") 38 | implementation("com.ccfraser.muirwik:muirwik-components:0.6.8") 39 | implementation(project(":core")) 40 | implementation(npm("react", reactVersion)) 41 | implementation(npm("react-dom", reactVersion)) 42 | implementation(npm("@material-ui/core", "4.11.0")) 43 | implementation(npm("audiobuffer-to-wav", "1.0.0")) 44 | implementation(npm("wavesurfer.js", "3.3.3")) 45 | } 46 | } 47 | val test by getting { 48 | dependencies { 49 | implementation("org.jetbrains.kotlin:kotlin-test-js") 50 | } 51 | } 52 | } 53 | } 54 | 55 | val copyWorkerDistributionFiles = tasks.register("copyWorkerDistributionFiles", Copy::class) { 56 | from("worker/build/distributions") 57 | into("$buildDir/distributions") 58 | }.dependsOn(":web:worker:build") 59 | 60 | val copyServiceWorkerDistributionFiles = tasks.register("copyServiceWorkerDistributionFiles", Copy::class) { 61 | from("serviceworker/build/distributions") 62 | into("$buildDir/distributions") 63 | }.dependsOn(":web:serviceworker:build") 64 | 65 | tasks.named("assemble").dependsOn(copyWorkerDistributionFiles) 66 | tasks.named("assemble").dependsOn(copyServiceWorkerDistributionFiles) 67 | -------------------------------------------------------------------------------- /web/serviceworker/build.gradle.kts: -------------------------------------------------------------------------------- 1 | import org.jetbrains.kotlin.gradle.targets.js.webpack.KotlinWebpackOutput.Target 2 | 3 | plugins { 4 | kotlin("js") 5 | } 6 | 7 | val kotlinVersion: String by rootProject.extra 8 | 9 | kotlin { 10 | target { 11 | browser { 12 | webpackTask { 13 | output.libraryTarget = Target.SELF 14 | } 15 | } 16 | } 17 | 18 | sourceSets { 19 | val main by getting { 20 | dependencies { 21 | implementation("org.jetbrains.kotlin:kotlin-stdlib-js:$kotlinVersion") 22 | } 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /web/serviceworker/src/main/kotlin/it/krzeminski/fsynth/web/serviceworker/main.kt: -------------------------------------------------------------------------------- 1 | import org.w3c.workers.ServiceWorkerGlobalScope 2 | 3 | external val self: ServiceWorkerGlobalScope 4 | 5 | fun main() { 6 | // The below implementation is mostly to satisfy validation performed by the browser, 7 | // in order to allow installing the app as a PWA. 8 | self.addEventListener("install", { event -> 9 | console.log("Service Worker installed! $event") 10 | }) 11 | self.addEventListener("activate", { event -> 12 | console.log("Service Worker is now active! $event") 13 | }) 14 | self.addEventListener("fetch", { event -> 15 | console.log("fetch event: $event") 16 | }) 17 | } 18 | -------------------------------------------------------------------------------- /web/src/main/kotlin/it/krzeminski/fsynth/PlaybackCustomization.kt: -------------------------------------------------------------------------------- 1 | package it.krzeminski.fsynth 2 | 3 | import com.ccfraser.muirwik.components.MSliderMark 4 | import com.ccfraser.muirwik.components.MSliderValueLabelDisplay 5 | import com.ccfraser.muirwik.components.mSlider 6 | import com.ccfraser.muirwik.components.mTypography 7 | import it.krzeminski.fsynth.types.SynthesisParameters 8 | import react.RBuilder 9 | import react.RComponent 10 | import react.RHandler 11 | import react.RProps 12 | import react.RState 13 | import kotlin.math.log 14 | import kotlin.math.pow 15 | import styled.StyleSheet 16 | import kotlinx.css.margin 17 | import kotlinx.css.marginLeft 18 | import kotlinx.css.px 19 | import styled.css 20 | 21 | class PlaybackCustomization(props: PlaybackCustomizationProps) : RComponent(props) { 22 | override fun RBuilder.render() { 23 | bitsPerSampleDowncasting() 24 | tempoChange() 25 | synthesisSamplesPerSecondMultiplier() 26 | playbackSamplesPerSecondMultiplier() 27 | } 28 | 29 | private object Styles : StyleSheet("PlaybackCustomization", isStatic = true) { 30 | val sectionHeader by css { 31 | margin(10.px) 32 | } 33 | val slider by css { 34 | marginLeft = 35.px 35 | } 36 | } 37 | 38 | private fun RBuilder.bitsPerSampleDowncasting() { 39 | mTypography("Bits per sample (downcasting)") { 40 | css(Styles.sectionHeader) 41 | } 42 | mSlider( 43 | min = 1, 44 | max = 32, 45 | value = props.synthesisParameters.downcastToBitsPerSample ?: 32, 46 | showMarks = true, 47 | marks = listOf( 48 | MSliderMark(1, "1 bit"), 49 | MSliderMark(8, "8 bits"), 50 | MSliderMark(16, "16 bits"), 51 | MSliderMark(24, "24 bits"), 52 | MSliderMark(32, "original")), 53 | valueLabelDisplay = MSliderValueLabelDisplay.auto, 54 | onChange = { _, newValue -> 55 | props.onSynthesisParametersChange(props.synthesisParameters.copy( 56 | downcastToBitsPerSample = newValue.toInt().let { if (it != 32) it else null })) 57 | }) { 58 | css(Styles.slider) 59 | } 60 | } 61 | 62 | private fun RBuilder.tempoChange() { 63 | mTypography("Tempo change (beats-per-second offset)") { 64 | css(Styles.sectionHeader) 65 | } 66 | mSlider( 67 | min = -100, 68 | max = 100, 69 | value = props.synthesisParameters.tempoOffset, 70 | showMarks = true, 71 | marks = listOf( 72 | MSliderMark(-100, "-100"), 73 | MSliderMark(-50, "-50"), 74 | MSliderMark(0, "original"), 75 | MSliderMark(50, "+50"), 76 | MSliderMark(100, "+100")), 77 | valueLabelDisplay = MSliderValueLabelDisplay.auto, 78 | onChange = { _, newValue -> 79 | props.onSynthesisParametersChange(props.synthesisParameters.copy( 80 | tempoOffset = newValue.toInt())) 81 | }) { 82 | css(Styles.slider) 83 | } 84 | } 85 | 86 | private fun RBuilder.synthesisSamplesPerSecondMultiplier() { 87 | mTypography("Synthesis samples-per-second multiplier") { 88 | css(Styles.sectionHeader) 89 | } 90 | mSlider( 91 | min = -2, 92 | max = 2, 93 | value = fromMultiplierToLogarithmicSliderValue( 94 | props.synthesisParameters.synthesisSamplesPerSecondMultiplier), 95 | showMarks = true, 96 | marks = listOf( 97 | MSliderMark(-2, "0.25x"), 98 | MSliderMark(-1, "0.5x"), 99 | MSliderMark(0, "original"), 100 | MSliderMark(1, "2x"), 101 | MSliderMark(2, "4x")), 102 | valueLabelDisplay = MSliderValueLabelDisplay.auto, 103 | valueLabelFormat = { value, _ -> "${fromLogarithmicSliderValueToMultiplier(value.toInt())}x" }, 104 | onChange = { _, newValue -> 105 | props.onSynthesisParametersChange(props.synthesisParameters.copy( 106 | synthesisSamplesPerSecondMultiplier = 107 | fromLogarithmicSliderValueToMultiplier(newValue.toInt()))) 108 | }) { 109 | css(Styles.slider) 110 | } 111 | } 112 | 113 | private fun RBuilder.playbackSamplesPerSecondMultiplier() { 114 | mTypography("Playback samples-per-second multiplier") { 115 | css(Styles.sectionHeader) 116 | } 117 | mSlider( 118 | min = -2, 119 | max = 2, 120 | value = fromMultiplierToLogarithmicSliderValue( 121 | props.synthesisParameters.playbackSamplesPerSecondMultiplier), 122 | showMarks = true, 123 | marks = listOf( 124 | MSliderMark(-2, "0.25x"), 125 | MSliderMark(-1, "0.5x"), 126 | MSliderMark(0, "original"), 127 | MSliderMark(1, "2x"), 128 | MSliderMark(2, "4x")), 129 | valueLabelDisplay = MSliderValueLabelDisplay.auto, 130 | valueLabelFormat = { value, _ -> "${fromLogarithmicSliderValueToMultiplier(value.toInt())}x" }, 131 | onChange = { _, newValue -> 132 | props.onSynthesisParametersChange(props.synthesisParameters.copy( 133 | playbackSamplesPerSecondMultiplier = 134 | fromLogarithmicSliderValueToMultiplier(newValue.toInt()))) 135 | }) { 136 | css(Styles.slider) 137 | } 138 | } 139 | 140 | private fun fromMultiplierToLogarithmicSliderValue(multiplier: Float) = 141 | log(multiplier, 2.0f) 142 | 143 | private fun fromLogarithmicSliderValueToMultiplier(sliderValue: Int) = 144 | 2.0f.pow(sliderValue.toFloat()) 145 | } 146 | 147 | external interface PlaybackCustomizationProps : RProps { 148 | var synthesisParameters: SynthesisParameters 149 | var onSynthesisParametersChange: (SynthesisParameters) -> Unit 150 | } 151 | 152 | fun RBuilder.playbackCustomization(handler: RHandler) = 153 | child(PlaybackCustomization::class) { 154 | handler() 155 | } 156 | -------------------------------------------------------------------------------- /web/src/main/kotlin/it/krzeminski/fsynth/Player.kt: -------------------------------------------------------------------------------- 1 | package it.krzeminski.fsynth 2 | 3 | import com.ccfraser.muirwik.components.MAppBarPosition 4 | import com.ccfraser.muirwik.components.MCircularProgressVariant 5 | import com.ccfraser.muirwik.components.MIconColor 6 | import com.ccfraser.muirwik.components.MTypographyColor 7 | import com.ccfraser.muirwik.components.MTypographyVariant 8 | import com.ccfraser.muirwik.components.accordion.mAccordion 9 | import com.ccfraser.muirwik.components.accordion.mAccordionDetails 10 | import com.ccfraser.muirwik.components.accordion.mAccordionSummary 11 | import com.ccfraser.muirwik.components.button.mIconButton 12 | import com.ccfraser.muirwik.components.list.mList 13 | import com.ccfraser.muirwik.components.list.mListItem 14 | import com.ccfraser.muirwik.components.list.mListItemSecondaryAction 15 | import com.ccfraser.muirwik.components.list.mListItemText 16 | import com.ccfraser.muirwik.components.mAppBar 17 | import com.ccfraser.muirwik.components.mCircularProgress 18 | import com.ccfraser.muirwik.components.mDivider 19 | import com.ccfraser.muirwik.components.mIcon 20 | import com.ccfraser.muirwik.components.mPaper 21 | import com.ccfraser.muirwik.components.mToolbar 22 | import com.ccfraser.muirwik.components.mTypography 23 | import it.krzeminski.fsynth.generated.gitInfo 24 | import it.krzeminski.fsynth.synthesis.durationInSeconds 25 | import it.krzeminski.fsynth.types.Song 26 | import it.krzeminski.fsynth.types.SynthesisParameters 27 | import it.krzeminski.fsynth.typings.toWav 28 | import kotlinx.coroutines.CoroutineScope 29 | import kotlinx.coroutines.MainScope 30 | import kotlinx.coroutines.launch 31 | import kotlinx.css.LinearDimension 32 | import kotlinx.css.margin 33 | import kotlinx.css.maxWidth 34 | import kotlinx.css.pc 35 | import kotlinx.css.px 36 | import kotlinx.css.width 37 | import org.w3c.files.Blob 38 | import react.RBuilder 39 | import react.RComponent 40 | import react.RHandler 41 | import react.RProps 42 | import react.RState 43 | import react.buildElement 44 | import react.dom.div 45 | import react.setState 46 | import styled.StyleSheet 47 | import styled.css 48 | 49 | class Player(props: PlayerProps) : RComponent(props), CoroutineScope by MainScope() { 50 | override fun PlayerState.init(props: PlayerProps) { 51 | lastSynthesizedAsWaveBlob = null 52 | currentlySynthesizedSong = null 53 | currentSynthesisProgress = 0 54 | synthesisParameters = SynthesisParameters( 55 | downcastToBitsPerSample = null, 56 | tempoOffset = 0, 57 | synthesisSamplesPerSecondMultiplier = 1.0f, 58 | playbackSamplesPerSecondMultiplier = 1.0f) 59 | } 60 | 61 | private object Styles : StyleSheet("Player", isStatic = true) { 62 | val backgroundPaper by css { 63 | width = 100.pc 64 | maxWidth = 400.px 65 | margin(vertical = 0.px, horizontal = LinearDimension.auto) 66 | } 67 | } 68 | 69 | override fun RBuilder.render() { 70 | mPaper(elevation = 6) { 71 | css(Styles.backgroundPaper) 72 | mAppBar(position = MAppBarPosition.static) { 73 | mToolbar { 74 | mTypography("fsynth", variant = MTypographyVariant.h5, color = MTypographyColor.inherit) 75 | } 76 | } 77 | state.lastSynthesizedAsWaveBlob?.let { lastSongInBase64 -> 78 | wavesurfer { 79 | attrs { 80 | waveData = lastSongInBase64 81 | } 82 | } 83 | } 84 | mDivider() 85 | props.songs.forEach { song -> 86 | mList { 87 | mListItem { 88 | mListItemText(primary = song.name, secondary = song.getHumanFriendlyDuration()) 89 | mListItemSecondaryAction { 90 | if (state.currentlySynthesizedSong == song) { 91 | mCircularProgress( 92 | value = state.currentSynthesisProgress.toDouble(), 93 | variant = MCircularProgressVariant.static) 94 | } else { 95 | mIconButton( 96 | "play_arrow", 97 | disabled = state.currentlySynthesizedSong != null, 98 | iconColor = if (state.currentlySynthesizedSong == null) null else MIconColor.disabled, 99 | onClick = { 100 | launch { 101 | setState { 102 | currentlySynthesizedSong = song 103 | currentSynthesisProgress = 0 104 | } 105 | val renderedSong = song.renderToAudioBuffer(state.synthesisParameters, 106 | progressHandler = { 107 | setState { 108 | currentSynthesisProgress = it 109 | } 110 | }) 111 | val songAsWavBlob = Blob(arrayOf(toWav(renderedSong))) 112 | setState { 113 | lastSynthesizedAsWaveBlob = songAsWavBlob 114 | currentlySynthesizedSong = null 115 | } 116 | } 117 | }) 118 | } 119 | } 120 | } 121 | } 122 | } 123 | mDivider() 124 | mAccordion { 125 | mAccordionSummary(expandIcon = buildElement { mIcon("expand_more") }) { 126 | +"Playback customization" 127 | } 128 | mAccordionDetails { 129 | div { 130 | playbackCustomization { 131 | attrs { 132 | synthesisParameters = state.synthesisParameters 133 | onSynthesisParametersChange = { newValue -> 134 | setState { synthesisParameters = newValue } 135 | } 136 | } 137 | } 138 | } 139 | } 140 | } 141 | } 142 | versionInfo { 143 | attrs.gitInfo = gitInfo 144 | } 145 | } 146 | } 147 | 148 | fun RBuilder.player(handler: RHandler) = child(Player::class) { 149 | handler() 150 | } 151 | 152 | external interface PlayerProps : RProps { 153 | var songs: List 154 | } 155 | 156 | external interface PlayerState : RState { 157 | /** 158 | * Null if no song has been synthesized yet. 159 | */ 160 | var lastSynthesizedAsWaveBlob: Blob? 161 | 162 | /** 163 | * Null if no song is currently being synthesized. 164 | */ 165 | var currentlySynthesizedSong: Song? 166 | var currentSynthesisProgress: Int 167 | var synthesisParameters: SynthesisParameters 168 | } 169 | 170 | private fun Song.getHumanFriendlyDuration(): String = 171 | with(durationInSeconds.toInt()) { 172 | val seconds = this.rem(60) 173 | val minutes = this / 60 174 | return "$minutes:${seconds.toString().padStart(2, '0')}" 175 | } 176 | -------------------------------------------------------------------------------- /web/src/main/kotlin/it/krzeminski/fsynth/SynthesisWorkerProxy.kt: -------------------------------------------------------------------------------- 1 | package it.krzeminski.fsynth 2 | 3 | import it.krzeminski.fsynth.types.Song 4 | import it.krzeminski.fsynth.types.SynthesisParameters 5 | import it.krzeminski.fsynth.typings.AudioBuffer 6 | import it.krzeminski.fsynth.typings.AudioContext 7 | import it.krzeminski.fsynth.worker.SynthesisRequest 8 | import it.krzeminski.fsynth.worker.SynthesisResponse 9 | import it.krzeminski.fsynth.worker.SynthesisWorker 10 | import kotlinx.browser.window 11 | import org.khronos.webgl.Float32Array 12 | import org.w3c.dom.Worker 13 | import kotlin.coroutines.resume 14 | import kotlin.coroutines.suspendCoroutine 15 | 16 | suspend fun Song.renderToAudioBuffer( 17 | synthesisParameters: SynthesisParameters, 18 | progressHandler: (Int) -> Unit 19 | ): AudioBuffer { 20 | val synthesisRequest = SynthesisRequest(name, synthesisParameters) 21 | val startTime = window.performance.now() 22 | val songData = SynthesisWorkerProxy.synthesize(synthesisRequest, progressHandler) 23 | 24 | val renderedSong = Float32Array(songData) 25 | val context = AudioContext() 26 | val playbackSamplesPerSecond = 27 | (44100.toFloat() * synthesisParameters.playbackSamplesPerSecondMultiplier).toInt() 28 | val audioContextBuffer = createAudioContextBuffer(context, renderedSong, playbackSamplesPerSecond) 29 | 30 | println("Synthesis time with worker overhead: ${(window.performance.now() - startTime) / 1000.0} s") 31 | 32 | return audioContextBuffer 33 | } 34 | 35 | private object SynthesisWorkerProxy : SynthesisWorker { 36 | private val workerJs = Worker("worker.js") 37 | 38 | override suspend fun synthesize( 39 | synthesisRequest: SynthesisRequest, 40 | progressHandler: (Int) -> Unit 41 | ): Array = suspendCoroutine { continuation -> 42 | with(workerJs) { 43 | postMessage(synthesisRequest) 44 | onmessage = { event -> 45 | val response = event.data.unsafeCast() 46 | when (response.type) { 47 | "progress" -> progressHandler(response.progress!!) 48 | "result" -> continuation.resume(response.songData!!) 49 | else -> throw IllegalArgumentException("${response.type} not handled!") 50 | } 51 | } 52 | } 53 | } 54 | } 55 | 56 | private fun createAudioContextBuffer(context: AudioContext, buffer: Float32Array, samplesPerSecond: Int): AudioBuffer { 57 | val contextBuffer = context.createBuffer( 58 | numberOfChannels = 1, length = buffer.length, sampleRate = samplesPerSecond) 59 | contextBuffer.copyToChannel(buffer, 0) 60 | return contextBuffer 61 | } 62 | -------------------------------------------------------------------------------- /web/src/main/kotlin/it/krzeminski/fsynth/VersionInfo.kt: -------------------------------------------------------------------------------- 1 | package it.krzeminski.fsynth 2 | 3 | import it.krzeminski.fsynth.generated.gitInfo 4 | import it.krzeminski.gitinfo.types.GitInfo 5 | import kotlinext.js.js 6 | import kotlinx.html.style 7 | import react.RBuilder 8 | import react.RComponent 9 | import react.RHandler 10 | import react.RProps 11 | import react.RState 12 | import react.dom.a 13 | import react.dom.div 14 | import react.dom.span 15 | import kotlin.js.Date 16 | 17 | class VersionInfo : RComponent() { 18 | override fun RBuilder.render() { 19 | div { 20 | attrs.style = js { 21 | color = "#555" 22 | fontSize = "0.8rem" 23 | textAlign = "center" 24 | marginTop = "10px" 25 | } 26 | +"Version " 27 | span { 28 | attrs.style = js { 29 | fontFamily = "monospace" 30 | } 31 | a("https://github.com/krzema12/fsynth/commit/${gitInfo.latestCommit.sha1}") { 32 | +gitInfo.latestCommit.sha1.substring(0, 8) 33 | } 34 | } 35 | +" from ${Date(gitInfo.latestCommit.timeUnixTimestamp*1000).toUTCString()}" 36 | } 37 | } 38 | } 39 | 40 | external interface VersionInfoProps : RProps { 41 | var gitInfo: GitInfo 42 | } 43 | 44 | fun RBuilder.versionInfo(handler: RHandler) = child(VersionInfo::class) { 45 | handler() 46 | } 47 | -------------------------------------------------------------------------------- /web/src/main/kotlin/it/krzeminski/fsynth/Wavesurfer.kt: -------------------------------------------------------------------------------- 1 | package it.krzeminski.fsynth 2 | 3 | import it.krzeminski.fsynth.typings.WaveSurfer 4 | import kotlinext.js.js 5 | import kotlinx.html.id 6 | import kotlinx.html.style 7 | import org.w3c.files.Blob 8 | import react.RBuilder 9 | import react.RComponent 10 | import react.RHandler 11 | import react.RProps 12 | import react.RState 13 | import react.dom.div 14 | 15 | class Wavesurfer(props: WavesurferProps) : RComponent(props) { 16 | lateinit var waveSurfer: WaveSurfer 17 | 18 | override fun componentDidMount() { 19 | waveSurfer = WaveSurfer(js { 20 | container = "#waveform" 21 | height = 80 22 | cursorWidth = 1 23 | progressColor = "#3F51B5" 24 | waveColor = "#EFEFEF" 25 | }) 26 | waveSurfer.init() 27 | props.waveData.let { 28 | waveSurfer.loadBlob(it) 29 | waveSurfer.on("ready") { 30 | waveSurfer.play() 31 | } 32 | } 33 | } 34 | 35 | override fun componentDidUpdate(prevProps: WavesurferProps, prevState: RState, snapshot: Any) { 36 | if (!shouldReloadAudio(prevProps)) { 37 | return 38 | } 39 | waveSurfer.loadBlob(props.waveData) 40 | waveSurfer.on("ready") { 41 | waveSurfer.play() 42 | } 43 | } 44 | 45 | override fun RBuilder.render() { 46 | div { 47 | div { 48 | attrs { 49 | id = "waveform" 50 | style = js { 51 | width = "380px" 52 | margin = "0 10px" 53 | height = "80px" 54 | } 55 | } 56 | } 57 | } 58 | } 59 | 60 | private fun shouldReloadAudio(prevProps: WavesurferProps): Boolean { 61 | return prevProps.waveData != props.waveData 62 | } 63 | } 64 | 65 | external interface WavesurferProps : RProps { 66 | var waveData: Blob 67 | } 68 | 69 | fun RBuilder.wavesurfer(handler: RHandler) = 70 | child(Wavesurfer::class) { 71 | handler() 72 | } 73 | -------------------------------------------------------------------------------- /web/src/main/kotlin/it/krzeminski/fsynth/main.kt: -------------------------------------------------------------------------------- 1 | package it.krzeminski.fsynth 2 | 3 | import it.krzeminski.fsynth.songs.allSongs 4 | import kotlinx.browser.document 5 | import kotlinx.browser.window 6 | import react.dom.render 7 | 8 | fun main() { 9 | // Install Progressive Web App worker 10 | window.addEventListener("load", { 11 | window.navigator.serviceWorker 12 | .register("serviceworker.js") 13 | .then { console.log("Service worker registered!") } 14 | .catch { console.error("Service worker registration failed: $it") } 15 | }) 16 | 17 | render(document.getElementById("root")) { 18 | player { 19 | attrs { 20 | songs = allSongs 21 | } 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /web/src/main/kotlin/it/krzeminski/fsynth/typings/AudioBufferToWav.kt: -------------------------------------------------------------------------------- 1 | package it.krzeminski.fsynth.typings 2 | 3 | import org.khronos.webgl.ArrayBuffer 4 | 5 | @JsModule("audiobuffer-to-wav") 6 | @JsName("default") 7 | external fun toWav(audioBuffer: AudioBuffer): ArrayBuffer = definedExternally 8 | -------------------------------------------------------------------------------- /web/src/main/kotlin/it/krzeminski/fsynth/typings/AudioRelatedClasses.kt: -------------------------------------------------------------------------------- 1 | package it.krzeminski.fsynth.typings 2 | 3 | import org.khronos.webgl.Float32Array 4 | 5 | external class AudioBuffer { 6 | fun copyToChannel(source: Float32Array, channel: Int): Unit = definedExternally 7 | } 8 | 9 | external class AudioContext { 10 | fun createBuffer(numberOfChannels: Int, length: Int, sampleRate: Int): AudioBuffer = definedExternally 11 | fun createBufferSource(): BufferSource = definedExternally 12 | 13 | val destination: String 14 | get() = definedExternally 15 | } 16 | 17 | external class BufferSource { 18 | fun connect(destination: String): Unit = definedExternally 19 | fun start(position: Int): Unit = definedExternally 20 | 21 | var buffer: AudioBuffer 22 | set(_) = definedExternally 23 | } 24 | -------------------------------------------------------------------------------- /web/src/main/kotlin/it/krzeminski/fsynth/typings/WaveSurfer.kt: -------------------------------------------------------------------------------- 1 | package it.krzeminski.fsynth.typings 2 | 3 | import org.w3c.files.Blob 4 | 5 | @JsModule("wavesurfer.js") 6 | @JsName("default") 7 | external class WaveSurfer(params: dynamic) { 8 | fun init(): Unit = definedExternally 9 | fun loadBlob(blob: Blob): Unit = definedExternally 10 | fun play(): Unit = definedExternally 11 | fun on(eventName: String, callback: () -> Unit): Unit = definedExternally 12 | } 13 | -------------------------------------------------------------------------------- /web/src/main/kotlin/it/krzeminski/testutils/TimeMeasurement.kt: -------------------------------------------------------------------------------- 1 | package it.krzeminski.testutils 2 | 3 | import kotlinx.browser.window 4 | 5 | inline fun measureTimeSeconds(block: () -> Unit): Double { 6 | val start = window.performance.now() 7 | block() 8 | return (window.performance.now() - start) / 1000.0 9 | } 10 | -------------------------------------------------------------------------------- /web/src/main/resources/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | fsynth Web demo 6 | 7 | 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /web/src/main/resources/manifest.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "name": "fsynth", 3 | "short_name": "fsynth", 4 | "start_url": ".", 5 | "display": "standalone", 6 | "background_color": "#fff", 7 | "description": "Music synthesizer, written for fun and to learn stuff.", 8 | "icons": [ 9 | { 10 | "src": "Logo.svg", 11 | "sizes": "378x378" 12 | } 13 | ] 14 | } -------------------------------------------------------------------------------- /web/webpack.config.d/hot-reload-fix.js: -------------------------------------------------------------------------------- 1 | // XXX: This Webpack plugin is a workaround for hot reload not working out-of-the-box in Kotlin JS plugin. 2 | // See https://youtrack.jetbrains.com/issue/KT-32273 3 | 4 | if (config.devServer) { 5 | config.devServer.watchOptions = { 6 | aggregateTimeout: 300, 7 | poll: 300 8 | }; 9 | config.devServer.stats = { 10 | warnings: false 11 | }; 12 | config.devServer.clientLogLevel = 'error'; 13 | } 14 | 15 | class KvWebpackPlugin { 16 | apply(compiler) { 17 | const fs = require('fs') 18 | compiler.hooks.watchRun.tapAsync("KvWebpackPlugin", (compiler, callback) => { 19 | var runCallback = true; 20 | for (let item of compiler.removedFiles.values()) { 21 | if (item == config.entry.main) { 22 | if (!fs.existsSync(item)) { 23 | fs.watchFile(item, {interval: 50}, (current, previous) => { 24 | if (current.ino > 0) { 25 | fs.unwatchFile(item); 26 | callback(); 27 | } 28 | }); 29 | runCallback = false; 30 | } 31 | } 32 | } 33 | if (runCallback) callback(); 34 | }); 35 | } 36 | }; 37 | config.plugins.push(new KvWebpackPlugin()) 38 | -------------------------------------------------------------------------------- /web/worker/build.gradle.kts: -------------------------------------------------------------------------------- 1 | import org.jetbrains.kotlin.gradle.targets.js.webpack.KotlinWebpackOutput.Target 2 | 3 | plugins { 4 | kotlin("js") 5 | } 6 | 7 | val kotlinVersion: String by rootProject.extra 8 | 9 | kotlin { 10 | target { 11 | browser { 12 | webpackTask { 13 | output.libraryTarget = Target.SELF 14 | } 15 | } 16 | } 17 | 18 | sourceSets { 19 | val main by getting { 20 | dependencies { 21 | implementation("org.jetbrains.kotlin:kotlin-stdlib-js:$kotlinVersion") 22 | implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core-js:1.4.3-native-mt") 23 | implementation(project(":core")) 24 | } 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /web/worker/src/main/kotlin/it/krzeminski/fsynth/web/worker/WebSoundRendering.kt: -------------------------------------------------------------------------------- 1 | package it.krzeminski.fsynth.web.worker 2 | 3 | import it.krzeminski.fsynth.postprocessing.reduceLevelsPerSample 4 | import it.krzeminski.fsynth.renderWithSampleRate 5 | import it.krzeminski.fsynth.types.Song 6 | import it.krzeminski.fsynth.types.SynthesisParameters 7 | import it.krzeminski.fsynth.web.worker.testutils.measureTimeSeconds 8 | import kotlin.math.pow 9 | 10 | fun Song.renderToArray(synthesisParameters: SynthesisParameters, onProgressChange: (Int) -> Unit): Array { 11 | lateinit var songAsArray: Array 12 | 13 | val songAfterTempoAdjustment = this.copy(beatsPerMinute = beatsPerMinute + synthesisParameters.tempoOffset) 14 | val synthesisSamplesPerSecond = (44100.toFloat() * synthesisParameters.synthesisSamplesPerSecondMultiplier).toInt() 15 | 16 | val timeInSeconds = measureTimeSeconds { 17 | songAsArray = songAfterTempoAdjustment.renderWithSampleRate( 18 | synthesisSamplesPerSecond, startTime = 0.0f, onProgressChange = onProgressChange) 19 | .map { it.applyLevelsPerSampleReduction(synthesisParameters.downcastToBitsPerSample) } 20 | .toList() 21 | .toTypedArray() 22 | } 23 | println("Synthesized in $timeInSeconds s") 24 | 25 | return songAsArray 26 | } 27 | 28 | private fun Float.applyLevelsPerSampleReduction(downcastToBitsPerSample: Int?) = 29 | if (downcastToBitsPerSample != null) { 30 | reduceLevelsPerSample(2.0f.pow(downcastToBitsPerSample).toInt()) 31 | } else { // No levels-per-sample reduction. 32 | this 33 | } 34 | -------------------------------------------------------------------------------- /web/worker/src/main/kotlin/it/krzeminski/fsynth/web/worker/main.kt: -------------------------------------------------------------------------------- 1 | package it.krzeminski.fsynth.web.worker 2 | 3 | import it.krzeminski.fsynth.songs.allSongs 4 | import it.krzeminski.fsynth.worker.SynthesisRequest 5 | import it.krzeminski.fsynth.worker.SynthesisResponse 6 | import it.krzeminski.fsynth.worker.SynthesisWorker 7 | import kotlinx.coroutines.GlobalScope 8 | import kotlinx.coroutines.launch 9 | import org.w3c.dom.DedicatedWorkerGlobalScope 10 | 11 | external val self: DedicatedWorkerGlobalScope 12 | 13 | fun main() { 14 | println("Worker starts!") 15 | 16 | self.onmessage = { e -> 17 | GlobalScope.launch { 18 | println("Worker got such request: ${JSON.stringify(e.data)}") 19 | val request = e.data.unsafeCast() 20 | val songData = SynthesisWorkerImpl.synthesize(request) { progress -> 21 | val responseMessage = SynthesisResponse(type = "progress", progress = progress) 22 | self.postMessage(responseMessage) 23 | println("Response message posted: $responseMessage") 24 | } 25 | val responseMessage = SynthesisResponse(type = "result", songData = songData) 26 | self.postMessage(responseMessage) 27 | println("Response message posted: $responseMessage") 28 | } 29 | } 30 | } 31 | 32 | object SynthesisWorkerImpl : SynthesisWorker { 33 | override suspend fun synthesize( 34 | synthesisRequest: SynthesisRequest, 35 | progressHandler: (Int) -> Unit 36 | ): Array { 37 | val song = allSongs.find { it.name == synthesisRequest.songName } 38 | return song?.let { 39 | println("Song found, synthesizing") 40 | val renderedSong = it.renderToArray(synthesisRequest.synthesisParameters, onProgressChange = { progress -> 41 | progressHandler(progress) 42 | }) 43 | println("Synthesis done") 44 | return renderedSong 45 | } ?: emptyArray() // TODO: proper error handling on the worker side. 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /web/worker/src/main/kotlin/it/krzeminski/fsynth/web/worker/testutils/TimeMeasurement.kt: -------------------------------------------------------------------------------- 1 | package it.krzeminski.fsynth.web.worker.testutils 2 | 3 | import it.krzeminski.fsynth.web.worker.self 4 | 5 | inline fun measureTimeSeconds(block: () -> Unit): Double { 6 | val start = self.performance.now() 7 | block() 8 | return (self.performance.now() - start) / 1000.0 9 | } 10 | -------------------------------------------------------------------------------- /web/worker/src/main/kotlin/it/krzeminski/fsynth/web/worker/typings/AudioRelatedClasses.kt: -------------------------------------------------------------------------------- 1 | package it.krzeminski.fsynth.web.worker.typings 2 | 3 | import org.khronos.webgl.Float32Array 4 | 5 | external class AudioBuffer { 6 | fun copyToChannel(source: Float32Array, channel: Int): Unit = definedExternally 7 | } 8 | 9 | external class BufferSource { 10 | fun connect(destination: String): Unit = definedExternally 11 | fun start(position: Int): Unit = definedExternally 12 | 13 | var buffer: AudioBuffer 14 | set(_) = definedExternally 15 | } 16 | --------------------------------------------------------------------------------