summary refs log tree commit diff stats
path: root/src/main/kotlin/space/autistic
diff options
context:
space:
mode:
Diffstat (limited to 'src/main/kotlin/space/autistic')
-rw-r--r--src/main/kotlin/space/autistic/radio/CommonProxy.kt10
-rw-r--r--src/main/kotlin/space/autistic/radio/PirateRadio.kt59
-rw-r--r--src/main/kotlin/space/autistic/radio/PirateRadioComponents.kt30
-rw-r--r--src/main/kotlin/space/autistic/radio/PirateRadioEntityTypes.kt26
-rw-r--r--src/main/kotlin/space/autistic/radio/PirateRadioItems.kt23
-rw-r--r--src/main/kotlin/space/autistic/radio/PirateRadioRegistries.kt12
-rw-r--r--src/main/kotlin/space/autistic/radio/PirateRadioRegistryKeys.kt13
-rw-r--r--src/main/kotlin/space/autistic/radio/SidedProxy.kt9
-rw-r--r--src/main/kotlin/space/autistic/radio/antenna/Antenna.kt9
-rw-r--r--src/main/kotlin/space/autistic/radio/antenna/AntennaSerializer.kt8
-rw-r--r--src/main/kotlin/space/autistic/radio/antenna/ConstAntenna.kt7
-rw-r--r--src/main/kotlin/space/autistic/radio/antenna/PirateRadioAntennaSerializers.kt18
-rw-r--r--src/main/kotlin/space/autistic/radio/antenna/WasmAntenna.kt7
-rw-r--r--src/main/kotlin/space/autistic/radio/cli/OfflineSimulator.kt237
-rw-r--r--src/main/kotlin/space/autistic/radio/complex/Complex.kt32
-rw-r--r--src/main/kotlin/space/autistic/radio/dsp/Biquad1stOrder.kt11
-rw-r--r--src/main/kotlin/space/autistic/radio/entity/DisposableTransmitterEntity.kt178
-rw-r--r--src/main/kotlin/space/autistic/radio/entity/ElectronicsTraderEntity.kt4
-rw-r--r--src/main/kotlin/space/autistic/radio/fmsim/FmFullConstants.kt114
-rw-r--r--src/main/kotlin/space/autistic/radio/fmsim/FmFullDemodulator.kt158
-rw-r--r--src/main/kotlin/space/autistic/radio/fmsim/FmFullMixer.kt4
-rw-r--r--src/main/kotlin/space/autistic/radio/fmsim/FmFullModulator.kt171
-rw-r--r--src/main/kotlin/space/autistic/radio/item/DisposableTransmitterItem.kt56
-rw-r--r--src/main/kotlin/space/autistic/radio/item/StorageCardItem.kt20
-rw-r--r--src/main/kotlin/space/autistic/radio/network/StorageCardUpdateC2SPayload.kt27
-rw-r--r--src/main/kotlin/space/autistic/radio/opus/OpusDecoder.kt77
-rw-r--r--src/main/kotlin/space/autistic/radio/opus/OpusFactory.kt26
-rw-r--r--src/main/kotlin/space/autistic/radio/wasm/Bindings.kt178
-rw-r--r--src/main/kotlin/space/autistic/radio/wasm/WasmExitException.kt4
29 files changed, 678 insertions, 850 deletions
diff --git a/src/main/kotlin/space/autistic/radio/CommonProxy.kt b/src/main/kotlin/space/autistic/radio/CommonProxy.kt
new file mode 100644
index 0000000..76a4b22
--- /dev/null
+++ b/src/main/kotlin/space/autistic/radio/CommonProxy.kt
@@ -0,0 +1,10 @@
+package space.autistic.radio
+
+import net.minecraft.entity.player.PlayerEntity
+import net.minecraft.item.ItemStack
+import net.minecraft.util.Hand
+
+open class CommonProxy : SidedProxy {
+    override fun useStorageCard(player: PlayerEntity, item: ItemStack, hand: Hand) {
+    }
+}
\ No newline at end of file
diff --git a/src/main/kotlin/space/autistic/radio/PirateRadio.kt b/src/main/kotlin/space/autistic/radio/PirateRadio.kt
index 54d0b9f..2a57d10 100644
--- a/src/main/kotlin/space/autistic/radio/PirateRadio.kt
+++ b/src/main/kotlin/space/autistic/radio/PirateRadio.kt
@@ -1,17 +1,58 @@
 package space.autistic.radio
 
+import com.mojang.serialization.Codec
 import net.fabricmc.api.ModInitializer
+import net.fabricmc.fabric.api.event.registry.DynamicRegistries
+import net.fabricmc.fabric.api.networking.v1.PayloadTypeRegistry
+import net.fabricmc.fabric.api.networking.v1.ServerPlayNetworking
+import net.minecraft.item.ItemStack
+import net.minecraft.registry.Registry
+import net.minecraft.registry.RegistryKey
+import net.minecraft.text.Text
+import net.minecraft.util.Identifier
 import org.slf4j.LoggerFactory
