summary refs log tree commit diff stats
diff options
context:
space:
mode:
authorSoniEx2 <endermoneymod@gmail.com>2025-03-20 15:12:37 -0300
committerSoniEx2 <endermoneymod@gmail.com>2025-03-20 15:12:37 -0300
commit32f4966e8d3e824619fabbd225ee8e74b62be7c7 (patch)
tree1bd61a9459b32b48e81279e99c33180312f7d1a5
parentb1b2b791f55c808483e404e5c35045506432a400 (diff)
Add a button to select audio output
-rw-r--r--gradle.properties2
-rw-r--r--gui/radio-receiver.c3
-rw-r--r--src/client/kotlin/space/autistic/radio/client/PirateRadioClient.kt10
-rw-r--r--src/client/kotlin/space/autistic/radio/client/fmsim/FmFullThread.kt80
-rw-r--r--src/client/kotlin/space/autistic/radio/client/gui/FmReceiverScreen.kt82
-rw-r--r--src/client/kotlin/space/autistic/radio/client/sound/PirateRadioSoundInstance.kt12
-rw-r--r--src/client/kotlin/space/autistic/radio/client/sound/ReceiverAudioStream.kt6
-rw-r--r--src/client/kotlin/space/autistic/radio/client/util/LevenshteinDistance.kt54
-rw-r--r--src/main/resources/assets/pirate-radio/lang/en_us.json6
9 files changed, 216 insertions, 39 deletions
diff --git a/gradle.properties b/gradle.properties
index cbc5cac..080b6d9 100644
--- a/gradle.properties
+++ b/gradle.properties
@@ -10,7 +10,7 @@ loader_version=0.16.10
 fabric_kotlin_version=1.13.1+kotlin.2.1.10
 
 # Mod Properties
-mod_version=1.0.3
+mod_version=1.0.4
 maven_group=space.autistic.radio
 archives_base_name=pirate-radio
 
diff --git a/gui/radio-receiver.c b/gui/radio-receiver.c
index 136b45f..defd419 100644
--- a/gui/radio-receiver.c
+++ b/gui/radio-receiver.c
@@ -61,6 +61,8 @@ extern void fp_set_dimensions(int x, int y, int width, int height);
 extern void fm_set_dimensions(int x, int y, int width, int height);
 [[clang::import_module("toggle-widget")]] [[clang::import_name("set-dimensions")]]
 extern void mode_set_dimensions(int x, int y, int width, int height);
+[[clang::import_module("audio-device-widget")]] [[clang::import_name("set-dimensions")]]
+extern void audio_device_set_dimensions(int x, int y, int width, int height);
 
 #define BACKGROUND_WIDTH 200
 #define BACKGROUND_HEIGHT 100
@@ -79,6 +81,7 @@ void init() {
 	vp_set_dimensions(base_x + 100 - 40, base_y + 20, 20, 20);
 	vm_set_dimensions(base_x + 120 - 40, base_y + 20, 20, 20);
 	mode_set_dimensions(base_x + 100 - 100, base_y + 40, 100, 20);
+	audio_device_set_dimensions(base_x, base_y + 60, 200, 20);
 }
 
 void render(int mouseX, int mouseY, float delta) {
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/fmsim/FmFullThread.kt b/src/client/kotlin/space/autistic/radio/client/fmsim/FmFullThread.kt
index d3b39b8..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,5 +1,6 @@
 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
@@ -7,22 +8,27 @@ 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
 
 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))
+    val EMPTY_TASK = FmTask(emptyMap(), FloatArray(3), PirateRadioClient.minecraftAudioDevice, null)
 
     val trackedTransmitterQueue = ArrayBlockingQueue<FmTask>(8)
 
