Create working scaffolding for sound synthesizing module: smnp.audio.synth

This commit is contained in:
2020-03-20 15:33:07 +01:00
parent 518bc37108
commit c28ae23774
12 changed files with 302 additions and 0 deletions

View File

@@ -0,0 +1,3 @@
dependencies {
implementation group: 'org.knowm.xchart', name: 'xchart', version: '3.6.2'
}

View File

@@ -0,0 +1,7 @@
version=0.0.1
pluginVersion=0.1
pluginId=smnp.audio.synth
pluginClass=
pluginProvider=Bartłomiej Pluta
pluginDependencies=

View File

@@ -0,0 +1,20 @@
package io.smnp.ext
import io.smnp.environment.Environment
import io.smnp.ext.function.SynthFunction
import io.smnp.ext.function.WaveFunction
import io.smnp.ext.synth.Synthesizer
import org.pf4j.Extension
@Extension
class SynthModule : NativeModuleProvider("smnp.audio.synth") {
override fun functions() = listOf(WaveFunction(), SynthFunction())
override fun onModuleLoad(environment: Environment) {
Synthesizer.init()
}
override fun beforeModuleDisposal(environment: Environment) {
Synthesizer.dispose()
}
}

View File

@@ -0,0 +1,20 @@
package io.smnp.ext.function
import io.smnp.callable.function.Function
import io.smnp.callable.function.FunctionDefinitionTool
import io.smnp.callable.signature.Signature.Companion.simple
import io.smnp.ext.synth.Synthesizer
import io.smnp.ext.synth.Wave
import io.smnp.type.enumeration.DataType.INT
import io.smnp.type.matcher.Matcher.Companion.listOf
import io.smnp.type.model.Value
class SynthFunction : Function("synth") {
override fun define(new: FunctionDefinitionTool) {
new function simple(listOf(INT)) body { _, (wave) ->
val bytes = (wave.value as List<Value>).map { (it.value as Int).toByte() }.toByteArray()
Synthesizer.synth(Wave(bytes))
Value.void()
}
}
}

View File

@@ -0,0 +1,29 @@
package io.smnp.ext.function
import io.smnp.callable.function.Function
import io.smnp.callable.function.FunctionDefinitionTool
import io.smnp.callable.signature.Signature
import io.smnp.ext.synth.AdsrEnvelope
import io.smnp.ext.synth.Synthesizer
import io.smnp.ext.synth.WaveCompiler
import io.smnp.type.enumeration.DataType
import io.smnp.type.matcher.Matcher
import io.smnp.type.model.Value
class WaveFunction : Function("wave") {
override fun define(new: FunctionDefinitionTool) {
new function Signature.vararg(Matcher.listOf(DataType.NOTE, DataType.INT, DataType.STRING)) body { _, (vararg) ->
val compiler = WaveCompiler(
listOf(1.0, 0.5),
AdsrEnvelope(0.1, 0.3, 0.7, 0.8),
120,
440.0,
Synthesizer.SAMPLING_RATE
)
val wave = compiler.compileLines(vararg.unwrapCollections() as List<List<Value>>)
Value.list(wave.bytes.map { Value.int(it.toInt()) }.toList())
}
}
}

View File

@@ -0,0 +1,26 @@
package io.smnp.ext.synth
class AdsrEnvelope(private val p1: Double, private val p2: Double, private val p3: Double, private val s: Double) : Envelope() {
override fun eval(x: Double, length: Int): Double {
val p1x = length * p1
val p2x = length * p2
val p3x = length * p3
val f1 = quad(0.0 to 0.0, p1x to 1.0, 2.0 * p1x to 0.0)
val f2 = quad(p1x to 1.0, p2x to s, p1x + 2.0 * (p2x - p1x) to 1.0)
val f3 = quad(p3x to s, length.toDouble() to 0.0, p3x + 2.0 * (length - p3x) to s)
return if (x >= 0 && x < p1x) {
f1(x)
} else if (x >= p1x && x < p2x) {
f2(x)
} else if (x >= p2x && x < p3x) {
s
} else {
f3(x)
}
}
override fun name() = "ADSR"
}

View File

@@ -0,0 +1,7 @@
package io.smnp.ext.synth
class ConstantEnvelope : Envelope() {
override fun name() = "Constant"
override fun eval(x: Double, length: Int) = 1.0
}

View File

@@ -0,0 +1,39 @@
package io.smnp.ext.synth
import org.knowm.xchart.QuickChart
import org.knowm.xchart.SwingWrapper
abstract class Envelope() {
abstract fun eval(x: Double, length: Int): Double
protected abstract fun name(): String
fun plot() {
val max = 100
val x = IntRange(0, max).map { it.toDouble() }
val chart = QuickChart.getChart(
name(),
"x",
"y",
"y(x)",
x,
x.map { eval(it, max) }
)
SwingWrapper(chart).displayChart()
}
fun apply(wave: Wave): Wave {
return Wave(wave.bytes.mapIndexed { index, byte ->
(byte * eval(
index.toDouble(),
wave.bytes.size
)).toByte()
}.toByteArray())
}
protected fun quad(a: Pair<Double, Double>, b: Pair<Double, Double>, c: Pair<Double, Double>): (Double) -> Double {
return { x ->
(a.second * ((x - b.first) / (a.first - b.first)) * ((x - c.first) / (a.first - c.first))) + (b.second * ((x - a.first) / (b.first - a.first)) * ((x - c.first) / (b.first - c.first))) + (c.second * ((x - a.first) / (c.first - a.first)) * ((x - b.first) / (c.first - b.first)))
}
}
}