+import space.autistic.radio.antenna.Antenna
+import space.autistic.radio.antenna.PirateRadioAntennaSerializers
+import space.autistic.radio.network.StorageCardUpdateC2SPayload
+import kotlin.math.max
+import kotlin.math.min
 
 object PirateRadio : ModInitializer {
-	const val MOD_ID = "pirate-radio"
-	private val logger = LoggerFactory.getLogger(MOD_ID)
+    var proxy: SidedProxy? = null
+    const val MOD_ID = "pirate-radio"
+    val logger = LoggerFactory.getLogger(MOD_ID)
 
-	override fun onInitialize() {
-		logger.info("This project is made with love by a queer trans person.\n" +
-				"The folks of these identities who have contributed to the project would like to make their identities known:\n" +
-				"Autgender; Not a person; Anticapitalist; Genderqueer; Trans.")
-		PirateRadioItems.initialize()
-		PirateRadioEntityTypes.initialize()
-	}
+
+    override fun onInitialize() {
+        if (proxy == null) {
+            proxy = CommonProxy()
+        }
+        logger.info(
+            "This project is made with love by a queer trans person.\n" +
+                    "The folks of these identities who have contributed to the project would like to make their identities known:\n" +
+                    "Autgender; Not a person; Anticapitalist; Genderqueer; Trans."
+        )
+        PirateRadioRegistries.initialize()
+        PirateRadioAntennaSerializers.initialize()
+        DynamicRegistries.registerSynced(PirateRadioRegistryKeys.ANTENNA, Antenna.CODEC)
+        PayloadTypeRegistry.playC2S()
+            .register(StorageCardUpdateC2SPayload.PAYLOAD_ID, StorageCardUpdateC2SPayload.CODEC)
+        ServerPlayNetworking.registerGlobalReceiver(StorageCardUpdateC2SPayload.PAYLOAD_ID) { payload, context ->
+            if (!context.player().isAlive) {
+                return@registerGlobalReceiver
+            }
+            val stack = context.player().inventory.getStack(payload.slot)
+            if (stack.isOf(PirateRadioItems.STORAGE_CARD)) {
+                stack.set(PirateRadioComponents.FREQUENCY, min(1000, max(768, payload.frequency)))
+                var text = payload.text
+                if (text.length > 16384) {
+                    text = text.substring(0, 16384)
+                }
+                stack.set(PirateRadioComponents.MESSAGE, Text.literal(text))
+            }
+        }
+        PirateRadioComponents.initialize()
+        PirateRadioItems.initialize()
+        PirateRadioEntityTypes.initialize()
+    }
 }
\ No newline at end of file
diff --git a/src/main/kotlin/space/autistic/radio/PirateRadioComponents.kt b/src/main/kotlin/space/autistic/radio/PirateRadioComponents.kt
new file mode 100644
index 0000000..f79f26a
--- /dev/null
+++ b/src/main/kotlin/space/autistic/radio/PirateRadioComponents.kt
@@ -0,0 +1,30 @@
+package space.autistic.radio
+
+import net.minecraft.component.ComponentType
+import net.minecraft.network.codec.PacketCodecs
+import net.minecraft.registry.Registries
+import net.minecraft.registry.Registry
+import net.minecraft.text.Text
+import net.minecraft.text.TextCodecs
+import net.minecraft.util.Identifier
+import net.minecraft.util.dynamic.Codecs
+
+object PirateRadioComponents {
+    val FREQUENCY = Registry.register(
+        Registries.DATA_COMPONENT_TYPE,
+        Identifier.of(PirateRadio.MOD_ID, "frequency"),
+        ComponentType.builder<Int>().codec(
+            Codecs.rangedInt(768, 1080)
+        ).packetCodec(PacketCodecs.VAR_INT).build()
+    )
+
+    val MESSAGE = Registry.register(
+        Registries.DATA_COMPONENT_TYPE,
+        Identifier.of(PirateRadio.MOD_ID, "message"),
+        ComponentType.builder<Text>().codec(TextCodecs.STRINGIFIED_CODEC).packetCodec(TextCodecs.REGISTRY_PACKET_CODEC)
+            .cache().build()
+    )
+
+    fun initialize() {
+    }
+}
\ No newline at end of file
diff --git a/src/main/kotlin/space/autistic/radio/PirateRadioEntityTypes.kt b/src/main/kotlin/space/autistic/radio/PirateRadioEntityTypes.kt
index f147394..3fbb34f 100644
--- a/src/main/kotlin/space/autistic/radio/PirateRadioEntityTypes.kt
+++ b/src/main/kotlin/space/autistic/radio/PirateRadioEntityTypes.kt
@@ -11,13 +11,31 @@ import net.minecraft.registry.RegistryKey
 import net.minecraft.registry.RegistryKeys
 import net.minecraft.util.Identifier
 import space.autistic.radio.entity.ElectronicsTraderEntity
