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/kotlin/space/autistic/radio/client/PirateRadioClient.kt10
-rw-r--r--src/client/kotlin/space/autistic/radio/client/flite/FliteFactory.kt15
-rw-r--r--src/client/kotlin/space/autistic/radio/client/fmsim/FmFullThread.kt331
-rw-r--r--src/client/kotlin/space/autistic/radio/client/gui/FmReceiverScreen.kt233
-rw-r--r--src/client/kotlin/space/autistic/radio/client/gui/StorageCardEditScreen.kt2
-rw-r--r--src/client/kotlin/space/autistic/radio/client/sound/PirateRadioSoundInstance.kt43
-rw-r--r--src/client/kotlin/space/autistic/radio/client/sound/ReceiverAudioStream.kt2
-rw-r--r--src/client/kotlin/space/autistic/radio/client/util/LevenshteinDistance.kt54
-rw-r--r--src/client/resources/pirate-radio.client-mixins.json14
9 files changed, 546 insertions, 158 deletions
diff --git a/src/client/kotlin/space/autistic/radio/client/PirateRadioClient.kt b/src/client/kotlin/space/autistic/radio/client/PirateRadioClient.kt
index b3c9f17..87fd144 100644
--- a/src/client/kotlin/space/autistic/radio/client/PirateRadioClient.kt
+++ b/src/client/kotlin/space/autistic/radio/client/PirateRadioClient.kt
@@ -20,6 +20,8 @@ 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
 
@@ -36,6 +38,14 @@ object PirateRadioClient : ClientModInitializer {
         }
     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()
diff --git a/src/client/kotlin/space/autistic/radio/client/flite/FliteFactory.kt b/src/client/kotlin/space/autistic/radio/client/flite/FliteFactory.kt
index 993cbe9..768a040 100644
--- a/src/client/kotlin/space/autistic/radio/client/flite/FliteFactory.kt
+++ b/src/client/kotlin/space/autistic/radio/client/flite/FliteFactory.kt
@@ -9,23 +9,28 @@ 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 {
-        throw UnsupportedOperationException()
+        // EBADF
+        return 8
     }
 
     private fun fd_seek(a: Int, b: Long, c: Int, d: Int): Int {
-        throw UnsupportedOperationException()
+        // EBADF
+        return 8
     }
 
     private fun fd_close(a: Int): Int {
-        throw UnsupportedOperationException()
+        // EBADF
+        return 8
     }
 
     private fun fd_write(a: Int, b: Int, c: Int, d: Int): Int {
-        throw UnsupportedOperationException()
+        // EBADF
+        return 8
     }
 
     private fun proc_exit(status: Int): Nothing {
@@ -51,7 +56,7 @@ object FliteFactory : () -> Instance {
 
     private fun getModuleInputStream(): InputStream {
         return FabricLoader.getInstance().getModContainer("pirate-radio").flatMap { it.findPath("flite.wasm") }
-            .map<InputStream?> { it.toFile().inputStream() }.orElseGet {
+            .map { it.inputStream() }.orElseGet {
                 this.javaClass.getResourceAsStream("/flite.wasm")
             }
     }
diff --git a/src/client/kotlin/space/autistic/radio/client/fmsim/FmFullThread.kt b/src/client/kotlin/space/autistic/radio/client/fmsim/FmFullThread.kt
index 0a6184f..bce7a72 100644
--- a/src/client/kotlin/space/autistic/radio/client/fmsim/FmFullThread.kt
+++ b/src/client/kotlin/space/autistic/radio/client/fmsim/FmFullThread.kt
@@ -1,25 +1,34 @@
 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.entity.DisposableTransmitterEntity
+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
-import kotlin.math.min
 
 object FmFullThread : Runnable {
     class FmTask(
-        val trackedTransmitters: Map<DisposableTransmitterEntity, PirateRadioSoundInstance.TrackedTransmitter>,
+        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))
+    val EMPTY_TASK = FmTask(emptyMap(), FloatArray(3), PirateRadioClient.minecraftAudioDevice, null)
 
     val trackedTransmitterQueue = ArrayBlockingQueue<FmTask>(8)
 
@@ -34,9 +43,12 @@ object FmFullThread : Runnable {
     // 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<DisposableTransmitterEntity, TtsModulator>()
+        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)
@@ -51,135 +63,232 @@ object FmFullThread : Runnable {
 
         val demodulator = FmFullDemodulator()
 
-        while (!Thread.interrupted()) {
-            currentTask = trackedTransmitterQueue.poll() ?: currentTask
-            if (currentTask === EMPTY_TASK) {
-                Thread.onSpinWait()
-                continue
-            }
-            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
+        // 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
+                            }
+                        }
                     }
-                    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
+                }
+                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
                         }
-                        if (actualSampleOffset == buf.capacity()) {
-                            actualSampleOffset = 0
-                            repeatTimeout = REPEAT_TIMEOUT
+                        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
                         }
-                        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()
-            }
+                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()
+                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)
                     }
-                    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())
+                    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()
+                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)
                     }
-                    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()
+                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)
                     }
-                    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())
+                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())
+                noiseFloor.noiseBlock { outputBuffer ->
+                    val mixingBuffer = mixingBuffers[1]
+                    for (i in 0 until mixingBuffer.capacity()) {
+                        mixingBuffer.put(i, mixingBuffer.get(i) + outputBuffer.get())
+                    }
                 }
-            }
 