View File

@@ -0,0 +1,27 @@
package io.smnp.ext.synth
import javax.sound.sampled.AudioFormat
import javax.sound.sampled.AudioSystem
import javax.sound.sampled.SourceDataLine
object Synthesizer {
const val SAMPLING_RATE = 44100.0 // [Hz]
private val format = AudioFormat(SAMPLING_RATE.toFloat(), 8, 1, true, false)
private var line: SourceDataLine = AudioSystem.getSourceDataLine(format)
fun synth(wave: Wave) {
line.write(wave.bytes, 0, wave.bytes.size)
}
fun init() {
line.open(format)
line.start()
}
fun dispose() {
line.drain()
line.stop()
line.close()
}
}

View File

@@ -0,0 +1,71 @@
package io.smnp.ext.synth
import org.knowm.xchart.QuickChart
import org.knowm.xchart.SwingWrapper
import java.io.ByteArrayOutputStream
import kotlin.math.roundToInt
import kotlin.math.sin
class Wave(val bytes: ByteArray) {
fun plot() {
val chart = QuickChart.getChart(
"Sine wave",
"X",
"Y",
"y(x)",
IntRange(0, bytes.size - 1).map { it.toDouble() }.toList(),
bytes.toList()
)
SwingWrapper(chart).displayChart()
}
operator fun plus(wave: Wave): Wave {
val stream = ByteArrayOutputStream()
stream.writeBytes(bytes)
stream.writeBytes(wave.bytes)
return Wave(stream.toByteArray())
}
operator fun times(value: Double): Wave {
return Wave(bytes.map { (it * value).toByte() }.toByteArray())
}
fun extend(bytes: ByteArray): Wave {
return this + Wave(bytes)
}
companion object {
val EMPTY = Wave(ByteArray(0))
fun merge(vararg waves: Wave): Wave {
val longestWaveSize = waves.maxBy { it.bytes.size }?.let { it.bytes.size }
?: throw RuntimeException("Empty waves are not supported")
val adjustedWaves = waves.map { it.extend(ByteArray(longestWaveSize - it.bytes.size)) }
val signal = IntRange(0, longestWaveSize-1).map { index ->
adjustedWaves.map { it.bytes[index].toDouble() }.sum()
}
val maxValue = Byte.MAX_VALUE.toDouble() / (signal.max() ?: 1.0)
return Wave(signal.map { (it * maxValue).toByte() }.toByteArray())
}
fun sine(frequency: Double, duration: Double, samplingRate: Double): Wave {
val length = (samplingRate * duration).roundToInt()
val buffer = ByteArray(length)
buffer.forEachIndexed { i, _ ->
buffer[i] = (Byte.MAX_VALUE * sin(i / samplingRate * 2.0 * Math.PI * frequency)).toByte()
}
return Wave(buffer)
}
fun zeros(duration: Double, samplingRate: Double): Wave {
return Wave(ByteArray((samplingRate * duration).roundToInt()))
}
}
}

View File

@@ -0,0 +1,52 @@
package io.smnp.ext.synth
import io.smnp.data.entity.Note
import io.smnp.data.enumeration.Pitch
import io.smnp.error.CustomException
import io.smnp.math.Fraction
import io.smnp.type.enumeration.DataType
import io.smnp.type.model.Value
import kotlin.math.pow
class WaveCompiler(
private val overtones: List<Double>,
private val envelope: Envelope,
private val bpm: Int,
tuning: Double,
private val samplingRate: Double
) {
private val semitone = 2.0.pow(1.0/12.0)
private val tuningTable = Pitch.values().mapIndexed { index, pitch -> pitch to tuning/semitone.pow(57-index) }.toMap()
fun compileLines(lines: List<List<Value>>): Wave {
return Wave.merge(*lines.map { compileLine(it) }.toTypedArray())
}
fun compileLine(line: List<Value>): Wave {
return line.fold(Wave.EMPTY) { acc, value ->
acc + when(value.type) {
DataType.NOTE -> compileNote(value.value as Note)
DataType.INT -> compileRest(value.value as Int)
DataType.STRING -> Wave.EMPTY
else -> throw CustomException("Invalid data type: '${value.typeName}")
}
}
}
private fun compileNote(note: Note) = sound((tuningTable[note.pitch] ?: error("")) * 2.0.pow(note.octave), duration(note.duration))
private fun duration(duration: Fraction) = 60.0 * 4.0 * duration.decimal / bpm
private fun compileRest(rest: Int) = Wave.zeros(duration(Fraction(1, rest)), samplingRate)
private fun sound(
frequency: Double,
duration: Double
): Wave {
val wave = Wave.merge(*overtones.mapIndexed { overtone, ratio ->
Wave.sine(frequency * (overtone + 1), duration, samplingRate) * ratio
}.toTypedArray())
return envelope.apply(wave)
}
}