summary refs log tree commit diff stats
path: root/src/client/kotlin
diff options
context:
space:
mode:
Diffstat (limited to 'src/client/kotlin')
-rw-r--r--src/client/kotlin/space/autistic/radio/client/antenna/AntennaModel.kt6
-rw-r--r--src/client/kotlin/space/autistic/radio/client/antenna/ConstAntennaModel.kt (renamed from src/client/kotlin/space/autistic/radio/client/antenna/NullModel.kt)4
-rw-r--r--src/client/kotlin/space/autistic/radio/client/antenna/WasmAntennaFactory.kt6
-rw-r--r--src/client/kotlin/space/autistic/radio/client/cli/OfflineSimulator.kt237
-rw-r--r--src/client/kotlin/space/autistic/radio/client/complex/Complex.kt32
-rw-r--r--src/client/kotlin/space/autistic/radio/client/dsp/Biquad1stOrder.kt11
-rw-r--r--src/client/kotlin/space/autistic/radio/client/fmsim/FmFullConstants.kt114
-rw-r--r--src/client/kotlin/space/autistic/radio/client/fmsim/FmFullDemodulator.kt162
-rw-r--r--src/client/kotlin/space/autistic/radio/client/fmsim/FmFullMixer.kt4
-rw-r--r--src/client/kotlin/space/autistic/radio/client/fmsim/FmFullModulator.kt170
-rw-r--r--src/client/kotlin/space/autistic/radio/client/irc/IRC.kt7
-rw-r--r--src/client/kotlin/space/autistic/radio/client/opus/OpusDecoder.kt77
-rw-r--r--src/client/kotlin/space/autistic/radio/client/opus/OpusFactory.kt26
-rw-r--r--src/client/kotlin/space/autistic/radio/client/reflection/MemoryReflection.kt14
14 files changed, 862 insertions, 8 deletions
diff --git a/src/client/kotlin/space/autistic/radio/client/antenna/AntennaModel.kt b/src/client/kotlin/space/autistic/radio/client/antenna/AntennaModel.kt
index 74a7c96..c1f3e13 100644
--- a/src/client/kotlin/space/autistic/radio/client/antenna/AntennaModel.kt
+++ b/src/client/kotlin/space/autistic/radio/client/antenna/AntennaModel.kt
@@ -5,15 +5,15 @@ import org.joml.Vector3d
 interface AntennaModel {
     /**
      * Returns the linear power level/gain to apply for a receiver at the given position. The receiver is assumed to be
-     * vertically oriented.
+     * vertically oriented. The gain should scale with distance, as appropriate.
      *
      * Note: 1.0f = 0dB, 0.5f = -3dB (approx.), 0.1f = -10dB.
      */
     fun apply(position: Vector3d): Float
 
     /**
-     * Returns whether to process block/material attenuation. Useful for "global" antennas (i.e. those that return a
-     * constant for all positions given to [apply]).
+     * Returns whether to process block/material attenuation. Useful (when false) for "global" antennas (i.e. those that
+     * return a constant for all positions given to [apply]).
      */
     fun shouldAttenuate(): Boolean
 }
\ No newline at end of file
diff --git a/src/client/kotlin/space/autistic/radio/client/antenna/NullModel.kt b/src/client/kotlin/space/autistic/radio/client/antenna/ConstAntennaModel.kt
index 3c188b6..fc531d2 100644
--- a/src/client/kotlin/space/autistic/radio/client/antenna/NullModel.kt
+++ b/src/client/kotlin/space/autistic/radio/client/antenna/ConstAntennaModel.kt
@@ -2,9 +2,9 @@ package space.autistic.radio.client.antenna
 
 import org.joml.Vector3d
 
