diff options
Diffstat (limited to 'src/client')
12 files changed, 654 insertions, 57 deletions
diff --git a/src/client/kotlin/space/autistic/radio/client/PirateRadioClient.kt b/src/client/kotlin/space/autistic/radio/client/PirateRadioClient.kt index 1a68c21..87fd144 100644 --- a/src/client/kotlin/space/autistic/radio/client/PirateRadioClient.kt +++ b/src/client/kotlin/space/autistic/radio/client/PirateRadioClient.kt @@ -16,9 +16,12 @@ 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 @@ -35,7 +38,16 @@ 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() EntityRendererRegistry.register(PirateRadioEntityTypes.ELECTRONICS_TRADER, ::ElectronicsTraderEntityRenderer) EntityRendererRegistry.register( diff --git a/src/client/kotlin/space/autistic/radio/client/cli/OfflineSimulator.kt b/src/client/kotlin/space/autistic/radio/client/cli/OfflineSimulator.kt index 2646ed2..c17d622 100644 --- a/src/client/kotlin/space/autistic/radio/client/cli/OfflineSimulator.kt +++ b/src/client/kotlin/space/autistic/radio/client/cli/OfflineSimulator.kt @@ -231,7 +231,7 @@ fun main(args: Array<String>) { w.y = plus100k.get() z.cmul(w) floatView.put(i, floatView.get(i) + z.x) - floatView.put(i, floatView.get(i) + z.y) + floatView.put(i + 1, floatView.get(i + 1) + z.y) } for (i in 0 until floatBufferLo.capacity() step 2) { z.x = floatBufferLo.get(i) @@ -243,7 +243,7 @@ fun main(args: Array<String>) { w.y = -minus100k.get() z.cmul(w) floatView.put(i, floatView.get(i) + z.x) - floatView.put(i, floatView.get(i) + z.y) + floatView.put(i + 1, floatView.get(i + 1) + z.y) } if (rfOutput) { outputStream.write(outputBuffer.array()) 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/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/FmFullMixer.kt b/src/client/kotlin/space/autistic/radio/client/fmsim/FmFullMixer.kt deleted file mode 100644 index 567d93f..0000000 --- a/src/client/kotlin/space/autistic/radio/client/fmsim/FmFullMixer.kt +++ /dev/null @@ -1,4 +0,0 @@ -package space.autistic.radio.client.fmsim - -class FmFullMixer { -} \ No newline at end of file diff --git a/src/client/kotlin/space/autistic/radio/client/fmsim/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/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 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 4a4f087..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,5 +1,6 @@ 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 @@ -9,14 +10,30 @@ 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 @@ -31,10 +48,11 @@ class PirateRadioSoundInstance(private val player: ClientPlayerEntity) : MovingS 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 } - .sortedByDescending { player.pos.squaredDistanceTo(it.pos) } as List<DisposableTransmitterEntity> + .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 } @@ -53,10 +71,52 @@ class PirateRadioSoundInstance(private val player: ClientPlayerEntity) : MovingS .fold(0f) { noise, entity -> noise + getPowerReceived(entity.pos.squaredDistanceTo(player.pos)) } - val mainLevels = main.map { getPowerReceived(it.pos.squaredDistanceTo(player.pos)) } - val lowerLevels = lower.map { getPowerReceived(it.pos.squaredDistanceTo(player.pos)) } - val upperLevels = upper.map { getPowerReceived(it.pos.squaredDistanceTo(player.pos)) } - // TODO + // 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 { 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 5ca802b..34d5585 100644 --- a/src/client/kotlin/space/autistic/radio/client/sound/ReceiverAudioStream.kt +++ b/src/client/kotlin/space/autistic/radio/client/sound/ReceiverAudioStream.kt @@ -1,34 +1,45 @@ package space.autistic.radio.client.sound import it.unimi.dsi.fastutil.floats.FloatConsumer -import net.minecraft.client.sound.AudioStream -import net.minecraft.client.sound.ChannelList -import java.nio.ByteBuffer +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 : AudioStream { +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() { - // TODO, nop for now, should stop the processing + trackedTransmitterQueue.clear() + trackedTransmitterQueue.offer(FmFullThread.EMPTY_TASK) } override fun getFormat(): AudioFormat { return format } - override fun read(size: Int): ByteBuffer { - val channelList = ChannelList(size + 8192) - - while (this.read(channelList) && channelList.currentBufferSize < size) { + override fun read(channelList: FloatConsumer): Boolean { + val buffer = bufferQueue.poll() ?: skipBuffer + while (buffer.hasRemaining()) { + channelList.accept(buffer.get()) } - - return channelList.buffer - } - - private fun read(channelList: FloatConsumer): Boolean { - channelList.accept(0f) - channelList.accept(0f) 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 |