summary refs log tree commit diff stats
path: root/src/client
diff options
context:
space:
mode:
Diffstat (limited to 'src/client')
-rw-r--r--src/client/java/space/autistic/radio/client/mixin/BlockStatesLoaderMixin.java43
-rw-r--r--src/client/kotlin/space/autistic/radio/client/ClientProxy.kt20
-rw-r--r--src/client/kotlin/space/autistic/radio/client/PirateRadioClient.kt58
-rw-r--r--src/client/kotlin/space/autistic/radio/client/PirateRadioDataGenerator.kt4
-rw-r--r--src/client/kotlin/space/autistic/radio/client/PirateRadioEntityModelLayers.kt1
-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.kt21
-rw-r--r--src/client/kotlin/space/autistic/radio/client/cli/Funny.kt7
-rw-r--r--src/client/kotlin/space/autistic/radio/client/cli/OfflineSimulator.kt265
-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/entity/DisposableTransmitterEntityRenderer.kt79
-rw-r--r--src/client/kotlin/space/autistic/radio/client/entity/ElectronicsTraderEntityRenderer.kt2
-rw-r--r--src/client/kotlin/space/autistic/radio/client/flite/FliteFactory.kt63
-rw-r--r--src/client/kotlin/space/autistic/radio/client/flite/FliteWrapper.kt35
-rw-r--r--src/client/kotlin/space/autistic/radio/client/fmsim/FastModulatedNoise.kt86
-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/FmFullModulator.kt170
-rw-r--r--src/client/kotlin/space/autistic/radio/client/fmsim/FmFullThread.kt294
-rw-r--r--src/client/kotlin/space/autistic/radio/client/fmsim/FmSimulatorMode.kt7
-rw-r--r--src/client/kotlin/space/autistic/radio/client/fmsim/NoiseFloor.kt31
-rw-r--r--src/client/kotlin/space/autistic/radio/client/gui/FmReceiverScreen.kt802
-rw-r--r--src/client/kotlin/space/autistic/radio/client/gui/StorageCardEditScreen.kt115
-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/sound/PirateRadioSoundInstance.kt165
-rw-r--r--src/client/kotlin/space/autistic/radio/client/sound/ReceiverAudioStream.kt45
-rw-r--r--src/client/kotlin/space/autistic/radio/client/util/LevenshteinDistance.kt54
-rw-r--r--src/client/resources/pirate-radio.client-mixins.json14
32 files changed, 2793 insertions, 27 deletions
diff --git a/src/client/java/space/autistic/radio/client/mixin/BlockStatesLoaderMixin.java b/src/client/java/space/autistic/radio/client/mixin/BlockStatesLoaderMixin.java
new file mode 100644
index 0000000..0e9b05e
--- /dev/null
+++ b/src/client/java/space/autistic/radio/client/mixin/BlockStatesLoaderMixin.java
@@ -0,0 +1,43 @@
+package space.autistic.radio.client.mixin;
+
+import net.minecraft.block.Block;
+import net.minecraft.block.BlockState;
+import net.minecraft.block.Blocks;
+import net.minecraft.client.render.model.BlockStatesLoader;
+import net.minecraft.state.StateManager;
+import net.minecraft.state.property.BooleanProperty;
+import net.minecraft.state.property.DirectionProperty;
+import net.minecraft.state.property.Property;
+import net.minecraft.util.Identifier;
+import net.minecraft.util.profiler.Profiler;
+import org.spongepowered.asm.mixin.Mixin;
+import org.spongepowered.asm.mixin.Shadow;
+import org.spongepowered.asm.mixin.Unique;
+import org.spongepowered.asm.mixin.injection.At;
+import org.spongepowered.asm.mixin.injection.Inject;
+import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
+
+@Mixin(BlockStatesLoader.class)
+public abstract class BlockStatesLoaderMixin {
+
+    @Shadow
+    abstract void loadBlockStates(Identifier id, StateManager<Block, BlockState> stateManager);
+
+    @Shadow
+    private Profiler profiler;
+
+    @Unique
+    private static final StateManager<Block, BlockState> STATE_MANAGER = new StateManager.Builder<Block, BlockState>(Blocks.AIR)
+            .add(DirectionProperty.of("facing"))
+            .build(Block::getDefaultState, BlockState::new);
+
+    @Inject(
+            method = {"load()V"},
+            at = {@At("HEAD")}
+    )
+    void onLoad(CallbackInfo callbackInfo) {
+        profiler.push("pirate_radio_static_definitions");
+        loadBlockStates(Identifier.of("pirate-radio", "disposable-transmitter"), STATE_MANAGER);
+        profiler.pop();
+    }
+}
diff --git a/src/client/kotlin/space/autistic/radio/client/ClientProxy.kt b/src/client/kotlin/space/autistic/radio/client/ClientProxy.kt
new file mode 100644
index 0000000..3c29cd3
--- /dev/null
+++ b/src/client/kotlin/space/autistic/radio/client/ClientProxy.kt
@@ -0,0 +1,20 @@
+package space.autistic.radio.client
+
+import net.minecraft.client.MinecraftClient
+import net.minecraft.client.network.ClientPlayerEntity
+import net.minecraft.entity.player.PlayerEntity
+import net.minecraft.item.ItemStack
+import net.minecraft.util.Hand
+import space.autistic.radio.CommonProxy
+import space.autistic.radio.PirateRadioItems.STORAGE_CARD
+import space.autistic.radio.client.gui.StorageCardEditScreen
+
+class ClientProxy : CommonProxy() {
+    override fun useStorageCard(player: PlayerEntity, item: ItemStack, hand: Hand) {
+        if (player is ClientPlayerEntity) {
+            if (item.isOf(STORAGE_CARD)) {
+                MinecraftClient.getInstance().setScreen(StorageCardEditScreen(player, item, hand))
+            }
+        }
+    }
+}
\ No newline at end of file
diff --git a/src/client/kotlin/space/autistic/radio/client/PirateRadioClient.kt b/src/client/kotlin/space/autistic/radio/client/PirateRadioClient.kt
index 54b7640..87fd144 100644
--- a/src/client/kotlin/space/autistic/radio/client/PirateRadioClient.kt
+++ b/src/client/kotlin/space/autistic/radio/client/PirateRadioClient.kt
@@ -1,25 +1,59 @@
 package space.autistic.radio.client
 
-import com.mojang.brigadier.CommandDispatcher
 import net.fabricmc.api.ClientModInitializer
 import net.fabricmc.fabric.api.client.command.v2.ClientCommandManager
 import net.fabricmc.fabric.api.client.command.v2.ClientCommandRegistrationCallback
-import net.fabricmc.fabric.api.client.command.v2.FabricClientCommandSource
+import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents
 import net.fabricmc.fabric.api.client.rendering.v1.EntityRendererRegistry
 import net.minecraft.client.MinecraftClient
-import net.minecraft.command.CommandRegistryAccess
+import net.minecraft.client.sound.SoundInstance
+import net.minecraft.entity.player.PlayerEntity
+import net.minecraft.item.ItemStack
+import net.minecraft.util.Hand
 import org.slf4j.LoggerFactory
 import space.autistic.radio.PirateRadio
 import space.autistic.radio.PirateRadio.MOD_ID
 import space.autistic.radio.PirateRadioEntityTypes
 import space.autistic.radio.client.entity.ElectronicsTraderEntityRenderer
+import space.autistic.radio.client.entity.DisposableTransmitterEntityRenderer
+import space.autistic.radio.client.fmsim.FmFullThread
+import space.autistic.radio.client.fmsim.FmSimulatorMode
 import space.autistic.radio.client.gui.FmReceiverScreen
+import space.autistic.radio.client.sound.PirateRadioSoundInstance
+import space.autistic.radio.client.sound.ReceiverAudioStream
+import javax.sound.sampled.Mixer
+import kotlin.math.max
+import kotlin.math.min
 
 object PirateRadioClient : ClientModInitializer {
-    private val logger = LoggerFactory.getLogger(MOD_ID)
+    private var soundInstance: SoundInstance? = null
+    var volume: Int = 0
+        set(value) {
+            field = min(10, max(0, value))
+        }
+    var stereo: Boolean = false
+    var frequency = 768
+        set(value) {
+            field = min(1080, max(768, value))
+        }
+    var mode = FmSimulatorMode.FULL
+
+    val minecraftAudioDevice = object : Mixer.Info("", "", "", "") {
+    }
+    val openAlAudioDevice = object : Mixer.Info("", "", "", "") {
+    }
+    val systemDefaultAudioDevice = object : Mixer.Info("", "", "", "") {
+    }
+    var audioDevice: Mixer.Info = if (ReceiverAudioStream.useNativeAudio) minecraftAudioDevice else openAlAudioDevice
 
     override fun onInitializeClient() {
+        Thread.ofPlatform().daemon().name("fm-receiver").start(FmFullThread)
+        PirateRadio.proxy = ClientProxy()
         EntityRendererRegistry.register(PirateRadioEntityTypes.ELECTRONICS_TRADER, ::ElectronicsTraderEntityRenderer)
+        EntityRendererRegistry.register(
+            PirateRadioEntityTypes.DISPOSABLE_TRANSMITTER,
+            ::DisposableTransmitterEntityRenderer
+        )
         PirateRadioEntityModelLayers.initialize()
         ClientCommandRegistrationCallback.EVENT.register { dispatcher, _ ->
             dispatcher.register(
@@ -31,5 +65,21 @@ object PirateRadioClient : ClientModInitializer {
                 }
             )
         }
+        ClientTickEvents.END_WORLD_TICK.register { world ->
+            if (volume > 0 && MinecraftClient.getInstance().player?.isRemoved == false) {
+                if (soundInstance == null) {
+                    soundInstance = PirateRadioSoundInstance(MinecraftClient.getInstance().player!!)
+                }
+                val soundManager = MinecraftClient.getInstance().soundManager
+                if (!soundManager.isPlaying(soundInstance)) {
+                    soundManager.play(soundInstance)
+                }
+            } else {
+                if (soundInstance != null) {
+                    MinecraftClient.getInstance().soundManager.stop(soundInstance)
+                    soundInstance = null
+                }
+            }
+        }
     }
 }
\ No newline at end of file
diff --git a/src/client/kotlin/space/autistic/radio/client/PirateRadioDataGenerator.kt b/src/client/kotlin/space/autistic/radio/client/PirateRadioDataGenerator.kt
index b5130a1..65e9677 100644
--- a/src/client/kotlin/space/autistic/radio/client/PirateRadioDataGenerator.kt
+++ b/src/client/kotlin/space/autistic/radio/client/PirateRadioDataGenerator.kt
@@ -32,7 +32,7 @@ class PirateRadioItemModelGenerator(output: FabricDataOutput) : FabricModelProvi
         modelGenderator.register(PirateRadioItems.SBC, Models.GENERATED)
         modelGenderator.register(PirateRadioItems.WIRE, Models.GENERATED)
         modelGenderator.register(PirateRadioItems.POWERBANK, Models.GENERATED)
-        modelGenderator.register(PirateRadioItems.FM_RECEIVER, Models.GENERATED)
+        //modelGenderator.register(PirateRadioItems.FM_RECEIVER, Models.GENERATED)
         modelGenderator.register(PirateRadioItems.STORAGE_CARD, Models.GENERATED)
         modelGenderator.register(PirateRadioItems.DISPOSABLE_TRANSMITTER, Models.GENERATED)
     }
