diff options
Diffstat (limited to 'src/client')
32 files changed, 2793 insertions, 27 deletions
diff --git a/src/client/java/space/autistic/radio/client/mixin/BlockStatesLoaderMixin.java b/src/client/java/space/autistic/radio/client/mixin/BlockStatesLoaderMixin.java new file mode 100644 index 0000000..0e9b05e --- /dev/null +++ b/src/client/java/space/autistic/radio/client/mixin/BlockStatesLoaderMixin.java @@ -0,0 +1,43 @@ +package space.autistic.radio.client.mixin; + +import net.minecraft.block.Block; +import net.minecraft.block.BlockState; +import net.minecraft.block.Blocks; +import net.minecraft.client.render.model.BlockStatesLoader; +import net.minecraft.state.StateManager; +import net.minecraft.state.property.BooleanProperty; +import net.minecraft.state.property.DirectionProperty; +import net.minecraft.state.property.Property; +import net.minecraft.util.Identifier; +import net.minecraft.util.profiler.Profiler; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Shadow; +import org.spongepowered.asm.mixin.Unique; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +@Mixin(BlockStatesLoader.class) +public abstract class BlockStatesLoaderMixin { + + @Shadow + abstract void loadBlockStates(Identifier id, StateManager<Block, BlockState> stateManager); + + @Shadow + private Profiler profiler; + + @Unique + private static final StateManager<Block, BlockState> STATE_MANAGER = new StateManager.Builder<Block, BlockState>(Blocks.AIR) + .add(DirectionProperty.of("facing")) + .build(Block::getDefaultState, BlockState::new); + + @Inject( + method = {"load()V"}, + at = {@At("HEAD")} + ) + void onLoad(CallbackInfo callbackInfo) { + profiler.push("pirate_radio_static_definitions"); + loadBlockStates(Identifier.of("pirate-radio", "disposable-transmitter"), STATE_MANAGER); + profiler.pop(); + } +} diff --git a/src/client/kotlin/space/autistic/radio/client/ClientProxy.kt b/src/client/kotlin/space/autistic/radio/client/ClientProxy.kt new file mode 100644 index 0000000..3c29cd3 --- /dev/null +++ b/src/client/kotlin/space/autistic/radio/client/ClientProxy.kt @@ -0,0 +1,20 @@ +package space.autistic.radio.client + +import net.minecraft.client.MinecraftClient +import net.minecraft.client.network.ClientPlayerEntity +import net.minecraft.entity.player.PlayerEntity +import net.minecraft.item.ItemStack +import net.minecraft.util.Hand +import space.autistic.radio.CommonProxy +import space.autistic.radio.PirateRadioItems.STORAGE_CARD +import space.autistic.radio.client.gui.StorageCardEditScreen + +class ClientProxy : CommonProxy() { + override fun useStorageCard(player: PlayerEntity, item: ItemStack, hand: Hand) { + if (player is ClientPlayerEntity) { + if (item.isOf(STORAGE_CARD)) { + MinecraftClient.getInstance().setScreen(StorageCardEditScreen(player, item, hand)) + } + } + } +} \ No newline at end of file diff --git a/src/client/kotlin/space/autistic/radio/client/PirateRadioClient.kt b/src/client/kotlin/space/autistic/radio/client/PirateRadioClient.kt index 54b7640..87fd144 100644 --- a/src/client/kotlin/space/autistic/radio/client/PirateRadioClient.kt +++ b/src/client/kotlin/space/autistic/radio/client/PirateRadioClient.kt @@ -1,25 +1,59 @@ package space.autistic.radio.client -import com.mojang.brigadier.CommandDispatcher import net.fabricmc.api.ClientModInitializer import net.fabricmc.fabric.api.client.command.v2.ClientCommandManager import net.fabricmc.fabric.api.client.command.v2.ClientCommandRegistrationCallback -import net.fabricmc.fabric.api.client.command.v2.FabricClientCommandSource +import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents import net.fabricmc.fabric.api.client.rendering.v1.EntityRendererRegistry import net.minecraft.client.MinecraftClient -import net.minecraft.command.CommandRegistryAccess +import net.minecraft.client.sound.SoundInstance +import net.minecraft.entity.player.PlayerEntity +import net.minecraft.item.ItemStack +import net.minecraft.util.Hand import org.slf4j.LoggerFactory import space.autistic.radio.PirateRadio import space.autistic.radio.PirateRadio.MOD_ID import space.autistic.radio.PirateRadioEntityTypes import space.autistic.radio.client.entity.ElectronicsTraderEntityRenderer +import space.autistic.radio.client.entity.DisposableTransmitterEntityRenderer +import space.autistic.radio.client.fmsim.FmFullThread +import space.autistic.radio.client.fmsim.FmSimulatorMode import space.autistic.radio.client.gui.FmReceiverScreen +import space.autistic.radio.client.sound.PirateRadioSoundInstance +import space.autistic.radio.client.sound.ReceiverAudioStream +import javax.sound.sampled.Mixer +import kotlin.math.max +import kotlin.math.min object PirateRadioClient : ClientModInitializer { - private val logger = LoggerFactory.getLogger(MOD_ID) + private var soundInstance: SoundInstance? = null + var volume: Int = 0 + set(value) { + field = min(10, max(0, value)) + } + var stereo: Boolean = false + var frequency = 768 + set(value) { + field = min(1080, max(768, value)) + } + var mode = FmSimulatorMode.FULL + + val minecraftAudioDevice = object : Mixer.Info("", "", "", "") { + } + val openAlAudioDevice = object : Mixer.Info("", "", "", "") { + } + val systemDefaultAudioDevice = object : Mixer.Info("", "", "", "") { + } + var audioDevice: Mixer.Info = if (ReceiverAudioStream.useNativeAudio) minecraftAudioDevice else openAlAudioDevice override fun onInitializeClient() { + Thread.ofPlatform().daemon().name("fm-receiver").start(FmFullThread) + PirateRadio.proxy = ClientProxy() EntityRendererRegistry.register(PirateRadioEntityTypes.ELECTRONICS_TRADER, ::ElectronicsTraderEntityRenderer) + EntityRendererRegistry.register( + PirateRadioEntityTypes.DISPOSABLE_TRANSMITTER, + ::DisposableTransmitterEntityRenderer + ) PirateRadioEntityModelLayers.initialize() ClientCommandRegistrationCallback.EVENT.register { dispatcher, _ -> dispatcher.register( @@ -31,5 +65,21 @@ object PirateRadioClient : ClientModInitializer { } ) } + ClientTickEvents.END_WORLD_TICK.register { world -> + if (volume > 0 && MinecraftClient.getInstance().player?.isRemoved == false) { + if (soundInstance == null) { + soundInstance = PirateRadioSoundInstance(MinecraftClient.getInstance().player!!) + } + val soundManager = MinecraftClient.getInstance().soundManager + if (!soundManager.isPlaying(soundInstance)) { + soundManager.play(soundInstance) + } + } else { + if (soundInstance != null) { + MinecraftClient.getInstance().soundManager.stop(soundInstance) + soundInstance = null + } + } + } } } \ No newline at end of file diff --git a/src/client/kotlin/space/autistic/radio/client/PirateRadioDataGenerator.kt b/src/client/kotlin/space/autistic/radio/client/PirateRadioDataGenerator.kt index b5130a1..65e9677 100644 --- a/src/client/kotlin/space/autistic/radio/client/PirateRadioDataGenerator.kt +++ b/src/client/kotlin/space/autistic/radio/client/PirateRadioDataGenerator.kt @@ -32,7 +32,7 @@ class PirateRadioItemModelGenerator(output: FabricDataOutput) : FabricModelProvi modelGenderator.register(PirateRadioItems.SBC, Models.GENERATED) modelGenderator.register(PirateRadioItems.WIRE, Models.GENERATED) modelGenderator.register(PirateRadioItems.POWERBANK, Models.GENERATED) - modelGenderator.register(PirateRadioItems.FM_RECEIVER, Models.GENERATED) + //modelGenderator.register(PirateRadioItems.FM_RECEIVER, Models.GENERATED) modelGenderator.register(PirateRadioItems.STORAGE_CARD, Models.GENERATED) modelGenderator.register(PirateRadioItems.DISPOSABLE_TRANSMITTER, Models.GENERATED) } @@ -46,7 +46,7 @@ class PirateRadioRecipeGenerator( override fun generate(exporter: RecipeExporter) { ShapelessRecipeJsonBuilder.create(RecipeCategory.MISC, PirateRadioItems.DISPOSABLE_TRANSMITTER) .input(PirateRadioItems.SBC).input(PirateRadioItems.WIRE).input(PirateRadioItems.POWERBANK) - .input(PirateRadioItems.STORAGE_CARD) + //.input(PirateRadioItems.STORAGE_CARD) .criterion("has_sbc", RecipeProvider.conditionsFromItem(PirateRadioItems.SBC)).offerTo(exporter) } diff --git a/src/client/kotlin/space/autistic/radio/client/PirateRadioEntityModelLayers.kt b/src/client/kotlin/space/autistic/radio/client/PirateRadioEntityModelLayers.kt index 765912d..604fdfd 100644 --- a/src/client/kotlin/space/autistic/radio/client/PirateRadioEntityModelLayers.kt +++ b/src/client/kotlin/space/autistic/radio/client/PirateRadioEntityModelLayers.kt @@ -9,6 +9,7 @@ import space.autistic.radio.PirateRadio object PirateRadioEntityModelLayers { val ELECTRONICS_TRADER = EntityModelLayer(Identifier.of(PirateRadio.MOD_ID, "electronics-trader"), "main") + val PIRATE_RADIO = EntityModelLayer(Identifier.of(PirateRadio.MOD_ID, "electronics-trader"), "main") fun initialize() { EntityModelLayerRegistry.registerModelLayer(ELECTRONICS_TRADER) { diff --git a/src/client/kotlin/space/autistic/radio/client/antenna/AntennaModel.kt b/src/client/kotlin/space/autistic/radio/client/antenna/AntennaModel.kt index 74a7c96..c1f3e13 100644 --- a/src/client/kotlin/space/autistic/radio/client/antenna/AntennaModel.kt +++ b/src/client/kotlin/space/autistic/radio/client/antenna/AntennaModel.kt @@ -5,15 +5,15 @@ import org.joml.Vector3d interface AntennaModel { /** * Returns the linear power level/gain to apply for a receiver at the given position. The receiver is assumed to be - * vertically oriented. + * vertically oriented. The gain should scale with distance, as appropriate. * * Note: 1.0f = 0dB, 0.5f = -3dB (approx.), 0.1f = -10dB. */ fun apply(position: Vector3d): Float /** - * Returns whether to process block/material attenuation. Useful for "global" antennas (i.e. those that return a - * constant for all positions given to [apply]). + * Returns whether to process block/material attenuation. Useful (when false) for "global" antennas (i.e. those that + * return a constant for all positions given to [apply]). */ fun shouldAttenuate(): Boolean } \ No newline at end of file diff --git a/src/client/kotlin/space/autistic/radio/client/antenna/NullModel.kt b/src/client/kotlin/space/autistic/radio/client/antenna/ConstAntennaModel.kt index 3c188b6..fc531d2 100644 --- a/src/client/kotlin/space/autistic/radio/client/antenna/NullModel.kt +++ b/src/client/kotlin/space/autistic/radio/client/antenna/ConstAntennaModel.kt @@ -2,9 +2,9 @@ package space.autistic.radio.client.antenna import org.joml.Vector3d -class NullModel : AntennaModel { +class ConstAntennaModel(private val level: Float) : AntennaModel { override fun apply(position: Vector3d): Float { - return 0f + return level } override fun shouldAttenuate(): Boolean { diff --git a/src/client/kotlin/space/autistic/radio/client/antenna/WasmAntennaFactory.kt b/src/client/kotlin/space/autistic/radio/client/antenna/WasmAntennaFactory.kt index 7181e95..38d0c97 100644 --- a/src/client/kotlin/space/autistic/radio/client/antenna/WasmAntennaFactory.kt +++ b/src/client/kotlin/space/autistic/radio/client/antenna/WasmAntennaFactory.kt @@ -12,9 +12,7 @@ import com.dylibso.chicory.wasm.types.Value import com.dylibso.chicory.wasm.types.ValueType import org.joml.Quaterniond import org.joml.Vector3d -import space.autistic.radio.PirateRadio -import java.util.logging.Level -import java.util.logging.Logger +import space.autistic.radio.PirateRadio.logger class WasmAntennaFactory(moduleBytes: ByteArray) : AntennaModelFactory { var failing = false @@ -25,7 +23,7 @@ class WasmAntennaFactory(moduleBytes: ByteArray) : AntennaModelFactory { // capped at 1MB per antenna .withMemoryLimits(MemoryLimits(0, 16)) } catch (e: ChicoryException) { - logger.log(Level.SEVERE, "Error while trying to parse antenna model.", e) + logger.error("Error while trying to parse antenna model.", e) failing = true null } @@ -33,7 +31,7 @@ class WasmAntennaFactory(moduleBytes: ByteArray) : AntennaModelFactory { override fun create(orientation: Quaterniond): AntennaModel { if (failing) { - return NullModel() + return ConstAntennaModel(0f) } try { val instance = instanceBuilder!!.build() @@ -53,11 +51,9 @@ class WasmAntennaFactory(moduleBytes: ByteArray) : AntennaModelFactory { orientation.w.toRawBits() ) if (instance.exports().global("should-attenuate").type != ValueType.I32) { - logger.log( - Level.SEVERE, "Error while trying to initialize antenna model: missing 'should-attenuate'" - ) + logger.error("Error while trying to initialize antenna model: missing 'should-attenuate'") failing = true - return NullModel() + return ConstAntennaModel(0f) } val shouldAttenuate = instance.exports().global("should-attenuate").value != 0L val apply = instance.export("apply") @@ -73,7 +69,7 @@ class WasmAntennaFactory(moduleBytes: ByteArray) : AntennaModelFactory { )[0] ) } catch (e: ChicoryException) { - logger.log(Level.SEVERE, "Error while trying to evaluate antenna model.", e) + logger.error("Error while trying to evaluate antenna model.", e) failing = true return 0f } @@ -84,14 +80,13 @@ class WasmAntennaFactory(moduleBytes: ByteArray) : AntennaModelFactory { } } } catch (e: ChicoryException) { - logger.log(Level.SEVERE, "Error while trying to initialize antenna model.", e) + logger.error("Error while trying to initialize antenna model.", e) failing = true - return NullModel() + return ConstAntennaModel(0f) } } companion object { private val defaultImports = ImportValues.builder().build() - private val logger = Logger.getLogger(PirateRadio.MOD_ID) } } \ No newline at end of file diff --git a/src/client/kotlin/space/autistic/radio/client/cli/Funny.kt b/src/client/kotlin/space/autistic/radio/client/cli/Funny.kt new file mode 100644 index 0000000..2e64d36 --- /dev/null +++ b/src/client/kotlin/space/autistic/radio/client/cli/Funny.kt @@ -0,0 +1,7 @@ +package space.autistic.radio.client.cli + +import space.autistic.radio.client.flite.FliteWrapper + + +fun main() { +} \ No newline at end of file diff --git a/src/client/kotlin/space/autistic/radio/client/cli/OfflineSimulator.kt b/src/client/kotlin/space/autistic/radio/client/cli/OfflineSimulator.kt new file mode 100644 index 0000000..c17d622 --- /dev/null +++ b/src/client/kotlin/space/autistic/radio/client/cli/OfflineSimulator.kt @@ -0,0 +1,265 @@ +package space.autistic.radio.client.cli + +import com.github.ooxi.jdatauri.DataUri +import org.joml.Vector2f +import space.autistic.radio.client.complex.cmul +import space.autistic.radio.client.flite.FliteWrapper +import space.autistic.radio.client.fmsim.FmFullConstants +import space.autistic.radio.client.fmsim.FmFullDemodulator +import space.autistic.radio.client.fmsim.FmFullModulator +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.io.DataOutputStream +import java.io.FileInputStream +import java.io.FileOutputStream +import java.io.InputStream +import java.net.URI +import java.nio.ByteBuffer +import java.nio.ByteOrder +import java.nio.FloatBuffer +import kotlin.io.path.inputStream +import kotlin.io.path.toPath +import kotlin.math.min +import kotlin.system.exitProcess + +fun printUsage() { + println("Usage: OfflineSimulator <-o|-O> OUTFILE.raw {[-p POWER] [-l|-h] [-m] file:///FILE.raw} [-m]") + println(" file:///FILE.raw (or ./FILE.raw - the ./ is required)") + println(" The raw input file. two-channel (even with -m), 48kHz 32-bit float.") + println(" -o OUTFILE.raw") + println(" The raw RF stream to output, 2x300kHz 32-bit float.") + println(" -O OUTFILE.raw") + println(" The raw audio stream to output, 2x48kHz 32-bit float.") + println(" -p POWER") + println(" The signal amplitude (power level), e.g. 1.0.") + println(" -l") + println(" Simulate a partial overlap on the lower half of the tuned-into frequency.") + println(" -h") + println(" Simulate a partial overlap on the upper half of the tuned-into frequency.") + println(" -m") + println(" Downconvert to mono. As the last option, demodulate as mono.") +} + +class SimFile(val power: Float, val band: Int, val filename: String, val stereo: Boolean) { + var closed: Boolean = false + val buffer: FloatBuffer = FloatBuffer.allocate(8192) + val modulator = FmFullModulator() + var stream: InputStream? = null +} + +fun main(args: Array<String>) { + if (args.isEmpty()) { + printUsage() + exitProcess(1) + } + var hasOutput = false + var inArg = "" + var output = "" + var rfOutput = true + var power = 1.0f + var band = 2 + var stereo = FmFullConstants.STEREO + val files: ArrayList<SimFile> = ArrayList() + for (arg in args) { + if (!hasOutput) { + if (arg == "-o" || arg == "-O") { + hasOutput = true + inArg = arg + } else { + printUsage() + exitProcess(1) + } + } else { + when (inArg) { + "-o" -> { + output = arg + rfOutput = true + inArg = "" + } + + "-O" -> { + output = arg + rfOutput = false + inArg = "" + } + + "-p" -> { + power = arg.toFloatOrNull() ?: run { + println("Error processing -p argument: not a valid float") + printUsage() + exitProcess(1) + } + inArg = "" + } + + "" -> { + if (!arg.startsWith("-")) { + files.add(SimFile(power, band, arg, stereo)) + inArg = "" + band = 2 + power = 1.0f + stereo = FmFullConstants.STEREO + } else { + when (arg) { + "-p" -> inArg = "-p" + "-l" -> band = 1 + "-h" -> band = 3 + "-m" -> stereo = FmFullConstants.MONO + else -> { + println("Unknown option") + printUsage() + exitProcess(1) + } + } + } + } + + else -> throw NotImplementedError(inArg) + } + } + } + + if (files.isEmpty()) { + printUsage() + exitProcess(1) + } + + println(ProcessHandle.current().pid()) + + FileOutputStream(output).buffered().use { outputStream -> + for (inputFile in files) { + if (inputFile.filename != "file:///dev/zero") { + if (inputFile.filename.startsWith("./")) { + inputFile.stream = FileInputStream(inputFile.filename) + } else if (inputFile.filename.startsWith("data:")) { + val uri = try { + DataUri.parse(inputFile.filename, Charsets.UTF_8) + } catch (e: IllegalArgumentException) { + println("error parsing data URI") + exitProcess(1) + } + if (!uri.mime.startsWith("text/")) { + println("unsupported data URI format") + exitProcess(1) + } + FliteWrapper.textToWave(uri.data.toString(uri.charset ?: Charsets.UTF_8)) { + // 8k mono -> 48k stereo float + val bbuf = ByteBuffer.allocate(it.capacity() * 2 * 6 * 4) + val fbuf = bbuf.order(ByteOrder.LITTLE_ENDIAN).asFloatBuffer() + while (it.hasRemaining()) { + val sample = (it.get().toFloat() + 0.5f) / 32767.5f + for (i in 0 until 2 * 6) { + fbuf.put(sample) + } + } + inputFile.stream = ByteArrayInputStream(bbuf.array()) + } + } else { + inputFile.stream = URI(inputFile.filename).toPath().inputStream() + } + } + } + + val buffer = ByteBuffer.allocate(2 * 4 * FmFullConstants.FFT_DATA_BLOCK_SIZE_LPF_48K_15K_3K1) + val plus100k = FloatBuffer.wrap(FmFullConstants.CBUFFER_100K_300K) + val minus100k = FloatBuffer.wrap(FmFullConstants.CBUFFER_100K_300K) + val demodulator = FmFullDemodulator() + var lastStereoPilot = false + while (true) { + // initialized to maximum buffer size, trimmed down later + var minBuffer = 8192 + for (inputFile in files) { + val stream = inputFile.stream + if (stream == null) { + if (inputFile.buffer.remaining() > 2 * FmFullConstants.FFT_DATA_BLOCK_SIZE_48K_300K) { + inputFile.modulator.flush(inputFile.power, inputFile.stereo) { + inputFile.buffer.put(it) + } + } + } else { + val bytes = stream.read(buffer.array()) + if (bytes <= 0) { + stream.close() + inputFile.stream = null + inputFile.closed = true + inputFile.modulator.flush(inputFile.power, inputFile.stereo) { + inputFile.buffer.put(it) + } + } else { + val floats = buffer.slice(0, bytes).order(ByteOrder.LITTLE_ENDIAN).asFloatBuffer() + var shouldFlush = true + inputFile.modulator.process(floats, inputFile.power, inputFile.stereo) { + inputFile.buffer.put(it) + shouldFlush = false + } + if (shouldFlush) { + inputFile.modulator.flush(inputFile.power, inputFile.stereo) { + inputFile.buffer.put(it) + } + } + } + } + minBuffer = min(minBuffer, inputFile.buffer.position()) + } + + val outputBuffer = ByteBuffer.allocate(minBuffer * 4) + val floatView = outputBuffer.order(ByteOrder.LITTLE_ENDIAN).asFloatBuffer() + val floatBufferLo = FloatBuffer.allocate(minBuffer) + val floatBufferHi = FloatBuffer.allocate(minBuffer) + for (inputFile in files) { + inputFile.buffer.flip() + val floatBuffer = when (inputFile.band) { + 1 -> floatBufferLo + 2 -> floatView + 3 -> floatBufferHi + else -> throw IllegalStateException() + } + for (i in 0 until floatBuffer.capacity()) { + floatBuffer.put(i, floatBuffer.get(i) + inputFile.buffer.get()) + } + inputFile.buffer.compact() + } + val z = Vector2f() + val w = Vector2f() + for (i in 0 until floatBufferHi.capacity() step 2) { + z.x = floatBufferHi.get(i) + z.y = floatBufferHi.get(i + 1) + if (!plus100k.hasRemaining()) { + plus100k.clear() + } + w.x = plus100k.get() + w.y = plus100k.get() + z.cmul(w) + floatView.put(i, floatView.get(i) + z.x) + floatView.put(i + 1, floatView.get(i + 1) + z.y) + } + for (i in 0 until floatBufferLo.capacity() step 2) { + z.x = floatBufferLo.get(i) + z.y = floatBufferLo.get(i + 1) + if (!minus100k.hasRemaining()) { + minus100k.clear() + } + w.x = minus100k.get() + w.y = -minus100k.get() + z.cmul(w) + floatView.put(i, floatView.get(i) + z.x) + floatView.put(i + 1, floatView.get(i + 1) + z.y) + } + if (rfOutput) { + outputStream.write(outputBuffer.array()) + } else { + demodulator.process(floatView, stereo) { stereoPilot, it -> + if (stereoPilot != lastStereoPilot) { + println(if (stereoPilot) "stereo" else "mono") + } + lastStereoPilot = stereoPilot + buffer.order(ByteOrder.LITTLE_ENDIAN).asFloatBuffer().put(0, it.array()) + outputStream.write(buffer.array()) + } + } + if (files.all { it.closed }) { + break + } + } + } +} \ No newline at end of file diff --git a/src/client/kotlin/space/autistic/radio/client/complex/Complex.kt b/src/client/kotlin/space/autistic/radio/client/complex/Complex.kt new file mode 100644 index 0000000..7ca6811 --- /dev/null +++ b/src/client/kotlin/space/autistic/radio/client/complex/Complex.kt @@ -0,0 +1,32 @@ +package space.autistic.radio.client.complex + +import org.joml.Vector2f +import org.joml.Vector2fc + +fun Vector2f.cmul(v: Vector2fc): Vector2f { + return this.cmul(v, this) +} + +fun Vector2f.cmul(v: Vector2fc, dest: Vector2f): Vector2f { + val a = this.x * v.x() + val b = this.y * v.y() + val c = (this.x() + this.y()) * (v.x() + v.y()) + val x = a - b + val y = c - a - b + dest.x = x + dest.y = y + return dest +} + +fun Vector2f.conjugate(): Vector2f { + return this.conjugate(this) +} + +fun Vector2f.conjugate(dest: Vector2f): Vector2f { + dest.x = this.x() + dest.y = -this.y() + return dest +} + +val I + get() = Vector2f(0f, 1f) \ No newline at end of file diff --git a/src/client/kotlin/space/autistic/radio/client/dsp/Biquad1stOrder.kt b/src/client/kotlin/space/autistic/radio/client/dsp/Biquad1stOrder.kt new file mode 100644 index 0000000..ddf5b7a --- /dev/null +++ b/src/client/kotlin/space/autistic/radio/client/dsp/Biquad1stOrder.kt @@ -0,0 +1,11 @@ +package space.autistic.radio.client.dsp + +class Biquad1stOrder(private val b0: Float, private val b1: Float, private val a1: Float) { + private var delaySlot = 0f + + fun process(samp: Float): Float { + val out = samp * b0 + delaySlot + delaySlot = samp * b1 - out * a1 + return out + } +} \ No newline at end of file diff --git a/src/client/kotlin/space/autistic/radio/client/entity/DisposableTransmitterEntityRenderer.kt b/src/client/kotlin/space/autistic/radio/client/entity/DisposableTransmitterEntityRenderer.kt new file mode 100644 index 0000000..61b1e19 --- /dev/null +++ b/src/client/kotlin/space/autistic/radio/client/entity/DisposableTransmitterEntityRenderer.kt @@ -0,0 +1,79 @@ +package space.autistic.radio.client.entity + +import net.minecraft.client.render.OverlayTexture +import net.minecraft.client.render.TexturedRenderLayers +import net.minecraft.client.render.VertexConsumerProvider +import net.minecraft.client.render.entity.EntityRenderer +import net.minecraft.client.render.entity.EntityRendererFactory +import net.minecraft.client.render.model.BakedModelManager +import net.minecraft.client.util.ModelIdentifier +import net.minecraft.client.util.math.MatrixStack +import net.minecraft.screen.PlayerScreenHandler +import net.minecraft.util.Identifier +import net.minecraft.util.math.Direction +import net.minecraft.util.math.RotationAxis +import space.autistic.radio.PirateRadio +import space.autistic.radio.entity.DisposableTransmitterEntity + +class DisposableTransmitterEntityRenderer(ctx: EntityRendererFactory.Context) : + EntityRenderer<DisposableTransmitterEntity>(ctx) { + + private val blockRenderManager = ctx.blockRenderManager + + override fun getTexture(entity: DisposableTransmitterEntity): Identifier { + return PlayerScreenHandler.BLOCK_ATLAS_TEXTURE + } + + override fun render( + entity: DisposableTransmitterEntity, + yaw: Float, + tickDelta: Float, + matrices: MatrixStack, + vertexConsumers: VertexConsumerProvider, + light: Int + ) { + super.render(entity, yaw, tickDelta, matrices, vertexConsumers, light) + + matrices.push() + val facing: Direction = entity.horizontalFacing + val vec3d = this.getPositionOffset(entity, tickDelta) + matrices.translate(-vec3d.getX(), -vec3d.getY(), -vec3d.getZ()) + val d = (1.0 - DisposableTransmitterEntity.DEPTH) / 2.0 + matrices.translate( + facing.offsetX.toDouble() * d, facing.offsetY.toDouble() * d, facing.offsetZ.toDouble() * d + ) + matrices.multiply(RotationAxis.POSITIVE_X.rotationDegrees(entity.pitch)) + matrices.multiply(RotationAxis.POSITIVE_Y.rotationDegrees(180.0f - entity.yaw)) + if (!entity.isInvisible) { + val bakedModelManager: BakedModelManager = this.blockRenderManager.models.modelManager + matrices.push() + matrices.translate(-0.5f, -0.5f, -0.5f) + this.blockRenderManager.modelRenderer.render( + matrices.peek(), + vertexConsumers.getBuffer(TexturedRenderLayers.getEntitySolid()), + null, + bakedModelManager.getModel(MODEL_ID[facing]), + 1.0f, + 1.0f, + 1.0f, + light, + OverlayTexture.DEFAULT_UV + ) + matrices.pop() + } + + matrices.pop() + } + + companion object { + private val STATES_ID = Identifier.of(PirateRadio.MOD_ID, "disposable-transmitter") + private val MODEL_ID = mapOf( + Direction.DOWN to ModelIdentifier(STATES_ID, "facing=down"), + Direction.UP to ModelIdentifier(STATES_ID, "facing=up"), + Direction.NORTH to ModelIdentifier(STATES_ID, "facing=north"), + Direction.SOUTH to ModelIdentifier(STATES_ID, "facing=south"), + Direction.WEST to ModelIdentifier(STATES_ID, "facing=west"), + Direction.EAST to ModelIdentifier(STATES_ID, "facing=east"), + ) + } +} \ No newline at end of file diff --git a/src/client/kotlin/space/autistic/radio/client/entity/ElectronicsTraderEntityRenderer.kt b/src/client/kotlin/space/autistic/radio/client/entity/ElectronicsTraderEntityRenderer.kt index 91c29db..5da8e17 100644 --- a/src/client/kotlin/space/autistic/radio/client/entity/ElectronicsTraderEntityRenderer.kt +++ b/src/client/kotlin/space/autistic/radio/client/entity/ElectronicsTraderEntityRenderer.kt @@ -16,7 +16,7 @@ class ElectronicsTraderEntityRenderer(context: EntityRendererFactory.Context) : ) { companion object { - val TEXTURE = Identifier.of(PirateRadio.MOD_ID, "electronics-trader") + val TEXTURE = Identifier.of(PirateRadio.MOD_ID, "textures/entity/electronics-trader.png") } override fun getTexture(entity: ElectronicsTraderEntity?): Identifier = TEXTURE diff --git a/src/client/kotlin/space/autistic/radio/client/flite/FliteFactory.kt b/src/client/kotlin/space/autistic/radio/client/flite/FliteFactory.kt new file mode 100644 index 0000000..768a040 --- /dev/null +++ b/src/client/kotlin/space/autistic/radio/client/flite/FliteFactory.kt @@ -0,0 +1,63 @@ +package space.autistic.radio.client.flite + +import com.dylibso.chicory.experimental.aot.AotMachineFactory +import com.dylibso.chicory.runtime.ImportValues +import com.dylibso.chicory.runtime.Instance +import com.dylibso.chicory.wasm.Parser +import net.fabricmc.loader.api.FabricLoader +import space.autistic.radio.wasm.Bindings +import space.autistic.radio.wasm.WasmExitException +import java.io.InputStream +import java.lang.invoke.MethodHandles +import kotlin.io.path.inputStream +import kotlin.reflect.jvm.javaMethod + +object FliteFactory : () -> Instance { + private fun fd_read(a: Int, b: Int, c: Int, d: Int): Int { + // EBADF + return 8 + } + + private fun fd_seek(a: Int, b: Long, c: Int, d: Int): Int { + // EBADF + return 8 + } + + private fun fd_close(a: Int): Int { + // EBADF + return 8 + } + + private fun fd_write(a: Int, b: Int, c: Int, d: Int): Int { + // EBADF + return 8 + } + + private fun proc_exit(status: Int): Nothing { + throw WasmExitException(status) + } + + private val lookup = MethodHandles.lookup() + private val defaultImports = ImportValues.builder() + .addFunction( + Bindings.bindFunc("wasi_snapshot_preview1", "fd_close", lookup, ::fd_close.javaMethod!!, this), + Bindings.bindFunc("wasi_snapshot_preview1", "fd_read", lookup, ::fd_read.javaMethod!!, this), + Bindings.bindFunc("wasi_snapshot_preview1", "fd_write", lookup, ::fd_write.javaMethod!!, this), + Bindings.bindFunc("wasi_snapshot_preview1", "fd_seek", lookup, ::fd_seek.javaMethod!!, this), + Bindings.bindFunc("wasi_snapshot_preview1", "proc_exit", lookup, ::proc_exit.javaMethod!!, this), + ).build() + private val module = Parser.parse(getModuleInputStream()) + private val instanceBuilder = + Instance.builder(module) + .withMachineFactory(AotMachineFactory(module)) + .withImportValues(defaultImports) + + override fun invoke(): Instance = instanceBuilder.build() + + private fun getModuleInputStream(): InputStream { + return FabricLoader.getInstance().getModContainer("pirate-radio").flatMap { it.findPath("flite.wasm") } + .map { it.inputStream() }.orElseGet { + this.javaClass.getResourceAsStream("/flite.wasm") + } + } +} \ No newline at end of file diff --git a/src/client/kotlin/space/autistic/radio/client/flite/FliteWrapper.kt b/src/client/kotlin/space/autistic/radio/client/flite/FliteWrapper.kt new file mode 100644 index 0000000..e73d4d0 --- /dev/null +++ b/src/client/kotlin/space/autistic/radio/client/flite/FliteWrapper.kt @@ -0,0 +1,35 @@ +package space.autistic.radio.client.flite + +import com.dylibso.chicory.runtime.ByteBufferMemory +import space.autistic.radio.reflection.getBuffer +import java.nio.ByteOrder +import java.nio.ShortBuffer +import java.util.function.Consumer + +object FliteWrapper { + // Produces audio at 8kHz mono + fun textToWave(s: String, consumer: Consumer<ShortBuffer>) { + val instance = FliteFactory.invoke() + instance.export("_initialize").apply() + + val voice = instance.export("flite_wrapper_init").apply()[0] + + val textToWaveImpl = instance.export("flite_text_to_wave") + val mallocImpl = instance.export("malloc") + val memory = instance.memory() as ByteBufferMemory + + val bytes = s.toByteArray() + val space = mallocImpl.apply((bytes.size + 1).toLong())[0].toInt() + memory.getBuffer().run { + put(space, bytes) + put(space + bytes.size, 0) + } + val wavedata = textToWaveImpl.apply(space.toLong(), voice)[0].toInt() + val numSamples = memory.readInt(wavedata + 8) + val samplesPtr = memory.readInt(wavedata + 16) + consumer.accept( + memory.getBuffer().slice(samplesPtr, numSamples * 2).order(ByteOrder.LITTLE_ENDIAN) + .asShortBuffer() + ) + } +} \ No newline at end of file diff --git a/src/client/kotlin/space/autistic/radio/client/fmsim/FastModulatedNoise.kt b/src/client/kotlin/space/autistic/radio/client/fmsim/FastModulatedNoise.kt new file mode 100644 index 0000000..9639a38 --- /dev/null +++ b/src/client/kotlin/space/autistic/radio/client/fmsim/FastModulatedNoise.kt @@ -0,0 +1,86 @@ +package space.autistic.radio.client.fmsim + +import org.joml.Vector2f +import space.autistic.radio.client.complex.cmul +import java.nio.FloatBuffer +import java.util.concurrent.ThreadLocalRandom +import java.util.function.Consumer + +// FIXME use more realistic model +class FastModulatedNoise(which: Which) { + + private val buffer = when (which) { + Which.BASE -> FloatBuffer.wrap(baseNoise) + Which.UPPER -> FloatBuffer.wrap(upperNoise) + Which.LOWER -> FloatBuffer.wrap(upperNoise) + } + private val flipSpectrum = which == Which.LOWER + private val outBuffer = FloatBuffer.allocate(2 * FmFullConstants.FFT_DATA_BLOCK_SIZE_48K_300K) + + // complex noise, in IQ format + fun generateNoise(power: Float, consumer: Consumer<FloatBuffer>) { + outBuffer.clear() + while (outBuffer.hasRemaining()) { + if (!buffer.hasRemaining()) { + buffer.clear() + } + if (flipSpectrum) { + outBuffer.put(buffer.get() * power) + outBuffer.put(-buffer.get() * power) + } else { + outBuffer.put(buffer.get() * power) + outBuffer.put(buffer.get() * power) + } + } + outBuffer.clear() + consumer.accept(outBuffer) + } + + enum class Which { + LOWER, BASE, UPPER + } + + companion object { + // 1 second + private val baseNoise = FloatArray(300000 * 2) + private val upperNoise = FloatArray(300000 * 2) + + init { + val fmsim = FmFullModulator() + val buffer = FloatBuffer.wrap(baseNoise) + val input = FloatBuffer.allocate(FmFullConstants.FFT_DATA_BLOCK_SIZE_LPF_48K_15K_3K1 * 2) + val random = ThreadLocalRandom.current() + while (buffer.hasRemaining()) { + input.clear() + while (input.hasRemaining()) { + val sample = random.nextFloat(1f) + input.put(sample) + input.put(sample) + } + input.clear() + fmsim.process(input, 1f, false) { + if (buffer.remaining() < it.remaining()) { + it.limit(it.position() + buffer.remaining()) + } + buffer.put(it) + } + } + buffer.clear() + val plus100k = FloatBuffer.wrap(FmFullConstants.CBUFFER_100K_300K) + val z = Vector2f() + val w = Vector2f() + for (i in baseNoise.indices step 2) { + z.x = baseNoise[i] + z.y = baseNoise[i + 1] + if (!plus100k.hasRemaining()) { + plus100k.clear() + } + w.x = plus100k.get() + w.y = plus100k.get() + z.cmul(w) + upperNoise[i] = z.x + upperNoise[i] = z.y + } + } + } +} \ No newline at end of file diff --git a/src/client/kotlin/space/autistic/radio/client/fmsim/FmFullConstants.kt b/src/client/kotlin/space/autistic/radio/client/fmsim/FmFullConstants.kt new file mode 100644 index 0000000..f5a49ce --- /dev/null +++ b/src/client/kotlin/space/autistic/radio/client/fmsim/FmFullConstants.kt @@ -0,0 +1,114 @@ +package space.autistic.radio.client.fmsim + +import kotlin.math.PI +import kotlin.math.cos +import kotlin.math.sin + +object FmFullConstants { + // tau = 75us, fh = 20396.25Hz + const val FM_PREEMPAHSIS_B0_48K = 6.7639647f + const val FM_PREEMPHASIS_B1_48K = -4.975628f + + /* const val FM_PREEMPHASIS_A0_48K = 1f */ + const val FM_PREEMPHASIS_A1_48K = 0.78833646f + + const val FM_DEEMPAHSIS_B0_48K = 1f / FM_PREEMPAHSIS_B0_48K + const val FM_DEEMPHASIS_B1_48K = FM_PREEMPHASIS_A1_48K / FM_PREEMPAHSIS_B0_48K + + /* const val FM_DEEMPHASIS_A0_48K = 1f */ + const val FM_DEEMPHASIS_A1_48K = FM_PREEMPHASIS_B1_48K / FM_PREEMPAHSIS_B0_48K + + val FIR_LPF_48K_15K_3K1 = floatArrayOf( + -0.0010006913216784596f, + 0.001505308784544468f, + -2.625857350794219e-18f, + -0.002777613466605544f, + 0.0030173989944159985f, + 0.002290070755407214f, + -0.008225799538195133f, + 0.004239063244313002f, + 0.010359899140894413f, + -0.017650796100497246f, + 1.510757873119297e-17f, + 0.029305754229426384f, + -0.02889496460556984f, + -0.020366130396723747f, + 0.07103750854730606f, + -0.03811456635594368f, + -0.10945471376180649f, + 0.29212409257888794f, + 0.6252123713493347f, + 0.29212409257888794f, + -0.10945471376180649f, + -0.03811456635594368f, + 0.07103750854730606f, + -0.020366130396723747f, + -0.02889496460556984f, + 0.029305754229426384f, + 1.510757873119297e-17f, + -0.017650796100497246f, + 0.010359899140894413f, + 0.004239063244313002f, + -0.008225799538195133f, + 0.002290070755407214f, + 0.0030173989944159985f, + -0.002777613466605544f, + -2.625857350794219e-18f, + 0.001505308784544468f, + -0.0010006913216784596f, + ) + + // chosen such that we can easily do 38kHz mixing in frequency (1500*38k/300k = shift of 95 bins, where 1500 comes + // from the 4/25 ratio 48k/300k i.e. 240*25/4) + // (the theoretical optimum, as per above, would be around 180) + // (we could have fudged the carrier frequency a bit but we chose not to) + // NOTE: latency = (data block size / 48000) seconds (84 -> 1.75 ms) + const val FFT_SIZE_LPF_48K_15K_3K1 = 2 * 120 + const val FFT_OVERLAP_LPF_48K_15K_3K1 = 36 + const val FFT_DATA_BLOCK_SIZE_LPF_48K_15K_3K1 = FFT_SIZE_LPF_48K_15K_3K1 - FFT_OVERLAP_LPF_48K_15K_3K1 + + init { + assert(FFT_OVERLAP_LPF_48K_15K_3K1 >= FIR_LPF_48K_15K_3K1.size - 1) + } + + const val DECIMATION_48K_300K = 4 + const val INTERPOLATION_48K_300K = 25 + + const val FFT_SIZE_48K_300K = FFT_SIZE_LPF_48K_15K_3K1 * INTERPOLATION_48K_300K / DECIMATION_48K_300K + const val FFT_OVERLAP_48K_300K = FFT_OVERLAP_LPF_48K_15K_3K1 * INTERPOLATION_48K_300K / DECIMATION_48K_300K + const val FFT_DATA_BLOCK_SIZE_48K_300K = FFT_SIZE_48K_300K - FFT_OVERLAP_48K_300K + + // how many bins to shift for 38kHz mixing + // assuming FFT_SIZE_LPF_48K_15K_3K1 *bins* (complex) + // 19 / 150 is the ratio between 38k/300k + const val FREQUENCY_MIXING_BINS_38K = + FFT_SIZE_LPF_48K_15K_3K1 * INTERPOLATION_48K_300K / DECIMATION_48K_300K * 19 / 150 + + // a single cycle of a 19kHz signal takes (1/19k)/(1/300k) or 300k/19k samples. + // since that number isn't exact, buffer an entire 19 cycles. + const val BUFFER_SIZE_19K_300K = 300 + + // using cosine is nicer + val BUFFER_19K_300K = FloatArray(BUFFER_SIZE_19K_300K) { + 0.1f * cos(2 * PI * 19000.0 * it.toDouble() / 300000.0).toFloat() + } + + // we want a carrier deviation of +-75kHz, at a sampling rate of 300kHz + const val CORRECTION_FACTOR = (75000.0 / (300000.0 / (2.0 * PI))).toFloat() + const val INVERSE_CORRECTION_FACTOR = 1 / CORRECTION_FACTOR + + // these are used for "low/high" mixing + const val CBUFFER_SIZE_100K_300K = 3 + + val CBUFFER_100K_300K = FloatArray(2 * CBUFFER_SIZE_100K_300K) { + val index = it / 2 + if (it and 1 == 0) { + 1f * sin(2 * PI * 100000.0 * index.toDouble() / 300000.0).toFloat() + } else { + 1f * cos(2 * PI * 100000.0 * index.toDouble() / 300000.0).toFloat() + } + } + + const val STEREO = true + const val MONO = false +} \ No newline at end of file diff --git a/src/client/kotlin/space/autistic/radio/client/fmsim/FmFullDemodulator.kt b/src/client/kotlin/space/autistic/radio/client/fmsim/FmFullDemodulator.kt new file mode 100644 index 0000000..7cf15af --- /dev/null +++ b/src/client/kotlin/space/autistic/radio/client/fmsim/FmFullDemodulator.kt @@ -0,0 +1,162 @@ +package space.autistic.radio.client.fmsim + +import org.joml.Vector2f +import org.jtransforms.fft.FloatFFT_1D +import space.autistic.radio.client.complex.I +import space.autistic.radio.client.complex.cmul +import space.autistic.radio.client.complex.conjugate +import space.autistic.radio.client.dsp.Biquad1stOrder +import java.nio.FloatBuffer +import java.util.function.BiConsumer + +class FmFullDemodulator { + private val inputBuffer = FloatBuffer.allocate(FmFullConstants.FFT_SIZE_48K_300K) + private val fft300kBuf = FloatBuffer.allocate(FmFullConstants.FFT_SIZE_48K_300K) + private val fft48kBuf = FloatBuffer.allocate(FmFullConstants.FFT_SIZE_LPF_48K_15K_3K1) + private val outputBuffer = FloatBuffer.allocate(2 * FmFullConstants.FFT_DATA_BLOCK_SIZE_LPF_48K_15K_3K1) + + init { + inputBuffer.position(2 * FmFullConstants.FFT_OVERLAP_48K_300K) + } + + // yep. + private val boxcarI = Biquad1stOrder(1f, 1f, 0f) + private val boxcarQ = Biquad1stOrder(1f, 1f, 0f) + private val delayI = Biquad1stOrder(0f, 1f, 0f) + private val delayQ = Biquad1stOrder(0f, 1f, 0f) + + private val deemphasisLeft = Biquad1stOrder( + FmFullConstants.FM_DEEMPAHSIS_B0_48K, + FmFullConstants.FM_DEEMPHASIS_B1_48K, + FmFullConstants.FM_DEEMPHASIS_A1_48K + ) + private val deemphasisRight = Biquad1stOrder( + FmFullConstants.FM_DEEMPAHSIS_B0_48K, + FmFullConstants.FM_DEEMPHASIS_B1_48K, + FmFullConstants.FM_DEEMPHASIS_A1_48K + ) + + private val lastStereoPilot = Vector2f() + private val lastStereoPilotPolarDiscriminator = Vector2f() + + /** + * Takes in samples at 300kHz, in I/Q format, and processes them for output. + * + * Calls consumer with processed samples at 48kHz, stereo. + */ + fun process(input: FloatBuffer, stereo: Boolean, consumer: BiConsumer<Boolean, FloatBuffer>) { + while (input.remaining() >= 2) { + val z = Vector2f() + val w = Vector2f() + while (input.remaining() >= 2 && inputBuffer.hasRemaining()) { + z.x = boxcarI.process(input.get()) + z.y = boxcarQ.process(input.get()) + // quadrature demodulation = FM demodulation + // see https://wiki.gnuradio.org/index.php/Quadrature_Demod and such + w.x = delayI.process(z.x) + w.y = -delayQ.process(z.y) + z.cmul(w) + inputBuffer.put(org.joml.Math.atan2(z.y, z.x) * FmFullConstants.INVERSE_CORRECTION_FACTOR) + } + if (!inputBuffer.hasRemaining()) { + var stereoPilot = false + fft300kBuf.put(0, inputBuffer.array()) + fft300k.realForward(fft300kBuf.array()) + for (i in 0 until fft48kBuf.capacity()) { + fft48kBuf.put(i, 0f) + } + for (i in 2 until (FmFullConstants.FREQUENCY_MIXING_BINS_38K - 2 and 1.inv()) step 2) { + z.x = fft300kBuf.get(i) + z.y = fft300kBuf.get(i + 1) + w.x = fir48kLpf.get(i) + w.y = fir48kLpf.get(i + 1) + z.cmul(w) + fft48kBuf.put(i, z.x) + fft48kBuf.put(i + 1, z.y) + } + fft48kBuf.put(0, fft300kBuf.get(0) * fir48kLpf.get(0)) + fft48k.realInverse(fft48kBuf.array(), false) + outputBuffer.clear() + fft48kBuf.position(FmFullConstants.FFT_OVERLAP_LPF_48K_15K_3K1) + for (i in 0 until FmFullConstants.FFT_DATA_BLOCK_SIZE_LPF_48K_15K_3K1) { + val sample = fft48kBuf.get() * (1f / FmFullConstants.FFT_SIZE_48K_300K) + outputBuffer.put(sample) + outputBuffer.put(sample) + } + outputBuffer.clear() + if (stereo) { + z.x = fft300kBuf.get(FmFullConstants.FREQUENCY_MIXING_BINS_38K) + z.y = fft300kBuf.get(FmFullConstants.FREQUENCY_MIXING_BINS_38K + 1) + z.conjugate(w).cmul(lastStereoPilot).conjugate().normalize() + if (lastStereoPilotPolarDiscriminator.distanceSquared(w) < 0.5f && z.lengthSquared() >= FmFullConstants.FFT_SIZE_48K_300K) { + stereoPilot = true + } + lastStereoPilot.set(z) + lastStereoPilotPolarDiscriminator.set(w) + if (stereoPilot) { + // w is our phase offset + // TODO check if this is mathematically sound + z.normalize().cmul(z).cmul(w.conjugate()).conjugate() + // z is our recovered 38kHz carrier, including phase offset + for (i in 0 until fft48kBuf.capacity()) { + fft48kBuf.put(i, 0f) + } + val base = FmFullConstants.FREQUENCY_MIXING_BINS_38K * 2 + val sz = Vector2f() + val sw = Vector2f() + for (i in 2 until (FmFullConstants.FREQUENCY_MIXING_BINS_38K - 2 and 1.inv()) step 2) { + sz.x = fft300kBuf.get(base + i) + sz.y = fft300kBuf.get(base + i + 1) + sw.x = fft300kBuf.get(base - i) + sw.y = fft300kBuf.get(base - i + 1) + sz.cmul(z).add(sw.cmul(z).conjugate()) + sw.x = fir48kLpf.get(i) + sw.y = fir48kLpf.get(i + 1) + sz.cmul(sw) + fft48kBuf.put(i, sz.x) + fft48kBuf.put(i + 1, sz.y) + } + sz.x = fft300kBuf.get(base) + sz.y = fft300kBuf.get(base + 1) + sz.cmul(z) + fft48kBuf.put(0, sz.x * fir48kLpf.get(0)) + fft48k.realInverse(fft48kBuf.array(), false) + outputBuffer.clear() + fft48kBuf.position(FmFullConstants.FFT_OVERLAP_LPF_48K_15K_3K1) + for (i in 0 until FmFullConstants.FFT_DATA_BLOCK_SIZE_LPF_48K_15K_3K1) { + val lmr = fft48kBuf.get() * (1f / FmFullConstants.FFT_SIZE_48K_300K) + val lpr = outputBuffer.get(outputBuffer.position()) + outputBuffer.put((lpr + lmr) * 0.5f) + outputBuffer.put((lpr - lmr) * 0.5f) + } + outputBuffer.clear() + } + } + inputBuffer.position(FmFullConstants.FFT_DATA_BLOCK_SIZE_48K_300K) + inputBuffer.compact() + for (i in 0 until outputBuffer.capacity() step 2) { + outputBuffer.put(i, deemphasisLeft.process(outputBuffer.get(i))) + } + for (i in 1 until outputBuffer.capacity() step 2) { + outputBuffer.put(i, deemphasisRight.process(outputBuffer.get(i))) + } + consumer.accept(stereoPilot, outputBuffer) + } + } + } + + fun flush(stereo: Boolean, consumer: BiConsumer<Boolean, FloatBuffer>) { + process(FloatBuffer.allocate(inputBuffer.remaining()), stereo, consumer) + } + + companion object { + private val fft300k = FloatFFT_1D(FmFullConstants.FFT_SIZE_48K_300K.toLong()) + private val fft48k = FloatFFT_1D(FmFullConstants.FFT_SIZE_LPF_48K_15K_3K1.toLong()) + private val fir48kLpf = FloatBuffer.allocate(FmFullConstants.FFT_SIZE_LPF_48K_15K_3K1) + + init { + fir48kLpf.put(0, FmFullConstants.FIR_LPF_48K_15K_3K1) + fft48k.realForward(fir48kLpf.array()) + } + } +} \ No newline at end of file diff --git a/src/client/kotlin/space/autistic/radio/client/fmsim/FmFullModulator.kt b/src/client/kotlin/space/autistic/radio/client/fmsim/FmFullModulator.kt new file mode 100644 index 0000000..65e208a --- /dev/null +++ b/src/client/kotlin/space/autistic/radio/client/fmsim/FmFullModulator.kt @@ -0,0 +1,170 @@ +package space.autistic.radio.client.fmsim + +import org.joml.Vector2f +import space.autistic.radio.client.complex.cmul +import space.autistic.radio.client.complex.conjugate +import space.autistic.radio.client.dsp.Biquad1stOrder +import java.nio.FloatBuffer +import java.util.function.Consumer +import org.jtransforms.fft.FloatFFT_1D +import space.autistic.radio.client.complex.I +import kotlin.math.max +import kotlin.math.min + +class FmFullModulator { + private val leftPlusRight = FloatBuffer.allocate(FmFullConstants.FFT_SIZE_LPF_48K_15K_3K1) + private val leftMinusRight = FloatBuffer.allocate(FmFullConstants.FFT_SIZE_LPF_48K_15K_3K1) + private val biquadLeft = Biquad1stOrder( + FmFullConstants.FM_PREEMPAHSIS_B0_48K, + FmFullConstants.FM_PREEMPHASIS_B1_48K, + FmFullConstants.FM_PREEMPHASIS_A1_48K + ) + private val biquadRight = Biquad1stOrder( + FmFullConstants.FM_PREEMPAHSIS_B0_48K, + FmFullConstants.FM_PREEMPHASIS_B1_48K, + FmFullConstants.FM_PREEMPHASIS_A1_48K + ) + private val fft48kBuffer = FloatBuffer.allocate(FmFullConstants.FFT_SIZE_LPF_48K_15K_3K1) + private val mixingBuffer = FloatBuffer.allocate(FmFullConstants.FFT_SIZE_48K_300K) + private val outputBuffer = FloatBuffer.allocate(2 * FmFullConstants.FFT_DATA_BLOCK_SIZE_48K_300K) + private val stereoPilot = FloatBuffer.wrap(FmFullConstants.BUFFER_19K_300K) + + private val cycle19k = Vector2f(0f, 1f) + private var lastSum = 0f + + init { + // pre-pad the buffers + leftPlusRight.position(FmFullConstants.FFT_OVERLAP_LPF_48K_15K_3K1) + leftMinusRight.position(FmFullConstants.FFT_OVERLAP_LPF_48K_15K_3K1) + } + + /** + * Takes in samples at 48kHz, interleaved stereo (even when set to MONO), and processes them for output. + * + * Calls consumer with processed samples at 300kHz in I/Q format. + */ + fun process(input: FloatBuffer, power: Float, stereo: Boolean, consumer: Consumer<FloatBuffer>) { + while (input.remaining() >= 2) { + while (input.remaining() >= 2 && leftPlusRight.hasRemaining()) { + // FIXME AGC (currently clamping/clipping) + val left = min(max(biquadLeft.process(input.get()), -1f), 1f) + val right = min(max(biquadRight.process(input.get()), -1f), 1f) + leftPlusRight.put(left + right) + leftMinusRight.put(left - right) + } + if (!leftPlusRight.hasRemaining()) { + // zero the mixing buffer + for (i in 0 until mixingBuffer.capacity()) { + mixingBuffer.put(i, 0f) + } + fft48kBuffer.put(0, leftPlusRight, 0, FmFullConstants.FFT_SIZE_LPF_48K_15K_3K1) + fft48k.realForward(fft48kBuffer.array()) + fft48kBuffer.array().forEachIndexed { index, fl -> + fft48kBuffer.put( + index, + 0.4f / FmFullConstants.FFT_SIZE_LPF_48K_15K_3K1 * fl + ) + } + val z = Vector2f() + val w = Vector2f() + for (i in 2 until (FmFullConstants.FREQUENCY_MIXING_BINS_38K - 2 and 1.inv()) step 2) { + z.x = fft48kBuffer.get(i) + z.y = fft48kBuffer.get(i + 1) + w.x = fir48kLpf.get(i) + w.y = fir48kLpf.get(i + 1) + z.cmul(w) + mixingBuffer.put(i, z.x) + mixingBuffer.put(i + 1, z.y) + } + mixingBuffer.put(0, fft48kBuffer.get(0) * fir48kLpf.get(0)) + if (stereo) { + fft48kBuffer.put(0, leftMinusRight, 0, FmFullConstants.FFT_SIZE_LPF_48K_15K_3K1) + fft48k.realForward(fft48kBuffer.array()) + fft48kBuffer.array().forEachIndexed { index, fl -> + fft48kBuffer.put( + index, + 0.2f / FmFullConstants.FFT_SIZE_LPF_48K_15K_3K1 * fl + ) + } + val base = FmFullConstants.FREQUENCY_MIXING_BINS_38K * 2 + for (i in 2 until (FmFullConstants.FREQUENCY_MIXING_BINS_38K - 2 and 1.inv()) step 2) { + z.x = fft48kBuffer.get(i) + z.y = fft48kBuffer.get(i + 1) + w.x = fir48kLpf.get(i) + w.y = fir48kLpf.get(i + 1) + z.cmul(w) + mixingBuffer.put(base + i, z.x) + mixingBuffer.put(base + i + 1, z.y) + } + mixingBuffer.put(base, fft48kBuffer.get(0) * fir48kLpf.get(0)) + // cycle (phase offset) is frequency-doubled the 19k carrier + // but we need to add a 90deg rotation because ??? + // TODO check if this is mathematically sound + val cycle = cycle19k.cmul(cycle19k, Vector2f()).cmul(I) + // bandwidth we care about is about half of 38k, so just, well, half it + for (i in 2 until (FmFullConstants.FREQUENCY_MIXING_BINS_38K - 2 and 1.inv()) step 2) { + z.x = mixingBuffer.get(base + i) + z.y = mixingBuffer.get(base + i + 1) + // we also need the conjugate + z.conjugate(w) + z.cmul(cycle) + w.cmul(cycle) + mixingBuffer.put(base + i, z.x) + mixingBuffer.put(base + i + 1, z.y) + mixingBuffer.put(base - i, mixingBuffer.get(base - i) + w.x) + mixingBuffer.put(base - i + 1, mixingBuffer.get(base - i + 1) + w.y) + } + // handle 38kHz itself + z.x = mixingBuffer.get(base) + z.y = mixingBuffer.get(base + 1) + z.cmul(cycle) + mixingBuffer.put(base, z.x) + mixingBuffer.put(base + 1, z.y) + // add pilot + mixingBuffer.put( + FmFullConstants.FREQUENCY_MIXING_BINS_38K, + 75f / FmFullConstants.FFT_SIZE_48K_300K * cycle19k.x + ) + mixingBuffer.put( + FmFullConstants.FREQUENCY_MIXING_BINS_38K + 1, + 75f / FmFullConstants.FFT_SIZE_48K_300K * cycle19k.y + ) + // phase correction factors (due to dropping 225 bins) + cycle19k.cmul(I.conjugate()) + } + // mark data block as processed + leftPlusRight.position(FmFullConstants.FFT_DATA_BLOCK_SIZE_LPF_48K_15K_3K1) + leftMinusRight.position(FmFullConstants.FFT_DATA_BLOCK_SIZE_LPF_48K_15K_3K1) + leftPlusRight.compact() + leftMinusRight.compact() + fft300k.realInverse(mixingBuffer.array(), false) + outputBuffer.clear() + var sum = lastSum + for (i in FmFullConstants.FFT_OVERLAP_48K_300K until FmFullConstants.FFT_SIZE_48K_300K) { + sum += mixingBuffer.get(i) * FmFullConstants.CORRECTION_FACTOR + outputBuffer.put(org.joml.Math.cos(sum) * power) + outputBuffer.put(org.joml.Math.sin(sum) * power) + } + lastSum = sum % (2 * Math.PI).toFloat() + outputBuffer.clear() + consumer.accept(outputBuffer) + } + } + input.compact() + } + + fun flush(power: Float, stereo: Boolean, consumer: Consumer<FloatBuffer>) { + process(FloatBuffer.allocate(2 * leftPlusRight.remaining()), power, stereo, consumer) + } + + companion object { + private val fft48k = FloatFFT_1D(FmFullConstants.FFT_SIZE_LPF_48K_15K_3K1.toLong()) + private val fft300k = FloatFFT_1D(FmFullConstants.FFT_SIZE_48K_300K.toLong()) + private val fir48kLpf = FloatBuffer.allocate(FmFullConstants.FFT_SIZE_LPF_48K_15K_3K1) + + init { + fir48kLpf.put(0, FmFullConstants.FIR_LPF_48K_15K_3K1) + fft48k.realForward(fir48kLpf.array()) + } + } +} \ No newline at end of file diff --git a/src/client/kotlin/space/autistic/radio/client/fmsim/FmFullThread.kt b/src/client/kotlin/space/autistic/radio/client/fmsim/FmFullThread.kt new file mode 100644 index 0000000..bce7a72 --- /dev/null +++ b/src/client/kotlin/space/autistic/radio/client/fmsim/FmFullThread.kt @@ -0,0 +1,294 @@ +package space.autistic.radio.client.fmsim + +import net.minecraft.client.sound.SoundSystem +import net.minecraft.util.math.MathHelper +import org.joml.Vector2f +import space.autistic.radio.PirateRadio +import space.autistic.radio.client.PirateRadioClient +import space.autistic.radio.client.complex.cmul +import space.autistic.radio.client.sound.PirateRadioSoundInstance +import space.autistic.radio.client.sound.ReceiverAudioStream +import space.autistic.radio.client.util.LevenshteinDistance +import java.nio.ByteBuffer +import java.nio.ByteOrder +import java.nio.FloatBuffer +import java.util.UUID +import java.util.concurrent.ArrayBlockingQueue +import javax.sound.sampled.AudioSystem +import javax.sound.sampled.LineUnavailableException +import javax.sound.sampled.Mixer +import kotlin.math.max + +object FmFullThread : Runnable { + class FmTask( + val trackedTransmitters: Map<UUID, PirateRadioSoundInstance.TrackedTransmitter>, + val noiseLevels: FloatArray, + val audioOutput: Mixer.Info, + val minecraftSoundDevice: String?, + ) + + // empty task, marker to shut off the thread + val EMPTY_TASK = FmTask(emptyMap(), FloatArray(3), PirateRadioClient.minecraftAudioDevice, null) + + val trackedTransmitterQueue = ArrayBlockingQueue<FmTask>(8) + + private class TtsModulator( + val buffer: FloatBuffer, + val modulator: FmFullModulator, + var power: Float, + var repeatTimeout: Int, + var mixingBuffer: FloatBuffer + ) + + // 3 seconds + private const val REPEAT_TIMEOUT = 8000 * 3 + + // default to 0.05s (1/20) + val bufferSize = System.getProperty("space.autistic.radio.buffer.size", "").toIntOrNull() ?: 2400 + + override fun run() { + var currentTask = EMPTY_TASK + val modulators = HashMap<UUID, TtsModulator>() + val mixingBuffers = Array(3) { FloatBuffer.allocate(FmFullConstants.FFT_DATA_BLOCK_SIZE_48K_300K * 2) } + + val inputBuffer = FloatBuffer.allocate(FmFullConstants.FFT_DATA_BLOCK_SIZE_LPF_48K_15K_3K1 * 2) + + val noiseGens = Array(3) { FastModulatedNoise(FastModulatedNoise.Which.entries[it]) } + + // -120dB or so + val noiseFloor = NoiseFloor(1e-12f) + + val plus100k = FloatBuffer.wrap(FmFullConstants.CBUFFER_100K_300K) + val minus100k = FloatBuffer.wrap(FmFullConstants.CBUFFER_100K_300K) + + val demodulator = FmFullDemodulator() + + // for native audio only + val outputBytes = ByteBuffer.allocate(bufferSize * 2 * 2).order(ByteOrder.LITTLE_ENDIAN) + var nativeAudio = if (ReceiverAudioStream.useNativeAudio) { + AudioSystem.getSourceDataLine(ReceiverAudioStream.format) + .apply { open(ReceiverAudioStream.format, FmFullThread.bufferSize * 2 * 2) } + } else { + null + } + var lastOutput = PirateRadioClient.audioDevice + var lastMinecraftSoundDevice: String? = null + + try { + while (!Thread.interrupted()) { + currentTask = trackedTransmitterQueue.poll() ?: currentTask + if (currentTask === EMPTY_TASK) { + Thread.onSpinWait() + continue + } + + val audioOutput = currentTask.audioOutput + if (lastOutput != audioOutput && audioOutput != PirateRadioClient.minecraftAudioDevice) { + nativeAudio = when (audioOutput) { + PirateRadioClient.systemDefaultAudioDevice -> { + try { + AudioSystem.getSourceDataLine(ReceiverAudioStream.format) + .apply { open(ReceiverAudioStream.format, FmFullThread.bufferSize * 2 * 2) } + } catch (e: LineUnavailableException) { + null + } + } + + PirateRadioClient.openAlAudioDevice -> { + null + } + + else -> { + try { + AudioSystem.getSourceDataLine(ReceiverAudioStream.format, audioOutput) + .apply { open(ReceiverAudioStream.format, FmFullThread.bufferSize * 2 * 2) } + } catch (e: LineUnavailableException) { + null + } + } + } + } + if (audioOutput == PirateRadioClient.minecraftAudioDevice) { + if (lastMinecraftSoundDevice != currentTask.minecraftSoundDevice || lastOutput != audioOutput) { + lastMinecraftSoundDevice = currentTask.minecraftSoundDevice + nativeAudio = if (lastMinecraftSoundDevice == "") { + try { + AudioSystem.getSourceDataLine(ReceiverAudioStream.format) + .apply { open(ReceiverAudioStream.format, FmFullThread.bufferSize * 2 * 2) } + } catch (e: LineUnavailableException) { + null + } + } else { + val device = lastMinecraftSoundDevice!!.removePrefix(SoundSystem.OPENAL_SOFT_ON) + try { + AudioSystem.getSourceDataLine( + ReceiverAudioStream.format, + AudioSystem.getMixerInfo().filter { + AudioSystem.getMixer(it).sourceLineInfo.isNotEmpty() + }.map { + it to LevenshteinDistance.calculate(it.description, device) + }.minByOrNull { it.second }?.first + ).apply { open(ReceiverAudioStream.format, FmFullThread.bufferSize * 2 * 2) } + } catch (e: LineUnavailableException) { + null + } + } + } + } + lastOutput = audioOutput + + modulators.keys.retainAll(currentTask.trackedTransmitters.keys) + currentTask.trackedTransmitters.forEach { (k, v) -> + modulators.compute(k) { _, modulator -> + if (modulator != null) { + modulator.power = v.power + modulator.mixingBuffer = mixingBuffers[v.frequencyOffset + 1] + return@compute modulator + } + val audioData = v.audio.getNow(null) + if (audioData != null) { + val buf = FloatBuffer.wrap(audioData) + var actualSampleOffset = Math.floorMod(v.sampleOffset, (buf.capacity() + REPEAT_TIMEOUT)) + var repeatTimeout = max(0, actualSampleOffset - buf.capacity()) + if (repeatTimeout > 0) { + actualSampleOffset = 0 + repeatTimeout = REPEAT_TIMEOUT - repeatTimeout + } + if (actualSampleOffset == buf.capacity()) { + actualSampleOffset = 0 + repeatTimeout = REPEAT_TIMEOUT + } + buf.position(actualSampleOffset) + TtsModulator( + buf, FmFullModulator(), v.power, repeatTimeout, mixingBuffers[v.frequencyOffset + 1] + ) + } else { + null + } + } + } + + mixingBuffers.forEach { + it.clear() + while (it.hasRemaining()) it.put(0f) + it.clear() + } + + modulators.values.forEach { + inputBuffer.clear() + while (inputBuffer.hasRemaining()) { + val sample = if (it.repeatTimeout > 0) { + it.repeatTimeout-- + 0f + } else { + it.buffer.get() + } + if (!it.buffer.hasRemaining()) { + it.repeatTimeout = REPEAT_TIMEOUT + it.buffer.clear() + } + for (i in 0 until 2 * 6) inputBuffer.put(sample) + } + inputBuffer.clear() + val mixingBuffer = it.mixingBuffer + it.modulator.process(inputBuffer, it.power, false) { outputBuffer -> + for (i in 0 until mixingBuffer.capacity()) { + mixingBuffer.put(i, mixingBuffer.get(i) + outputBuffer.get()) + } + } + } + + if (modulators.any { it.value.mixingBuffer === mixingBuffers[2] }) { + val floatBufferHi = mixingBuffers[2] + val floatView = mixingBuffers[1] + val z = Vector2f() + val w = Vector2f() + for (i in 0 until floatBufferHi.capacity() step 2) { + z.x = floatBufferHi.get(i) + z.y = floatBufferHi.get(i + 1) + if (!plus100k.hasRemaining()) { + plus100k.clear() + } + w.x = plus100k.get() + w.y = plus100k.get() + z.cmul(w) + floatView.put(i, floatView.get(i) + z.x) + floatView.put(i + 1, floatView.get(i + 1) + z.y) + } + } + + if (modulators.any { it.value.mixingBuffer === mixingBuffers[0] }) { + val floatBufferLo = mixingBuffers[0] + val floatView = mixingBuffers[1] + val z = Vector2f() + val w = Vector2f() + for (i in 0 until floatBufferLo.capacity() step 2) { + z.x = floatBufferLo.get(i) + z.y = floatBufferLo.get(i + 1) + if (!minus100k.hasRemaining()) { + minus100k.clear() + } + w.x = minus100k.get() + w.y = -minus100k.get() + z.cmul(w) + floatView.put(i, floatView.get(i) + z.x) + floatView.put(i + 1, floatView.get(i + 1) + z.y) + } + } + + noiseGens.forEachIndexed { index, v -> + if (currentTask.noiseLevels[index] != 0f) { + v.generateNoise(currentTask.noiseLevels[index]) { outputBuffer -> + val mixingBuffer = mixingBuffers[1] + for (i in 0 until mixingBuffer.capacity()) { + mixingBuffer.put(i, mixingBuffer.get(i) + outputBuffer.get()) + } + } + } + } + + noiseFloor.noiseBlock { outputBuffer -> + val mixingBuffer = mixingBuffers[1] + for (i in 0 until mixingBuffer.capacity()) { + mixingBuffer.put(i, mixingBuffer.get(i) + outputBuffer.get()) + } + } + + @Suppress("NAME_SHADOWING") + val nativeAudio = nativeAudio + demodulator.process(mixingBuffers[1], PirateRadioClient.stereo) { _, audioBuffer -> + // TODO stereo pilot + // we *want* backpressure + // FIXME use bigger buffers? + if (nativeAudio != null) { + while (audioBuffer.hasRemaining()) { + if (!outputBytes.hasRemaining()) { + val written = nativeAudio.write(outputBytes.array(), 0, outputBytes.capacity()) + outputBytes.position(written).compact() + if (written == 0) { + nativeAudio.start() + continue + } + } + val volume = PirateRadioClient.volume + outputBytes.putShort( + (MathHelper.clamp( + (audioBuffer.get() * 32767.5f - 0.5f).toInt(), -32768, 32767 + ) * volume * volume / 100).toShort() + ) + } + } else { + ReceiverAudioStream.bufferQueue.put( + FloatBuffer.allocate(audioBuffer.capacity()).put(audioBuffer).clear() + ) + } + } + } + } catch (e: Throwable) { + PirateRadio.logger.error("Quitting FM simulation thread, as something went wrong!", e) + // for some reason it doesn't print stack trace but we still wanna propagate the exception to the thread + // itself + throw e + } + } +} diff --git a/src/client/kotlin/space/autistic/radio/client/fmsim/FmSimulatorMode.kt b/src/client/kotlin/space/autistic/radio/client/fmsim/FmSimulatorMode.kt new file mode 100644 index 0000000..a8bc9fd --- /dev/null +++ b/src/client/kotlin/space/autistic/radio/client/fmsim/FmSimulatorMode.kt @@ -0,0 +1,7 @@ +package space.autistic.radio.client.fmsim + +enum class FmSimulatorMode { + FULL, + FAST, + DEAF +} diff --git a/src/client/kotlin/space/autistic/radio/client/fmsim/NoiseFloor.kt b/src/client/kotlin/space/autistic/radio/client/fmsim/NoiseFloor.kt new file mode 100644 index 0000000..92df212 --- /dev/null +++ b/src/client/kotlin/space/autistic/radio/client/fmsim/NoiseFloor.kt @@ -0,0 +1,31 @@ +package space.autistic.radio.client.fmsim + +import java.nio.FloatBuffer +import java.util.concurrent.ThreadLocalRandom +import java.util.function.Consumer + +class NoiseFloor(level: Float) { + private val buffer = FloatBuffer.allocate(300000 * 2) + private val outputBuffer = FloatBuffer.allocate(FmFullConstants.FFT_DATA_BLOCK_SIZE_48K_300K * 2) + + init { + // FIXME is this how you generate IQ noise? + val random = ThreadLocalRandom.current() + val dLevel = level.toDouble() + while (buffer.hasRemaining()) { + buffer.put(random.nextGaussian(0.0, dLevel).toFloat()) + buffer.put(0f) + } + } + + // complex noise, in IQ format? + fun noiseBlock(consumer: Consumer<FloatBuffer>) { + outputBuffer.clear() + while (outputBuffer.hasRemaining()) { + if (!buffer.hasRemaining()) buffer.clear() + outputBuffer.put(buffer.get()) + } + outputBuffer.clear() + consumer.accept(outputBuffer) + } +} \ No newline at end of file diff --git a/src/client/kotlin/space/autistic/radio/client/gui/FmReceiverScreen.kt b/src/client/kotlin/space/autistic/radio/client/gui/FmReceiverScreen.kt index 4bd4db2..8886498 100644 --- a/src/client/kotlin/space/autistic/radio/client/gui/FmReceiverScreen.kt +++ b/src/client/kotlin/space/autistic/radio/client/gui/FmReceiverScreen.kt @@ -1,11 +1,809 @@ package space.autistic.radio.client.gui +import com.dylibso.chicory.experimental.aot.AotMachineFactory +import com.dylibso.chicory.runtime.* +import com.dylibso.chicory.wasm.ChicoryException +import com.dylibso.chicory.wasm.InvalidException +import com.dylibso.chicory.wasm.Parser +import com.dylibso.chicory.wasm.types.FunctionType +import com.dylibso.chicory.wasm.types.Value +import com.dylibso.chicory.wasm.types.ValueType +import com.mojang.blaze3d.systems.RenderSystem +import net.minecraft.client.MinecraftClient +import net.minecraft.client.gui.DrawContext import net.minecraft.client.gui.screen.Screen +import net.minecraft.client.gui.widget.ButtonWidget +import net.minecraft.client.gui.widget.ClickableWidget +import net.minecraft.client.gui.widget.CyclingButtonWidget +import net.minecraft.client.gui.widget.SliderWidget +import net.minecraft.client.render.GameRenderer +import net.minecraft.client.util.InputUtil +import net.minecraft.screen.ScreenTexts +import net.minecraft.text.StringVisitable import net.minecraft.text.Text +import net.minecraft.util.Colors +import net.minecraft.util.Identifier +import net.minecraft.util.math.MathHelper +import org.lwjgl.glfw.GLFW +import org.slf4j.LoggerFactory +import org.slf4j.event.Level +import space.autistic.radio.PirateRadio +import space.autistic.radio.PirateRadio.logger +import space.autistic.radio.client.PirateRadioClient +import space.autistic.radio.client.fmsim.FmSimulatorMode +import space.autistic.radio.wasm.Bindings.Companion.bindFunc +import java.lang.invoke.MethodHandles +import java.util.* +import javax.sound.sampled.AudioSystem +import javax.sound.sampled.Mixer +import kotlin.math.max +import kotlin.reflect.jvm.javaMethod + class FmReceiverScreen : Screen(Text.translatable("pirate-radio.fm-receiver")) { + private var loggingEventBuilder = wasmLogger.makeLoggingEventBuilder(Level.INFO) + private var instance: Instance? = null + private var failure: Exception? = null + set(value) { + field = value + clearChildren() + } + private var packTitle: Text? = null + + private var backgroundWidth = 0 + private var backgroundHeight = 0 + private var backgroundTextureWidth = 256 + private var backgroundTextureHeight = 256 + + private var drawContext: DrawContext? = null + + private var textObjects = ArrayList<Text?>() + private var textObjectsFree = ArrayList<Int>() + + private val frequencyPlusWidget = ButtonWidget.builder(Text.translatable("pirate-radio.frequency.plus")) { + if (InputUtil.isKeyPressed( + MinecraftClient.getInstance().window.handle, GLFW.GLFW_KEY_LEFT_SHIFT + ) || InputUtil.isKeyPressed(MinecraftClient.getInstance().window.handle, GLFW.GLFW_KEY_RIGHT_SHIFT) + ) { + PirateRadioClient.frequency += 10 + } else { + PirateRadioClient.frequency++ + } + }.dimensions(0, 0, 20, 20) + .narrationSupplier { ButtonWidget.getNarrationMessage(Text.translatable("pirate-radio.frequency.plus.narrated")) } + .build() + private val frequencyMinusWidget = ButtonWidget.builder(Text.translatable("pirate-radio.frequency.minus")) { + if (InputUtil.isKeyPressed( + MinecraftClient.getInstance().window.handle, GLFW.GLFW_KEY_LEFT_SHIFT + ) || InputUtil.isKeyPressed(MinecraftClient.getInstance().window.handle, GLFW.GLFW_KEY_RIGHT_SHIFT) + ) { + PirateRadioClient.frequency -= 10 + } else { + PirateRadioClient.frequency-- + } + }.dimensions(20, 0, 20, 20) + .narrationSupplier { ButtonWidget.getNarrationMessage(Text.translatable("pirate-radio.frequency.plus.narrated")) } + .build() + + private val volumePlusWidget = ButtonWidget.builder(Text.translatable("pirate-radio.volume.plus")) { + PirateRadioClient.volume++ + }.dimensions(0, 20, 20, 20) + .narrationSupplier { ButtonWidget.getNarrationMessage(Text.translatable("pirate-radio.volume.plus.narrated")) } + .build() + private val volumeMinusWidget = ButtonWidget.builder(Text.translatable("pirate-radio.volume.minus")) { + PirateRadioClient.volume-- + }.dimensions(20, 20, 20, 20) + .narrationSupplier { ButtonWidget.getNarrationMessage(Text.translatable("pirate-radio.volume.minus.narrated")) } + .build() + private var frequencySlider = makeFrequencySlider(0, 0, 320, 20) + private fun makeFrequencySlider(x: Int, y: Int, width: Int, height: Int) = object : SliderWidget( + x, y, width, height, ScreenTexts.EMPTY, (PirateRadioClient.frequency - 768).toDouble() / (1080.0 - 768.0) + ) { + init { + updateMessage() + } + + override fun updateMessage() { + message = Text.translatable( + "pirate-radio.frequency.selected", PirateRadioClient.frequency / 10, PirateRadioClient.frequency % 10 + ) + } + + override fun applyValue() { + PirateRadioClient.frequency = MathHelper.clampedLerp(768.0, 1080.0, value).toInt() + } + + override fun keyPressed(keyCode: Int, scanCode: Int, modifiers: Int): Boolean { + val oldValue = this.value + val oldFrequency = PirateRadioClient.frequency + val changed = super.keyPressed(keyCode, scanCode, modifiers) + val bl = keyCode == GLFW.GLFW_KEY_LEFT + if (bl || keyCode == GLFW.GLFW_KEY_RIGHT) { + if (changed) { + val delta = if (bl) -1 else 1 + val newValue = (oldFrequency + delta - 768).toDouble() / (1080.0 - 768.0) + this.value = MathHelper.clamp(newValue, 0.0, 1.0) + if (oldValue != this.value) { + this.applyValue() + } + + this.updateMessage() + } + } + return changed + } + } + + private var volumeSlider = makeVolumeSlider(0, 20, 320, 20) + private fun makeVolumeSlider(x: Int, y: Int, width: Int, height: Int) = object : SliderWidget( + x, y, width, height, ScreenTexts.EMPTY, PirateRadioClient.volume.toDouble() / 10.0 + ) { + init { + updateMessage() + } + + override fun updateMessage() { + message = if (PirateRadioClient.volume == 0) { + Text.translatable("pirate-radio.volume.selected", Text.translatable("pirate-radio.volume.off")) + } else { + Text.translatable("pirate-radio.volume.selected", PirateRadioClient.volume) + } + } + + override fun applyValue() { + PirateRadioClient.volume = MathHelper.clampedLerp(0.0, 10.0, value).toInt() + } + + + override fun keyPressed(keyCode: Int, scanCode: Int, modifiers: Int): Boolean { + val oldValue = this.value + val oldVolume = PirateRadioClient.volume + val changed = super.keyPressed(keyCode, scanCode, modifiers) + val bl = keyCode == GLFW.GLFW_KEY_LEFT + if (bl || keyCode == GLFW.GLFW_KEY_RIGHT) { + if (changed) { + val delta = if (bl) -1 else 1 + val newValue = (oldVolume + delta).toDouble() / 10.0 + this.value = MathHelper.clamp(newValue, 0.0, 1.0) + if (oldValue != this.value) { + this.applyValue() + } + + this.updateMessage() + } + } + return changed + } + } + + private val toggleModes = ButtonWidget.builder(Text.translatable("pirate-radio.mode")) { + PirateRadioClient.mode = when (PirateRadioClient.mode) { + FmSimulatorMode.FULL -> FmSimulatorMode.FAST + else -> FmSimulatorMode.FULL + } + }.position(0, 40).build() + private val cycleModes = CyclingButtonWidget.builder { info: FmSimulatorMode -> + when (info) { + FmSimulatorMode.FAST -> Text.translatable("pirate-radio.mode.fast") + FmSimulatorMode.FULL -> Text.translatable("pirate-radio.mode.full") + FmSimulatorMode.DEAF -> Text.translatable("pirate-radio.mode.deaf") + } + }.values( + listOf( + FmSimulatorMode.FULL, FmSimulatorMode.FAST + ) + ).initially(PirateRadioClient.mode).build(0, 40, 320, 20, Text.translatable("pirate-radio.mode")) { _, it -> + PirateRadioClient.mode = it + } + private val audioDevice = CyclingButtonWidget.builder { info: Mixer.Info -> + when (info) { + PirateRadioClient.minecraftAudioDevice -> Text.translatable("pirate-radio.device.system-matching-minecraft") + PirateRadioClient.systemDefaultAudioDevice -> Text.translatable("pirate-radio.device.system") + PirateRadioClient.openAlAudioDevice -> Text.translatable("pirate-radio.device.openal") + else -> Text.literal(info.description) + } + }.values( + listOf( + PirateRadioClient.minecraftAudioDevice, + PirateRadioClient.systemDefaultAudioDevice, + PirateRadioClient.openAlAudioDevice, + ), AudioSystem.getMixerInfo().filter { + AudioSystem.getMixer(it).sourceLineInfo.isNotEmpty() + }).initially(PirateRadioClient.audioDevice) + .build(0, 60, 320, 20, Text.translatable("pirate-radio.device.choose")) { _, it -> + PirateRadioClient.audioDevice = it + } + + private val buttons = arrayListOf<ClickableWidget>( + frequencyPlusWidget, frequencyMinusWidget, volumePlusWidget, volumeMinusWidget, toggleModes, audioDevice + ) + override fun init() { - // TODO + if (failure == null && instance == null) { + try { + setupWasm() + } catch (e: WasmScreenException) { + logger.error("Failed to setup wasm.", e) + failure = e + } + } + if (failure == null) { + try { + setBaseLayout(0) + instance!!.export("init").apply() + } catch (e: ChicoryException) { + failure = WasmScreenException("Skin failed to initialize", e) + logger.error("Failed to initialize.", failure) + } catch (e: WasmScreenException) { + failure = e + logger.error("Failed to initialize.", failure) + } + } + } + + override fun renderBackground(context: DrawContext, mouseX: Int, mouseY: Int, delta: Float) { + super.renderInGameBackground(context) + + if (failure == null) { + if (backgroundWidth or backgroundHeight != 0) { + RenderSystem.setShader(GameRenderer::getPositionTexProgram) + RenderSystem.setShaderColor(1.0f, 1.0f, 1.0f, 1.0f) + RenderSystem.setShaderTexture(0, TEXTURE) + val x = (width - backgroundWidth) / 2 + val y = (height - backgroundHeight) / 2 + context.drawTexture( + TEXTURE, + x, + y, + 0f, + 0f, + backgroundWidth, + backgroundHeight, + backgroundTextureWidth, + backgroundTextureHeight + ) + } + } + + + if (client!!.debugHud.shouldShowDebugHud()) { + context.drawText( + textRenderer, + Text.translatable("pirate-radio.skin-pack", packTitle), + 0, + height - textRenderer.fontHeight, + Colors.WHITE, + true + ) + } + } + + override fun render(context: DrawContext, mouseX: Int, mouseY: Int, delta: Float) { + super.render(context, mouseX, mouseY, delta) + + if (failure != null) { + context.drawTextWrapped( + textRenderer, StringVisitable.plain(failure!!.message), (width - 320) / 2, 0, 320, Colors.WHITE + ) + } else { + try { + drawContext = context + instance!!.export("render").apply(mouseX.toLong(), mouseY.toLong(), Value.floatToLong(delta)) + } catch (e: ChicoryException) { + failure = WasmScreenException("Skin failed to initialize", e) + logger.error("Failed to initialize.", failure) + } catch (e: WasmScreenException) { + failure = e + logger.error("Failed to initialize.", failure) + } finally { + drawContext = null + } + } + } + + override fun shouldPause() = false + + private fun loggerLog() { + loggingEventBuilder.log() + } + + private fun loggerLogMessage(message: String) { + loggingEventBuilder.log(message) + } + + private fun loggerBegin(level: Int) { + loggingEventBuilder = wasmLogger.makeLoggingEventBuilder( + try { + Level.intToLevel(level) + } catch (e: IllegalArgumentException) { + Level.INFO + } + ) + } + + private fun loggerSetMessage(message: String) { + loggingEventBuilder.setMessage(message) + } + + private fun loggerAddArgumentString(arg: String) { + loggingEventBuilder.addArgument(arg) + } + + private fun loggerAddArgumentInt(arg: Int) { + loggingEventBuilder.addArgument(arg) + } + + private fun loggerAddArgumentLong(arg: Long) { + loggingEventBuilder.addArgument(arg) + } + + private fun loggerAddArgumentFloat(arg: Float) { + loggingEventBuilder.addArgument(arg) + } + + private fun loggerAddArgumentDouble(arg: Double) { + loggingEventBuilder.addArgument(arg) + } + + private fun setBackgroundSize(width: Int, height: Int) { + this.backgroundWidth = max(width, 0) + this.backgroundHeight = max(height, 0) + } + + private fun setBackgroundTextureSize(width: Int, height: Int) { + this.backgroundTextureWidth = max(width, 0) + this.backgroundTextureHeight = max(height, 0) + } + + private fun renderTextTranslatable(text: String, x: Int, y: Int, color: Int, shadow: Boolean) { + drawContext?.drawText(textRenderer, Text.translatable(text), x, y, color, shadow) + } + + private fun renderTextLiteral(text: String, x: Int, y: Int, color: Int, shadow: Boolean) { + drawContext?.drawText(textRenderer, Text.literal(text), x, y, color, shadow) + } + + private fun renderTextObject(handle: Int, x: Int, y: Int, color: Int, shadow: Boolean) { + if (handle >= 0 && handle < textObjects.size && textObjects[handle] != null) { + drawContext?.drawText(textRenderer, textObjects[handle], x, y, color, shadow) + } else { + throw WasmScreenException("Invalid text handle: $handle", null) + } + } + + private fun frequencyPlusWidgetDimensions(x: Int, y: Int, width: Int, height: Int) { + frequencyPlusWidget.x = x + frequencyPlusWidget.y = y + frequencyPlusWidget.width = width + frequencyPlusWidget.height = height + } + + private fun frequencyMinusWidgetDimensions(x: Int, y: Int, width: Int, height: Int) { + frequencyMinusWidget.x = x + frequencyMinusWidget.y = y + frequencyMinusWidget.width = width + frequencyMinusWidget.height = height + } + + private fun volumePlusWidgetDimensions(x: Int, y: Int, width: Int, height: Int) { + volumePlusWidget.x = x + volumePlusWidget.y = y + volumePlusWidget.width = width + volumePlusWidget.height = height + } + + private fun volumeMinusWidgetDimensions(x: Int, y: Int, width: Int, height: Int) { + volumeMinusWidget.x = x + volumeMinusWidget.y = y + volumeMinusWidget.width = width + volumeMinusWidget.height = height + } + + private fun toggleWidgetDimensions(x: Int, y: Int, width: Int, height: Int) { + toggleModes.x = x + toggleModes.y = y + toggleModes.width = width + toggleModes.height = height + } + + private fun audioDeviceWidgetDimensions(x: Int, y: Int, width: Int, height: Int) { + audioDevice.setDimensionsAndPosition(width, height, x, y) + } + + private fun buttonsDimensions(index: Int, x: Int, y: Int, width: Int, height: Int): Boolean { + if (index < 0 || index >= buttons.size) { + return false + } + buttons[index].setDimensionsAndPosition(width, height, x, y) + return true + } + + private fun getFrequency(): Int = PirateRadioClient.frequency + + private fun getMode(): Int = PirateRadioClient.mode.ordinal + + private fun getStereo(): Boolean = PirateRadioClient.stereo + + private fun getVolume(): Int = PirateRadioClient.volume + + private fun getStereoPilot(): Float = 0f + + private fun getWidth(): Int = width + + private fun getHeight(): Int = height + + private fun setBaseLayout(layout: Int) { + when (layout) { + 0 -> { + clearChildren() + addDrawableChild(frequencyPlusWidget) + addDrawableChild(frequencyMinusWidget) + addDrawableChild(volumePlusWidget) + addDrawableChild(volumeMinusWidget) + addDrawableChild(toggleModes) + addDrawableChild(audioDevice) + buttons.clear() + buttons.addAll( + listOf( + frequencyPlusWidget, + frequencyMinusWidget, + volumePlusWidget, + volumeMinusWidget, + toggleModes, + audioDevice + ) + ) + } + + 1 -> { + clearChildren() + frequencySlider = makeFrequencySlider( + frequencySlider.x, + frequencySlider.y, + frequencySlider.width, + frequencySlider.height + ) + volumeSlider = makeVolumeSlider(volumeSlider.x, volumeSlider.y, volumeSlider.width, volumeSlider.height) + addDrawableChild(frequencySlider) + addDrawableChild(volumeSlider) + addDrawableChild(cycleModes) + addDrawableChild(audioDevice) + cycleModes.value = PirateRadioClient.mode + buttons.clear() + buttons.addAll(listOf(frequencySlider, volumeSlider, cycleModes, audioDevice)) + } + + else -> throw WasmScreenException("Invalid base layout: $layout", null) + } + } + + // useful for drawing the stereo pilot, not that it's implemented + private fun drawImage( + red: Float, green: Float, blue: Float, alpha: Float, x: Int, y: Int, u: Float, v: Float, w: Int, h: Int + ) { + val drawContext = drawContext ?: throw WasmScreenException("Inappropriate draw call", null) + RenderSystem.setShader(GameRenderer::getPositionTexProgram) + RenderSystem.setShaderColor(red, green, blue, alpha) + RenderSystem.setShaderTexture(0, TEXTURE) + drawContext.drawTexture( + TEXTURE, x, y, u, v, w, h, backgroundTextureWidth, backgroundTextureHeight + ) + RenderSystem.setShaderColor(1f, 1f, 1f, 1f) + } + + private fun textFree(handle: Int) { + if (handle >= 0 && handle < textObjects.size && textObjects[handle] != null) { + textObjects[handle] = null + textObjectsFree.add(handle) + } else { + throw WasmScreenException("Invalid text handle: $handle", null) + } + } + + private fun textLiteral(text: String): Int { + val index = if (textObjectsFree.isNotEmpty()) { + textObjectsFree.removeLast() + } else { + textObjects.add(null) + textObjects.size - 1 + } + textObjects[index] = Text.literal(text) + return index + } + + private fun textTranslatable(text: String): Int { + val index = if (textObjectsFree.isNotEmpty()) { + textObjectsFree.removeLast() + } else { + textObjects.add(null) + textObjects.size - 1 + } + textObjects[index] = Text.translatable(text) + return index + } + + private fun textTranslatableArguments(text: String, args: List<Int>): Int { + args.forEach { handle -> + if (handle < 0 || handle >= textObjects.size || textObjects[handle] == null) { + throw WasmScreenException("Invalid text handle: $handle", null) + } + } + val index = if (textObjectsFree.isNotEmpty()) { + textObjectsFree.removeLast() + } else { + textObjects.add(null) + textObjects.size - 1 + } + textObjects[index] = Text.translatable(text, *Array(args.size) { textObjects[args[it]] }) + return index + } + + private fun setupWasm() { + // this should never throw + val resource = this.client!!.resourceManager.getResourceOrThrow(WASM_GUI) + packTitle = resource.pack.info.title + val module = try { + Parser.parse(resource.inputStream) + } catch (e: ChicoryException) { + throw WasmScreenException("Skin failed to load: error parsing module", e) + } + val importValues = ImportValues.builder() + importValues.addFunction( + bindFunc( + "text", "free", lookup, this::textFree.javaMethod!!, this + ) + ) + importValues.addFunction( + bindFunc( + "text", "literal", lookup, this::textLiteral.javaMethod!!, this + ) + ) + importValues.addFunction( + bindFunc( + "text", "translatable", lookup, this::textTranslatable.javaMethod!!, this + ) + ) + importValues.addFunction( + bindFunc( + "text", "translatable-arguments", lookup, this::textTranslatableArguments.javaMethod!!, this + ) + ) + importValues.addFunction( + bindFunc( + "frequency-plus-widget", + "set-dimensions", + lookup, + this::frequencyPlusWidgetDimensions.javaMethod!!, + this + ) + ) + importValues.addFunction( + bindFunc( + "frequency-minus-widget", + "set-dimensions", + lookup, + this::frequencyMinusWidgetDimensions.javaMethod!!, + this + ) + ) + importValues.addFunction( + bindFunc( + "volume-plus-widget", "set-dimensions", lookup, this::volumePlusWidgetDimensions.javaMethod!!, this + ) + ) + importValues.addFunction( + bindFunc( + "volume-minus-widget", "set-dimensions", lookup, this::volumeMinusWidgetDimensions.javaMethod!!, this + ) + ) + importValues.addFunction( + bindFunc( + "toggle-widget", "set-dimensions", lookup, this::toggleWidgetDimensions.javaMethod!!, this + ) + ) + importValues.addFunction( + bindFunc( + "audio-device-widget", "set-dimensions", lookup, this::audioDeviceWidgetDimensions.javaMethod!!, this + ) + ) + importValues.addFunction( + bindFunc( + "buttons", "set-dimensions", lookup, this::buttonsDimensions.javaMethod!!, this + ) + ) + importValues.addFunction( + bindFunc( + "buttons", "set-base-layout", lookup, this::setBaseLayout.javaMethod!!, this + ) + ) + importValues.addFunction( + bindFunc( + "simulator", "get-frequency", lookup, this::getFrequency.javaMethod!!, this + ) + ) + importValues.addFunction( + bindFunc( + "simulator", "get-volume", lookup, this::getVolume.javaMethod!!, this + ) + ) + importValues.addFunction( + bindFunc( + "simulator", "get-mode", lookup, this::getMode.javaMethod!!, this + ) + ) + importValues.addFunction( + bindFunc( + "simulator", "get-stereo", lookup, this::getStereo.javaMethod!!, this + ) + ) + importValues.addFunction( + bindFunc( + "simulator", "get-stereo-pilot", lookup, this::getStereoPilot.javaMethod!!, this + ) + ) + importValues.addFunction( + bindFunc( + "screen", "get-width", lookup, this::getWidth.javaMethod!!, this + ) + ) + importValues.addFunction( + bindFunc( + "screen", "get-height", lookup, this::getHeight.javaMethod!!, this + ) + ) + importValues.addFunction( + bindFunc( + "screen", "draw-image", lookup, this::drawImage.javaMethod!!, this + ) + ) + importValues.addFunction( + bindFunc( + "screen", "set-background-size", lookup, this::setBackgroundSize.javaMethod!!, this + ) + ) + importValues.addFunction( + bindFunc( + "screen", "set-background-texture-size", lookup, this::setBackgroundTextureSize.javaMethod!!, this + ) + ) + importValues.addFunction( + bindFunc( + "screen", "render-text-translatable", lookup, this::renderTextTranslatable.javaMethod!!, this + ) + ) + importValues.addFunction( + bindFunc( + "screen", "render-text-literal", lookup, this::renderTextLiteral.javaMethod!!, this + ) + ) + importValues.addFunction( + bindFunc( + "screen", "render-text-object", lookup, this::renderTextObject.javaMethod!!, this + ) + ) + importValues.addFunction( + bindFunc( + "screen", "set-background-size", lookup, this::setBackgroundSize.javaMethod!!, this + ) + ) + importValues.addFunction( + bindFunc( + "logger", "log", lookup, this::loggerLog.javaMethod!!, this + ) + ) + importValues.addFunction( + bindFunc( + "logger", "log-message", lookup, this::loggerLogMessage.javaMethod!!, this + ) + ) + importValues.addFunction( + bindFunc( + "logger", "begin", lookup, this::loggerBegin.javaMethod!!, this + ) + ) + importValues.addFunction( + bindFunc( + "logger", "log-message", lookup, this::loggerSetMessage.javaMethod!!, this + ) + ) + importValues.addFunction( + bindFunc( + "logger", "add-argument-string", lookup, this::loggerAddArgumentString.javaMethod!!, this + ) + ) + importValues.addFunction( + bindFunc( + "logger", "add-argument-int", lookup, this::loggerAddArgumentInt.javaMethod!!, this + ) + ) + importValues.addFunction( + bindFunc( + "logger", "add-argument-long", lookup, this::loggerAddArgumentLong.javaMethod!!, this + ) + ) + importValues.addFunction( + bindFunc( + "logger", "add-argument-float", lookup, this::loggerAddArgumentFloat.javaMethod!!, this + ) + ) + importValues.addFunction( + bindFunc( + "logger", "add-argument-double", lookup, this::loggerAddArgumentDouble.javaMethod!!, this + ) + ) + val builder = Instance.builder(module) + builder.withMachineFactory(AotMachineFactory(module)) + builder.withImportValues(importValues.build()) + val instance = try { + builder.build() + } catch (e: ChicoryException) { + throw WasmScreenException("Skin failed to load: error constructing module", e) + } + var initialize: ExportFunction? = null + try { + initialize = instance.export("_initialize") + } catch (_: InvalidException) { + // export may not exist, it's fine + } catch (e: ChicoryException) { + throw WasmScreenException("Skin failed to load: error initializing module", e) + } + try { + initialize?.apply() + } catch (e: ChicoryException) { + throw WasmScreenException("Skin failed to load: error initializing module", e) + } + try { + checkFuncExports( + instance, + "init" to FunctionType.empty(), + "render" to FunctionType.of(arrayOf(ValueType.I32, ValueType.I32, ValueType.F32), emptyArray()) + ) + } catch (e: ChicoryException) { + throw WasmScreenException("Skin failed to load: error checking exports", e) + } + this.instance = instance + } + + private class WasmScreenException(message: String?, cause: Throwable?) : Exception(message, cause) + + @Throws(WasmScreenException::class) + private fun checkFuncExports(instance: Instance, vararg exports: Pair<String, FunctionType>) { + val sb = StringBuilder() + exports.forEach { (name, type) -> + when (exportStatus(instance, name, type)) { + ExportStatus.MISSING -> sb.append("missing: ", name, type, "\n") + ExportStatus.TYPE_MISMATCH -> sb.append("type mismatch: ", name, type, "\n") + ExportStatus.OK -> {} + } + } + if (sb.isNotEmpty()) { + sb.setLength(sb.length - 1) + throw WasmScreenException("Skin failed to load: error checking exports:\n$sb", null) + } + } + + private enum class ExportStatus { + OK, MISSING, TYPE_MISMATCH, + } + + private fun exportStatus(instance: Instance, name: String, type: FunctionType): ExportStatus { + try { + // because instance.exportType doesn't check if it's actually a function + instance.export(name) + return if (instance.exportType(name) == type) ExportStatus.OK else ExportStatus.TYPE_MISMATCH + } catch (_: InvalidException) { + return ExportStatus.MISSING + } + } + + companion object { + private val WASM_GUI = Identifier.of(PirateRadio.MOD_ID, "guis/radio-receiver.wasm") + + // the texture is at a fixed location but the respack can replace it too + private val TEXTURE = Identifier.of(PirateRadio.MOD_ID, "textures/gui/radio-receiver.png") + + private val wasmLogger = LoggerFactory.getLogger(PirateRadio.MOD_ID + "/wasm/radio-receiver") + private val lookup = MethodHandles.lookup() } -} \ No newline at end of file +} diff --git a/src/client/kotlin/space/autistic/radio/client/gui/StorageCardEditScreen.kt b/src/client/kotlin/space/autistic/radio/client/gui/StorageCardEditScreen.kt new file mode 100644 index 0000000..6e5da3a --- /dev/null +++ b/src/client/kotlin/space/autistic/radio/client/gui/StorageCardEditScreen.kt @@ -0,0 +1,115 @@ +package space.autistic.radio.client.gui + +import net.fabricmc.fabric.api.client.networking.v1.ClientPlayNetworking +import net.minecraft.client.gui.DrawContext +import net.minecraft.client.gui.screen.Screen +import net.minecraft.client.gui.widget.ButtonWidget +import net.minecraft.client.gui.widget.EditBoxWidget +import net.minecraft.client.gui.widget.TextFieldWidget +import net.minecraft.client.util.NarratorManager +import net.minecraft.entity.player.PlayerEntity +import net.minecraft.item.ItemStack +import net.minecraft.screen.ScreenTexts +import net.minecraft.text.Text +import net.minecraft.util.Colors +import net.minecraft.util.Hand +import space.autistic.radio.PirateRadioComponents +import space.autistic.radio.network.StorageCardUpdateC2SPayload + +class StorageCardEditScreen( + private val player: PlayerEntity, private val storageCard: ItemStack, private val hand: Hand +) : Screen(NarratorManager.EMPTY) { + private var dirty = false + private var frequency = 768 + private lateinit var editBox: EditBoxWidget + private lateinit var frequencyBox: TextFieldWidget + private lateinit var doneButton: ButtonWidget + + override fun init() { + if (!this::editBox.isInitialized) { + editBox = EditBoxWidget( + textRenderer, + (width - WIDTH) / 2, + (height - HEIGHT) / 2, + WIDTH, + HEIGHT, + Text.translatable("pirate-radio.message"), + Text.translatable("pirate-radio.message") + ) + editBox.text = storageCard.get(PirateRadioComponents.MESSAGE)?.literalString ?: "" + editBox.setMaxLength(16384) + editBox.setChangeListener { + dirty = true + } + } + frequency = storageCard.getOrDefault(PirateRadioComponents.FREQUENCY, 768) + if (!this::frequencyBox.isInitialized) { + frequencyBox = + TextFieldWidget(textRenderer, FREQ_WIDTH, FREQ_HEIGHT, Text.translatable("pirate-radio.frequency.edit")) + frequencyBox.setMaxLength(5) + frequencyBox.text = (frequency / 10).toString() + "." + (frequency % 10).toString() + frequencyBox.setTextPredicate { + FREQ_REGEX_CHARACTERS.matches(it) + } + frequencyBox.setChangedListener { + if (FREQ_REGEX.matches(it)) { + frequency = it.replace(".", "").toInt() + frequencyBox.setEditableColor(Colors.WHITE) + dirty = true + } else { + frequencyBox.setEditableColor(Colors.RED) + } + } + } + editBox.x = (width - WIDTH) / 2 + editBox.y = (height - HEIGHT) / 2 + frequencyBox.x = editBox.x + frequencyBox.y = editBox.y - FREQ_HEIGHT + addDrawableChild(frequencyBox) + addDrawableChild(editBox) + doneButton = this.addDrawableChild( + ButtonWidget.builder(ScreenTexts.DONE) { + client!!.setScreen(null) + this.saveChanges() + }.dimensions(editBox.x + editBox.width - 98, frequencyBox.y, 98, 20).build() + ) + } + + override fun render(context: DrawContext, mouseX: Int, mouseY: Int, delta: Float) { + super.render(context, mouseX, mouseY, delta) + + context.drawText( + textRenderer, + Text.translatable("pirate-radio.frequency.edit"), + frequencyBox.x, + frequencyBox.y - textRenderer.fontHeight, + Colors.WHITE, + true + ) + } + + private fun saveChanges() { + if (this.dirty) { + this.writeNbtData() + val slot = if (this.hand == Hand.MAIN_HAND) player.inventory.selectedSlot else 40 + ClientPlayNetworking.send(StorageCardUpdateC2SPayload(slot, this.editBox.text, this.frequency)) + } + } + + private fun writeNbtData() { + this.storageCard.set(PirateRadioComponents.MESSAGE, Text.literal(this.editBox.text)) + this.storageCard.set(PirateRadioComponents.FREQUENCY, this.frequency) + } + + companion object { + const val WIDTH = 192 + const val HEIGHT = 144 + + const val FREQ_WIDTH = 40 + const val FREQ_HEIGHT = 20 + + val FREQ_REGEX = Regex("^76\\.[8-9]|7[7-9]\\.[0-9]|[8-9][0-9]\\.[0-9]|100\\.0$") + val FREQ_REGEX_CHARACTERS = Regex("^[0-9]*\\.?[0-9]?$") + + } +} \ No newline at end of file diff --git a/src/client/kotlin/space/autistic/radio/client/irc/IRC.kt b/src/client/kotlin/space/autistic/radio/client/irc/IRC.kt new file mode 100644 index 0000000..ee9a99c --- /dev/null +++ b/src/client/kotlin/space/autistic/radio/client/irc/IRC.kt @@ -0,0 +1,7 @@ +package space.autistic.radio.client.irc + +/** + * Internet Radio Client. + */ +class IRC { +} \ No newline at end of file diff --git a/src/client/kotlin/space/autistic/radio/client/opus/OpusDecoder.kt b/src/client/kotlin/space/autistic/radio/client/opus/OpusDecoder.kt new file mode 100644 index 0000000..98e80d4 --- /dev/null +++ b/src/client/kotlin/space/autistic/radio/client/opus/OpusDecoder.kt @@ -0,0 +1,77 @@ +package space.autistic.radio.client.opus + +import com.dylibso.chicory.runtime.ByteBufferMemory +import space.autistic.radio.reflection.getBuffer +import java.nio.ByteOrder + +class OpusDecoder(sampleRate: Int, private val channels: Int) { + private val instance = OpusFactory() + + init { + instance.export("_initialize").apply() + } + + private val errorPtr = instance.export("malloc").apply(4)[0] + + init { + if (errorPtr == 0L) { + throw IllegalStateException() + } + instance.memory().writeI32(errorPtr.toInt(), 0) + } + + private val decoder = + instance.export("opus_decoder_create").apply(sampleRate.toLong(), channels.toLong(), errorPtr)[0] + + init { + val error = instance.memory().readI32(errorPtr.toInt()) + if (error < 0) { + throw IllegalStateException( + instance.memory().readCString(instance.export("opus_strerror").apply(error)[0].toInt()) + ) + } + } + + private val opusDecodeFloat = instance.export("opus_decode_float") + + private val outBuf = instance.export("malloc").apply((4 * MAX_FRAME_SIZE * channels).toLong())[0] + + init { + if (outBuf == 0L) { + throw IllegalStateException() + } + } + + private val cbits = instance.export("malloc").apply(MAX_PACKET_SIZE.toLong())[0] + + init { + if (cbits == 0L) { + throw IllegalStateException() + } + } + + private val memory = instance.memory() as ByteBufferMemory + + fun decode(packet: ByteArray): FloatArray { + if (packet.size > MAX_PACKET_SIZE) { + throw IllegalArgumentException("packet too big") + } + memory.getBuffer().put(cbits.toInt(), packet) + val decoded = + opusDecodeFloat.apply(decoder, cbits, packet.size.toLong(), outBuf, MAX_FRAME_SIZE.toLong(), 0L)[0] + if (decoded < 0L) { + throw IllegalStateException( + instance.memory().readCString(instance.export("opus_strerror").apply(decoded)[0].toInt()) + ) + } + val out = FloatArray(decoded.toInt()) + memory.getBuffer().slice(outBuf.toInt(), outBuf.toInt() + 4 * channels * decoded.toInt()) + .order(ByteOrder.LITTLE_ENDIAN).asFloatBuffer().get(out) + return out + } + + companion object { + const val MAX_FRAME_SIZE = 6 * 960 + const val MAX_PACKET_SIZE = 3 * 1276 + } +} \ No newline at end of file diff --git a/src/client/kotlin/space/autistic/radio/client/opus/OpusFactory.kt b/src/client/kotlin/space/autistic/radio/client/opus/OpusFactory.kt new file mode 100644 index 0000000..1562a57 --- /dev/null +++ b/src/client/kotlin/space/autistic/radio/client/opus/OpusFactory.kt @@ -0,0 +1,26 @@ +package space.autistic.radio.client.opus + +import com.dylibso.chicory.experimental.aot.AotMachineFactory +import com.dylibso.chicory.runtime.ImportValues +import com.dylibso.chicory.runtime.Instance +import com.dylibso.chicory.wasm.Parser +import net.fabricmc.loader.api.FabricLoader +import java.io.InputStream + +object OpusFactory : () -> Instance { + private val defaultImports = ImportValues.builder().build() + private val module = Parser.parse(getModuleInputStream()) + private val instanceBuilder = + Instance.builder(module) + .withMachineFactory(AotMachineFactory(module)) + .withImportValues(defaultImports) + + override fun invoke(): Instance = instanceBuilder.build() + + private fun getModuleInputStream(): InputStream { + return FabricLoader.getInstance().getModContainer("pirate-radio").flatMap { it.findPath("opus.wasm") } + .map<InputStream?> { it.toFile().inputStream() }.orElseGet { + this.javaClass.getResourceAsStream("/opus.wasm") + } + } +} \ No newline at end of file diff --git a/src/client/kotlin/space/autistic/radio/client/sound/PirateRadioSoundInstance.kt b/src/client/kotlin/space/autistic/radio/client/sound/PirateRadioSoundInstance.kt new file mode 100644 index 0000000..435a488 --- /dev/null +++ b/src/client/kotlin/space/autistic/radio/client/sound/PirateRadioSoundInstance.kt @@ -0,0 +1,165 @@ +package space.autistic.radio.client.sound + +import com.dylibso.chicory.wasm.ChicoryException +import net.fabricmc.fabric.api.client.sound.v1.FabricSoundInstance +import net.minecraft.client.MinecraftClient +import net.minecraft.client.network.ClientPlayerEntity +import net.minecraft.client.sound.* +import net.minecraft.sound.SoundCategory +import net.minecraft.sound.SoundEvents +import net.minecraft.util.Identifier +import space.autistic.radio.PirateRadioEntityTypes +import space.autistic.radio.client.PirateRadioClient +import space.autistic.radio.client.flite.FliteWrapper +import space.autistic.radio.client.fmsim.FmFullThread +import space.autistic.radio.client.fmsim.FmFullThread.trackedTransmitterQueue +import space.autistic.radio.client.fmsim.FmSimulatorMode +import space.autistic.radio.entity.DisposableTransmitterEntity +import space.autistic.radio.wasm.WasmExitException +import java.lang.ref.WeakReference +import java.nio.FloatBuffer +import java.util.UUID +import java.util.concurrent.CompletableFuture +import kotlin.math.PI + +class PirateRadioSoundInstance(private val player: ClientPlayerEntity) : MovingSoundInstance( + SoundEvents.INTENTIONALLY_EMPTY, SoundCategory.MUSIC, SoundInstance.createRandom() +) { + private val futuresCache = HashMap<String, WeakReference<CompletableFuture<FloatArray>>>() + + class TrackedTransmitter( + val power: Float, val sampleOffset: Int, val audio: CompletableFuture<FloatArray>, val frequencyOffset: Int + ) { + override fun toString(): String { + return "TrackedTransmitter(power=$power, sampleOffset=$sampleOffset, audio=$audio, frequencyOffset=$frequencyOffset)" + } + } + + init { + this.repeat = false + this.repeatDelay = 0 + this.volume = 1f + this.pitch = 1f + this.relative = true + } + + override fun tick() { + if (player.isRemoved) { + this.setDone() + return + } + // find relevant entities + @Suppress("UNCHECKED_CAST") val trackedEntities: List<DisposableTransmitterEntity> = + player.clientWorld.entities.filter { it.type == PirateRadioEntityTypes.DISPOSABLE_TRANSMITTER } + .filter { (it as DisposableTransmitterEntity).frequency <= PirateRadioClient.frequency + 1 && it.frequency >= PirateRadioClient.frequency - 1 && it.text.isNotEmpty() } + .sortedBy { player.pos.squaredDistanceTo(it.pos) } as List<DisposableTransmitterEntity> + val main = trackedEntities.filter { it.frequency == PirateRadioClient.frequency } + .take(if (PirateRadioClient.mode == FmSimulatorMode.FAST) 1 else 2) + val lower = trackedEntities.filter { it.frequency == PirateRadioClient.frequency - 1 } + .take(if (PirateRadioClient.mode == FmSimulatorMode.FAST) 0 else 1) + val upper = trackedEntities.filter { it.frequency == PirateRadioClient.frequency + 1 } + .take(if (PirateRadioClient.mode == FmSimulatorMode.FAST) 0 else 1) + val mainNoise = trackedEntities.filter { it.frequency == PirateRadioClient.frequency }.drop(main.size) + .fold(0f) { noise, entity -> + noise + getPowerReceived(entity.pos.squaredDistanceTo(player.pos)) + } + val lowerNoise = trackedEntities.filter { it.frequency == PirateRadioClient.frequency - 1 }.drop(lower.size) + .fold(0f) { noise, entity -> + noise + getPowerReceived(entity.pos.squaredDistanceTo(player.pos)) + } + val upperNoise = trackedEntities.filter { it.frequency == PirateRadioClient.frequency + 1 }.drop(upper.size) + .fold(0f) { noise, entity -> + noise + getPowerReceived(entity.pos.squaredDistanceTo(player.pos)) + } + // updated tracked transmitters + val trackedTransmitters: MutableMap<UUID, TrackedTransmitter> = HashMap() + listOf(lower, main, upper).flatten().associateTo(trackedTransmitters) { + val text = it.text + val audio = futuresCache[text]?.get() ?: CompletableFuture.supplyAsync { + try { + lateinit var buffer: FloatBuffer + FliteWrapper.textToWave(text) { + buffer = FloatBuffer.allocate(it.capacity()) + while (it.hasRemaining()) { + val sample = (it.get().toFloat() + 0.5f) / 32767.5f + buffer.put(sample) + } + } + buffer.array() + } catch (e: ChicoryException) { + floatArrayOf() + } catch (e: WasmExitException) { + floatArrayOf() + } + } + futuresCache[text] = WeakReference(audio) + it.uuid to TrackedTransmitter( + getPowerReceived(it.pos.squaredDistanceTo(player.pos)), + it.age * (8000 / 20), + audio, + it.frequency - PirateRadioClient.frequency + ) + } + + + val audioOutput = PirateRadioClient.audioDevice + val minecraftSoundDevice = if (audioOutput === PirateRadioClient.minecraftAudioDevice) { + MinecraftClient.getInstance().options.soundDevice.value + } else { + null + } + + // this can be empty but it is not EMPTY_TASK + trackedTransmitterQueue.offer( + FmFullThread.FmTask( + trackedTransmitters, floatArrayOf(lowerNoise, mainNoise, upperNoise), audioOutput, minecraftSoundDevice + ) + ) + volume = PirateRadioClient.volume.toFloat() / 10 + volume *= volume + } + + private fun getPowerReceived(rsq: Double): Float { + // https://www.antenna-theory.com/basics/friis.php + // also fudge results if too close to transmitter + if (rsq < 1.0) return (FRIIS_FACTOR * rsq).toFloat() + return (FRIIS_FACTOR / rsq).toFloat() + } + + override fun getAudioStream( + loader: SoundLoader?, id: Identifier?, repeatInstantly: Boolean + ): CompletableFuture<AudioStream> { + // TODO setup thread + return CompletableFuture.completedFuture(ReceiverAudioStream) + } + + override fun getVolume(): Float { + return this.volume + } + + override fun getPitch(): Float { + return this.pitch + } + + override fun getSound(): Sound { + return Sound( + FabricSoundInstance.EMPTY_SOUND, + { 1f }, + { 1f }, + 1, + Sound.RegistrationType.SOUND_EVENT, + true, + false, + 16 + ) + } + + companion object { + private const val TRANSMIT_POWER = 0.01 + + // for 100MHz + private const val WAVELENGTH = 3.0 + private const val UNIT_SPHERE = 4.0 * PI + private const val FRIIS_FACTOR = TRANSMIT_POWER * WAVELENGTH / (UNIT_SPHERE * UNIT_SPHERE) + } +} diff --git a/src/client/kotlin/space/autistic/radio/client/sound/ReceiverAudioStream.kt b/src/client/kotlin/space/autistic/radio/client/sound/ReceiverAudioStream.kt new file mode 100644 index 0000000..34d5585 --- /dev/null +++ b/src/client/kotlin/space/autistic/radio/client/sound/ReceiverAudioStream.kt @@ -0,0 +1,45 @@ +package space.autistic.radio.client.sound + +import it.unimi.dsi.fastutil.floats.FloatConsumer +import net.minecraft.client.sound.BufferedAudioStream +import space.autistic.radio.client.fmsim.FmFullConstants +import space.autistic.radio.client.fmsim.FmFullThread +import space.autistic.radio.client.fmsim.FmFullThread.trackedTransmitterQueue +import java.nio.FloatBuffer +import java.util.Properties +import java.util.concurrent.ArrayBlockingQueue +import javax.sound.sampled.AudioFormat +import kotlin.math.max + +object ReceiverAudioStream : BufferedAudioStream { + private val format = AudioFormat(48000f, 16, 2, true, false) + + val bufferQueue = ArrayBlockingQueue<FloatBuffer>( + max( + 0, + System.getProperty("space.autistic.radio.buffers", "").toIntOrNull() ?: 250 + ) + ) + + val useNativeAudio = System.getProperty("space.autistic.radio.output", "native") == "native" + + private val skipBuffer = FloatBuffer.allocate(FmFullConstants.FFT_DATA_BLOCK_SIZE_LPF_48K_15K_3K1 * 2) + get() = field.clear() + + override fun close() { + trackedTransmitterQueue.clear() + trackedTransmitterQueue.offer(FmFullThread.EMPTY_TASK) + } + + override fun getFormat(): AudioFormat { + return format + } + + override fun read(channelList: FloatConsumer): Boolean { + val buffer = bufferQueue.poll() ?: skipBuffer + while (buffer.hasRemaining()) { + channelList.accept(buffer.get()) + } + return true + } +} \ No newline at end of file diff --git a/src/client/kotlin/space/autistic/radio/client/util/LevenshteinDistance.kt b/src/client/kotlin/space/autistic/radio/client/util/LevenshteinDistance.kt new file mode 100644 index 0000000..6f04ce1 --- /dev/null +++ b/src/client/kotlin/space/autistic/radio/client/util/LevenshteinDistance.kt @@ -0,0 +1,54 @@ +//The MIT License (MIT) +// +//Copyright (c) 2024 +// +//Permission is hereby granted, free of charge, to any person obtaining a copy +//of this software and associated documentation files (the "Software"), to deal +//in the Software without restriction, including without limitation the rights +//to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +//copies of the Software, and to permit persons to whom the Software is +//furnished to do so, subject to the following conditions: +// +//The above copyright notice and this permission notice shall be included in +//all copies or substantial portions of the Software. +// +//THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +//IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +//FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +//AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +//LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +//OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +//THE SOFTWARE. + +package space.autistic.radio.client.util + +// taken from https://github.com/Volcano-Bay-Studios/pavlovian-dogs/blob/master/src%2Fmain%2Fjava%2Fxyz%2Fvolcanobay%2Fpavloviandogs%2Futil%2FLevenshteinDistance.java +object LevenshteinDistance { + fun calculate(x: String, y: String): Int { + val dp = Array(x.length + 1) { IntArray(y.length + 1) } + + for (i in 0..x.length) { + for (j in 0..y.length) { + if (i == 0) { + dp[i][j] = j + } else if (j == 0) { + dp[i][j] = i + } else { + dp[i][j] = min( + dp[i - 1][j - 1] + costOfSubstitution(x[i - 1], y[j - 1]), dp[i - 1][j] + 1, dp[i][j - 1] + 1 + ) + } + } + } + + return dp[x.length][y.length] + } + + private fun costOfSubstitution(a: Char, b: Char): Int { + return if (a == b) 0 else 1 + } + + private fun min(vararg numbers: Int): Int { + return numbers.minOrNull() ?: Int.MAX_VALUE + } +} \ No newline at end of file diff --git a/src/client/resources/pirate-radio.client-mixins.json b/src/client/resources/pirate-radio.client-mixins.json new file mode 100644 index 0000000..27a5861 --- /dev/null +++ b/src/client/resources/pirate-radio.client-mixins.json @@ -0,0 +1,14 @@ +{ + "required": true, + "minVersion": "0.8", + "package": "space.autistic.radio.client.mixin", + "compatibilityLevel": "JAVA_21", + "mixins": [ + ], + "client": [ + "BlockStatesLoaderMixin" + ], + "injectors": { + "defaultRequire": 1 + } +} \ No newline at end of file |