-class NullModel : AntennaModel {
+class ConstAntennaModel(private val level: Float) : AntennaModel {
     override fun apply(position: Vector3d): Float {
-        return 0f
+        return level
     }
 
     override fun shouldAttenuate(): Boolean {
diff --git a/src/client/kotlin/space/autistic/radio/client/antenna/WasmAntennaFactory.kt b/src/client/kotlin/space/autistic/radio/client/antenna/WasmAntennaFactory.kt
index 7181e95..51743dd 100644
--- a/src/client/kotlin/space/autistic/radio/client/antenna/WasmAntennaFactory.kt
+++ b/src/client/kotlin/space/autistic/radio/client/antenna/WasmAntennaFactory.kt
@@ -33,7 +33,7 @@ class WasmAntennaFactory(moduleBytes: ByteArray) : AntennaModelFactory {
 
     override fun create(orientation: Quaterniond): AntennaModel {
         if (failing) {
-            return NullModel()
+            return ConstAntennaModel(0f)
         }
         try {
             val instance = instanceBuilder!!.build()
@@ -57,7 +57,7 @@ class WasmAntennaFactory(moduleBytes: ByteArray) : AntennaModelFactory {
                     Level.SEVERE, "Error while trying to initialize antenna model: missing 'should-attenuate'"
                 )
                 failing = true
-                return NullModel()
+                return ConstAntennaModel(0f)
             }
             val shouldAttenuate = instance.exports().global("should-attenuate").value != 0L
             val apply = instance.export("apply")
@@ -86,7 +86,7 @@ class WasmAntennaFactory(moduleBytes: ByteArray) : AntennaModelFactory {
         } catch (e: ChicoryException) {
             logger.log(Level.SEVERE, "Error while trying to initialize antenna model.", e)
             failing = true
-            return NullModel()
+            return ConstAntennaModel(0f)
         }
     }
 
diff --git a/src/client/kotlin/space/autistic/radio/client/cli/OfflineSimulator.kt b/src/client/kotlin/space/autistic/radio/client/cli/OfflineSimulator.kt
new file mode 100644
index 0000000..68c4719
--- /dev/null
+++ b/src/client/kotlin/space/autistic/radio/client/cli/OfflineSimulator.kt
@@ -0,0 +1,237 @@
+package space.autistic.radio.client.cli
+
+import org.joml.Vector2f
+import space.autistic.radio.client.complex.cmul
+import space.autistic.radio.client.fmsim.FmFullConstants
+import space.autistic.radio.client.fmsim.FmFullDemodulator
+import space.autistic.radio.client.fmsim.FmFullModulator
+import java.io.FileInputStream
+import java.io.FileOutputStream
+import java.io.InputStream
+import java.net.URI
+import java.nio.ByteBuffer
+import java.nio.ByteOrder
+import java.nio.FloatBuffer
+import kotlin.io.path.inputStream
+import kotlin.io.path.toPath
+import kotlin.math.min
+import kotlin.system.exitProcess
+
+fun printUsage() {
+    println("Usage: OfflineSimulator <-o|-O> OUTFILE.raw {[-p POWER] [-l|-h] [-m] file:///FILE.raw} [-m]")
+    println("    file:///FILE.raw (or ./FILE.raw - the ./ is required)")
+    println("        The raw input file. two-channel (even with -m), 48kHz 32-bit float.")
+    println("    -o OUTFILE.raw")
+    println("        The raw RF stream to output, 2x300kHz 32-bit float.")
+    println("    -O OUTFILE.raw")
+    println("        The raw audio stream to output, 2x48kHz 32-bit float.")
+    println("    -p POWER")
+    println("        The signal amplitude (power level), e.g. 1.0.")
+    println("    -l")
+    println("        Simulate a partial overlap on the lower half of the tuned-into frequency.")
+    println("    -h")
+    println("        Simulate a partial overlap on the upper half of the tuned-into frequency.")
+    println("    -m")
+    println("        Downconvert to mono. As the last option, demodulate as mono.")
+}
+
+class SimFile(val power: Float, val band: Int, val filename: String, val stereo: Boolean) {
+    var closed: Boolean = false
+    val buffer: FloatBuffer = FloatBuffer.allocate(8192)
+    val modulator = FmFullModulator()
+    var stream: InputStream? = null
+}
+
+fun main(args: Array<String>) {
+    if (args.isEmpty()) {
+        printUsage()
+        exitProcess(1)
+    }
+    var hasOutput = false
+    var inArg = ""
+    var output = ""
+    var rfOutput = true
+    var power = 1.0f
+    var band = 2
+    var stereo = FmFullConstants.STEREO
+    val files: ArrayList<SimFile> = ArrayList()
+    for (arg in args) {
+        if (!hasOutput) {
+            if (arg == "-o" || arg == "-O") {
+                hasOutput = true
+                inArg = arg
+            } else {
+                printUsage()
+                exitProcess(1)
+            }
+        } else {
+            when (inArg) {
+                "-o" -> {
+                    output = arg
+                    rfOutput = true
+                    inArg = ""
+                }
+
+                "-O" -> {
+                    output = arg
+                    rfOutput = false
+                    inArg = ""
+                }
+
+                "-p" -> {
+                    power = arg.toFloatOrNull() ?: run {
+                        println("Error processing -p argument: not a valid float")
+                        printUsage()
+                        exitProcess(1)
+                    }
+                    inArg = ""
+                }
+
+                "" -> {
+                    if (!arg.startsWith("-")) {
+                        files.add(SimFile(power, band, arg, stereo))
+                        inArg = ""
+                        band = 2
+                        power = 1.0f
+                        stereo = FmFullConstants.STEREO
+                    } else {
+                        when (arg) {
+                            "-p" -> inArg = "-p"
+                            "-l" -> band = 1
+                            "-h" -> band = 3
+                            "-m" -> stereo = FmFullConstants.MONO
+                            else -> {
+                                println("Unknown option")
+                                printUsage()
+                                exitProcess(1)
+                            }
+                        }
+                    }
+                }
+
+                else -> throw NotImplementedError(inArg)
+            }
+        }
+    }
+
+    if (files.isEmpty()) {
+        printUsage()
+        exitProcess(1)
+    }
+
+    println(ProcessHandle.current().pid())
+
+    FileOutputStream(output).buffered().use { outputStream ->
+        for (inputFile in files) {
+            if (inputFile.filename != "file:///dev/zero") {
+                if (inputFile.filename.startsWith("./")) {
+                    inputFile.stream = FileInputStream(inputFile.filename)
+                } else {
+                    inputFile.stream = URI(inputFile.filename).toPath().inputStream()
+                }
+            }
+        }
+
+        val buffer = ByteBuffer.allocate(2 * 4 * FmFullConstants.FFT_DATA_BLOCK_SIZE_LPF_48K_15K_3K1)
+        val plus100k = FloatBuffer.wrap(FmFullConstants.CBUFFER_100K_300K)
+        val minus100k = FloatBuffer.wrap(FmFullConstants.CBUFFER_100K_300K)
+        val demodulator = FmFullDemodulator()
+        var lastStereoPilot = false
+        while (true) {
+            // initialized to maximum buffer size, trimmed down later
+            var minBuffer = 8192
+            for (inputFile in files) {
+                val stream = inputFile.stream
+                if (stream == null) {
+                    if (inputFile.buffer.remaining() > 2 * FmFullConstants.FFT_DATA_BLOCK_SIZE_48K_300K) {
+                        inputFile.modulator.flush(inputFile.power, inputFile.stereo) {
+                            inputFile.buffer.put(it)
+                        }
+                    }
+                } else {
+                    val bytes = stream.read(buffer.array())
+                    if (bytes <= 0) {
+                        stream.close()
+                        inputFile.stream = null
+                        inputFile.closed = true
+                        inputFile.modulator.flush(inputFile.power, inputFile.stereo) {
+                            inputFile.buffer.put(it)
+                        }
+                    } else {
+                        val floats = buffer.slice(0, bytes).order(ByteOrder.LITTLE_ENDIAN).asFloatBuffer()
+                        var shouldFlush = true
+                        inputFile.modulator.process(floats, inputFile.power, inputFile.stereo) {
+                            inputFile.buffer.put(it)
+                            shouldFlush = false
+                        }
+                        if (shouldFlush) {
+                            inputFile.modulator.flush(inputFile.power, inputFile.stereo) {
+                                inputFile.buffer.put(it)
+                            }
+                        }
+                    }
+                }
+                minBuffer = min(minBuffer, inputFile.buffer.position())
+            }
+
+            val outputBuffer = ByteBuffer.allocate(minBuffer * 4)
+            val floatView = outputBuffer.order(ByteOrder.LITTLE_ENDIAN).asFloatBuffer()
+            val floatBufferLo = FloatBuffer.allocate(minBuffer)
+            val floatBufferHi = FloatBuffer.allocate(minBuffer)
+            for (inputFile in files) {
+                inputFile.buffer.flip()
+                val floatBuffer = when (inputFile.band) {
+                    1 -> floatBufferLo
+                    2 -> floatView
+                    3 -> floatBufferHi
+                    else -> throw IllegalStateException()
+                }
+                for (i in 0 until floatBuffer.capacity()) {
+                    floatBuffer.put(i, floatBuffer.get(i) + inputFile.buffer.get())
+                }
+                inputFile.buffer.compact()
+            }
+            val z = Vector2f()
+            val w = Vector2f()
+            for (i in 0 until floatBufferHi.capacity() step 2) {
+                z.x = floatBufferHi.get(i)
+                z.y = floatBufferHi.get(i + 1)
+                if (!plus100k.hasRemaining()) {
+                    plus100k.clear()
+                }
+                w.x = plus100k.get()
+                w.y = plus100k.get()
+                z.cmul(w)
+                floatView.put(i, floatView.get(i) + z.x)
+                floatView.put(i, floatView.get(i) + z.y)
+            }
+            for (i in 0 until floatBufferLo.capacity() step 2) {
+                z.x = floatBufferLo.get(i)
+                z.y = floatBufferLo.get(i + 1)
+                if (!minus100k.hasRemaining()) {
+                    minus100k.clear()
+                }
+                w.x = minus100k.get()
+                w.y = -minus100k.get()
+                z.cmul(w)
+                floatView.put(i, floatView.get(i) + z.x)
+                floatView.put(i, floatView.get(i) + z.y)
+            }
+            if (rfOutput) {
+                outputStream.write(outputBuffer.array())
+            } else {
+                demodulator.process(floatView, stereo) { stereoPilot, it ->
+                    if (stereoPilot != lastStereoPilot) {
+                        println(if (stereoPilot) "stereo" else "mono")
+                    }
+                    lastStereoPilot = stereoPilot
+                    buffer.order(ByteOrder.LITTLE_ENDIAN).asFloatBuffer().put(0, it.array())
+                    outputStream.write(buffer.array())
+                }
+            }
+            if (files.all { it.closed }) {
+                break
+            }
+        }
+    }
+}
\ No newline at end of file
diff --git a/src/client/kotlin/space/autistic/radio/client/complex/Complex.kt b/src/client/kotlin/space/autistic/radio/client/complex/Complex.kt
new file mode 100644
index 0000000..7ca6811
--- /dev/null
+++ b/src/client/kotlin/space/autistic/radio/client/complex/Complex.kt
@@ -0,0 +1,32 @@
+package space.autistic.radio.client.complex
+
+import org.joml.Vector2f
+import org.joml.Vector2fc
+
+fun Vector2f.cmul(v: Vector2fc): Vector2f {
+    return this.cmul(v, this)
+}
+
+fun Vector2f.cmul(v: Vector2fc, dest: Vector2f): Vector2f {
+    val a = this.x * v.x()
+    val b = this.y * v.y()
+    val c = (this.x() + this.y()) * (v.x() + v.y())
+    val x = a - b
+    val y = c - a - b
+    dest.x = x
+    dest.y = y
+    return dest
+}
+
+fun Vector2f.conjugate(): Vector2f {
+    return this.conjugate(this)
+}
+
+fun Vector2f.conjugate(dest: Vector2f): Vector2f {
+    dest.x = this.x()
+    dest.y = -this.y()
+    return dest
+}
+
+val I
+    get() = Vector2f(0f, 1f)
\ No newline at end of file
diff --git a/src/client/kotlin/space/autistic/radio/client/dsp/Biquad1stOrder.kt b/src/client/kotlin/space/autistic/radio/client/dsp/Biquad1stOrder.kt
new file mode 100644
index 0000000..ddf5b7a
--- /dev/null
+++ b/src/client/kotlin/space/autistic/radio/client/dsp/Biquad1stOrder.kt
@@ -0,0 +1,11 @@
+package space.autistic.radio.client.dsp
+
+class Biquad1stOrder(private val b0: Float, private val b1: Float, private val a1: Float) {
+    private var delaySlot = 0f
+
+    fun process(samp: Float): Float {
+        val out = samp * b0 + delaySlot
+        delaySlot = samp * b1 - out * a1
+        return out
+    }
+}
\ No newline at end of file
diff --git a/src/client/kotlin/space/autistic/radio/client/fmsim/FmFullConstants.kt b/src/client/kotlin/space/autistic/radio/client/fmsim/FmFullConstants.kt
new file mode 100644
index 0000000..f5a49ce
--- /dev/null
+++ b/src/client/kotlin/space/autistic/radio/client/fmsim/FmFullConstants.kt
@@ -0,0 +1,114 @@
+package space.autistic.radio.client.fmsim
+
+import kotlin.math.PI
+import kotlin.math.cos
+import kotlin.math.sin
+
+object FmFullConstants {
+    // tau = 75us, fh = 20396.25Hz
+    const val FM_PREEMPAHSIS_B0_48K = 6.7639647f
+    const val FM_PREEMPHASIS_B1_48K = -4.975628f
+
+    /* const val FM_PREEMPHASIS_A0_48K = 1f */
+    const val FM_PREEMPHASIS_A1_48K = 0.78833646f
+
+    const val FM_DEEMPAHSIS_B0_48K = 1f / FM_PREEMPAHSIS_B0_48K
+    const val FM_DEEMPHASIS_B1_48K = FM_PREEMPHASIS_A1_48K / FM_PREEMPAHSIS_B0_48K
+
+    /* const val FM_DEEMPHASIS_A0_48K = 1f */
+    const val FM_DEEMPHASIS_A1_48K = FM_PREEMPHASIS_B1_48K / FM_PREEMPAHSIS_B0_48K
+
+    val FIR_LPF_48K_15K_3K1 = floatArrayOf(
+        -0.0010006913216784596f,
+        0.001505308784544468f,
+        -2.625857350794219e-18f,
+        -0.002777613466605544f,
+        0.0030173989944159985f,
+        0.002290070755407214f,
+        -0.008225799538195133f,
+        0.004239063244313002f,
+        0.010359899140894413f,
+        -0.017650796100497246f,
+        1.510757873119297e-17f,
+        0.029305754229426384f,
+        -0.02889496460556984f,
+        -0.020366130396723747f,
+        0.07103750854730606f,
+        -0.03811456635594368f,
+        -0.10945471376180649f,
+        0.29212409257888794f,
+        0.6252123713493347f,
+        0.29212409257888794f,
+        -0.10945471376180649f,
+        -0.03811456635594368f,
+        0.07103750854730606f,
+        -0.020366130396723747f,
+        -0.02889496460556984f,
+        0.029305754229426384f,
+        1.510757873119297e-17f,
+        -0.017650796100497246f,
+        0.010359899140894413f,
+        0.004239063244313002f,
+        -0.008225799538195133f,
+        0.002290070755407214f,
+        0.0030173989944159985f,
+        -0.002777613466605544f,
+        -2.625857350794219e-18f,
+        0.001505308784544468f,
+        -0.0010006913216784596f,
+    )
+
+    // chosen such that we can easily do 38kHz mixing in frequency (1500*38k/300k = shift of 95 bins, where 1500 comes
+    // from the 4/25 ratio 48k/300k i.e. 240*25/4)
+    // (the theoretical optimum, as per above, would be around 180)
+    // (we could have fudged the carrier frequency a bit but we chose not to)
+    // NOTE: latency = (data block size / 48000) seconds (84 -> 1.75 ms)
+    const val FFT_SIZE_LPF_48K_15K_3K1 = 2 * 120
+    const val FFT_OVERLAP_LPF_48K_15K_3K1 = 36
+    const val FFT_DATA_BLOCK_SIZE_LPF_48K_15K_3K1 = FFT_SIZE_LPF_48K_15K_3K1 - FFT_OVERLAP_LPF_48K_15K_3K1
+
+    init {
+        assert(FFT_OVERLAP_LPF_48K_15K_3K1 >= FIR_LPF_48K_15K_3K1.size - 1)
+    }
+
+    const val DECIMATION_48K_300K = 4
+    const val INTERPOLATION_48K_300K = 25
+
+    const val FFT_SIZE_48K_300K = FFT_SIZE_LPF_48K_15K_3K1 * INTERPOLATION_48K_300K / DECIMATION_48K_300K
+    const val FFT_OVERLAP_48K_300K = FFT_OVERLAP_LPF_48K_15K_3K1 * INTERPOLATION_48K_300K / DECIMATION_48K_300K
+    const val FFT_DATA_BLOCK_SIZE_48K_300K = FFT_SIZE_48K_300K - FFT_OVERLAP_48K_300K
+
+    // how many bins to shift for 38kHz mixing
+    // assuming FFT_SIZE_LPF_48K_15K_3K1 *bins* (complex)
+    // 19 / 150 is the ratio between 38k/300k
+    const val FREQUENCY_MIXING_BINS_38K =
+        FFT_SIZE_LPF_48K_15K_3K1 * INTERPOLATION_48K_300K / DECIMATION_48K_300K * 19 / 150
+
+    // a single cycle of a 19kHz signal takes (1/19k)/(1/300k) or 300k/19k samples.
+    // since that number isn't exact, buffer an entire 19 cycles.
+    const val BUFFER_SIZE_19K_300K = 300
+
+    // using cosine is nicer
+    val BUFFER_19K_300K = FloatArray(BUFFER_SIZE_19K_300K) {
+        0.1f * cos(2 * PI * 19000.0 * it.toDouble() / 300000.0).toFloat()
+    }
+
+    // we want a carrier deviation of +-75kHz, at a sampling rate of 300kHz
+    const val CORRECTION_FACTOR = (75000.0 / (300000.0 / (2.0 * PI))).toFloat()
+    const val INVERSE_CORRECTION_FACTOR = 1 / CORRECTION_FACTOR
+
+    // these are used for "low/high" mixing
+    const val CBUFFER_SIZE_100K_300K = 3
+
+    val CBUFFER_100K_300K = FloatArray(2 * CBUFFER_SIZE_100K_300K) {
+        val index = it / 2
+        if (it and 1 == 0) {
+            1f * sin(2 * PI * 100000.0 * index.toDouble() / 300000.0).toFloat()
+        } else {
+            1f * cos(2 * PI * 100000.0 * index.toDouble() / 300000.0).toFloat()
+        }
+    }
+
+    const val STEREO = true
+    const val MONO = false
+}
\ No newline at end of file
diff --git a/src/client/kotlin/space/autistic/radio/client/fmsim/FmFullDemodulator.kt b/src/client/kotlin/space/autistic/radio/client/fmsim/FmFullDemodulator.kt
new file mode 100644
index 0000000..7cf15af
--- /dev/null
+++ b/src/client/kotlin/space/autistic/radio/client/fmsim/FmFullDemodulator.kt
@@ -0,0 +1,162 @@
+package space.autistic.radio.client.fmsim
+
+import org.joml.Vector2f
+import org.jtransforms.fft.FloatFFT_1D
+import space.autistic.radio.client.complex.I
+import space.autistic.radio.client.complex.cmul
+import space.autistic.radio.client.complex.conjugate
+import space.autistic.radio.client.dsp.Biquad1stOrder
+import java.nio.FloatBuffer
+import java.util.function.BiConsumer
+
+class FmFullDemodulator {
+    private val inputBuffer = FloatBuffer.allocate(FmFullConstants.FFT_SIZE_48K_300K)
+    private val fft300kBuf = FloatBuffer.allocate(FmFullConstants.FFT_SIZE_48K_300K)
+    private val fft48kBuf = FloatBuffer.allocate(FmFullConstants.FFT_SIZE_LPF_48K_15K_3K1)
+    private val outputBuffer = FloatBuffer.allocate(2 * FmFullConstants.FFT_DATA_BLOCK_SIZE_LPF_48K_15K_3K1)
+
+    init {
+        inputBuffer.position(2 * FmFullConstants.FFT_OVERLAP_48K_300K)
+    }
+
+    // yep.
+    private val boxcarI = Biquad1stOrder(1f, 1f, 0f)
+    private val boxcarQ = Biquad1stOrder(1f, 1f, 0f)
+    private val delayI = Biquad1stOrder(0f, 1f, 0f)
+    private val delayQ = Biquad1stOrder(0f, 1f, 0f)
+
+    private val deemphasisLeft = Biquad1stOrder(
+        FmFullConstants.FM_DEEMPAHSIS_B0_48K,
+        FmFullConstants.FM_DEEMPHASIS_B1_48K,
+        FmFullConstants.FM_DEEMPHASIS_A1_48K
+    )
+    private val deemphasisRight = Biquad1stOrder(
+        FmFullConstants.FM_DEEMPAHSIS_B0_48K,
+        FmFullConstants.FM_DEEMPHASIS_B1_48K,
+        FmFullConstants.FM_DEEMPHASIS_A1_48K
+    )
+
+    private val lastStereoPilot = Vector2f()
+    private val lastStereoPilotPolarDiscriminator = Vector2f()
+
+    /**
+     * Takes in samples at 300kHz, in I/Q format, and processes them for output.
+     *
+     * Calls consumer with processed samples at 48kHz, stereo.
+     */
+    fun process(input: FloatBuffer, stereo: Boolean, consumer: BiConsumer<Boolean, FloatBuffer>) {
+        while (input.remaining() >= 2) {
+            val z = Vector2f()
+            val w = Vector2f()
+            while (input.remaining() >= 2 && inputBuffer.hasRemaining()) {
+                z.x = boxcarI.process(input.get())
+                z.y = boxcarQ.process(input.get())
+                // quadrature demodulation = FM demodulation
+                // see https://wiki.gnuradio.org/index.php/Quadrature_Demod and such
+                w.x = delayI.process(z.x)
+                w.y = -delayQ.process(z.y)
+                z.cmul(w)
+                inputBuffer.put(org.joml.Math.atan2(z.y, z.x) * FmFullConstants.INVERSE_CORRECTION_FACTOR)
+            }
+            if (!inputBuffer.hasRemaining()) {
+                var stereoPilot = false
+                fft300kBuf.put(0, inputBuffer.array())
+                fft300k.realForward(fft300kBuf.array())
+                for (i in 0 until fft48kBuf.capacity()) {
+                    fft48kBuf.put(i, 0f)
+                }
+                for (i in 2 until (FmFullConstants.FREQUENCY_MIXING_BINS_38K - 2 and 1.inv()) step 2) {
+                    z.x = fft300kBuf.get(i)
+                    z.y = fft300kBuf.get(i + 1)
+                    w.x = fir48kLpf.get(i)
+                    w.y = fir48kLpf.get(i + 1)
+                    z.cmul(w)
+                    fft48kBuf.put(i, z.x)
+                    fft48kBuf.put(i + 1, z.y)
+                }
+                fft48kBuf.put(0, fft300kBuf.get(0) * fir48kLpf.get(0))
+                fft48k.realInverse(fft48kBuf.array(), false)
+                outputBuffer.clear()
+                fft48kBuf.position(FmFullConstants.FFT_OVERLAP_LPF_48K_15K_3K1)
+                for (i in 0 until FmFullConstants.FFT_DATA_BLOCK_SIZE_LPF_48K_15K_3K1) {
+                    val sample = fft48kBuf.get() * (1f / FmFullConstants.FFT_SIZE_48K_300K)
+                    outputBuffer.put(sample)
+                    outputBuffer.put(sample)
+                }
+                outputBuffer.clear()
+                if (stereo) {
+                    z.x = fft300kBuf.get(FmFullConstants.FREQUENCY_MIXING_BINS_38K)
+                    z.y = fft300kBuf.get(FmFullConstants.FREQUENCY_MIXING_BINS_38K + 1)
+                    z.conjugate(w).cmul(lastStereoPilot).conjugate().normalize()
+                    if (lastStereoPilotPolarDiscriminator.distanceSquared(w) < 0.5f && z.lengthSquared() >= FmFullConstants.FFT_SIZE_48K_300K) {
+                        stereoPilot = true
+                    }
+                    lastStereoPilot.set(z)
+                    lastStereoPilotPolarDiscriminator.set(w)
+                    if (stereoPilot) {
+                        // w is our phase offset
+                        // TODO check if this is mathematically sound
+                        z.normalize().cmul(z).cmul(w.conjugate()).conjugate()
+                        // z is our recovered 38kHz carrier, including phase offset
+                        for (i in 0 until fft48kBuf.capacity()) {
+                            fft48kBuf.put(i, 0f)
+                        }
+                        val base = FmFullConstants.FREQUENCY_MIXING_BINS_38K * 2
+                        val sz = Vector2f()
+                        val sw = Vector2f()
+                        for (i in 2 until (FmFullConstants.FREQUENCY_MIXING_BINS_38K - 2 and 1.inv()) step 2) {
+                            sz.x = fft300kBuf.get(base + i)
+                            sz.y = fft300kBuf.get(base + i + 1)
+                            sw.x = fft300kBuf.get(base - i)
+                            sw.y = fft300kBuf.get(base - i + 1)
+                            sz.cmul(z).add(sw.cmul(z).conjugate())
+                            sw.x = fir48kLpf.get(i)
+                            sw.y = fir48kLpf.get(i + 1)
+                            sz.cmul(sw)
+                            fft48kBuf.put(i, sz.x)
+                            fft48kBuf.put(i + 1, sz.y)
+                        }
+                        sz.x = fft300kBuf.get(base)
+                        sz.y = fft300kBuf.get(base + 1)
+                        sz.cmul(z)
+                        fft48kBuf.put(0, sz.x * fir48kLpf.get(0))
+                        fft48k.realInverse(fft48kBuf.array(), false)
+                        outputBuffer.clear()
+                        fft48kBuf.position(FmFullConstants.FFT_OVERLAP_LPF_48K_15K_3K1)
+                        for (i in 0 until FmFullConstants.FFT_DATA_BLOCK_SIZE_LPF_48K_15K_3K1) {
+                            val lmr = fft48kBuf.get() * (1f / FmFullConstants.FFT_SIZE_48K_300K)
+                            val lpr = outputBuffer.get(outputBuffer.position())
+                            outputBuffer.put((lpr + lmr) * 0.5f)
+                            outputBuffer.put((lpr - lmr) * 0.5f)
+                        }
+                        outputBuffer.clear()
+                    }
+                }
+                inputBuffer.position(FmFullConstants.FFT_DATA_BLOCK_SIZE_48K_300K)
+                inputBuffer.compact()
+                for (i in 0 until outputBuffer.capacity() step 2) {
+                    outputBuffer.put(i, deemphasisLeft.process(outputBuffer.get(i)))
+                }
+                for (i in 1 until outputBuffer.capacity() step 2) {
+                    outputBuffer.put(i, deemphasisRight.process(outputBuffer.get(i)))
+                }
+                consumer.accept(stereoPilot, outputBuffer)
+            }
+        }
+    }
+
+    fun flush(stereo: Boolean, consumer: BiConsumer<Boolean, FloatBuffer>) {
+        process(FloatBuffer.allocate(inputBuffer.remaining()), stereo, consumer)
+    }
+
+    companion object {
+        private val fft300k = FloatFFT_1D(FmFullConstants.FFT_SIZE_48K_300K.toLong())
+        private val fft48k = FloatFFT_1D(FmFullConstants.FFT_SIZE_LPF_48K_15K_3K1.toLong())
+        private val fir48kLpf = FloatBuffer.allocate(FmFullConstants.FFT_SIZE_LPF_48K_15K_3K1)
+
+        init {
+            fir48kLpf.put(0, FmFullConstants.FIR_LPF_48K_15K_3K1)
+            fft48k.realForward(fir48kLpf.array())
+        }
+    }
+}
\ No newline at end of file
diff --git a/src/client/kotlin/space/autistic/radio/client/fmsim/FmFullMixer.kt b/src/client/kotlin/space/autistic/radio/client/fmsim/FmFullMixer.kt
new file mode 100644
index 0000000..567d93f
--- /dev/null
+++ b/src/client/kotlin/space/autistic/radio/client/fmsim/FmFullMixer.kt
@@ -0,0 +1,4 @@
+package space.autistic.radio.client.fmsim
+
+class FmFullMixer {
+}
\ No newline at end of file
diff --git a/src/client/kotlin/space/autistic/radio/client/fmsim/FmFullModulator.kt b/src/client/kotlin/space/autistic/radio/client/fmsim/FmFullModulator.kt
new file mode 100644
index 0000000..65e208a
--- /dev/null
+++ b/src/client/kotlin/space/autistic/radio/client/fmsim/FmFullModulator.kt
@@ -0,0 +1,170 @@
+package space.autistic.radio.client.fmsim
+
+import org.joml.Vector2f
+import space.autistic.radio.client.complex.cmul
+import space.autistic.radio.client.complex.conjugate
+import space.autistic.radio.client.dsp.Biquad1stOrder
+import java.nio.FloatBuffer
+import java.util.function.Consumer
+import org.jtransforms.fft.FloatFFT_1D
+import space.autistic.radio.client.complex.I
+import kotlin.math.max
+import kotlin.math.min
+
+class FmFullModulator {
+    private val leftPlusRight = FloatBuffer.allocate(FmFullConstants.FFT_SIZE_LPF_48K_15K_3K1)
+    private val leftMinusRight = FloatBuffer.allocate(FmFullConstants.FFT_SIZE_LPF_48K_15K_3K1)
+    private val biquadLeft = Biquad1stOrder(
+        FmFullConstants.FM_PREEMPAHSIS_B0_48K,
+        FmFullConstants.FM_PREEMPHASIS_B1_48K,
+        FmFullConstants.FM_PREEMPHASIS_A1_48K
+    )
+    private val biquadRight = Biquad1stOrder(
+        FmFullConstants.FM_PREEMPAHSIS_B0_48K,
+        FmFullConstants.FM_PREEMPHASIS_B1_48K,
+        FmFullConstants.FM_PREEMPHASIS_A1_48K
+    )
+    private val fft48kBuffer = FloatBuffer.allocate(FmFullConstants.FFT_SIZE_LPF_48K_15K_3K1)
+    private val mixingBuffer = FloatBuffer.allocate(FmFullConstants.FFT_SIZE_48K_300K)
+    private val outputBuffer = FloatBuffer.allocate(2 * FmFullConstants.FFT_DATA_BLOCK_SIZE_48K_300K)
+    private val stereoPilot = FloatBuffer.wrap(FmFullConstants.BUFFER_19K_300K)
+
+    private val cycle19k = Vector2f(0f, 1f)
+    private var lastSum = 0f
+
+    init {
+        // pre-pad the buffers
+        leftPlusRight.position(FmFullConstants.FFT_OVERLAP_LPF_48K_15K_3K1)
+        leftMinusRight.position(FmFullConstants.FFT_OVERLAP_LPF_48K_15K_3K1)
+    }
+
+    /**
+     * Takes in samples at 48kHz, interleaved stereo (even when set to MONO), and processes them for output.
+     *
+     * Calls consumer with processed samples at 300kHz in I/Q format.
+     */
+    fun process(input: FloatBuffer, power: Float, stereo: Boolean, consumer: Consumer<FloatBuffer>) {
+        while (input.remaining() >= 2) {
+            while (input.remaining() >= 2 && leftPlusRight.hasRemaining()) {
+                // FIXME AGC (currently clamping/clipping)
+                val left = min(max(biquadLeft.process(input.get()), -1f), 1f)
+                val right = min(max(biquadRight.process(input.get()), -1f), 1f)
+                leftPlusRight.put(left + right)
+                leftMinusRight.put(left - right)
+            }
+            if (!leftPlusRight.hasRemaining()) {
+                // zero the mixing buffer
+                for (i in 0 until mixingBuffer.capacity()) {
+                    mixingBuffer.put(i, 0f)
+                }
+                fft48kBuffer.put(0, leftPlusRight, 0, FmFullConstants.FFT_SIZE_LPF_48K_15K_3K1)
+                fft48k.realForward(fft48kBuffer.array())
+                fft48kBuffer.array().forEachIndexed { index, fl ->
+                    fft48kBuffer.put(
+                        index,
+                        0.4f / FmFullConstants.FFT_SIZE_LPF_48K_15K_3K1 * fl
+                    )
+                }
+                val z = Vector2f()
+                val w = Vector2f()
+                for (i in 2 until (FmFullConstants.FREQUENCY_MIXING_BINS_38K - 2 and 1.inv()) step 2) {
+                    z.x = fft48kBuffer.get(i)
+                    z.y = fft48kBuffer.get(i + 1)
+                    w.x = fir48kLpf.get(i)
+                    w.y = fir48kLpf.get(i + 1)
+                    z.cmul(w)
+                    mixingBuffer.put(i, z.x)
+                    mixingBuffer.put(i + 1, z.y)
+                }
+                mixingBuffer.put(0, fft48kBuffer.get(0) * fir48kLpf.get(0))
+                if (stereo) {
+                    fft48kBuffer.put(0, leftMinusRight, 0, FmFullConstants.FFT_SIZE_LPF_48K_15K_3K1)
+                    fft48k.realForward(fft48kBuffer.array())
+                    fft48kBuffer.array().forEachIndexed { index, fl ->
+                        fft48kBuffer.put(
+                            index,
+                            0.2f / FmFullConstants.FFT_SIZE_LPF_48K_15K_3K1 * fl
+                        )
+                    }
+                    val base = FmFullConstants.FREQUENCY_MIXING_BINS_38K * 2
+                    for (i in 2 until (FmFullConstants.FREQUENCY_MIXING_BINS_38K - 2 and 1.inv()) step 2) {
+                        z.x = fft48kBuffer.get(i)
+                        z.y = fft48kBuffer.get(i + 1)
+                        w.x = fir48kLpf.get(i)
+                        w.y = fir48kLpf.get(i + 1)
+                        z.cmul(w)
+                        mixingBuffer.put(base + i, z.x)
+                        mixingBuffer.put(base + i + 1, z.y)
+                    }
+                    mixingBuffer.put(base, fft48kBuffer.get(0) * fir48kLpf.get(0))
+                    // cycle (phase offset) is frequency-doubled the 19k carrier
+                    // but we need to add a 90deg rotation because ???
+                    // TODO check if this is mathematically sound
+                    val cycle = cycle19k.cmul(cycle19k, Vector2f()).cmul(I)
+                    // bandwidth we care about is about half of 38k, so just, well, half it
+                    for (i in 2 until (FmFullConstants.FREQUENCY_MIXING_BINS_38K - 2 and 1.inv()) step 2) {
+                        z.x = mixingBuffer.get(base + i)
+                        z.y = mixingBuffer.get(base + i + 1)
+                        // we also need the conjugate
+                        z.conjugate(w)
+                        z.cmul(cycle)
+                        w.cmul(cycle)
+                        mixingBuffer.put(base + i, z.x)
+                        mixingBuffer.put(base + i + 1, z.y)
+                        mixingBuffer.put(base - i, mixingBuffer.get(base - i) + w.x)
+                        mixingBuffer.put(base - i + 1, mixingBuffer.get(base - i + 1) + w.y)
+                    }
+                    // handle 38kHz itself
+                    z.x = mixingBuffer.get(base)
+                    z.y = mixingBuffer.get(base + 1)
+                    z.cmul(cycle)
+                    mixingBuffer.put(base, z.x)
+                    mixingBuffer.put(base + 1, z.y)
+                    // add pilot
+                    mixingBuffer.put(
+                        FmFullConstants.FREQUENCY_MIXING_BINS_38K,
+                        75f / FmFullConstants.FFT_SIZE_48K_300K * cycle19k.x
+                    )
+                    mixingBuffer.put(
+                        FmFullConstants.FREQUENCY_MIXING_BINS_38K + 1,
+                        75f / FmFullConstants.FFT_SIZE_48K_300K * cycle19k.y
+                    )
+                    // phase correction factors (due to dropping 225 bins)
+                    cycle19k.cmul(I.conjugate())
+                }
+                // mark data block as processed
+                leftPlusRight.position(FmFullConstants.FFT_DATA_BLOCK_SIZE_LPF_48K_15K_3K1)
+                leftMinusRight.position(FmFullConstants.FFT_DATA_BLOCK_SIZE_LPF_48K_15K_3K1)
+                leftPlusRight.compact()
+                leftMinusRight.compact()
+                fft300k.realInverse(mixingBuffer.array(), false)
+                outputBuffer.clear()
+                var sum = lastSum
+                for (i in FmFullConstants.FFT_OVERLAP_48K_300K until FmFullConstants.FFT_SIZE_48K_300K) {
+                    sum += mixingBuffer.get(i) * FmFullConstants.CORRECTION_FACTOR
+                    outputBuffer.put(org.joml.Math.cos(sum) * power)
+                    outputBuffer.put(org.joml.Math.sin(sum) * power)
+                }
+                lastSum = sum % (2 * Math.PI).toFloat()
+                outputBuffer.clear()
+                consumer.accept(outputBuffer)
+            }
+        }
+        input.compact()
+    }
+
+    fun flush(power: Float, stereo: Boolean, consumer: Consumer<FloatBuffer>) {
+        process(FloatBuffer.allocate(2 * leftPlusRight.remaining()), power, stereo, consumer)
+    }
+
+    companion object {
+        private val fft48k = FloatFFT_1D(FmFullConstants.FFT_SIZE_LPF_48K_15K_3K1.toLong())
+        private val fft300k = FloatFFT_1D(FmFullConstants.FFT_SIZE_48K_300K.toLong())
+        private val fir48kLpf = FloatBuffer.allocate(FmFullConstants.FFT_SIZE_LPF_48K_15K_3K1)
+
+        init {
+            fir48kLpf.put(0, FmFullConstants.FIR_LPF_48K_15K_3K1)
+            fft48k.realForward(fir48kLpf.array())
+        }
+    }
+}
\ No newline at end of file
diff --git a/src/client/kotlin/space/autistic/radio/client/irc/IRC.kt b/src/client/kotlin/space/autistic/radio/client/irc/IRC.kt
new file mode 100644
index 0000000..ee9a99c
--- /dev/null
+++ b/src/client/kotlin/space/autistic/radio/client/irc/IRC.kt
@@ -0,0 +1,7 @@
+package space.autistic.radio.client.irc
+
+/**
+ * Internet Radio Client.
+ */
+class IRC {
+}
\ No newline at end of file
diff --git a/src/client/kotlin/space/autistic/radio/client/opus/OpusDecoder.kt b/src/client/kotlin/space/autistic/radio/client/opus/OpusDecoder.kt
new file mode 100644
index 0000000..fe78393
--- /dev/null
+++ b/src/client/kotlin/space/autistic/radio/client/opus/OpusDecoder.kt
@@ -0,0 +1,77 @@
+package space.autistic.radio.client.opus
+
+import com.dylibso.chicory.runtime.ByteBufferMemory
+import space.autistic.radio.client.reflection.getBuffer
+import java.nio.ByteOrder
+
+class OpusDecoder(sampleRate: Int, private val channels: Int) {
+    private val instance = OpusFactory()
+
+    init {
+        instance.export("_initialize").apply()
+    }
+
+    private val errorPtr = instance.export("malloc").apply(4)[0]
+
+    init {
+        if (errorPtr == 0L) {
+            throw IllegalStateException()
+        }
+        instance.memory().writeI32(errorPtr.toInt(), 0)
+    }
+
+    private val decoder =
+        instance.export("opus_decoder_create").apply(sampleRate.toLong(), channels.toLong(), errorPtr)[0]
+
+    init {
+        val error = instance.memory().readI32(errorPtr.toInt())
+        if (error < 0) {
+            throw IllegalStateException(
+                instance.memory().readCString(instance.export("opus_strerror").apply(error)[0].toInt())
+            )
+        }
+    }
+
+    private val opusDecodeFloat = instance.export("opus_decode_float")
+
+    private val outBuf = instance.export("malloc").apply((4 * MAX_FRAME_SIZE * channels).toLong())[0]
+
+    init {
+        if (outBuf == 0L) {
+            throw IllegalStateException()
+        }
+    }
+
+    private val cbits = instance.export("malloc").apply(MAX_PACKET_SIZE.toLong())[0]
+
+    init {
+        if (cbits == 0L) {
+            throw IllegalStateException()
+        }
+    }
+
+    private val memory = instance.memory() as ByteBufferMemory
+
+    fun decode(packet: ByteArray): FloatArray {
+        if (packet.size > MAX_PACKET_SIZE) {
+            throw IllegalArgumentException("packet too big")
+        }
+        memory.getBuffer().put(cbits.toInt(), packet)
+        val decoded =
+            opusDecodeFloat.apply(decoder, cbits, packet.size.toLong(), outBuf, MAX_FRAME_SIZE.toLong(), 0L)[0]
+        if (decoded < 0L) {
+            throw IllegalStateException(
+                instance.memory().readCString(instance.export("opus_strerror").apply(decoded)[0].toInt())
+            )
+        }
+        val out = FloatArray(decoded.toInt())
+        memory.getBuffer().slice(outBuf.toInt(), outBuf.toInt() + 4 * channels * decoded.toInt())
+            .order(ByteOrder.LITTLE_ENDIAN).asFloatBuffer().get(out)
+        return out
+    }
+
+    companion object {
+        const val MAX_FRAME_SIZE = 6 * 960
+        const val MAX_PACKET_SIZE = 3 * 1276
+    }
+}
\ No newline at end of file
diff --git a/src/client/kotlin/space/autistic/radio/client/opus/OpusFactory.kt b/src/client/kotlin/space/autistic/radio/client/opus/OpusFactory.kt
new file mode 100644
index 0000000..1562a57
--- /dev/null
+++ b/src/client/kotlin/space/autistic/radio/client/opus/OpusFactory.kt
@@ -0,0 +1,26 @@
+package space.autistic.radio.client.opus
+
+import com.dylibso.chicory.experimental.aot.AotMachineFactory
+import com.dylibso.chicory.runtime.ImportValues
+import com.dylibso.chicory.runtime.Instance
+import com.dylibso.chicory.wasm.Parser
+import net.fabricmc.loader.api.FabricLoader
+import java.io.InputStream
+
+object OpusFactory : () -> Instance {
+	private val defaultImports = ImportValues.builder().build()
+	private val module = Parser.parse(getModuleInputStream())
+	private val instanceBuilder =
+		Instance.builder(module)
+			.withMachineFactory(AotMachineFactory(module))
+			.withImportValues(defaultImports)
+
+	override fun invoke(): Instance = instanceBuilder.build()
+
+	private fun getModuleInputStream(): InputStream {
+		return FabricLoader.getInstance().getModContainer("pirate-radio").flatMap { it.findPath("opus.wasm") }
+			.map<InputStream?> { it.toFile().inputStream() }.orElseGet {
+				this.javaClass.getResourceAsStream("/opus.wasm")
+			}
+	}
+}
\ No newline at end of file
diff --git a/src/client/kotlin/space/autistic/radio/client/reflection/MemoryReflection.kt b/src/client/kotlin/space/autistic/radio/client/reflection/MemoryReflection.kt
new file mode 100644
index 0000000..b1da224
--- /dev/null
+++ b/src/client/kotlin/space/autistic/radio/client/reflection/MemoryReflection.kt
@@ -0,0 +1,14 @@
+package space.autistic.radio.client.reflection
+
+import com.dylibso.chicory.runtime.ByteBufferMemory
+import java.lang.invoke.MethodHandles
+import java.nio.ByteBuffer
+
+fun ByteBufferMemory.getBuffer(): ByteBuffer {
+    return MemoryReflection.buffer.get(this) as ByteBuffer
+}
+
+object MemoryReflection {
+    val buffer = MethodHandles.privateLookupIn(ByteBufferMemory::class.java, MethodHandles.lookup())
+        .findVarHandle(ByteBufferMemory::class.java, "buffer", ByteBuffer::class.java)
+}
\ No newline at end of file