@@ -46,7 +46,7 @@ class PirateRadioRecipeGenerator(
     override fun generate(exporter: RecipeExporter) {
         ShapelessRecipeJsonBuilder.create(RecipeCategory.MISC, PirateRadioItems.DISPOSABLE_TRANSMITTER)
             .input(PirateRadioItems.SBC).input(PirateRadioItems.WIRE).input(PirateRadioItems.POWERBANK)
-            .input(PirateRadioItems.STORAGE_CARD)
+            //.input(PirateRadioItems.STORAGE_CARD)
             .criterion("has_sbc", RecipeProvider.conditionsFromItem(PirateRadioItems.SBC)).offerTo(exporter)
     }
 
diff --git a/src/client/kotlin/space/autistic/radio/client/PirateRadioEntityModelLayers.kt b/src/client/kotlin/space/autistic/radio/client/PirateRadioEntityModelLayers.kt
index 765912d..604fdfd 100644
--- a/src/client/kotlin/space/autistic/radio/client/PirateRadioEntityModelLayers.kt
+++ b/src/client/kotlin/space/autistic/radio/client/PirateRadioEntityModelLayers.kt
@@ -9,6 +9,7 @@ import space.autistic.radio.PirateRadio
 
 object PirateRadioEntityModelLayers {
     val ELECTRONICS_TRADER = EntityModelLayer(Identifier.of(PirateRadio.MOD_ID, "electronics-trader"), "main")
+    val PIRATE_RADIO = EntityModelLayer(Identifier.of(PirateRadio.MOD_ID, "electronics-trader"), "main")
 
     fun initialize() {
         EntityModelLayerRegistry.registerModelLayer(ELECTRONICS_TRADER) {
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..38d0c97 100644
--- a/src/client/kotlin/space/autistic/radio/client/antenna/WasmAntennaFactory.kt
+++ b/src/client/kotlin/space/autistic/radio/client/antenna/WasmAntennaFactory.kt
@@ -12,9 +12,7 @@ import com.dylibso.chicory.wasm.types.Value
 import com.dylibso.chicory.wasm.types.ValueType
 import org.joml.Quaterniond
 import org.joml.Vector3d
-import space.autistic.radio.PirateRadio
-import java.util.logging.Level
-import java.util.logging.Logger
+import space.autistic.radio.PirateRadio.logger
 
 class WasmAntennaFactory(moduleBytes: ByteArray) : AntennaModelFactory {
     var failing = false
@@ -25,7 +23,7 @@ class WasmAntennaFactory(moduleBytes: ByteArray) : AntennaModelFactory {
                 // capped at 1MB per antenna
                 .withMemoryLimits(MemoryLimits(0, 16))
         } catch (e: ChicoryException) {
-            logger.log(Level.SEVERE, "Error while trying to parse antenna model.", e)
+            logger.error("Error while trying to parse antenna model.", e)
             failing = true
             null
         }
@@ -33,7 +31,7 @@ class WasmAntennaFactory(moduleBytes: ByteArray) : AntennaModelFactory {
 
     override fun create(orientation: Quaterniond): AntennaModel {
         if (failing) {
-            return NullModel()
+            return ConstAntennaModel(0f)
         }
         try {
             val instance = instanceBuilder!!.build()
@@ -53,11 +51,9 @@ class WasmAntennaFactory(moduleBytes: ByteArray) : AntennaModelFactory {
                 orientation.w.toRawBits()
             )
             if (instance.exports().global("should-attenuate").type != ValueType.I32) {
-                logger.log(
-                    Level.SEVERE, "Error while trying to initialize antenna model: missing 'should-attenuate'"
-                )
+                logger.error("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")
@@ -73,7 +69,7 @@ class WasmAntennaFactory(moduleBytes: ByteArray) : AntennaModelFactory {
                             )[0]
                         )
                     } catch (e: ChicoryException) {
-                        logger.log(Level.SEVERE, "Error while trying to evaluate antenna model.", e)
+                        logger.error("Error while trying to evaluate antenna model.", e)
                         failing = true
                         return 0f
                     }
@@ -84,14 +80,13 @@ class WasmAntennaFactory(moduleBytes: ByteArray) : AntennaModelFactory {
                 }
             }
         } catch (e: ChicoryException) {
-            logger.log(Level.SEVERE, "Error while trying to initialize antenna model.", e)
+            logger.error("Error while trying to initialize antenna model.", e)
             failing = true
-            return NullModel()
+            return ConstAntennaModel(0f)
         }
     }
 
     companion object {
         private val defaultImports = ImportValues.builder().build()
-        private val logger = Logger.getLogger(PirateRadio.MOD_ID)
     }
 }
\ No newline at end of file
diff --git a/src/client/kotlin/space/autistic/radio/client/cli/Funny.kt b/src/client/kotlin/space/autistic/radio/client/cli/Funny.kt
new file mode 100644
index 0000000..2e64d36
--- /dev/null
+++ b/src/client/kotlin/space/autistic/radio/client/cli/Funny.kt
@@ -0,0 +1,7 @@
+package space.autistic.radio.client.cli
+
+import space.autistic.radio.client.flite.FliteWrapper
+
+
+fun main() {
+}
\ No newline at end of file
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..c17d622
--- /dev/null
+++ b/src/client/kotlin/space/autistic/radio/client/cli/OfflineSimulator.kt
@@ -0,0 +1,265 @@
+package space.autistic.radio.client.cli
+
+import com.github.ooxi.jdatauri.DataUri
+import org.joml.Vector2f
+import space.autistic.radio.client.complex.cmul
+import space.autistic.radio.client.flite.FliteWrapper
+import space.autistic.radio.client.fmsim.FmFullConstants
+import space.autistic.radio.client.fmsim.FmFullDemodulator
+import space.autistic.radio.client.fmsim.FmFullModulator
+import java.io.ByteArrayInputStream
+import java.io.ByteArrayOutputStream
+import java.io.DataOutputStream
+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 if (inputFile.filename.startsWith("data:")) {
+                    val uri = try {
+                        DataUri.parse(inputFile.filename, Charsets.UTF_8)
+                    } catch (e: IllegalArgumentException) {
+                        println("error parsing data URI")
+                        exitProcess(1)
+                    }
+                    if (!uri.mime.startsWith("text/")) {
+                        println("unsupported data URI format")
+                        exitProcess(1)
+                    }
+                    FliteWrapper.textToWave(uri.data.toString(uri.charset ?: Charsets.UTF_8)) {
+                        // 8k mono -> 48k stereo float
+                        val bbuf = ByteBuffer.allocate(it.capacity() * 2 * 6 * 4)
+                        val fbuf = bbuf.order(ByteOrder.LITTLE_ENDIAN).asFloatBuffer()
+                        while (it.hasRemaining()) {
+                            val sample = (it.get().toFloat() + 0.5f) / 32767.5f
+                            for (i in 0 until 2 * 6) {
+                                fbuf.put(sample)
+                            }
+                        }
+                        inputFile.stream = ByteArrayInputStream(bbuf.array())
+                    }
+                } 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 + 1, floatView.get(i + 1) + 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 + 1, floatView.get(i + 1) + 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/entity/DisposableTransmitterEntityRenderer.kt b/src/client/kotlin/space/autistic/radio/client/entity/DisposableTransmitterEntityRenderer.kt
new file mode 100644
index 0000000..61b1e19
--- /dev/null
+++ b/src/client/kotlin/space/autistic/radio/client/entity/DisposableTransmitterEntityRenderer.kt
@@ -0,0 +1,79 @@
+package space.autistic.radio.client.entity
+
+import net.minecraft.client.render.OverlayTexture
+import net.minecraft.client.render.TexturedRenderLayers
+import net.minecraft.client.render.VertexConsumerProvider
+import net.minecraft.client.render.entity.EntityRenderer
+import net.minecraft.client.render.entity.EntityRendererFactory
+import net.minecraft.client.render.model.BakedModelManager
+import net.minecraft.client.util.ModelIdentifier
+import net.minecraft.client.util.math.MatrixStack
+import net.minecraft.screen.PlayerScreenHandler
+import net.minecraft.util.Identifier
+import net.minecraft.util.math.Direction
+import net.minecraft.util.math.RotationAxis
+import space.autistic.radio.PirateRadio
+import space.autistic.radio.entity.DisposableTransmitterEntity
+
+class DisposableTransmitterEntityRenderer(ctx: EntityRendererFactory.Context) :
+    EntityRenderer<DisposableTransmitterEntity>(ctx) {
+
+    private val blockRenderManager = ctx.blockRenderManager
+
+    override fun getTexture(entity: DisposableTransmitterEntity): Identifier {
+        return PlayerScreenHandler.BLOCK_ATLAS_TEXTURE
+    }
+
+    override fun render(
+        entity: DisposableTransmitterEntity,
+        yaw: Float,
+        tickDelta: Float,
+        matrices: MatrixStack,
+        vertexConsumers: VertexConsumerProvider,
+        light: Int
+    ) {
+        super.render(entity, yaw, tickDelta, matrices, vertexConsumers, light)
+
+        matrices.push()
+        val facing: Direction = entity.horizontalFacing
+        val vec3d = this.getPositionOffset(entity, tickDelta)
+        matrices.translate(-vec3d.getX(), -vec3d.getY(), -vec3d.getZ())
+        val d = (1.0 - DisposableTransmitterEntity.DEPTH) / 2.0
+        matrices.translate(
+            facing.offsetX.toDouble() * d, facing.offsetY.toDouble() * d, facing.offsetZ.toDouble() * d
+        )
+        matrices.multiply(RotationAxis.POSITIVE_X.rotationDegrees(entity.pitch))
+        matrices.multiply(RotationAxis.POSITIVE_Y.rotationDegrees(180.0f - entity.yaw))
+        if (!entity.isInvisible) {
+            val bakedModelManager: BakedModelManager = this.blockRenderManager.models.modelManager
+            matrices.push()
+            matrices.translate(-0.5f, -0.5f, -0.5f)
+            this.blockRenderManager.modelRenderer.render(
+                matrices.peek(),
+                vertexConsumers.getBuffer(TexturedRenderLayers.getEntitySolid()),
+                null,
+                bakedModelManager.getModel(MODEL_ID[facing]),
+                1.0f,
+                1.0f,
+                1.0f,
+                light,
+                OverlayTexture.DEFAULT_UV
+            )
+            matrices.pop()
+        }
+
+        matrices.pop()
+    }
+
+    companion object {
+        private val STATES_ID = Identifier.of(PirateRadio.MOD_ID, "disposable-transmitter")
+        private val MODEL_ID = mapOf(
+            Direction.DOWN to ModelIdentifier(STATES_ID, "facing=down"),
+            Direction.UP to ModelIdentifier(STATES_ID, "facing=up"),
+            Direction.NORTH to ModelIdentifier(STATES_ID, "facing=north"),
+            Direction.SOUTH to ModelIdentifier(STATES_ID, "facing=south"),
+            Direction.WEST to ModelIdentifier(STATES_ID, "facing=west"),
+            Direction.EAST to ModelIdentifier(STATES_ID, "facing=east"),
+        )
+    }
+}
\ No newline at end of file
diff --git a/src/client/kotlin/space/autistic/radio/client/entity/ElectronicsTraderEntityRenderer.kt b/src/client/kotlin/space/autistic/radio/client/entity/ElectronicsTraderEntityRenderer.kt
index 91c29db..5da8e17 100644
--- a/src/client/kotlin/space/autistic/radio/client/entity/ElectronicsTraderEntityRenderer.kt
+++ b/src/client/kotlin/space/autistic/radio/client/entity/ElectronicsTraderEntityRenderer.kt
@@ -16,7 +16,7 @@ class ElectronicsTraderEntityRenderer(context: EntityRendererFactory.Context) :
     ) {
 
     companion object {
-        val TEXTURE = Identifier.of(PirateRadio.MOD_ID, "electronics-trader")
+        val TEXTURE = Identifier.of(PirateRadio.MOD_ID, "textures/entity/electronics-trader.png")
     }
 
     override fun getTexture(entity: ElectronicsTraderEntity?): Identifier = TEXTURE
diff --git a/src/client/kotlin/space/autistic/radio/client/flite/FliteFactory.kt b/src/client/kotlin/space/autistic/radio/client/flite/FliteFactory.kt
new file mode 100644
index 0000000..768a040
--- /dev/null
+++ b/src/client/kotlin/space/autistic/radio/client/flite/FliteFactory.kt
@@ -0,0 +1,63 @@
+package space.autistic.radio.client.flite
+
+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 space.autistic.radio.wasm.Bindings
+import space.autistic.radio.wasm.WasmExitException
+import java.io.InputStream
+import java.lang.invoke.MethodHandles
+import kotlin.io.path.inputStream
+import kotlin.reflect.jvm.javaMethod
+
+object FliteFactory : () -> Instance {
+    private fun fd_read(a: Int, b: Int, c: Int, d: Int): Int {
+        // EBADF
+        return 8
+    }
+
+    private fun fd_seek(a: Int, b: Long, c: Int, d: Int): Int {
+        // EBADF
+        return 8
+    }
+
+    private fun fd_close(a: Int): Int {
+        // EBADF
+        return 8
+    }
+
+    private fun fd_write(a: Int, b: Int, c: Int, d: Int): Int {
+        // EBADF
+        return 8
+    }
+
+    private fun proc_exit(status: Int): Nothing {
+        throw WasmExitException(status)
+    }
+
+    private val lookup = MethodHandles.lookup()
+    private val defaultImports = ImportValues.builder()
+        .addFunction(
+            Bindings.bindFunc("wasi_snapshot_preview1", "fd_close", lookup, ::fd_close.javaMethod!!, this),
+            Bindings.bindFunc("wasi_snapshot_preview1", "fd_read", lookup, ::fd_read.javaMethod!!, this),
+            Bindings.bindFunc("wasi_snapshot_preview1", "fd_write", lookup, ::fd_write.javaMethod!!, this),
+            Bindings.bindFunc("wasi_snapshot_preview1", "fd_seek", lookup, ::fd_seek.javaMethod!!, this),
+            Bindings.bindFunc("wasi_snapshot_preview1", "proc_exit", lookup, ::proc_exit.javaMethod!!, this),
+        ).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("flite.wasm") }
+            .map { it.inputStream() }.orElseGet {
+                this.javaClass.getResourceAsStream("/flite.wasm")
+            }
+    }
+}
\ No newline at end of file
diff --git a/src/client/kotlin/space/autistic/radio/client/flite/FliteWrapper.kt b/src/client/kotlin/space/autistic/radio/client/flite/FliteWrapper.kt
new file mode 100644
index 0000000..e73d4d0
--- /dev/null
+++ b/src/client/kotlin/space/autistic/radio/client/flite/FliteWrapper.kt
@@ -0,0 +1,35 @@
+package space.autistic.radio.client.flite
+
+import com.dylibso.chicory.runtime.ByteBufferMemory
+import space.autistic.radio.reflection.getBuffer
+import java.nio.ByteOrder
+import java.nio.ShortBuffer
+import java.util.function.Consumer
+
+object FliteWrapper {
+    // Produces audio at 8kHz mono
+    fun textToWave(s: String, consumer: Consumer<ShortBuffer>) {
+        val instance = FliteFactory.invoke()
+        instance.export("_initialize").apply()
+
+        val voice = instance.export("flite_wrapper_init").apply()[0]
+
+        val textToWaveImpl = instance.export("flite_text_to_wave")
+        val mallocImpl = instance.export("malloc")
+        val memory = instance.memory() as ByteBufferMemory
+
+        val bytes = s.toByteArray()
+        val space = mallocImpl.apply((bytes.size + 1).toLong())[0].toInt()
+        memory.getBuffer().run {
+            put(space, bytes)
+            put(space + bytes.size, 0)
+        }
+        val wavedata = textToWaveImpl.apply(space.toLong(), voice)[0].toInt()
+        val numSamples = memory.readInt(wavedata + 8)
+        val samplesPtr = memory.readInt(wavedata + 16)
+        consumer.accept(
+            memory.getBuffer().slice(samplesPtr, numSamples * 2).order(ByteOrder.LITTLE_ENDIAN)
+                .asShortBuffer()
+        )
+    }
+}
\ No newline at end of file
diff --git a/src/client/kotlin/space/autistic/radio/client/fmsim/FastModulatedNoise.kt b/src/client/kotlin/space/autistic/radio/client/fmsim/FastModulatedNoise.kt
new file mode 100644
index 0000000..9639a38
--- /dev/null
+++ b/src/client/kotlin/space/autistic/radio/client/fmsim/FastModulatedNoise.kt
@@ -0,0 +1,86 @@
+package space.autistic.radio.client.fmsim
+
+import org.joml.Vector2f
+import space.autistic.radio.client.complex.cmul
+import java.nio.FloatBuffer
+import java.util.concurrent.ThreadLocalRandom
+import java.util.function.Consumer
+
+// FIXME use more realistic model
+class FastModulatedNoise(which: Which) {
+
+    private val buffer = when (which) {
+        Which.BASE -> FloatBuffer.wrap(baseNoise)
+        Which.UPPER -> FloatBuffer.wrap(upperNoise)
+        Which.LOWER -> FloatBuffer.wrap(upperNoise)
+    }
+    private val flipSpectrum = which == Which.LOWER
+    private val outBuffer = FloatBuffer.allocate(2 * FmFullConstants.FFT_DATA_BLOCK_SIZE_48K_300K)
+
+    // complex noise, in IQ format
+    fun generateNoise(power: Float, consumer: Consumer<FloatBuffer>) {
+        outBuffer.clear()
+        while (outBuffer.hasRemaining()) {
+            if (!buffer.hasRemaining()) {
+                buffer.clear()
+            }
+            if (flipSpectrum) {
+                outBuffer.put(buffer.get() * power)
+                outBuffer.put(-buffer.get() * power)
+            } else {
+                outBuffer.put(buffer.get() * power)
+                outBuffer.put(buffer.get() * power)
+            }
+        }
+        outBuffer.clear()
+        consumer.accept(outBuffer)
+    }
+
+    enum class Which {
+        LOWER, BASE, UPPER
+    }
+
+    companion object {
+        // 1 second
+        private val baseNoise = FloatArray(300000 * 2)
+        private val upperNoise = FloatArray(300000 * 2)
+
+        init {
+            val fmsim = FmFullModulator()
+            val buffer = FloatBuffer.wrap(baseNoise)
+            val input = FloatBuffer.allocate(FmFullConstants.FFT_DATA_BLOCK_SIZE_LPF_48K_15K_3K1 * 2)
+            val random = ThreadLocalRandom.current()
+            while (buffer.hasRemaining()) {
+                input.clear()
+                while (input.hasRemaining()) {
+                    val sample = random.nextFloat(1f)
+                    input.put(sample)
+                    input.put(sample)
+                }
+                input.clear()
+                fmsim.process(input, 1f, false) {
+                    if (buffer.remaining() < it.remaining()) {
+                        it.limit(it.position() + buffer.remaining())
+                    }
+                    buffer.put(it)
+                }
+            }
+            buffer.clear()
+            val plus100k = FloatBuffer.wrap(FmFullConstants.CBUFFER_100K_300K)
+            val z = Vector2f()
+            val w = Vector2f()
+            for (i in baseNoise.indices step 2) {
+                z.x = baseNoise[i]
+                z.y = baseNoise[i + 1]
+                if (!plus100k.hasRemaining()) {
+                    plus100k.clear()
+                }
+                w.x = plus100k.get()
+                w.y = plus100k.get()
+                z.cmul(w)
+                upperNoise[i] = z.x
+                upperNoise[i] = z.y
+            }
+        }
+    }
+}
\ 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/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/fmsim/FmFullThread.kt b/src/client/kotlin/space/autistic/radio/client/fmsim/FmFullThread.kt
new file mode 100644
index 0000000..bce7a72
--- /dev/null
+++ b/src/client/kotlin/space/autistic/radio/client/fmsim/FmFullThread.kt
@@ -0,0 +1,294 @@
+package space.autistic.radio.client.fmsim
+
+import net.minecraft.client.sound.SoundSystem
+import net.minecraft.util.math.MathHelper
+import org.joml.Vector2f
+import space.autistic.radio.PirateRadio
+import space.autistic.radio.client.PirateRadioClient
+import space.autistic.radio.client.complex.cmul
+import space.autistic.radio.client.sound.PirateRadioSoundInstance
+import space.autistic.radio.client.sound.ReceiverAudioStream
+import space.autistic.radio.client.util.LevenshteinDistance
+import java.nio.ByteBuffer
+import java.nio.ByteOrder
+import java.nio.FloatBuffer
+import java.util.UUID
+import java.util.concurrent.ArrayBlockingQueue
+import javax.sound.sampled.AudioSystem
+import javax.sound.sampled.LineUnavailableException
+import javax.sound.sampled.Mixer
+import kotlin.math.max
+
+object FmFullThread : Runnable {
+    class FmTask(
+        val trackedTransmitters: Map<UUID, PirateRadioSoundInstance.TrackedTransmitter>,
+        val noiseLevels: FloatArray,
+        val audioOutput: Mixer.Info,
+        val minecraftSoundDevice: String?,
+    )
+
+    // empty task, marker to shut off the thread
+    val EMPTY_TASK = FmTask(emptyMap(), FloatArray(3), PirateRadioClient.minecraftAudioDevice, null)
+
+    val trackedTransmitterQueue = ArrayBlockingQueue<FmTask>(8)
+
+    private class TtsModulator(
+        val buffer: FloatBuffer,
+        val modulator: FmFullModulator,
+        var power: Float,
+        var repeatTimeout: Int,
+        var mixingBuffer: FloatBuffer
+    )
+
+    // 3 seconds
+    private const val REPEAT_TIMEOUT = 8000 * 3
+
+    // default to 0.05s (1/20)
+    val bufferSize = System.getProperty("space.autistic.radio.buffer.size", "").toIntOrNull() ?: 2400
+
+    override fun run() {
+        var currentTask = EMPTY_TASK
+        val modulators = HashMap<UUID, TtsModulator>()
+        val mixingBuffers = Array(3) { FloatBuffer.allocate(FmFullConstants.FFT_DATA_BLOCK_SIZE_48K_300K * 2) }
+
+        val inputBuffer = FloatBuffer.allocate(FmFullConstants.FFT_DATA_BLOCK_SIZE_LPF_48K_15K_3K1 * 2)
+
+        val noiseGens = Array(3) { FastModulatedNoise(FastModulatedNoise.Which.entries[it]) }
+
+        // -120dB or so
+        val noiseFloor = NoiseFloor(1e-12f)
+
+        val plus100k = FloatBuffer.wrap(FmFullConstants.CBUFFER_100K_300K)
+        val minus100k = FloatBuffer.wrap(FmFullConstants.CBUFFER_100K_300K)
+
+        val demodulator = FmFullDemodulator()
+
+        // for native audio only
+        val outputBytes = ByteBuffer.allocate(bufferSize * 2 * 2).order(ByteOrder.LITTLE_ENDIAN)
+        var nativeAudio = if (ReceiverAudioStream.useNativeAudio) {
+            AudioSystem.getSourceDataLine(ReceiverAudioStream.format)
+                .apply { open(ReceiverAudioStream.format, FmFullThread.bufferSize * 2 * 2) }
+        } else {
+            null
+        }
+        var lastOutput = PirateRadioClient.audioDevice
+        var lastMinecraftSoundDevice: String? = null
+
+        try {
+            while (!Thread.interrupted()) {
+                currentTask = trackedTransmitterQueue.poll() ?: currentTask
+                if (currentTask === EMPTY_TASK) {
+                    Thread.onSpinWait()
+                    continue
+                }
+
+                val audioOutput = currentTask.audioOutput
+                if (lastOutput != audioOutput && audioOutput != PirateRadioClient.minecraftAudioDevice) {
+                    nativeAudio = when (audioOutput) {
+                        PirateRadioClient.systemDefaultAudioDevice -> {
+                            try {
+                                AudioSystem.getSourceDataLine(ReceiverAudioStream.format)
+                                    .apply { open(ReceiverAudioStream.format, FmFullThread.bufferSize * 2 * 2) }
+                            } catch (e: LineUnavailableException) {
+                                null
+                            }
+                        }
+
+                        PirateRadioClient.openAlAudioDevice -> {
+                            null
+                        }
+
+                        else -> {
+                            try {
+                                AudioSystem.getSourceDataLine(ReceiverAudioStream.format, audioOutput)
+                                    .apply { open(ReceiverAudioStream.format, FmFullThread.bufferSize * 2 * 2) }
+                            } catch (e: LineUnavailableException) {
+                                null
+                            }
+                        }
+                    }
+                }
+                if (audioOutput == PirateRadioClient.minecraftAudioDevice) {
+                    if (lastMinecraftSoundDevice != currentTask.minecraftSoundDevice || lastOutput != audioOutput) {
+                        lastMinecraftSoundDevice = currentTask.minecraftSoundDevice
+                        nativeAudio = if (lastMinecraftSoundDevice == "") {
+                            try {
+                                AudioSystem.getSourceDataLine(ReceiverAudioStream.format)
+                                    .apply { open(ReceiverAudioStream.format, FmFullThread.bufferSize * 2 * 2) }
+                            } catch (e: LineUnavailableException) {
+                                null
+                            }
+                        } else {
+                            val device = lastMinecraftSoundDevice!!.removePrefix(SoundSystem.OPENAL_SOFT_ON)
+                            try {
+                                AudioSystem.getSourceDataLine(
+                                    ReceiverAudioStream.format,
+                                    AudioSystem.getMixerInfo().filter {
+                                        AudioSystem.getMixer(it).sourceLineInfo.isNotEmpty()
+                                    }.map {
+                                        it to LevenshteinDistance.calculate(it.description, device)
+                                    }.minByOrNull { it.second }?.first
+                                ).apply { open(ReceiverAudioStream.format, FmFullThread.bufferSize * 2 * 2) }
+                            } catch (e: LineUnavailableException) {
+                                null
+                            }
+                        }
+                    }
+                }
+                lastOutput = audioOutput
+
+                modulators.keys.retainAll(currentTask.trackedTransmitters.keys)
+                currentTask.trackedTransmitters.forEach { (k, v) ->
+                    modulators.compute(k) { _, modulator ->
+                        if (modulator != null) {
+                            modulator.power = v.power
+                            modulator.mixingBuffer = mixingBuffers[v.frequencyOffset + 1]
+                            return@compute modulator
+                        }
+                        val audioData = v.audio.getNow(null)
+                        if (audioData != null) {
+                            val buf = FloatBuffer.wrap(audioData)
+                            var actualSampleOffset = Math.floorMod(v.sampleOffset, (buf.capacity() + REPEAT_TIMEOUT))
+                            var repeatTimeout = max(0, actualSampleOffset - buf.capacity())
+                            if (repeatTimeout > 0) {
+                                actualSampleOffset = 0
+                                repeatTimeout = REPEAT_TIMEOUT - repeatTimeout
+                            }
+                            if (actualSampleOffset == buf.capacity()) {
+                                actualSampleOffset = 0
+                                repeatTimeout = REPEAT_TIMEOUT
+                            }
+                            buf.position(actualSampleOffset)
+                            TtsModulator(
+                                buf, FmFullModulator(), v.power, repeatTimeout, mixingBuffers[v.frequencyOffset + 1]
+                            )
+                        } else {
+                            null
+                        }
+                    }
+                }
+
+                mixingBuffers.forEach {
+                    it.clear()
+                    while (it.hasRemaining()) it.put(0f)
+                    it.clear()
+                }
+
+                modulators.values.forEach {
+                    inputBuffer.clear()
+                    while (inputBuffer.hasRemaining()) {
+                        val sample = if (it.repeatTimeout > 0) {
+                            it.repeatTimeout--
+                            0f
+                        } else {
+                            it.buffer.get()
+                        }
+                        if (!it.buffer.hasRemaining()) {
+                            it.repeatTimeout = REPEAT_TIMEOUT
+                            it.buffer.clear()
+                        }
+                        for (i in 0 until 2 * 6) inputBuffer.put(sample)
+                    }
+                    inputBuffer.clear()
+                    val mixingBuffer = it.mixingBuffer
+                    it.modulator.process(inputBuffer, it.power, false) { outputBuffer ->
+                        for (i in 0 until mixingBuffer.capacity()) {
+                            mixingBuffer.put(i, mixingBuffer.get(i) + outputBuffer.get())
+                        }
+                    }
+                }
+
+                if (modulators.any { it.value.mixingBuffer === mixingBuffers[2] }) {
+                    val floatBufferHi = mixingBuffers[2]
+                    val floatView = mixingBuffers[1]
+                    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 + 1, floatView.get(i + 1) + z.y)
+                    }
+                }
+
+                if (modulators.any { it.value.mixingBuffer === mixingBuffers[0] }) {
+                    val floatBufferLo = mixingBuffers[0]
+                    val floatView = mixingBuffers[1]
+                    val z = Vector2f()
+                    val w = Vector2f()
+                    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 + 1, floatView.get(i + 1) + z.y)
+                    }
+                }
+
+                noiseGens.forEachIndexed { index, v ->
+                    if (currentTask.noiseLevels[index] != 0f) {
+                        v.generateNoise(currentTask.noiseLevels[index]) { outputBuffer ->
+                            val mixingBuffer = mixingBuffers[1]
+                            for (i in 0 until mixingBuffer.capacity()) {
+                                mixingBuffer.put(i, mixingBuffer.get(i) + outputBuffer.get())
+                            }
+                        }
+                    }
+                }
+
+                noiseFloor.noiseBlock { outputBuffer ->
+                    val mixingBuffer = mixingBuffers[1]
+                    for (i in 0 until mixingBuffer.capacity()) {
+                        mixingBuffer.put(i, mixingBuffer.get(i) + outputBuffer.get())
+                    }
+                }
+
+                @Suppress("NAME_SHADOWING")
+                val nativeAudio = nativeAudio
+                demodulator.process(mixingBuffers[1], PirateRadioClient.stereo) { _, audioBuffer ->
+                    // TODO stereo pilot
+                    // we *want* backpressure
+                    // FIXME use bigger buffers?
+                    if (nativeAudio != null) {
+                        while (audioBuffer.hasRemaining()) {
+                            if (!outputBytes.hasRemaining()) {
+                                val written = nativeAudio.write(outputBytes.array(), 0, outputBytes.capacity())
+                                outputBytes.position(written).compact()
+                                if (written == 0) {
+                                    nativeAudio.start()
+                                    continue
+                                }
+                            }
+                            val volume = PirateRadioClient.volume
+                            outputBytes.putShort(
+                                (MathHelper.clamp(
+                                    (audioBuffer.get() * 32767.5f - 0.5f).toInt(), -32768, 32767
+                                ) * volume * volume / 100).toShort()
+                            )
+                        }
+                    } else {
+                        ReceiverAudioStream.bufferQueue.put(
+                            FloatBuffer.allocate(audioBuffer.capacity()).put(audioBuffer).clear()
+                        )
+                    }
+                }
+            }
+        } catch (e: Throwable) {
+            PirateRadio.logger.error("Quitting FM simulation thread, as something went wrong!", e)
+            // for some reason it doesn't print stack trace but we still wanna propagate the exception to the thread
+            // itself
+            throw e
+        }
+    }
+}
diff --git a/src/client/kotlin/space/autistic/radio/client/fmsim/FmSimulatorMode.kt b/src/client/kotlin/space/autistic/radio/client/fmsim/FmSimulatorMode.kt
new file mode 100644
index 0000000..a8bc9fd
--- /dev/null
+++ b/src/client/kotlin/space/autistic/radio/client/fmsim/FmSimulatorMode.kt
@@ -0,0 +1,7 @@
+package space.autistic.radio.client.fmsim
+
+enum class FmSimulatorMode {
+    FULL,
+    FAST,
+    DEAF
+}
diff --git a/src/client/kotlin/space/autistic/radio/client/fmsim/NoiseFloor.kt b/src/client/kotlin/space/autistic/radio/client/fmsim/NoiseFloor.kt
new file mode 100644
index 0000000..92df212
--- /dev/null
+++ b/src/client/kotlin/space/autistic/radio/client/fmsim/NoiseFloor.kt
@@ -0,0 +1,31 @@
+package space.autistic.radio.client.fmsim
+
+import java.nio.FloatBuffer
+import java.util.concurrent.ThreadLocalRandom
+import java.util.function.Consumer
+
+class NoiseFloor(level: Float) {
+    private val buffer = FloatBuffer.allocate(300000 * 2)
+    private val outputBuffer = FloatBuffer.allocate(FmFullConstants.FFT_DATA_BLOCK_SIZE_48K_300K * 2)
+
+    init {
+        // FIXME is this how you generate IQ noise?
+        val random = ThreadLocalRandom.current()
+        val dLevel = level.toDouble()
+        while (buffer.hasRemaining()) {
+            buffer.put(random.nextGaussian(0.0, dLevel).toFloat())
+            buffer.put(0f)
+        }
+    }
+
+    // complex noise, in IQ format?
+    fun noiseBlock(consumer: Consumer<FloatBuffer>) {
+        outputBuffer.clear()
+        while (outputBuffer.hasRemaining()) {
+            if (!buffer.hasRemaining()) buffer.clear()
+            outputBuffer.put(buffer.get())
+        }
+        outputBuffer.clear()
+        consumer.accept(outputBuffer)
+    }
+}
\ No newline at end of file
diff --git a/src/client/kotlin/space/autistic/radio/client/gui/FmReceiverScreen.kt b/src/client/kotlin/space/autistic/radio/client/gui/FmReceiverScreen.kt
index 4bd4db2..8886498 100644
--- a/src/client/kotlin/space/autistic/radio/client/gui/FmReceiverScreen.kt
+++ b/src/client/kotlin/space/autistic/radio/client/gui/FmReceiverScreen.kt
@@ -1,11 +1,809 @@
 package space.autistic.radio.client.gui
 