+import space.autistic.radio.entity.DisposableTransmitterEntity
 
 object PirateRadioEntityTypes {
-    val ELECTRONICS_TRADER_KEY = RegistryKey.of(RegistryKeys.ENTITY_TYPE, Identifier.of(PirateRadio.MOD_ID, "electronics-trader"))
-    val ELECTRONICS_TRADER = register(EntityType.Builder.create(::ElectronicsTraderEntity, SpawnGroup.MISC).dimensions(0.6F, 1.95F).eyeHeight(1.62F).maxTrackingRange(10), ELECTRONICS_TRADER_KEY)
+    val ELECTRONICS_TRADER_KEY =
+        RegistryKey.of(RegistryKeys.ENTITY_TYPE, Identifier.of(PirateRadio.MOD_ID, "electronics-trader"))
+    val ELECTRONICS_TRADER = register(
+        EntityType.Builder.create(::ElectronicsTraderEntity, SpawnGroup.MISC).dimensions(0.6F, 1.95F).eyeHeight(1.62F)
+            .maxTrackingRange(10), ELECTRONICS_TRADER_KEY
+    )
 
-    fun <T : Entity> register(entityTypeBuilder: EntityType.Builder<T>, registryKey: RegistryKey<EntityType<*>>): EntityType<T> {
-        return Registry.register(Registries.ENTITY_TYPE, registryKey.value, entityTypeBuilder.build(registryKey.value.path))
+    val DISPOSABLE_TRANSMITTER_KEY = RegistryKey.of(RegistryKeys.ENTITY_TYPE, Identifier.of(PirateRadio.MOD_ID, "disposable-transmitter"))
+    val DISPOSABLE_TRANSMITTER = register(
+        EntityType.Builder.create(::DisposableTransmitterEntity, SpawnGroup.MISC).dimensions(0.5F, 0.5F).eyeHeight(0F)
+            .maxTrackingRange(10).trackingTickInterval(Integer.MAX_VALUE), DISPOSABLE_TRANSMITTER_KEY
+    )
+
+    fun <T : Entity> register(
+        entityTypeBuilder: EntityType.Builder<T>,
+        registryKey: RegistryKey<EntityType<*>>
+    ): EntityType<T> {
+        return Registry.register(
+            Registries.ENTITY_TYPE,
+            registryKey.value,
+            entityTypeBuilder.build(registryKey.value.path)
+        )
     }
 
     fun initialize() {
diff --git a/src/main/kotlin/space/autistic/radio/PirateRadioItems.kt b/src/main/kotlin/space/autistic/radio/PirateRadioItems.kt
index 490acaf..e00e4e6 100644
--- a/src/main/kotlin/space/autistic/radio/PirateRadioItems.kt
+++ b/src/main/kotlin/space/autistic/radio/PirateRadioItems.kt
@@ -2,12 +2,16 @@ package space.autistic.radio
 
 import net.fabricmc.fabric.api.itemgroup.v1.ItemGroupEvents
 import net.minecraft.item.Item
+import net.minecraft.item.ItemFrameItem
 import net.minecraft.item.ItemGroups
 import net.minecraft.registry.Registries
 import net.minecraft.registry.Registry
 import net.minecraft.registry.RegistryKey
 import net.minecraft.registry.RegistryKeys
+import net.minecraft.text.Text
 import net.minecraft.util.Identifier
+import space.autistic.radio.item.DisposableTransmitterItem
+import space.autistic.radio.item.StorageCardItem
 
 object PirateRadioItems {
     val SBC_KEY = RegistryKey.of(RegistryKeys.ITEM, Identifier.of(PirateRadio.MOD_ID, "sbc"))
@@ -17,11 +21,20 @@ object PirateRadioItems {
     val POWERBANK_KEY = RegistryKey.of(RegistryKeys.ITEM, Identifier.of(PirateRadio.MOD_ID, "powerbank"))
     val POWERBANK = register(Item(Item.Settings()), POWERBANK_KEY)
     val STORAGE_CARD_KEY = RegistryKey.of(RegistryKeys.ITEM, Identifier.of(PirateRadio.MOD_ID, "storage-card"))
-    val STORAGE_CARD = register(Item(Item.Settings()), STORAGE_CARD_KEY)
-    val DISPOSABLE_TRANSMITTER_KEY = RegistryKey.of(RegistryKeys.ITEM, Identifier.of(PirateRadio.MOD_ID, "disposable-transmitter"))
-    val DISPOSABLE_TRANSMITTER = register(Item(Item.Settings()), DISPOSABLE_TRANSMITTER_KEY)
-    val FM_RECEIVER_KEY = RegistryKey.of(RegistryKeys.ITEM, Identifier.of(PirateRadio.MOD_ID, "fm-receiver"))
-    val FM_RECEIVER = register(Item(Item.Settings()), FM_RECEIVER_KEY)
+    val STORAGE_CARD = register(
+        StorageCardItem(
+            Item.Settings().maxCount(1).component(PirateRadioComponents.FREQUENCY, 768)
+                .component(PirateRadioComponents.MESSAGE, Text.literal(""))
+        ), STORAGE_CARD_KEY
+    )
+    val DISPOSABLE_TRANSMITTER_KEY =
+        RegistryKey.of(RegistryKeys.ITEM, Identifier.of(PirateRadio.MOD_ID, "disposable-transmitter"))
+    val DISPOSABLE_TRANSMITTER = register(
+        DisposableTransmitterItem(PirateRadioEntityTypes.DISPOSABLE_TRANSMITTER, Item.Settings()),
+        DISPOSABLE_TRANSMITTER_KEY
+    )
+//    val FM_RECEIVER_KEY = RegistryKey.of(RegistryKeys.ITEM, Identifier.of(PirateRadio.MOD_ID, "fm-receiver"))
+//    val FM_RECEIVER = register(Item(Item.Settings()), FM_RECEIVER_KEY)
 
     fun register(item: Item, registryKey: RegistryKey<Item>): Item {
         return Registry.register(Registries.ITEM, registryKey.value, item)
diff --git a/src/main/kotlin/space/autistic/radio/PirateRadioRegistries.kt b/src/main/kotlin/space/autistic/radio/PirateRadioRegistries.kt
new file mode 100644
index 0000000..6010d3c
--- /dev/null
+++ b/src/main/kotlin/space/autistic/radio/PirateRadioRegistries.kt
@@ -0,0 +1,12 @@
+package space.autistic.radio
+
+import net.fabricmc.fabric.api.event.registry.FabricRegistryBuilder
+import net.fabricmc.fabric.api.event.registry.RegistryAttribute
+
+object PirateRadioRegistries {
+    val ANTENNA_SERIALIZER = FabricRegistryBuilder.createSimple(PirateRadioRegistryKeys.ANTENNA_SERIALIZER)
+        .attribute(RegistryAttribute.SYNCED).buildAndRegister()
+
+    fun initialize() {
+    }
+}
\ No newline at end of file
diff --git a/src/main/kotlin/space/autistic/radio/PirateRadioRegistryKeys.kt b/src/main/kotlin/space/autistic/radio/PirateRadioRegistryKeys.kt
new file mode 100644
index 0000000..eb5db1f
--- /dev/null
+++ b/src/main/kotlin/space/autistic/radio/PirateRadioRegistryKeys.kt
@@ -0,0 +1,13 @@
+package space.autistic.radio
+
+import net.minecraft.registry.Registry
+import net.minecraft.registry.RegistryKey
+import net.minecraft.util.Identifier
+import space.autistic.radio.PirateRadio.MOD_ID
+import space.autistic.radio.antenna.Antenna
+import space.autistic.radio.antenna.AntennaSerializer
+
+object PirateRadioRegistryKeys {
+    val ANTENNA_SERIALIZER = RegistryKey.ofRegistry<AntennaSerializer<*>>(Identifier.of(MOD_ID, "antenna_serializer"))
+    val ANTENNA = RegistryKey.ofRegistry<Antenna<*>>(Identifier.of(MOD_ID, "antenna"))
+}
\ No newline at end of file
diff --git a/src/main/kotlin/space/autistic/radio/SidedProxy.kt b/src/main/kotlin/space/autistic/radio/SidedProxy.kt
new file mode 100644
index 0000000..0e34ec9
--- /dev/null
+++ b/src/main/kotlin/space/autistic/radio/SidedProxy.kt
@@ -0,0 +1,9 @@
+package space.autistic.radio
+
+import net.minecraft.entity.player.PlayerEntity
+import net.minecraft.item.ItemStack
+import net.minecraft.util.Hand
+
+interface SidedProxy {
+    fun useStorageCard(player: PlayerEntity, item: ItemStack, hand: Hand)
+}
\ No newline at end of file
diff --git a/src/main/kotlin/space/autistic/radio/antenna/Antenna.kt b/src/main/kotlin/space/autistic/radio/antenna/Antenna.kt
new file mode 100644
index 0000000..c403081
--- /dev/null
+++ b/src/main/kotlin/space/autistic/radio/antenna/Antenna.kt
@@ -0,0 +1,9 @@
+package space.autistic.radio.antenna
+
+import space.autistic.radio.PirateRadioRegistries
+
+data class Antenna<T>(val type: AntennaSerializer<T>, val data: T) {
+    companion object {
+        val CODEC = PirateRadioRegistries.ANTENNA_SERIALIZER.codec.dispatch({ it.type }, AntennaSerializer<*>::codec)
+    }
+}
\ No newline at end of file
diff --git a/src/main/kotlin/space/autistic/radio/antenna/AntennaSerializer.kt b/src/main/kotlin/space/autistic/radio/antenna/AntennaSerializer.kt
new file mode 100644
index 0000000..11d0234
--- /dev/null
+++ b/src/main/kotlin/space/autistic/radio/antenna/AntennaSerializer.kt
@@ -0,0 +1,8 @@
+package space.autistic.radio.antenna
+
+import com.mojang.serialization.MapCodec
+
+interface AntennaSerializer<T> {
+    val codec: MapCodec<Antenna<T>>
+        get
+}
diff --git a/src/main/kotlin/space/autistic/radio/antenna/ConstAntenna.kt b/src/main/kotlin/space/autistic/radio/antenna/ConstAntenna.kt
new file mode 100644
index 0000000..401972c
--- /dev/null
+++ b/src/main/kotlin/space/autistic/radio/antenna/ConstAntenna.kt
@@ -0,0 +1,7 @@
+package space.autistic.radio.antenna
+
+import com.mojang.serialization.Codec
+
+object ConstAntenna : AntennaSerializer<Float> {
+    override val codec = Codec.FLOAT.fieldOf("level").xmap({ Antenna(this, it) }, { it.data })
+}
\ No newline at end of file
diff --git a/src/main/kotlin/space/autistic/radio/antenna/PirateRadioAntennaSerializers.kt b/src/main/kotlin/space/autistic/radio/antenna/PirateRadioAntennaSerializers.kt
new file mode 100644
index 0000000..19cfff8
--- /dev/null
+++ b/src/main/kotlin/space/autistic/radio/antenna/PirateRadioAntennaSerializers.kt
@@ -0,0 +1,18 @@
+package space.autistic.radio.antenna
+
+import net.minecraft.registry.Registry
+import net.minecraft.util.Identifier
+import space.autistic.radio.PirateRadio
+import space.autistic.radio.PirateRadioRegistries
+
+object PirateRadioAntennaSerializers {
+    val CONST = register(Identifier.of(PirateRadio.MOD_ID, "const"), ConstAntenna)
+    val WASM = register(Identifier.of(PirateRadio.MOD_ID, "wasm"), WasmAntenna)
+
+    private fun <T> register(id: Identifier, antennaSerializer: AntennaSerializer<T>): AntennaSerializer<T> {
+        return Registry.register(PirateRadioRegistries.ANTENNA_SERIALIZER, id, antennaSerializer)
+    }
+
+    fun initialize() {
+    }
+}
\ No newline at end of file
diff --git a/src/main/kotlin/space/autistic/radio/antenna/WasmAntenna.kt b/src/main/kotlin/space/autistic/radio/antenna/WasmAntenna.kt
new file mode 100644
index 0000000..32c96bb
--- /dev/null
+++ b/src/main/kotlin/space/autistic/radio/antenna/WasmAntenna.kt
@@ -0,0 +1,7 @@
+package space.autistic.radio.antenna
+
+import net.minecraft.util.Identifier
+
+object WasmAntenna : AntennaSerializer<Identifier> {
+    override val codec = Identifier.CODEC.fieldOf("model").xmap({ Antenna(this, it) }, { it.data })
+}
\ No newline at end of file
diff --git a/src/main/kotlin/space/autistic/radio/cli/OfflineSimulator.kt b/src/main/kotlin/space/autistic/radio/cli/OfflineSimulator.kt
deleted file mode 100644
index bc16814..0000000
--- a/src/main/kotlin/space/autistic/radio/cli/OfflineSimulator.kt
+++ /dev/null
@@ -1,237 +0,0 @@
-package space.autistic.radio.cli
-
-import org.joml.Vector2f
-import space.autistic.radio.complex.cmul
-import space.autistic.radio.fmsim.FmFullConstants
-import space.autistic.radio.fmsim.FmFullDemodulator
-import space.autistic.radio.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/main/kotlin/space/autistic/radio/complex/Complex.kt b/src/main/kotlin/space/autistic/radio/complex/Complex.kt
deleted file mode 100644
index 918dac2..0000000
--- a/src/main/kotlin/space/autistic/radio/complex/Complex.kt
+++ /dev/null
@@ -1,32 +0,0 @@
-package space.autistic.radio.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/main/kotlin/space/autistic/radio/dsp/Biquad1stOrder.kt b/src/main/kotlin/space/autistic/radio/dsp/Biquad1stOrder.kt
deleted file mode 100644
index 8f86218..0000000
--- a/src/main/kotlin/space/autistic/radio/dsp/Biquad1stOrder.kt
+++ /dev/null
@@ -1,11 +0,0 @@
-package space.autistic.radio.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/main/kotlin/space/autistic/radio/entity/DisposableTransmitterEntity.kt b/src/main/kotlin/space/autistic/radio/entity/DisposableTransmitterEntity.kt
new file mode 100644
index 0000000..b50849d
--- /dev/null
+++ b/src/main/kotlin/space/autistic/radio/entity/DisposableTransmitterEntity.kt
@@ -0,0 +1,178 @@
+package space.autistic.radio.entity
+
+import net.minecraft.entity.Entity
+import net.minecraft.entity.EntityType
+import net.minecraft.entity.data.DataTracker
+import net.minecraft.entity.data.TrackedDataHandlerRegistry
+import net.minecraft.entity.decoration.AbstractDecorationEntity
+import net.minecraft.entity.player.PlayerEntity
+import net.minecraft.nbt.NbtCompound
+import net.minecraft.network.listener.ClientPlayPacketListener
+import net.minecraft.network.packet.Packet
+import net.minecraft.network.packet.s2c.play.EntitySpawnS2CPacket
+import net.minecraft.server.network.EntityTrackerEntry
+import net.minecraft.util.ActionResult
+import net.minecraft.util.Hand
+import net.minecraft.util.math.*
+import net.minecraft.world.World
+import net.minecraft.world.event.GameEvent
+import space.autistic.radio.PirateRadioComponents
+import space.autistic.radio.PirateRadioEntityTypes
+import space.autistic.radio.PirateRadioItems
+import kotlin.math.max
+import kotlin.math.min
+
+class DisposableTransmitterEntity : AbstractDecorationEntity {
+    var despawnDelay = 60 * 60 * 20
+
+    constructor(type: EntityType<out DisposableTransmitterEntity>?, world: World?) : super(type, world)
+
+    constructor(
+        type: EntityType<out DisposableTransmitterEntity>?,
+        world: World?,
+        pos: BlockPos,
+        facing: Direction
+    ) : super(
+        type, world, pos
+    ) {
+        this.setFacing(facing)
+    }
+
+    constructor(
+        world: World,
+        blockPos2: BlockPos,
+        direction: Direction
+    ) : this(PirateRadioEntityTypes.DISPOSABLE_TRANSMITTER, world, blockPos2, direction)
+
+    override fun initDataTracker(builder: DataTracker.Builder) {
+        builder.add(TEXT, "")
+        builder.add(FREQUENCY, 768)
+    }
+
+    override fun tick() {
+        super.tick()
+        if (!world.isClient) {
+            this.tickDespawnDelay();
+        }
+    }
+
+    private fun tickDespawnDelay() {
+        if (this.despawnDelay > 0 && --this.despawnDelay == 0) {
+            this.discard()
+        }
+    }
+
+    override fun setFacing(facing: Direction) {
+        this.facing = facing
+        if (facing.axis.isHorizontal) {
+            this.pitch = 0.0f
+            this.yaw = (this.facing.horizontal * 90).toFloat()
+        } else {
+            this.pitch = (-90 * facing.direction.offset()).toFloat()
+            this.yaw = 0.0f
+        }
+
+        this.prevPitch = this.pitch
+        this.prevYaw = this.yaw
+        this.updateAttachmentPosition()
+    }
+
+    override fun calculateBoundingBox(pos: BlockPos, side: Direction): Box {
+        val center = Vec3d.ofCenter(pos).offset(side, -(1.0 - DEPTH) / 2.0)
+        val axis = side.axis
+        val dx = if (axis === Direction.Axis.X) DEPTH else WIDTH
+        val dy = if (axis === Direction.Axis.Y) DEPTH else HEIGHT
+        val dz = if (axis === Direction.Axis.Z) DEPTH else WIDTH
+        return Box.of(center, dx, dy, dz)
+    }
+
+    // leave this true for performance
+    override fun canStayAttached(): Boolean = true
+
+    override fun onBreak(breaker: Entity?) {
+        // hmm, what to do here...
+    }
+
+    override fun onPlace() {
+        // hmm, what to do here...
+    }
+
+    var text: String
+        get() {
+            return this.dataTracker[TEXT]
+        }
+        set(value) {
+            this.dataTracker[TEXT] = value
+        }
+
+    var frequency: Int
+        get() {
+            return this.dataTracker[FREQUENCY]
+        }
+        set(value) {
+            this.dataTracker[FREQUENCY] = value
+        }
+
+    override fun writeCustomDataToNbt(nbt: NbtCompound) {
+        super.writeCustomDataToNbt(nbt)
+        nbt.putByte("Facing", this.facing.id.toByte())
+        nbt.putInt("DespawnDelay", this.despawnDelay)
+        nbt.putString("Text", this.text)
+        nbt.putBoolean("Invisible", this.isInvisible)
+        nbt.putInt("Frequency", this.frequency)
+    }
+
+    override fun readCustomDataFromNbt(nbt: NbtCompound) {
+        super.readCustomDataFromNbt(nbt)
+        this.despawnDelay = nbt.getInt("DespawnDelay")
+        this.setFacing(Direction.byId(nbt.getByte("Facing").toInt()))
+        this.text = nbt.getString("Text")
+        this.isInvisible = nbt.getBoolean("Invisible")
+        this.frequency = min(1080, max(768, nbt.getInt("Frequency")))
+    }
+
+    override fun createSpawnPacket(entityTrackerEntry: EntityTrackerEntry): Packet<ClientPlayPacketListener> {
+        return EntitySpawnS2CPacket(this, facing.id, this.getAttachedBlockPos())
+    }
+
+    override fun onSpawnPacket(packet: EntitySpawnS2CPacket) {
+        super.onSpawnPacket(packet)
+        this.setFacing(Direction.byId(packet.entityData))
+    }
+
+    override fun getBodyYaw(): Float {
+        val direction = this.horizontalFacing
+        val i = if (direction.axis.isVertical) 90 * direction.direction.offset() else 0
+        return MathHelper.wrapDegrees(180 + direction.horizontal * 90 + i).toFloat()
+    }
+
+    override fun interact(player: PlayerEntity, hand: Hand): ActionResult {
+        val itemStack = player.getStackInHand(hand)
+        val noTextInTransmitter = this.text.isEmpty()
+        val isCard = itemStack.isOf(PirateRadioItems.STORAGE_CARD)
+        val holdingMessage = isCard && (itemStack.get(PirateRadioComponents.MESSAGE)?.literalString ?: "").isNotEmpty()
+        if (!world.isClient) {
+            if (noTextInTransmitter) {
+                if (holdingMessage && !this.isRemoved) {
+                    this.frequency = itemStack.getOrDefault(PirateRadioComponents.FREQUENCY, 768)
+                    this.text = itemStack.get(PirateRadioComponents.MESSAGE)?.literalString ?: ""
+                    this.emitGameEvent(GameEvent.BLOCK_CHANGE, player)
+                    itemStack.decrementUnlessCreative(1, player)
+                }
+            }
+            return ActionResult.CONSUME
+        } else {
+            return if (noTextInTransmitter && holdingMessage) ActionResult.SUCCESS else ActionResult.PASS
+        }
+    }
+
+    companion object {
+        private val TEXT =
+            DataTracker.registerData(DisposableTransmitterEntity::class.java, TrackedDataHandlerRegistry.STRING)
+        private val FREQUENCY =
+            DataTracker.registerData(DisposableTransmitterEntity::class.java, TrackedDataHandlerRegistry.INTEGER)
+        const val DEPTH = 0.0625
+        private const val WIDTH = 0.75
+        private const val HEIGHT = 0.75
+    }
+}
\ No newline at end of file
diff --git a/src/main/kotlin/space/autistic/radio/entity/ElectronicsTraderEntity.kt b/src/main/kotlin/space/autistic/radio/entity/ElectronicsTraderEntity.kt
index 3aa53b1..1acbeb4 100644
--- a/src/main/kotlin/space/autistic/radio/entity/ElectronicsTraderEntity.kt
+++ b/src/main/kotlin/space/autistic/radio/entity/ElectronicsTraderEntity.kt
@@ -21,7 +21,7 @@ class ElectronicsTraderEntity(entityType: EntityType<out ElectronicsTraderEntity
     override fun fillRecipes() {
         val offers = this.getOffers()
         offers.add(TradeOffer(TradedItem(Items.EMERALD, 5), ItemStack(PirateRadioItems.POWERBANK), 3, 0, 0f))
-        offers.add(TradeOffer(TradedItem(Items.EMERALD, 10), ItemStack(PirateRadioItems.FM_RECEIVER), 3, 0, 0f))
+//        offers.add(TradeOffer(TradedItem(Items.EMERALD, 10), ItemStack(PirateRadioItems.FM_RECEIVER), 3, 0, 0f))
         offers.add(TradeOffer(TradedItem(Items.EMERALD, 15), ItemStack(PirateRadioItems.SBC), 3, 0, 0f))
         offers.add(TradeOffer(TradedItem(Items.EMERALD, 5), ItemStack(PirateRadioItems.STORAGE_CARD), 3, 0, 0f))
         offers.add(TradeOffer(TradedItem(Items.EMERALD, 1), ItemStack(PirateRadioItems.WIRE), 3, 0, 0f))
@@ -29,7 +29,7 @@ class ElectronicsTraderEntity(entityType: EntityType<out ElectronicsTraderEntity
 
     override fun tickMovement() {
         if (!this.world.isClient) {
-            super.setDespawnDelay(1000)
+            super.setDespawnDelay(-1)
         }
         super.tickMovement()
     }
diff --git a/src/main/kotlin/space/autistic/radio/fmsim/FmFullConstants.kt b/src/main/kotlin/space/autistic/radio/fmsim/FmFullConstants.kt
deleted file mode 100644
index 6b92328..0000000
--- a/src/main/kotlin/space/autistic/radio/fmsim/FmFullConstants.kt
+++ /dev/null
@@ -1,114 +0,0 @@
-package space.autistic.radio.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/main/kotlin/space/autistic/radio/fmsim/FmFullDemodulator.kt b/src/main/kotlin/space/autistic/radio/fmsim/FmFullDemodulator.kt
deleted file mode 100644
index de44e69..0000000
--- a/src/main/kotlin/space/autistic/radio/fmsim/FmFullDemodulator.kt
+++ /dev/null
@@ -1,158 +0,0 @@
-package space.autistic.radio.fmsim
-
-import org.joml.Vector2f
-import org.jtransforms.fft.FloatFFT_1D
-import space.autistic.radio.complex.I
-import space.autistic.radio.complex.cmul
-import space.autistic.radio.complex.conjugate
-import space.autistic.radio.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)
-            }
-        }
-    }
-
-    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/main/kotlin/space/autistic/radio/fmsim/FmFullMixer.kt b/src/main/kotlin/space/autistic/radio/fmsim/FmFullMixer.kt
deleted file mode 100644
index 654d50f..0000000
--- a/src/main/kotlin/space/autistic/radio/fmsim/FmFullMixer.kt
+++ /dev/null
@@ -1,4 +0,0 @@
-package space.autistic.radio.fmsim
-
-class FmFullMixer {
-}
\ No newline at end of file
diff --git a/src/main/kotlin/space/autistic/radio/fmsim/FmFullModulator.kt b/src/main/kotlin/space/autistic/radio/fmsim/FmFullModulator.kt
deleted file mode 100644
index 1f3849e..0000000
--- a/src/main/kotlin/space/autistic/radio/fmsim/FmFullModulator.kt
+++ /dev/null
@@ -1,171 +0,0 @@
-package space.autistic.radio.fmsim
-
-import org.joml.Vector2f
-import space.autistic.radio.complex.cmul
-import space.autistic.radio.complex.conjugate
-import space.autistic.radio.dsp.Biquad1stOrder
-import java.nio.FloatBuffer
-import java.util.function.Consumer
-import org.jtransforms.fft.FloatFFT_1D
-import space.autistic.radio.complex.I
-import kotlin.math.max
-import kotlin.math.min
-import kotlin.math.sqrt
-
-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/main/kotlin/space/autistic/radio/item/DisposableTransmitterItem.kt b/src/main/kotlin/space/autistic/radio/item/DisposableTransmitterItem.kt
new file mode 100644
index 0000000..c9a53e4
--- /dev/null
+++ b/src/main/kotlin/space/autistic/radio/item/DisposableTransmitterItem.kt
@@ -0,0 +1,56 @@
+package space.autistic.radio.item
+
+import net.minecraft.component.DataComponentTypes
+import net.minecraft.component.type.NbtComponent
+import net.minecraft.entity.EntityType
+import net.minecraft.entity.decoration.AbstractDecorationEntity
+import net.minecraft.item.ItemFrameItem
+import net.minecraft.item.ItemUsageContext
+import net.minecraft.util.ActionResult
+import net.minecraft.world.event.GameEvent
+import space.autistic.radio.PirateRadioEntityTypes
+import space.autistic.radio.entity.DisposableTransmitterEntity
+
+class DisposableTransmitterItem(
+    private val entityType: EntityType<out AbstractDecorationEntity>?,
+    settings: Settings?
+) :
+    ItemFrameItem(entityType, settings) {
+
+    override fun useOnBlock(context: ItemUsageContext): ActionResult {
+        val blockPos = context.blockPos
+        val direction = context.side
+        val blockPos2 = blockPos.offset(direction)
+        val playerEntity = context.player
+        val itemStack = context.stack
+        if (playerEntity != null && !this.canPlaceOn(playerEntity, direction, itemStack, blockPos2)) {
+            return ActionResult.FAIL
+        } else {
+            val world = context.world
+            val abstractDecorationEntity: AbstractDecorationEntity
+            if (this.entityType === PirateRadioEntityTypes.DISPOSABLE_TRANSMITTER) {
+                abstractDecorationEntity = DisposableTransmitterEntity(world, blockPos2, direction)
+            } else {
+                return ActionResult.success(world.isClient)
+            }
+
+            val nbtComponent = itemStack.getOrDefault(DataComponentTypes.ENTITY_DATA, NbtComponent.DEFAULT)
+            if (!nbtComponent.isEmpty) {
+                EntityType.loadFromEntityNbt(world, playerEntity, abstractDecorationEntity, nbtComponent)
+            }
+
+            if (abstractDecorationEntity.canStayAttached()) {
+                if (!world.isClient) {
+                    abstractDecorationEntity.onPlace()
+                    world.emitGameEvent(playerEntity, GameEvent.ENTITY_PLACE, abstractDecorationEntity.pos)
+                    world.spawnEntity(abstractDecorationEntity)
+                }
+
+                itemStack.decrement(1)
+                return ActionResult.success(world.isClient)
+            } else {
+                return ActionResult.CONSUME
+            }
+        }
+    }
+}
\ No newline at end of file
diff --git a/src/main/kotlin/space/autistic/radio/item/StorageCardItem.kt b/src/main/kotlin/space/autistic/radio/item/StorageCardItem.kt
new file mode 100644
index 0000000..da1b057
--- /dev/null
+++ b/src/main/kotlin/space/autistic/radio/item/StorageCardItem.kt
@@ -0,0 +1,20 @@
+package space.autistic.radio.item
+
+import net.minecraft.entity.player.PlayerEntity
+import net.minecraft.item.Item
+import net.minecraft.item.ItemStack
+import net.minecraft.stat.Stats
+import net.minecraft.util.Hand
+import net.minecraft.util.TypedActionResult
+import net.minecraft.world.World
+import space.autistic.radio.PirateRadio
+
+class StorageCardItem(settings: Settings) : Item(settings) {
+
+    override fun use(world: World, user: PlayerEntity, hand: Hand): TypedActionResult<ItemStack> {
+        val itemStack = user.getStackInHand(hand)
+        PirateRadio.proxy!!.useStorageCard(user, itemStack, hand)
+        user.incrementStat(Stats.USED.getOrCreateStat(this))
+        return TypedActionResult.success(itemStack, world.isClient())
+    }
+}
\ No newline at end of file
diff --git a/src/main/kotlin/space/autistic/radio/network/StorageCardUpdateC2SPayload.kt b/src/main/kotlin/space/autistic/radio/network/StorageCardUpdateC2SPayload.kt
new file mode 100644
index 0000000..9ffa75a
--- /dev/null
+++ b/src/main/kotlin/space/autistic/radio/network/StorageCardUpdateC2SPayload.kt
@@ -0,0 +1,27 @@
+package space.autistic.radio.network
+
+import net.minecraft.network.RegistryByteBuf
+import net.minecraft.network.codec.PacketCodec
+import net.minecraft.network.codec.PacketCodecs
+import net.minecraft.network.packet.CustomPayload
+import net.minecraft.util.Identifier
+import space.autistic.radio.PirateRadio
+
+@JvmRecord
+data class StorageCardUpdateC2SPayload(val slot: Int, val text: String, val frequency: Int) : CustomPayload {
+    override fun getId(): CustomPayload.Id<out CustomPayload> = PAYLOAD_ID
+
+    companion object {
+        val PAYLOAD_IDENTIFIER = Identifier.of(PirateRadio.MOD_ID, "storage-card-update")
+        val PAYLOAD_ID = CustomPayload.Id<StorageCardUpdateC2SPayload>(PAYLOAD_IDENTIFIER)
+        val CODEC: PacketCodec<RegistryByteBuf, StorageCardUpdateC2SPayload> = PacketCodec.tuple(
+            PacketCodecs.VAR_INT,
+            StorageCardUpdateC2SPayload::slot,
+            PacketCodecs.STRING,
+            StorageCardUpdateC2SPayload::text,
+            PacketCodecs.VAR_INT,
+            StorageCardUpdateC2SPayload::frequency,
+            ::StorageCardUpdateC2SPayload
+        )
+    }
+}
\ No newline at end of file
diff --git a/src/main/kotlin/space/autistic/radio/opus/OpusDecoder.kt b/src/main/kotlin/space/autistic/radio/opus/OpusDecoder.kt
deleted file mode 100644
index 56fce2b..0000000
--- a/src/main/kotlin/space/autistic/radio/opus/OpusDecoder.kt
+++ /dev/null
@@ -1,77 +0,0 @@
-package space.autistic.radio.opus
-
-import com.dylibso.chicory.runtime.ByteBufferMemory
-import space.autistic.radio.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/main/kotlin/space/autistic/radio/opus/OpusFactory.kt b/src/main/kotlin/space/autistic/radio/opus/OpusFactory.kt
deleted file mode 100644
index 70e0c3c..0000000
--- a/src/main/kotlin/space/autistic/radio/opus/OpusFactory.kt
+++ /dev/null
@@ -1,26 +0,0 @@
-package space.autistic.radio.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/main/kotlin/space/autistic/radio/wasm/Bindings.kt b/src/main/kotlin/space/autistic/radio/wasm/Bindings.kt
new file mode 100644
index 0000000..5ff4102
--- /dev/null
+++ b/src/main/kotlin/space/autistic/radio/wasm/Bindings.kt
@@ -0,0 +1,178 @@
+package space.autistic.radio.wasm
+
+import com.dylibso.chicory.runtime.HostFunction
+import com.dylibso.chicory.runtime.ImportFunction
+import com.dylibso.chicory.runtime.Instance
+import com.dylibso.chicory.wasm.types.Value
+import com.dylibso.chicory.wasm.types.ValueType
+import java.lang.invoke.MethodHandles
+import java.lang.invoke.MethodType
+import java.lang.reflect.Method
+import java.lang.reflect.ParameterizedType
+
+class Bindings {
+
+    companion object {
+        @JvmStatic
+        fun longToInt(long: Long): Int = long.toInt()
+
+        @JvmStatic
+        fun stringArg(instance: Instance, address: Long): String {
+            return instance.memory().readCString(address.toInt())
+        }
+
+        @JvmStatic
+        fun boolArg(bool: Long): Boolean {
+            return bool != 0L
+        }
+
+        @JvmStatic
+        fun intListArg(instance: Instance, argc: Long, argv: Long): List<Int> {
+            return IntArray(argc.toInt()) {
+                instance.memory().readInt(argv.toInt() + 4 * it)
+            }.toList()
+        }
+
+        private val lookup = MethodHandles.lookup()
+        fun bindFunc(
+            module: String,
+            name: String,
+            inLookup: MethodHandles.Lookup,
+            method: Method,
+            receiver: Any
+        ): ImportFunction {
+            val baseHandle = inLookup.unreflect(method).bindTo(receiver)
+            val wasmParameters = ArrayList<ValueType>()
+            val filters = method.genericParameterTypes.map {
+                when (it) {
+                    Int::class.java -> {
+                        wasmParameters.add(ValueType.I32)
+                        lookup.findStatic(
+                            Bindings::class.java, "longToInt", MethodType.methodType(Int::class.java, Long::class.java)
+                        )
+                    }
+
+                    Long::class.java -> {
+                        wasmParameters.add(ValueType.I64)
+                        MethodHandles.identity(Long::class.java)
+                    }
+
+                    Float::class.java -> {
+                        wasmParameters.add(ValueType.F32)
+                        lookup.findStatic(
+                            Value::class.java, "longToFloat", MethodType.methodType(Float::class.java, Long::class.java)
+                        )
+                    }
+
+                    Double::class.java -> {
+                        wasmParameters.add(ValueType.F64)
+                        lookup.findStatic(
+                            Value::class.java,
+                            "longToDouble",
+                            MethodType.methodType(Double::class.java, Long::class.java)
+                        )
+                    }
+
+                    String::class.java -> {
+                        wasmParameters.add(ValueType.I32)
+                        lookup.findStatic(
+                            Bindings::class.java,
+                            "stringArg",
+                            MethodType.methodType(String::class.java, Instance::class.java, Long::class.java)
+                        )
+                    }
+
+                    Boolean::class.java -> {
+                        wasmParameters.add(ValueType.I32)
+                        lookup.findStatic(
+                            Bindings::class.java,
+                            "boolArg",
+                            MethodType.methodType(Boolean::class.java, Long::class.java)
+                        )
+                    }
+
+                    is ParameterizedType -> {
+                        if (it.rawType == List::class.java) {
+                            val converter = when (it.actualTypeArguments[0]) {
+                                java.lang.Integer::class.java -> "intListArg"
+                                else -> throw IllegalArgumentException(it.actualTypeArguments[0].toString())
+                            }
+                            wasmParameters.add(ValueType.I32)
+                            wasmParameters.add(ValueType.I32)
+                            lookup.findStatic(
+                                Bindings::class.java,
+                                converter,
+                                MethodType.methodType(
+                                    List::class.java,
+                                    Instance::class.java,
+                                    Long::class.java,
+                                    Long::class.java
+                                )
+                            )
+                        } else {
+                            throw IllegalArgumentException(it.toString())
+                        }
+                    }
+
+                    else -> throw IllegalArgumentException(it.toString())
+                }
+            }
+            val filterTypes = ArrayList<Class<*>>()
+            filters.forEach { methodHandle ->
+                filterTypes.addAll(methodHandle.type().parameterList())
+            }
+            var i = 0
+            val permutation = IntArray(filterTypes.size) {
+                if (filterTypes[it] == Instance::class.java) 0 else ++i
+            }
+            var handle = baseHandle
+            var j = 0
+            filters.forEach {
+                handle = MethodHandles.collectArguments(handle, j, it)
+                j += it.type().parameterCount()
+            }
+            val newtype = MethodType.methodType(
+                baseHandle.type().returnType(), Instance::class.java, *Array(i) { Long::class.java })
+            handle = MethodHandles.permuteArguments(handle, newtype, *permutation)
+            handle = handle.asSpreader(LongArray::class.java, i)
+            return when (method.genericReturnType) {
+                Void.TYPE -> HostFunction(module, name, wasmParameters, emptyList()) { instance, args ->
+                    handle.invokeExact(instance, args)
+                    Value.EMPTY_VALUES
+                }
+
+                Void::class.java -> HostFunction(module, name, wasmParameters, emptyList()) { instance, args ->
+                    handle.invokeExact(instance, args)
+                    throw IllegalStateException("unreachable")
+                }
+
+                Int::class.java -> HostFunction(module, name, wasmParameters, listOf(ValueType.I32)) { instance, args ->
+                    val result: Int = handle.invokeExact(instance, args) as Int
+                    longArrayOf(result.toLong())
+                }
+
+                Boolean::class.java -> HostFunction(
+                    module,
+                    name,
+                    wasmParameters,
+                    listOf(ValueType.I32)
+                ) { instance, args ->
+                    val result: Boolean = handle.invokeExact(instance, args) as Boolean
+                    longArrayOf(if (result) 1L else 0L)
+                }
+
+                Float::class.java -> HostFunction(
+                    module,
+                    name,
+                    wasmParameters,
+                    listOf(ValueType.F32)
+                ) { instance, args ->
+                    val result: Float = handle.invokeExact(instance, args) as Float
+                    longArrayOf(Value.floatToLong(result))
+                }
+
+                else -> throw IllegalArgumentException(method.genericReturnType.toString())
+            }
+        }
+    }
+}
\ No newline at end of file
diff --git a/src/main/kotlin/space/autistic/radio/wasm/WasmExitException.kt b/src/main/kotlin/space/autistic/radio/wasm/WasmExitException.kt
new file mode 100644
index 0000000..43c08be
--- /dev/null
+++ b/src/main/kotlin/space/autistic/radio/wasm/WasmExitException.kt
@@ -0,0 +1,4 @@
+package space.autistic.radio.wasm
+
+class WasmExitException(val status: Int) : Exception() {
+}