@@ -38,7 +44,7 @@ object FmFullThread : Runnable {
     private const val REPEAT_TIMEOUT = 8000 * 3
 
     // default to 0.05s (1/20)
-    private val bufferSize = System.getProperty("space.autistic.radio.buffer.size", "").toIntOrNull() ?: 2400
+    val bufferSize = System.getProperty("space.autistic.radio.buffer.size", "").toIntOrNull() ?: 2400
 
     override fun run() {
         var currentTask = EMPTY_TASK
@@ -59,12 +65,14 @@ object FmFullThread : Runnable {
 
         // for native audio only
         val outputBytes = ByteBuffer.allocate(bufferSize * 2 * 2).order(ByteOrder.LITTLE_ENDIAN)
-        val nativeAudio = if (ReceiverAudioStream.useNativeAudio) {
-            javax.sound.sampled.AudioSystem.getSourceDataLine(ReceiverAudioStream.format)
+        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()) {
@@ -73,6 +81,62 @@ object FmFullThread : Runnable {
                     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 ->
@@ -190,14 +254,16 @@ object FmFullThread : Runnable {
                     }
                 }
 
+                @Suppress("NAME_SHADOWING")
+                val nativeAudio = nativeAudio
                 demodulator.process(mixingBuffers[1], PirateRadioClient.stereo) { _, audioBuffer ->
                     // TODO stereo pilot
                     // we *want* backpressure
                     // FIXME use bigger buffers?
-                    if (ReceiverAudioStream.useNativeAudio) {
+                    if (nativeAudio != null) {
                         while (audioBuffer.hasRemaining()) {
                             if (!outputBytes.hasRemaining()) {
-                                val written = nativeAudio!!.write(outputBytes.array(), 0, outputBytes.capacity())
+                                val written = nativeAudio.write(outputBytes.array(), 0, outputBytes.capacity())
                                 outputBytes.position(written).compact()
                                 if (written == 0) {
                                     nativeAudio.start()
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..e4ea8ea 100644
--- a/src/client/kotlin/space/autistic/radio/client/gui/FmReceiverScreen.kt
+++ b/src/client/kotlin/space/autistic/radio/client/gui/FmReceiverScreen.kt
@@ -14,6 +14,7 @@ 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.render.GameRenderer
 import net.minecraft.client.util.InputUtil
 import net.minecraft.text.StringVisitable
@@ -29,6 +30,10 @@ 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.collections.ArrayList
 import kotlin.math.max
 import kotlin.reflect.jvm.javaMethod
 
@@ -55,8 +60,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 +72,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 {
@@ -93,6 +100,28 @@ class FmReceiverScreen : Screen(Text.translatable("pirate-radio.fm-receiver")) {
             else -> FmSimulatorMode.FULL
         }
     }.position(0, 40).build()
+    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 = arrayOf(
+        frequencyPlusWidget, frequencyMinusWidget, volumePlusWidget, volumeMinusWidget, toggleModes, audioDevice
+    )
 
     override fun init() {
         if (failure == null && instance == null) {
@@ -111,7 +140,7 @@ class FmReceiverScreen : Screen(Text.translatable("pirate-radio.fm-receiver")) {
                 addDrawableChild(volumePlusWidget)
                 addDrawableChild(volumeMinusWidget)
                 addDrawableChild(toggleModes)
-                volumePlusWidget.visible
+                addDrawableChild(audioDevice)
             } catch (e: ChicoryException) {
                 failure = WasmScreenException("Skin failed to initialize", e)
                 logger.error("Failed to initialize.", failure)
@@ -287,6 +316,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
@@ -303,31 +344,14 @@ class FmReceiverScreen : Screen(Text.translatable("pirate-radio.fm-receiver")) {
 
     // 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 +468,16 @@ 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(
                 "simulator", "get-frequency", lookup, this::getFrequency.javaMethod!!, this
             )
         )
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 98db4d6..435a488 100644
--- a/src/client/kotlin/space/autistic/radio/client/sound/PirateRadioSoundInstance.kt
+++ b/src/client/kotlin/space/autistic/radio/client/sound/PirateRadioSoundInstance.kt
@@ -2,6 +2,7 @@ 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
@@ -99,10 +100,19 @@ class PirateRadioSoundInstance(private val player: ClientPlayerEntity) : MovingS
                 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 dc6d9b7..34d5585 100644
--- a/src/client/kotlin/space/autistic/radio/client/sound/ReceiverAudioStream.kt
+++ b/src/client/kotlin/space/autistic/radio/client/sound/ReceiverAudioStream.kt
@@ -36,11 +36,7 @@ object ReceiverAudioStream : BufferedAudioStream {
     }
 
     override fun read(channelList: FloatConsumer): Boolean {
-        val buffer = if (useNativeAudio) {
-            bufferQueue.poll() ?: skipBuffer
-        } else {
-            skipBuffer
-        }
+        val buffer = bufferQueue.poll() ?: skipBuffer
         while (buffer.hasRemaining()) {
             channelList.accept(buffer.get())
         }
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/main/resources/assets/pirate-radio/lang/en_us.json b/src/main/resources/assets/pirate-radio/lang/en_us.json
index 81145f0..974deb9 100644
--- a/src/main/resources/assets/pirate-radio/lang/en_us.json
+++ b/src/main/resources/assets/pirate-radio/lang/en_us.json
@@ -26,5 +26,9 @@
   "pirate-radio.frequency.selected": "Frequency: %s.%s MHz",
   "pirate-radio.storage-card": "SD Card",
   "pirate-radio.message": "Message...",
-  "pirate-radio.frequency.edit": "Frequency"
+  "pirate-radio.frequency.edit": "Frequency",
+  "pirate-radio.device.choose": "Audio output",
+  "pirate-radio.device.system": "Direct (System default)",
+  "pirate-radio.device.openal": "Minecraft sound system",
+  "pirate-radio.device.system-matching-minecraft": "Direct (guess from Minecraft)"
 }
\ No newline at end of file