+import com.dylibso.chicory.experimental.aot.AotMachineFactory
+import com.dylibso.chicory.runtime.*
+import com.dylibso.chicory.wasm.ChicoryException
+import com.dylibso.chicory.wasm.InvalidException
+import com.dylibso.chicory.wasm.Parser
+import com.dylibso.chicory.wasm.types.FunctionType
+import com.dylibso.chicory.wasm.types.Value
+import com.dylibso.chicory.wasm.types.ValueType
+import com.mojang.blaze3d.systems.RenderSystem
+import net.minecraft.client.MinecraftClient
+import net.minecraft.client.gui.DrawContext
 import net.minecraft.client.gui.screen.Screen
+import net.minecraft.client.gui.widget.ButtonWidget
+import net.minecraft.client.gui.widget.ClickableWidget
+import net.minecraft.client.gui.widget.CyclingButtonWidget
+import net.minecraft.client.gui.widget.SliderWidget
+import net.minecraft.client.render.GameRenderer
+import net.minecraft.client.util.InputUtil
+import net.minecraft.screen.ScreenTexts
+import net.minecraft.text.StringVisitable
 import net.minecraft.text.Text
+import net.minecraft.util.Colors
+import net.minecraft.util.Identifier
+import net.minecraft.util.math.MathHelper
+import org.lwjgl.glfw.GLFW
+import org.slf4j.LoggerFactory
+import org.slf4j.event.Level
+import space.autistic.radio.PirateRadio
+import space.autistic.radio.PirateRadio.logger
+import space.autistic.radio.client.PirateRadioClient
+import space.autistic.radio.client.fmsim.FmSimulatorMode
+import space.autistic.radio.wasm.Bindings.Companion.bindFunc
+import java.lang.invoke.MethodHandles
+import java.util.*
+import javax.sound.sampled.AudioSystem
+import javax.sound.sampled.Mixer
+import kotlin.math.max
+import kotlin.reflect.jvm.javaMethod
+
 
 class FmReceiverScreen : Screen(Text.translatable("pirate-radio.fm-receiver")) {
 
+    private var loggingEventBuilder = wasmLogger.makeLoggingEventBuilder(Level.INFO)
+    private var instance: Instance? = null
+    private var failure: Exception? = null
+        set(value) {
+            field = value
+            clearChildren()
+        }
+    private var packTitle: Text? = null
+
+    private var backgroundWidth = 0
+    private var backgroundHeight = 0
+    private var backgroundTextureWidth = 256
+    private var backgroundTextureHeight = 256
+
+    private var drawContext: DrawContext? = null
+
+    private var textObjects = ArrayList<Text?>()
+    private var textObjectsFree = ArrayList<Int>()
+
+    private val frequencyPlusWidget = ButtonWidget.builder(Text.translatable("pirate-radio.frequency.plus")) {
+        if (InputUtil.isKeyPressed(
+                MinecraftClient.getInstance().window.handle, GLFW.GLFW_KEY_LEFT_SHIFT
+            ) || InputUtil.isKeyPressed(MinecraftClient.getInstance().window.handle, GLFW.GLFW_KEY_RIGHT_SHIFT)
+        ) {
+            PirateRadioClient.frequency += 10
+        } else {
+            PirateRadioClient.frequency++
+        }
+    }.dimensions(0, 0, 20, 20)
+        .narrationSupplier { ButtonWidget.getNarrationMessage(Text.translatable("pirate-radio.frequency.plus.narrated")) }
+        .build()
+    private val frequencyMinusWidget = ButtonWidget.builder(Text.translatable("pirate-radio.frequency.minus")) {
+        if (InputUtil.isKeyPressed(
+                MinecraftClient.getInstance().window.handle, GLFW.GLFW_KEY_LEFT_SHIFT
+            ) || InputUtil.isKeyPressed(MinecraftClient.getInstance().window.handle, GLFW.GLFW_KEY_RIGHT_SHIFT)
+        ) {
+            PirateRadioClient.frequency -= 10
+        } else {
+            PirateRadioClient.frequency--
+        }
+    }.dimensions(20, 0, 20, 20)
+        .narrationSupplier { ButtonWidget.getNarrationMessage(Text.translatable("pirate-radio.frequency.plus.narrated")) }
+        .build()
+
+    private val volumePlusWidget = ButtonWidget.builder(Text.translatable("pirate-radio.volume.plus")) {
+        PirateRadioClient.volume++
+    }.dimensions(0, 20, 20, 20)
+        .narrationSupplier { ButtonWidget.getNarrationMessage(Text.translatable("pirate-radio.volume.plus.narrated")) }
+        .build()
+    private val volumeMinusWidget = ButtonWidget.builder(Text.translatable("pirate-radio.volume.minus")) {
+        PirateRadioClient.volume--
+    }.dimensions(20, 20, 20, 20)
+        .narrationSupplier { ButtonWidget.getNarrationMessage(Text.translatable("pirate-radio.volume.minus.narrated")) }
+        .build()
+    private var frequencySlider = makeFrequencySlider(0, 0, 320, 20)
+    private fun makeFrequencySlider(x: Int, y: Int, width: Int, height: Int) = object : SliderWidget(
+        x, y, width, height, ScreenTexts.EMPTY, (PirateRadioClient.frequency - 768).toDouble() / (1080.0 - 768.0)
+    ) {
+        init {
+            updateMessage()
+        }
+
+        override fun updateMessage() {
+            message = Text.translatable(
+                "pirate-radio.frequency.selected", PirateRadioClient.frequency / 10, PirateRadioClient.frequency % 10
+            )
+        }
+
+        override fun applyValue() {
+            PirateRadioClient.frequency = MathHelper.clampedLerp(768.0, 1080.0, value).toInt()
+        }
+
+        override fun keyPressed(keyCode: Int, scanCode: Int, modifiers: Int): Boolean {
+            val oldValue = this.value
+            val oldFrequency = PirateRadioClient.frequency
+            val changed = super.keyPressed(keyCode, scanCode, modifiers)
+            val bl = keyCode == GLFW.GLFW_KEY_LEFT
+            if (bl || keyCode == GLFW.GLFW_KEY_RIGHT) {
+                if (changed) {
+                    val delta = if (bl) -1 else 1
+                    val newValue = (oldFrequency + delta - 768).toDouble() / (1080.0 - 768.0)
+                    this.value = MathHelper.clamp(newValue, 0.0, 1.0)
+                    if (oldValue != this.value) {
+                        this.applyValue()
+                    }
+
+                    this.updateMessage()
+                }
+            }
+            return changed
+        }
+    }
+
+    private var volumeSlider = makeVolumeSlider(0, 20, 320, 20)
+    private fun makeVolumeSlider(x: Int, y: Int, width: Int, height: Int) = object : SliderWidget(
+        x, y, width, height, ScreenTexts.EMPTY, PirateRadioClient.volume.toDouble() / 10.0
+    ) {
+        init {
+            updateMessage()
+        }
+
+        override fun updateMessage() {
+            message = if (PirateRadioClient.volume == 0) {
+                Text.translatable("pirate-radio.volume.selected", Text.translatable("pirate-radio.volume.off"))
+            } else {
+                Text.translatable("pirate-radio.volume.selected", PirateRadioClient.volume)
+            }
+        }
+
+        override fun applyValue() {
+            PirateRadioClient.volume = MathHelper.clampedLerp(0.0, 10.0, value).toInt()
+        }
+
+
+        override fun keyPressed(keyCode: Int, scanCode: Int, modifiers: Int): Boolean {
+            val oldValue = this.value
+            val oldVolume = PirateRadioClient.volume
+            val changed = super.keyPressed(keyCode, scanCode, modifiers)
+            val bl = keyCode == GLFW.GLFW_KEY_LEFT
+            if (bl || keyCode == GLFW.GLFW_KEY_RIGHT) {
+                if (changed) {
+                    val delta = if (bl) -1 else 1
+                    val newValue = (oldVolume + delta).toDouble() / 10.0
+                    this.value = MathHelper.clamp(newValue, 0.0, 1.0)
+                    if (oldValue != this.value) {
+                        this.applyValue()
+                    }
+
+                    this.updateMessage()
+                }
+            }
+            return changed
+        }
+    }
+
+    private val toggleModes = ButtonWidget.builder(Text.translatable("pirate-radio.mode")) {
+        PirateRadioClient.mode = when (PirateRadioClient.mode) {
+            FmSimulatorMode.FULL -> FmSimulatorMode.FAST
+            else -> FmSimulatorMode.FULL
+        }
+    }.position(0, 40).build()
+    private val cycleModes = CyclingButtonWidget.builder { info: FmSimulatorMode ->
+        when (info) {
+            FmSimulatorMode.FAST -> Text.translatable("pirate-radio.mode.fast")
+            FmSimulatorMode.FULL -> Text.translatable("pirate-radio.mode.full")
+            FmSimulatorMode.DEAF -> Text.translatable("pirate-radio.mode.deaf")
+        }
+    }.values(
+        listOf(
+            FmSimulatorMode.FULL, FmSimulatorMode.FAST
+        )
+    ).initially(PirateRadioClient.mode).build(0, 40, 320, 20, Text.translatable("pirate-radio.mode")) { _, it ->
+        PirateRadioClient.mode = it
+    }
+    private val audioDevice = CyclingButtonWidget.builder { info: Mixer.Info ->
+        when (info) {
+            PirateRadioClient.minecraftAudioDevice -> Text.translatable("pirate-radio.device.system-matching-minecraft")
+            PirateRadioClient.systemDefaultAudioDevice -> Text.translatable("pirate-radio.device.system")
+            PirateRadioClient.openAlAudioDevice -> Text.translatable("pirate-radio.device.openal")
+            else -> Text.literal(info.description)
+        }
+    }.values(
+        listOf(
+            PirateRadioClient.minecraftAudioDevice,
+            PirateRadioClient.systemDefaultAudioDevice,
+            PirateRadioClient.openAlAudioDevice,
+        ), AudioSystem.getMixerInfo().filter {
+            AudioSystem.getMixer(it).sourceLineInfo.isNotEmpty()
+        }).initially(PirateRadioClient.audioDevice)
+        .build(0, 60, 320, 20, Text.translatable("pirate-radio.device.choose")) { _, it ->
+            PirateRadioClient.audioDevice = it
+        }
+
+    private val buttons = arrayListOf<ClickableWidget>(
+        frequencyPlusWidget, frequencyMinusWidget, volumePlusWidget, volumeMinusWidget, toggleModes, audioDevice
+    )
+
     override fun init() {
-        // TODO
+        if (failure == null && instance == null) {
+            try {
+                setupWasm()
+            } catch (e: WasmScreenException) {
+                logger.error("Failed to setup wasm.", e)
+                failure = e
+            }
+        }
+        if (failure == null) {
+            try {
+                setBaseLayout(0)
+                instance!!.export("init").apply()
+            } catch (e: ChicoryException) {
+                failure = WasmScreenException("Skin failed to initialize", e)
+                logger.error("Failed to initialize.", failure)
+            } catch (e: WasmScreenException) {
+                failure = e
+                logger.error("Failed to initialize.", failure)
+            }
+        }
+    }
+
+    override fun renderBackground(context: DrawContext, mouseX: Int, mouseY: Int, delta: Float) {
+        super.renderInGameBackground(context)
+
+        if (failure == null) {
+            if (backgroundWidth or backgroundHeight != 0) {
+                RenderSystem.setShader(GameRenderer::getPositionTexProgram)
+                RenderSystem.setShaderColor(1.0f, 1.0f, 1.0f, 1.0f)
+                RenderSystem.setShaderTexture(0, TEXTURE)
+                val x = (width - backgroundWidth) / 2
+                val y = (height - backgroundHeight) / 2
+                context.drawTexture(
+                    TEXTURE,
+                    x,
+                    y,
+                    0f,
+                    0f,
+                    backgroundWidth,
+                    backgroundHeight,
+                    backgroundTextureWidth,
+                    backgroundTextureHeight
+                )
+            }
+        }
+
+
+        if (client!!.debugHud.shouldShowDebugHud()) {
+            context.drawText(
+                textRenderer,
+                Text.translatable("pirate-radio.skin-pack", packTitle),
+                0,
+                height - textRenderer.fontHeight,
+                Colors.WHITE,
+                true
+            )
+        }
+    }
+
+    override fun render(context: DrawContext, mouseX: Int, mouseY: Int, delta: Float) {
+        super.render(context, mouseX, mouseY, delta)
+
+        if (failure != null) {
+            context.drawTextWrapped(
+                textRenderer, StringVisitable.plain(failure!!.message), (width - 320) / 2, 0, 320, Colors.WHITE
+            )
+        } else {
+            try {
+                drawContext = context
+                instance!!.export("render").apply(mouseX.toLong(), mouseY.toLong(), Value.floatToLong(delta))
+            } catch (e: ChicoryException) {
+                failure = WasmScreenException("Skin failed to initialize", e)
+                logger.error("Failed to initialize.", failure)
+            } catch (e: WasmScreenException) {
+                failure = e
+                logger.error("Failed to initialize.", failure)
+            } finally {
+                drawContext = null
+            }
+        }
+    }
+
+    override fun shouldPause() = false
+
+    private fun loggerLog() {
+        loggingEventBuilder.log()
+    }
+
+    private fun loggerLogMessage(message: String) {
+        loggingEventBuilder.log(message)
+    }
+
+    private fun loggerBegin(level: Int) {
+        loggingEventBuilder = wasmLogger.makeLoggingEventBuilder(
+            try {
+                Level.intToLevel(level)
+            } catch (e: IllegalArgumentException) {
+                Level.INFO
+            }
+        )
+    }
+
+    private fun loggerSetMessage(message: String) {
+        loggingEventBuilder.setMessage(message)
+    }
+
+    private fun loggerAddArgumentString(arg: String) {
+        loggingEventBuilder.addArgument(arg)
+    }
+
+    private fun loggerAddArgumentInt(arg: Int) {
+        loggingEventBuilder.addArgument(arg)
+    }
+
+    private fun loggerAddArgumentLong(arg: Long) {
+        loggingEventBuilder.addArgument(arg)
+    }
+
+    private fun loggerAddArgumentFloat(arg: Float) {
+        loggingEventBuilder.addArgument(arg)
+    }
+
+    private fun loggerAddArgumentDouble(arg: Double) {
+        loggingEventBuilder.addArgument(arg)
+    }
+
+    private fun setBackgroundSize(width: Int, height: Int) {
+        this.backgroundWidth = max(width, 0)
+        this.backgroundHeight = max(height, 0)
+    }
+
+    private fun setBackgroundTextureSize(width: Int, height: Int) {
+        this.backgroundTextureWidth = max(width, 0)
+        this.backgroundTextureHeight = max(height, 0)
+    }
+
+    private fun renderTextTranslatable(text: String, x: Int, y: Int, color: Int, shadow: Boolean) {
+        drawContext?.drawText(textRenderer, Text.translatable(text), x, y, color, shadow)
+    }
+
+    private fun renderTextLiteral(text: String, x: Int, y: Int, color: Int, shadow: Boolean) {
+        drawContext?.drawText(textRenderer, Text.literal(text), x, y, color, shadow)
+    }
+
+    private fun renderTextObject(handle: Int, x: Int, y: Int, color: Int, shadow: Boolean) {
+        if (handle >= 0 && handle < textObjects.size && textObjects[handle] != null) {
+            drawContext?.drawText(textRenderer, textObjects[handle], x, y, color, shadow)
+        } else {
+            throw WasmScreenException("Invalid text handle: $handle", null)
+        }
+    }
+
+    private fun frequencyPlusWidgetDimensions(x: Int, y: Int, width: Int, height: Int) {
+        frequencyPlusWidget.x = x
+        frequencyPlusWidget.y = y
+        frequencyPlusWidget.width = width
+        frequencyPlusWidget.height = height
+    }
+
+    private fun frequencyMinusWidgetDimensions(x: Int, y: Int, width: Int, height: Int) {
+        frequencyMinusWidget.x = x
+        frequencyMinusWidget.y = y
+        frequencyMinusWidget.width = width
+        frequencyMinusWidget.height = height
+    }
+
+    private fun volumePlusWidgetDimensions(x: Int, y: Int, width: Int, height: Int) {
+        volumePlusWidget.x = x
+        volumePlusWidget.y = y
+        volumePlusWidget.width = width
+        volumePlusWidget.height = height
+    }
+
+    private fun volumeMinusWidgetDimensions(x: Int, y: Int, width: Int, height: Int) {
+        volumeMinusWidget.x = x
+        volumeMinusWidget.y = y
+        volumeMinusWidget.width = width
+        volumeMinusWidget.height = height
+    }
+
+    private fun toggleWidgetDimensions(x: Int, y: Int, width: Int, height: Int) {
+        toggleModes.x = x
+        toggleModes.y = y
+        toggleModes.width = width
+        toggleModes.height = height
+    }
+
+    private fun audioDeviceWidgetDimensions(x: Int, y: Int, width: Int, height: Int) {
+        audioDevice.setDimensionsAndPosition(width, height, x, y)
+    }
+
+    private fun buttonsDimensions(index: Int, x: Int, y: Int, width: Int, height: Int): Boolean {
+        if (index < 0 || index >= buttons.size) {
+            return false
+        }
+        buttons[index].setDimensionsAndPosition(width, height, x, y)
+        return true
+    }
+
+    private fun getFrequency(): Int = PirateRadioClient.frequency
+
+    private fun getMode(): Int = PirateRadioClient.mode.ordinal
+
+    private fun getStereo(): Boolean = PirateRadioClient.stereo
+
+    private fun getVolume(): Int = PirateRadioClient.volume
+
+    private fun getStereoPilot(): Float = 0f
+
+    private fun getWidth(): Int = width
+
+    private fun getHeight(): Int = height
+
+    private fun setBaseLayout(layout: Int) {
+        when (layout) {
+            0 -> {
+                clearChildren()
+                addDrawableChild(frequencyPlusWidget)
+                addDrawableChild(frequencyMinusWidget)
+                addDrawableChild(volumePlusWidget)
+                addDrawableChild(volumeMinusWidget)
+                addDrawableChild(toggleModes)
+                addDrawableChild(audioDevice)
+                buttons.clear()
+                buttons.addAll(
+                    listOf(
+                        frequencyPlusWidget,
+                        frequencyMinusWidget,
+                        volumePlusWidget,
+                        volumeMinusWidget,
+                        toggleModes,
+                        audioDevice
+                    )
+                )
+            }
+
+            1 -> {
+                clearChildren()
+                frequencySlider = makeFrequencySlider(
+                    frequencySlider.x,
+                    frequencySlider.y,
+                    frequencySlider.width,
+                    frequencySlider.height
+                )
+                volumeSlider = makeVolumeSlider(volumeSlider.x, volumeSlider.y, volumeSlider.width, volumeSlider.height)
+                addDrawableChild(frequencySlider)
+                addDrawableChild(volumeSlider)
+                addDrawableChild(cycleModes)
+                addDrawableChild(audioDevice)
+                cycleModes.value = PirateRadioClient.mode
+                buttons.clear()
+                buttons.addAll(listOf(frequencySlider, volumeSlider, cycleModes, audioDevice))
+            }
+
+            else -> throw WasmScreenException("Invalid base layout: $layout", null)
+        }
+    }
+
+    // useful for drawing the stereo pilot, not that it's implemented
+    private fun drawImage(
+        red: Float, green: Float, blue: Float, alpha: Float, x: Int, y: Int, u: Float, v: Float, w: Int, h: Int
+    ) {
+        val drawContext = drawContext ?: throw WasmScreenException("Inappropriate draw call", null)
+        RenderSystem.setShader(GameRenderer::getPositionTexProgram)
+        RenderSystem.setShaderColor(red, green, blue, alpha)
+        RenderSystem.setShaderTexture(0, TEXTURE)
+        drawContext.drawTexture(
+            TEXTURE, x, y, u, v, w, h, backgroundTextureWidth, backgroundTextureHeight
+        )
+        RenderSystem.setShaderColor(1f, 1f, 1f, 1f)
+    }
+
+    private fun textFree(handle: Int) {
+        if (handle >= 0 && handle < textObjects.size && textObjects[handle] != null) {
+            textObjects[handle] = null
+            textObjectsFree.add(handle)
+        } else {
+            throw WasmScreenException("Invalid text handle: $handle", null)
+        }
+    }
+
+    private fun textLiteral(text: String): Int {
+        val index = if (textObjectsFree.isNotEmpty()) {
+            textObjectsFree.removeLast()
+        } else {
+            textObjects.add(null)
+            textObjects.size - 1
+        }
+        textObjects[index] = Text.literal(text)
+        return index
+    }
+
+    private fun textTranslatable(text: String): Int {
+        val index = if (textObjectsFree.isNotEmpty()) {
+            textObjectsFree.removeLast()
+        } else {
+            textObjects.add(null)
+            textObjects.size - 1
+        }
+        textObjects[index] = Text.translatable(text)
+        return index
+    }
+
+    private fun textTranslatableArguments(text: String, args: List<Int>): Int {
+        args.forEach { handle ->
+            if (handle < 0 || handle >= textObjects.size || textObjects[handle] == null) {
+                throw WasmScreenException("Invalid text handle: $handle", null)
+            }
+        }
+        val index = if (textObjectsFree.isNotEmpty()) {
+            textObjectsFree.removeLast()
+        } else {
+            textObjects.add(null)
+            textObjects.size - 1
+        }
+        textObjects[index] = Text.translatable(text, *Array(args.size) { textObjects[args[it]] })
+        return index
+    }
+
+    private fun setupWasm() {
+        // this should never throw
+        val resource = this.client!!.resourceManager.getResourceOrThrow(WASM_GUI)
+        packTitle = resource.pack.info.title
+        val module = try {
+            Parser.parse(resource.inputStream)
+        } catch (e: ChicoryException) {
+            throw WasmScreenException("Skin failed to load: error parsing module", e)
+        }
+        val importValues = ImportValues.builder()
+        importValues.addFunction(
+            bindFunc(
+                "text", "free", lookup, this::textFree.javaMethod!!, this
+            )
+        )
+        importValues.addFunction(
+            bindFunc(
+                "text", "literal", lookup, this::textLiteral.javaMethod!!, this
+            )
+        )
+        importValues.addFunction(
+            bindFunc(
+                "text", "translatable", lookup, this::textTranslatable.javaMethod!!, this
+            )
+        )
+        importValues.addFunction(
+            bindFunc(
+                "text", "translatable-arguments", lookup, this::textTranslatableArguments.javaMethod!!, this
+            )
+        )
+        importValues.addFunction(
+            bindFunc(
+                "frequency-plus-widget",
+                "set-dimensions",
+                lookup,
+                this::frequencyPlusWidgetDimensions.javaMethod!!,
+                this
+            )
+        )
+        importValues.addFunction(
+            bindFunc(
+                "frequency-minus-widget",
+                "set-dimensions",
+                lookup,
+                this::frequencyMinusWidgetDimensions.javaMethod!!,
+                this
+            )
+        )
+        importValues.addFunction(
+            bindFunc(
+                "volume-plus-widget", "set-dimensions", lookup, this::volumePlusWidgetDimensions.javaMethod!!, this
+            )
+        )
+        importValues.addFunction(
+            bindFunc(
+                "volume-minus-widget", "set-dimensions", lookup, this::volumeMinusWidgetDimensions.javaMethod!!, this
+            )
+        )
+        importValues.addFunction(
+            bindFunc(
+                "toggle-widget", "set-dimensions", lookup, this::toggleWidgetDimensions.javaMethod!!, this
+            )
+        )
+        importValues.addFunction(
+            bindFunc(
+                "audio-device-widget", "set-dimensions", lookup, this::audioDeviceWidgetDimensions.javaMethod!!, this
+            )
+        )
+        importValues.addFunction(
+            bindFunc(
+                "buttons", "set-dimensions", lookup, this::buttonsDimensions.javaMethod!!, this
+            )
+        )
+        importValues.addFunction(
+            bindFunc(
+                "buttons", "set-base-layout", lookup, this::setBaseLayout.javaMethod!!, this
+            )
+        )
+        importValues.addFunction(
+            bindFunc(
+                "simulator", "get-frequency", lookup, this::getFrequency.javaMethod!!, this
+            )
+        )
+        importValues.addFunction(
+            bindFunc(
+                "simulator", "get-volume", lookup, this::getVolume.javaMethod!!, this
+            )
+        )
+        importValues.addFunction(
+            bindFunc(
+                "simulator", "get-mode", lookup, this::getMode.javaMethod!!, this
+            )
+        )
+        importValues.addFunction(
+            bindFunc(
+                "simulator", "get-stereo", lookup, this::getStereo.javaMethod!!, this
+            )
+        )
+        importValues.addFunction(
+            bindFunc(
+                "simulator", "get-stereo-pilot", lookup, this::getStereoPilot.javaMethod!!, this
+            )
+        )
+        importValues.addFunction(
+            bindFunc(
+                "screen", "get-width", lookup, this::getWidth.javaMethod!!, this
+            )
+        )
+        importValues.addFunction(
+            bindFunc(
+                "screen", "get-height", lookup, this::getHeight.javaMethod!!, this
+            )
+        )
+        importValues.addFunction(
+            bindFunc(
+                "screen", "draw-image", lookup, this::drawImage.javaMethod!!, this
+            )
+        )
+        importValues.addFunction(
+            bindFunc(
+                "screen", "set-background-size", lookup, this::setBackgroundSize.javaMethod!!, this
+            )
+        )
+        importValues.addFunction(
+            bindFunc(
+                "screen", "set-background-texture-size", lookup, this::setBackgroundTextureSize.javaMethod!!, this
+            )
+        )
+        importValues.addFunction(
+            bindFunc(
+                "screen", "render-text-translatable", lookup, this::renderTextTranslatable.javaMethod!!, this
+            )
+        )
+        importValues.addFunction(
+            bindFunc(
+                "screen", "render-text-literal", lookup, this::renderTextLiteral.javaMethod!!, this
+            )
+        )
+        importValues.addFunction(
+            bindFunc(
+                "screen", "render-text-object", lookup, this::renderTextObject.javaMethod!!, this
+            )
+        )
+        importValues.addFunction(
+            bindFunc(
+                "screen", "set-background-size", lookup, this::setBackgroundSize.javaMethod!!, this
+            )
+        )
+        importValues.addFunction(
+            bindFunc(
+                "logger", "log", lookup, this::loggerLog.javaMethod!!, this
+            )
+        )
+        importValues.addFunction(
+            bindFunc(
+                "logger", "log-message", lookup, this::loggerLogMessage.javaMethod!!, this
+            )
+        )
+        importValues.addFunction(
+            bindFunc(
+                "logger", "begin", lookup, this::loggerBegin.javaMethod!!, this
+            )
+        )
+        importValues.addFunction(
+            bindFunc(
+                "logger", "log-message", lookup, this::loggerSetMessage.javaMethod!!, this
+            )
+        )
+        importValues.addFunction(
+            bindFunc(
+                "logger", "add-argument-string", lookup, this::loggerAddArgumentString.javaMethod!!, this
+            )
+        )
+        importValues.addFunction(
+            bindFunc(
+                "logger", "add-argument-int", lookup, this::loggerAddArgumentInt.javaMethod!!, this
+            )
+        )
+        importValues.addFunction(
+            bindFunc(
+                "logger", "add-argument-long", lookup, this::loggerAddArgumentLong.javaMethod!!, this
+            )
+        )
+        importValues.addFunction(
+            bindFunc(
+                "logger", "add-argument-float", lookup, this::loggerAddArgumentFloat.javaMethod!!, this
+            )
+        )
+        importValues.addFunction(
+            bindFunc(
+                "logger", "add-argument-double", lookup, this::loggerAddArgumentDouble.javaMethod!!, this
+            )
+        )
+        val builder = Instance.builder(module)
+        builder.withMachineFactory(AotMachineFactory(module))
+        builder.withImportValues(importValues.build())
+        val instance = try {
+            builder.build()
+        } catch (e: ChicoryException) {
+            throw WasmScreenException("Skin failed to load: error constructing module", e)
+        }
+        var initialize: ExportFunction? = null
+        try {
+            initialize = instance.export("_initialize")
+        } catch (_: InvalidException) {
+            // export may not exist, it's fine
+        } catch (e: ChicoryException) {
+            throw WasmScreenException("Skin failed to load: error initializing module", e)
+        }
+        try {
+            initialize?.apply()
+        } catch (e: ChicoryException) {
+            throw WasmScreenException("Skin failed to load: error initializing module", e)
+        }
+        try {
+            checkFuncExports(
+                instance,
+                "init" to FunctionType.empty(),
+                "render" to FunctionType.of(arrayOf(ValueType.I32, ValueType.I32, ValueType.F32), emptyArray())
+            )
+        } catch (e: ChicoryException) {
+            throw WasmScreenException("Skin failed to load: error checking exports", e)
+        }
+        this.instance = instance
+    }
+
+    private class WasmScreenException(message: String?, cause: Throwable?) : Exception(message, cause)
+
+    @Throws(WasmScreenException::class)
+    private fun checkFuncExports(instance: Instance, vararg exports: Pair<String, FunctionType>) {
+        val sb = StringBuilder()
+        exports.forEach { (name, type) ->
+            when (exportStatus(instance, name, type)) {
+                ExportStatus.MISSING -> sb.append("missing: ", name, type, "\n")
+                ExportStatus.TYPE_MISMATCH -> sb.append("type mismatch: ", name, type, "\n")
+                ExportStatus.OK -> {}
+            }
+        }
+        if (sb.isNotEmpty()) {
+            sb.setLength(sb.length - 1)
+            throw WasmScreenException("Skin failed to load: error checking exports:\n$sb", null)
+        }
+    }
+
+    private enum class ExportStatus {
+        OK, MISSING, TYPE_MISMATCH,
+    }
+
+    private fun exportStatus(instance: Instance, name: String, type: FunctionType): ExportStatus {
+        try {
+            // because instance.exportType doesn't check if it's actually a function
+            instance.export(name)
+            return if (instance.exportType(name) == type) ExportStatus.OK else ExportStatus.TYPE_MISMATCH
+        } catch (_: InvalidException) {
+            return ExportStatus.MISSING
+        }
+    }
+
+    companion object {
+        private val WASM_GUI = Identifier.of(PirateRadio.MOD_ID, "guis/radio-receiver.wasm")
+
+        // the texture is at a fixed location but the respack can replace it too
+        private val TEXTURE = Identifier.of(PirateRadio.MOD_ID, "textures/gui/radio-receiver.png")
+
+        private val wasmLogger = LoggerFactory.getLogger(PirateRadio.MOD_ID + "/wasm/radio-receiver")
+        private val lookup = MethodHandles.lookup()
     }
-}
\ No newline at end of file
+}
diff --git a/src/client/kotlin/space/autistic/radio/client/gui/StorageCardEditScreen.kt b/src/client/kotlin/space/autistic/radio/client/gui/StorageCardEditScreen.kt
new file mode 100644
index 0000000..6e5da3a
--- /dev/null
+++ b/src/client/kotlin/space/autistic/radio/client/gui/StorageCardEditScreen.kt
@@ -0,0 +1,115 @@
+package space.autistic.radio.client.gui
+
+import net.fabricmc.fabric.api.client.networking.v1.ClientPlayNetworking
+import net.minecraft.client.gui.DrawContext
+import net.minecraft.client.gui.screen.Screen
+import net.minecraft.client.gui.widget.ButtonWidget
+import net.minecraft.client.gui.widget.EditBoxWidget
+import net.minecraft.client.gui.widget.TextFieldWidget
+import net.minecraft.client.util.NarratorManager
+import net.minecraft.entity.player.PlayerEntity
+import net.minecraft.item.ItemStack
+import net.minecraft.screen.ScreenTexts
+import net.minecraft.text.Text
+import net.minecraft.util.Colors
+import net.minecraft.util.Hand
+import space.autistic.radio.PirateRadioComponents
+import space.autistic.radio.network.StorageCardUpdateC2SPayload
+
+class StorageCardEditScreen(
+    private val player: PlayerEntity, private val storageCard: ItemStack, private val hand: Hand
+) : Screen(NarratorManager.EMPTY) {
+    private var dirty = false
+    private var frequency = 768
+    private lateinit var editBox: EditBoxWidget
+    private lateinit var frequencyBox: TextFieldWidget
+    private lateinit var doneButton: ButtonWidget
+
+    override fun init() {
+        if (!this::editBox.isInitialized) {
+            editBox = EditBoxWidget(
+                textRenderer,
+                (width - WIDTH) / 2,
+                (height - HEIGHT) / 2,
+                WIDTH,
+                HEIGHT,
+                Text.translatable("pirate-radio.message"),
+                Text.translatable("pirate-radio.message")
+            )
+            editBox.text = storageCard.get(PirateRadioComponents.MESSAGE)?.literalString ?: ""
+            editBox.setMaxLength(16384)
+            editBox.setChangeListener {
+                dirty = true
+            }
+        }
+        frequency = storageCard.getOrDefault(PirateRadioComponents.FREQUENCY, 768)
+        if (!this::frequencyBox.isInitialized) {
+            frequencyBox =
+                TextFieldWidget(textRenderer, FREQ_WIDTH, FREQ_HEIGHT, Text.translatable("pirate-radio.frequency.edit"))
+            frequencyBox.setMaxLength(5)
+            frequencyBox.text = (frequency / 10).toString() + "." + (frequency % 10).toString()
+            frequencyBox.setTextPredicate {
+                FREQ_REGEX_CHARACTERS.matches(it)
+            }
+            frequencyBox.setChangedListener {
+                if (FREQ_REGEX.matches(it)) {
+                    frequency = it.replace(".", "").toInt()
+                    frequencyBox.setEditableColor(Colors.WHITE)
+                    dirty = true
+                } else {
+                    frequencyBox.setEditableColor(Colors.RED)
+                }
+            }
+        }
+        editBox.x = (width - WIDTH) / 2
+        editBox.y = (height - HEIGHT) / 2
+        frequencyBox.x = editBox.x
+        frequencyBox.y = editBox.y - FREQ_HEIGHT
+        addDrawableChild(frequencyBox)
+        addDrawableChild(editBox)
+        doneButton = this.addDrawableChild(
+            ButtonWidget.builder(ScreenTexts.DONE) {
+                client!!.setScreen(null)
+                this.saveChanges()
+            }.dimensions(editBox.x + editBox.width - 98, frequencyBox.y, 98, 20).build()
+        )
+    }
+
+    override fun render(context: DrawContext, mouseX: Int, mouseY: Int, delta: Float) {
+        super.render(context, mouseX, mouseY, delta)
+
+        context.drawText(
+            textRenderer,
+            Text.translatable("pirate-radio.frequency.edit"),
+            frequencyBox.x,
+            frequencyBox.y - textRenderer.fontHeight,
+            Colors.WHITE,
+            true
+        )
+    }
+
+    private fun saveChanges() {
+        if (this.dirty) {
+            this.writeNbtData()
+            val slot = if (this.hand == Hand.MAIN_HAND) player.inventory.selectedSlot else 40
+            ClientPlayNetworking.send(StorageCardUpdateC2SPayload(slot, this.editBox.text, this.frequency))
+        }
+    }
+
+    private fun writeNbtData() {
+        this.storageCard.set(PirateRadioComponents.MESSAGE, Text.literal(this.editBox.text))
+        this.storageCard.set(PirateRadioComponents.FREQUENCY, this.frequency)
+    }
+
+    companion object {
+        const val WIDTH = 192
+        const val HEIGHT = 144
+
+        const val FREQ_WIDTH = 40
+        const val FREQ_HEIGHT = 20
+
+        val FREQ_REGEX = Regex("^76\\.[8-9]|7[7-9]\\.[0-9]|[8-9][0-9]\\.[0-9]|100\\.0$")
+        val FREQ_REGEX_CHARACTERS = Regex("^[0-9]*\\.?[0-9]?$")
+
+    }
+}
\ 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..98e80d4
--- /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.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/sound/PirateRadioSoundInstance.kt b/src/client/kotlin/space/autistic/radio/client/sound/PirateRadioSoundInstance.kt
new file mode 100644
index 0000000..435a488
--- /dev/null
+++ b/src/client/kotlin/space/autistic/radio/client/sound/PirateRadioSoundInstance.kt
@@ -0,0 +1,165 @@
+package space.autistic.radio.client.sound
+
+import com.dylibso.chicory.wasm.ChicoryException
+import net.fabricmc.fabric.api.client.sound.v1.FabricSoundInstance
+import net.minecraft.client.MinecraftClient
+import net.minecraft.client.network.ClientPlayerEntity
+import net.minecraft.client.sound.*
+import net.minecraft.sound.SoundCategory
+import net.minecraft.sound.SoundEvents
+import net.minecraft.util.Identifier
+import space.autistic.radio.PirateRadioEntityTypes
+import space.autistic.radio.client.PirateRadioClient
+import space.autistic.radio.client.flite.FliteWrapper
+import space.autistic.radio.client.fmsim.FmFullThread
+import space.autistic.radio.client.fmsim.FmFullThread.trackedTransmitterQueue
+import space.autistic.radio.client.fmsim.FmSimulatorMode
+import space.autistic.radio.entity.DisposableTransmitterEntity
+import space.autistic.radio.wasm.WasmExitException
+import java.lang.ref.WeakReference
+import java.nio.FloatBuffer
+import java.util.UUID
+import java.util.concurrent.CompletableFuture
+import kotlin.math.PI
+
+class PirateRadioSoundInstance(private val player: ClientPlayerEntity) : MovingSoundInstance(
+    SoundEvents.INTENTIONALLY_EMPTY, SoundCategory.MUSIC, SoundInstance.createRandom()
+) {
+    private val futuresCache = HashMap<String, WeakReference<CompletableFuture<FloatArray>>>()
+
+    class TrackedTransmitter(
+        val power: Float, val sampleOffset: Int, val audio: CompletableFuture<FloatArray>, val frequencyOffset: Int
+    ) {
+        override fun toString(): String {
+            return "TrackedTransmitter(power=$power, sampleOffset=$sampleOffset, audio=$audio, frequencyOffset=$frequencyOffset)"
+        }
+    }
+
+    init {
+        this.repeat = false
+        this.repeatDelay = 0
+        this.volume = 1f
+        this.pitch = 1f
+        this.relative = true
+    }
+
+    override fun tick() {
+        if (player.isRemoved) {
+            this.setDone()
+            return
+        }
+        // find relevant entities
+        @Suppress("UNCHECKED_CAST") val trackedEntities: List<DisposableTransmitterEntity> =
+            player.clientWorld.entities.filter { it.type == PirateRadioEntityTypes.DISPOSABLE_TRANSMITTER }
+                .filter { (it as DisposableTransmitterEntity).frequency <= PirateRadioClient.frequency + 1 && it.frequency >= PirateRadioClient.frequency - 1 && it.text.isNotEmpty() }
+                .sortedBy { player.pos.squaredDistanceTo(it.pos) } as List<DisposableTransmitterEntity>
+        val main = trackedEntities.filter { it.frequency == PirateRadioClient.frequency }
+            .take(if (PirateRadioClient.mode == FmSimulatorMode.FAST) 1 else 2)
+        val lower = trackedEntities.filter { it.frequency == PirateRadioClient.frequency - 1 }
+            .take(if (PirateRadioClient.mode == FmSimulatorMode.FAST) 0 else 1)
+        val upper = trackedEntities.filter { it.frequency == PirateRadioClient.frequency + 1 }
+            .take(if (PirateRadioClient.mode == FmSimulatorMode.FAST) 0 else 1)
+        val mainNoise = trackedEntities.filter { it.frequency == PirateRadioClient.frequency }.drop(main.size)
+            .fold(0f) { noise, entity ->
+                noise + getPowerReceived(entity.pos.squaredDistanceTo(player.pos))
+            }
+        val lowerNoise = trackedEntities.filter { it.frequency == PirateRadioClient.frequency - 1 }.drop(lower.size)
+            .fold(0f) { noise, entity ->
+                noise + getPowerReceived(entity.pos.squaredDistanceTo(player.pos))
+            }
+        val upperNoise = trackedEntities.filter { it.frequency == PirateRadioClient.frequency + 1 }.drop(upper.size)
+            .fold(0f) { noise, entity ->
+                noise + getPowerReceived(entity.pos.squaredDistanceTo(player.pos))
+            }
+        // updated tracked transmitters
+        val trackedTransmitters: MutableMap<UUID, TrackedTransmitter> = HashMap()
+        listOf(lower, main, upper).flatten().associateTo(trackedTransmitters) {
+            val text = it.text
+            val audio = futuresCache[text]?.get() ?: CompletableFuture.supplyAsync {
+                try {
+                    lateinit var buffer: FloatBuffer
+                    FliteWrapper.textToWave(text) {
+                        buffer = FloatBuffer.allocate(it.capacity())
+                        while (it.hasRemaining()) {
+                            val sample = (it.get().toFloat() + 0.5f) / 32767.5f
+                            buffer.put(sample)
+                        }
+                    }
+                    buffer.array()
+                } catch (e: ChicoryException) {
+                    floatArrayOf()
+                } catch (e: WasmExitException) {
+                    floatArrayOf()
+                }
+            }
+            futuresCache[text] = WeakReference(audio)
+            it.uuid to TrackedTransmitter(
+                getPowerReceived(it.pos.squaredDistanceTo(player.pos)),
+                it.age * (8000 / 20),
+                audio,
+                it.frequency - PirateRadioClient.frequency
+            )
+        }
+
+
+        val audioOutput = PirateRadioClient.audioDevice
+        val minecraftSoundDevice = if (audioOutput === PirateRadioClient.minecraftAudioDevice) {
+            MinecraftClient.getInstance().options.soundDevice.value
+        } else {
+            null
+        }
+
+        // this can be empty but it is not EMPTY_TASK
+        trackedTransmitterQueue.offer(
+            FmFullThread.FmTask(
+                trackedTransmitters, floatArrayOf(lowerNoise, mainNoise, upperNoise), audioOutput, minecraftSoundDevice
+            )
+        )
+        volume = PirateRadioClient.volume.toFloat() / 10
+        volume *= volume
+    }
+
+    private fun getPowerReceived(rsq: Double): Float {
+        // https://www.antenna-theory.com/basics/friis.php
+        // also fudge results if too close to transmitter
+        if (rsq < 1.0) return (FRIIS_FACTOR * rsq).toFloat()
+        return (FRIIS_FACTOR / rsq).toFloat()
+    }
+
+    override fun getAudioStream(
+        loader: SoundLoader?, id: Identifier?, repeatInstantly: Boolean
+    ): CompletableFuture<AudioStream> {
+        // TODO setup thread
+        return CompletableFuture.completedFuture(ReceiverAudioStream)
+    }
+
+    override fun getVolume(): Float {
+        return this.volume
+    }
+
+    override fun getPitch(): Float {
+        return this.pitch
+    }
+
+    override fun getSound(): Sound {
+        return Sound(
+            FabricSoundInstance.EMPTY_SOUND,
+            { 1f },
+            { 1f },
+            1,
+            Sound.RegistrationType.SOUND_EVENT,
+            true,
+            false,
+            16
+        )
+    }
+
+    companion object {
+        private const val TRANSMIT_POWER = 0.01
+
+        // for 100MHz
+        private const val WAVELENGTH = 3.0
+        private const val UNIT_SPHERE = 4.0 * PI
+        private const val FRIIS_FACTOR = TRANSMIT_POWER * WAVELENGTH / (UNIT_SPHERE * UNIT_SPHERE)
+    }
+}
diff --git a/src/client/kotlin/space/autistic/radio/client/sound/ReceiverAudioStream.kt b/src/client/kotlin/space/autistic/radio/client/sound/ReceiverAudioStream.kt
new file mode 100644
index 0000000..34d5585
--- /dev/null
+++ b/src/client/kotlin/space/autistic/radio/client/sound/ReceiverAudioStream.kt
@@ -0,0 +1,45 @@
+package space.autistic.radio.client.sound
+
+import it.unimi.dsi.fastutil.floats.FloatConsumer
+import net.minecraft.client.sound.BufferedAudioStream
+import space.autistic.radio.client.fmsim.FmFullConstants
+import space.autistic.radio.client.fmsim.FmFullThread
+import space.autistic.radio.client.fmsim.FmFullThread.trackedTransmitterQueue
+import java.nio.FloatBuffer
+import java.util.Properties
+import java.util.concurrent.ArrayBlockingQueue
+import javax.sound.sampled.AudioFormat
+import kotlin.math.max
+
+object ReceiverAudioStream : BufferedAudioStream {
+    private val format = AudioFormat(48000f, 16, 2, true, false)
+
+    val bufferQueue = ArrayBlockingQueue<FloatBuffer>(
+        max(
+            0,
+            System.getProperty("space.autistic.radio.buffers", "").toIntOrNull() ?: 250
+        )
+    )
+
+    val useNativeAudio = System.getProperty("space.autistic.radio.output", "native") == "native"
+
+    private val skipBuffer = FloatBuffer.allocate(FmFullConstants.FFT_DATA_BLOCK_SIZE_LPF_48K_15K_3K1 * 2)
+        get() = field.clear()
+
+    override fun close() {
+        trackedTransmitterQueue.clear()
+        trackedTransmitterQueue.offer(FmFullThread.EMPTY_TASK)
+    }
+
+    override fun getFormat(): AudioFormat {
+        return format
+    }
+
+    override fun read(channelList: FloatConsumer): Boolean {
+        val buffer = bufferQueue.poll() ?: skipBuffer
+        while (buffer.hasRemaining()) {
+            channelList.accept(buffer.get())
+        }
+        return true
+    }
+}
\ No newline at end of file
diff --git a/src/client/kotlin/space/autistic/radio/client/util/LevenshteinDistance.kt b/src/client/kotlin/space/autistic/radio/client/util/LevenshteinDistance.kt
new file mode 100644
index 0000000..6f04ce1
--- /dev/null
+++ b/src/client/kotlin/space/autistic/radio/client/util/LevenshteinDistance.kt
@@ -0,0 +1,54 @@
+//The MIT License (MIT)
+//
+//Copyright (c) 2024
+//
+//Permission is hereby granted, free of charge, to any person obtaining a copy
+//of this software and associated documentation files (the "Software"), to deal
+//in the Software without restriction, including without limitation the rights
+//to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+//copies of the Software, and to permit persons to whom the Software is
+//furnished to do so, subject to the following conditions:
+//
+//The above copyright notice and this permission notice shall be included in
+//all copies or substantial portions of the Software.
+//
+//THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+//IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+//FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+//AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+//LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+//OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+//THE SOFTWARE.
+
+package space.autistic.radio.client.util
+
+// taken from https://github.com/Volcano-Bay-Studios/pavlovian-dogs/blob/master/src%2Fmain%2Fjava%2Fxyz%2Fvolcanobay%2Fpavloviandogs%2Futil%2FLevenshteinDistance.java
+object LevenshteinDistance {
+    fun calculate(x: String, y: String): Int {
+        val dp = Array(x.length + 1) { IntArray(y.length + 1) }
+
+        for (i in 0..x.length) {
+            for (j in 0..y.length) {
+                if (i == 0) {
+                    dp[i][j] = j
+                } else if (j == 0) {
+                    dp[i][j] = i
+                } else {
+                    dp[i][j] = min(
+                        dp[i - 1][j - 1] + costOfSubstitution(x[i - 1], y[j - 1]), dp[i - 1][j] + 1, dp[i][j - 1] + 1
+                    )
+                }
+            }
+        }
+
+        return dp[x.length][y.length]
+    }
+
+    private fun costOfSubstitution(a: Char, b: Char): Int {
+        return if (a == b) 0 else 1
+    }
+
+    private fun min(vararg numbers: Int): Int {
+        return numbers.minOrNull() ?: Int.MAX_VALUE
+    }
+}
\ No newline at end of file
diff --git a/src/client/resources/pirate-radio.client-mixins.json b/src/client/resources/pirate-radio.client-mixins.json
new file mode 100644
index 0000000..27a5861
--- /dev/null
+++ b/src/client/resources/pirate-radio.client-mixins.json
@@ -0,0 +1,14 @@
+{
+  "required": true,
+  "minVersion": "0.8",
+  "package": "space.autistic.radio.client.mixin",
+  "compatibilityLevel": "JAVA_21",
+  "mixins": [
+  ],
+  "client": [
+    "BlockStatesLoaderMixin"
+  ],
+  "injectors": {
+    "defaultRequire": 1
+  }
+}
\ No newline at end of file