-            demodulator.process(mixingBuffers[1], PirateRadioClient.stereo) { _, audioBuffer ->
-                // TODO stereo pilot
-                // we *want* backpressure
-                // FIXME use bigger buffers?
-                ReceiverAudioStream.bufferQueue.put(FloatBuffer.allocate(audioBuffer.capacity()).put(audioBuffer).clear())
+                @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
         }
     }
-}
\ 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 f5fd729..8886498 100644
--- a/src/client/kotlin/space/autistic/radio/client/gui/FmReceiverScreen.kt
+++ b/src/client/kotlin/space/autistic/radio/client/gui/FmReceiverScreen.kt
@@ -14,12 +14,16 @@ 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
@@ -29,6 +33,9 @@ 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
 
@@ -55,8 +62,9 @@ class FmReceiverScreen : Screen(Text.translatable("pirate-radio.fm-receiver")) {
     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)
+        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 {
@@ -66,8 +74,9 @@ class FmReceiverScreen : Screen(Text.translatable("pirate-radio.fm-receiver")) {
         .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)
+        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 {
@@ -87,12 +96,128 @@ class FmReceiverScreen : Screen(Text.translatable("pirate-radio.fm-receiver")) {
     }.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() {
         if (failure == null && instance == null) {
@@ -105,13 +230,8 @@ class FmReceiverScreen : Screen(Text.translatable("pirate-radio.fm-receiver")) {
         }
         if (failure == null) {
             try {
+                setBaseLayout(0)
                 instance!!.export("init").apply()
-                addDrawableChild(frequencyPlusWidget)
-                addDrawableChild(frequencyMinusWidget)
-                addDrawableChild(volumePlusWidget)
-                addDrawableChild(volumeMinusWidget)
-                addDrawableChild(toggleModes)
-                volumePlusWidget.visible
             } catch (e: ChicoryException) {
                 failure = WasmScreenException("Skin failed to initialize", e)
                 logger.error("Failed to initialize.", failure)
@@ -287,6 +407,18 @@ class FmReceiverScreen : Screen(Text.translatable("pirate-radio.fm-receiver")) {
         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
@@ -301,33 +433,61 @@ class FmReceiverScreen : Screen(Text.translatable("pirate-radio.fm-receiver")) {
 
     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
+        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
+            TEXTURE, x, y, u, v, w, h, backgroundTextureWidth, backgroundTextureHeight
         )
         RenderSystem.setShaderColor(1f, 1f, 1f, 1f)
     }
@@ -444,6 +604,21 @@ class FmReceiverScreen : Screen(Text.translatable("pirate-radio.fm-receiver")) {
         )
         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
             )
         )
diff --git a/src/client/kotlin/space/autistic/radio/client/gui/StorageCardEditScreen.kt b/src/client/kotlin/space/autistic/radio/client/gui/StorageCardEditScreen.kt
index a1dd5d5..6e5da3a 100644
--- a/src/client/kotlin/space/autistic/radio/client/gui/StorageCardEditScreen.kt
+++ b/src/client/kotlin/space/autistic/radio/client/gui/StorageCardEditScreen.kt
@@ -108,7 +108,7 @@ class StorageCardEditScreen(
         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]|10[0-7]\\.[0-9]|108\\.0$")
+        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]?$")
 
     }
diff --git a/src/client/kotlin/space/autistic/radio/client/sound/PirateRadioSoundInstance.kt b/src/client/kotlin/space/autistic/radio/client/sound/PirateRadioSoundInstance.kt
index 5fb80bc..435a488 100644
--- a/src/client/kotlin/space/autistic/radio/client/sound/PirateRadioSoundInstance.kt
+++ b/src/client/kotlin/space/autistic/radio/client/sound/PirateRadioSoundInstance.kt
@@ -1,6 +1,8 @@
 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
@@ -13,8 +15,10 @@ 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
 
@@ -47,7 +51,7 @@ class PirateRadioSoundInstance(private val player: ClientPlayerEntity) : MovingS
         // 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 }
+                .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)
@@ -68,32 +72,47 @@ class PirateRadioSoundInstance(private val player: ClientPlayerEntity) : MovingS
                 noise + getPowerReceived(entity.pos.squaredDistanceTo(player.pos))
             }
         // updated tracked transmitters
-        val trackedTransmitters: MutableMap<DisposableTransmitterEntity, TrackedTransmitter> = HashMap()
-        listOf(lower, main, upper).flatten().associateWithTo(trackedTransmitters) {
+        val trackedTransmitters: MutableMap<UUID, TrackedTransmitter> = HashMap()
+        listOf(lower, main, upper).flatten().associateTo(trackedTransmitters) {
             val text = it.text
             val audio = futuresCache[text]?.get() ?: CompletableFuture.supplyAsync {
-                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)
+                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()
                 }
-                buffer.array()
             }
             futuresCache[text] = WeakReference(audio)
-            TrackedTransmitter(
+            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)
+                trackedTransmitters, floatArrayOf(lowerNoise, mainNoise, upperNoise), audioOutput, minecraftSoundDevice
             )
         )
         volume = PirateRadioClient.volume.toFloat() / 10
diff --git a/src/client/kotlin/space/autistic/radio/client/sound/ReceiverAudioStream.kt b/src/client/kotlin/space/autistic/radio/client/sound/ReceiverAudioStream.kt
index 274e727..34d5585 100644
--- a/src/client/kotlin/space/autistic/radio/client/sound/ReceiverAudioStream.kt
+++ b/src/client/kotlin/space/autistic/radio/client/sound/ReceiverAudioStream.kt
@@ -21,6 +21,8 @@ object ReceiverAudioStream : BufferedAudioStream {
         )
     )
 
+    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()
 
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