diff options
8 files changed, 362 insertions, 28 deletions
diff --git a/src/client/kotlin/space/autistic/radio/client/PirateRadioClient.kt b/src/client/kotlin/space/autistic/radio/client/PirateRadioClient.kt index 1a68c21..b3c9f17 100644 --- a/src/client/kotlin/space/autistic/radio/client/PirateRadioClient.kt +++ b/src/client/kotlin/space/autistic/radio/client/PirateRadioClient.kt @@ -16,6 +16,7 @@ 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 @@ -36,6 +37,7 @@ object PirateRadioClient : ClientModInitializer { var mode = FmSimulatorMode.FULL override fun onInitializeClient() { + Thread.ofPlatform().daemon().name("fm-receiver").start(FmFullThread) PirateRadio.proxy = ClientProxy() EntityRendererRegistry.register(PirateRadioEntityTypes.ELECTRONICS_TRADER, ::ElectronicsTraderEntityRenderer) EntityRendererRegistry.register( diff --git a/src/client/kotlin/space/autistic/radio/client/cli/OfflineSimulator.kt b/src/client/kotlin/space/autistic/radio/client/cli/OfflineSimulator.kt index 2646ed2..c17d622 100644 --- a/src/client/kotlin/space/autistic/radio/client/cli/OfflineSimulator.kt +++ b/src/client/kotlin/space/autistic/radio/client/cli/OfflineSimulator.kt @@ -231,7 +231,7 @@ fun main(args: Array<String>) { w.y = plus100k.get() z.cmul(w) floatView.put(i, floatView.get(i) + z.x) - floatView.put(i, floatView.get(i) + z.y) + floatView.put(i + 1, floatView.get(i + 1) + z.y) } for (i in 0 until floatBufferLo.capacity() step 2) { z.x = floatBufferLo.get(i) @@ -243,7 +243,7 @@ fun main(args: Array<String>) { w.y = -minus100k.get() z.cmul(w) floatView.put(i, floatView.get(i) + z.x) - floatView.put(i, floatView.get(i) + z.y) + floatView.put(i + 1, floatView.get(i + 1) + z.y) } if (rfOutput) { outputStream.write(outputBuffer.array()) diff --git a/src/client/kotlin/space/autistic/radio/client/fmsim/FastModulatedNoise.kt b/src/client/kotlin/space/autistic/radio/client/fmsim/FastModulatedNoise.kt new file mode 100644 index 0000000..d6bbdb2 --- /dev/null +++ b/src/client/kotlin/space/autistic/radio/client/fmsim/FastModulatedNoise.kt @@ -0,0 +1,84 @@ +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 { + // 0.1 second + private val baseNoise = FloatArray(300000 * 2 / 10) + private val upperNoise = FloatArray(300000 * 2 / 10) + + 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()) { + input.put(random.nextFloat(1f)) + } + input.clear() + fmsim.process(input, 1f, false) { + if (buffer.remaining() < it.remaining()) { + it.limit(it.position() + buffer.remaining()) + } + buffer.put(it) + } + } + buffer.clear() + val plus100k = FloatBuffer.wrap(FmFullConstants.CBUFFER_100K_300K) + val z = Vector2f() + val w = Vector2f() + for (i in baseNoise.indices step 2) { + z.x = baseNoise[i] + z.y = baseNoise[i + 1] + if (!plus100k.hasRemaining()) { + plus100k.clear() + } + w.x = plus100k.get() + w.y = plus100k.get() + z.cmul(w) + upperNoise[i] = z.x + upperNoise[i] = z.y + } + } + } +} \ No newline at end of file diff --git a/src/client/kotlin/space/autistic/radio/client/fmsim/FmFullMixer.kt b/src/client/kotlin/space/autistic/radio/client/fmsim/FmFullMixer.kt deleted file mode 100644 index 567d93f..0000000 --- a/src/client/kotlin/space/autistic/radio/client/fmsim/FmFullMixer.kt +++ /dev/null @@ -1,4 +0,0 @@ -package space.autistic.radio.client.fmsim - -class FmFullMixer { -} \ No newline at end of file diff --git a/src/client/kotlin/space/autistic/radio/client/fmsim/FmFullThread.kt b/src/client/kotlin/space/autistic/radio/client/fmsim/FmFullThread.kt new file mode 100644 index 0000000..1fd401e --- /dev/null +++ b/src/client/kotlin/space/autistic/radio/client/fmsim/FmFullThread.kt @@ -0,0 +1,184 @@ +package space.autistic.radio.client.fmsim + +import org.joml.Vector2f +import space.autistic.radio.client.PirateRadioClient +import space.autistic.radio.client.complex.cmul +import space.autistic.radio.client.sound.PirateRadioSoundInstance +import space.autistic.radio.client.sound.ReceiverAudioStream +import space.autistic.radio.entity.DisposableTransmitterEntity +import java.nio.ByteOrder +import java.nio.FloatBuffer +import java.util.concurrent.ArrayBlockingQueue +import kotlin.math.max +import kotlin.math.min + +object FmFullThread : Runnable { + class FmTask( + val trackedTransmitters: Map<DisposableTransmitterEntity, PirateRadioSoundInstance.TrackedTransmitter>, + val noiseLevels: FloatArray, + ) + + // empty task, marker to shut off the thread + val EMPTY_TASK = FmTask(emptyMap(), FloatArray(3)) + + 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 + + override fun run() { + var currentTask = EMPTY_TASK + val modulators = HashMap<DisposableTransmitterEntity, 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() + + while (!Thread.interrupted()) { + currentTask = trackedTransmitterQueue.poll() ?: currentTask + if (currentTask === EMPTY_TASK) { + Thread.onSpinWait() + continue + } + modulators.keys.retainAll(currentTask.trackedTransmitters.keys) + currentTask.trackedTransmitters.forEach { (k, v) -> + modulators.compute(k) { _, modulator -> + if (modulator != null) { + modulator.power = v.power + modulator.mixingBuffer = mixingBuffers[v.frequencyOffset + 1] + return@compute modulator + } + 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) + } + 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()) +// } +// } + + demodulator.process(mixingBuffers[1], PirateRadioClient.stereo) { _, audioBuffer -> + // TODO stereo pilot + // we *want* backpressure + // FIXME use bigger buffers? + ReceiverAudioStream.bufferQueue.put(FloatBuffer.allocate(audioBuffer.capacity()).put(audioBuffer).clear()) + } + } + } +} \ No newline at end of file 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..ea6a380 --- /dev/null +++ b/src/client/kotlin/space/autistic/radio/client/fmsim/NoiseFloor.kt @@ -0,0 +1,24 @@ +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(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()) + } + } + + // complex noise, in IQ format? + fun noiseBlock(consumer: Consumer<FloatBuffer>) { + buffer.clear() + consumer.accept(buffer) + } +} \ 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 index 4a4f087..2fb6fb4 100644 --- a/src/client/kotlin/space/autistic/radio/client/sound/PirateRadioSoundInstance.kt +++ b/src/client/kotlin/space/autistic/radio/client/sound/PirateRadioSoundInstance.kt @@ -1,7 +1,6 @@ package space.autistic.radio.client.sound 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 @@ -9,14 +8,28 @@ 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 java.lang.ref.WeakReference +import java.nio.FloatBuffer import java.util.concurrent.CompletableFuture import kotlin.math.PI class PirateRadioSoundInstance(private val player: ClientPlayerEntity) : MovingSoundInstance( SoundEvents.INTENTIONALLY_EMPTY, SoundCategory.MUSIC, SoundInstance.createRandom() ) { + private val futuresCache = HashMap<String, WeakReference<CompletableFuture<FloatArray>>>() + + class TrackedTransmitter( + val power: Float, val sampleOffset: Int, val audio: CompletableFuture<FloatArray>, val frequencyOffset: Int + ) { + override fun toString(): String { + return "TrackedTransmitter(power=$power, sampleOffset=$sampleOffset, audio=$audio, frequencyOffset=$frequencyOffset)" + } + } init { this.repeat = false @@ -31,10 +44,11 @@ class PirateRadioSoundInstance(private val player: ClientPlayerEntity) : MovingS this.setDone() return } + // find relevant entities @Suppress("UNCHECKED_CAST") val trackedEntities: List<DisposableTransmitterEntity> = player.clientWorld.entities.filter { it.type == PirateRadioEntityTypes.DISPOSABLE_TRANSMITTER } .filter { (it as DisposableTransmitterEntity).frequency <= PirateRadioClient.frequency + 1 && it.frequency >= PirateRadioClient.frequency - 1 } - .sortedByDescending { player.pos.squaredDistanceTo(it.pos) } as List<DisposableTransmitterEntity> + .sortedBy { player.pos.squaredDistanceTo(it.pos) } as List<DisposableTransmitterEntity> val main = trackedEntities.filter { it.frequency == PirateRadioClient.frequency } .take(if (PirateRadioClient.mode == FmSimulatorMode.FAST) 1 else 2) val lower = trackedEntities.filter { it.frequency == PirateRadioClient.frequency - 1 } @@ -53,10 +67,38 @@ class PirateRadioSoundInstance(private val player: ClientPlayerEntity) : MovingS .fold(0f) { noise, entity -> noise + getPowerReceived(entity.pos.squaredDistanceTo(player.pos)) } - val mainLevels = main.map { getPowerReceived(it.pos.squaredDistanceTo(player.pos)) } - val lowerLevels = lower.map { getPowerReceived(it.pos.squaredDistanceTo(player.pos)) } - val upperLevels = upper.map { getPowerReceived(it.pos.squaredDistanceTo(player.pos)) } - // TODO + // updated tracked transmitters + val trackedTransmitters: MutableMap<DisposableTransmitterEntity, TrackedTransmitter> = HashMap() + listOf(lower, main, upper).flatten().associateWithTo(trackedTransmitters) { + val text = it.text + val audio = futuresCache[text]?.get() ?: CompletableFuture.supplyAsync { + lateinit var buffer: FloatBuffer + FliteWrapper.textToWave(text) { + buffer = FloatBuffer.allocate(it.capacity()) + while (it.hasRemaining()) { + val sample = (it.get().toFloat() + 0.5f) / 32767.5f + buffer.put(sample) + } + } + buffer.array() + } + futuresCache[text] = WeakReference(audio) + TrackedTransmitter( + getPowerReceived(it.pos.squaredDistanceTo(player.pos)), + it.age * (8000 / 20), + audio, + it.frequency - PirateRadioClient.frequency + ) + } + if (player.world.time % 200L == 0L) { + println("tracking transmitters: $trackedTransmitters") + } + // this can be empty but it is not EMPTY_TASK + trackedTransmitterQueue.offer( + FmFullThread.FmTask( + trackedTransmitters, floatArrayOf(lowerNoise, mainNoise, upperNoise) + ) + ) } private fun getPowerReceived(rsq: Double): Float { diff --git a/src/client/kotlin/space/autistic/radio/client/sound/ReceiverAudioStream.kt b/src/client/kotlin/space/autistic/radio/client/sound/ReceiverAudioStream.kt index 5ca802b..720d65d 100644 --- a/src/client/kotlin/space/autistic/radio/client/sound/ReceiverAudioStream.kt +++ b/src/client/kotlin/space/autistic/radio/client/sound/ReceiverAudioStream.kt @@ -1,34 +1,36 @@ package space.autistic.radio.client.sound import it.unimi.dsi.fastutil.floats.FloatConsumer -import net.minecraft.client.sound.AudioStream -import net.minecraft.client.sound.ChannelList -import java.nio.ByteBuffer +import net.minecraft.client.sound.BufferedAudioStream +import space.autistic.radio.client.fmsim.FmFullConstants +import space.autistic.radio.client.fmsim.FmFullThread +import space.autistic.radio.client.fmsim.FmFullThread.trackedTransmitterQueue +import java.nio.FloatBuffer +import java.util.concurrent.ArrayBlockingQueue import javax.sound.sampled.AudioFormat -object ReceiverAudioStream : AudioStream { +object ReceiverAudioStream : BufferedAudioStream { private val format = AudioFormat(48000f, 16, 2, true, false) + val bufferQueue = ArrayBlockingQueue<FloatBuffer>(200) + + private val skipBuffer = FloatBuffer.allocate(FmFullConstants.FFT_DATA_BLOCK_SIZE_LPF_48K_15K_3K1 * 2) + get() = field.clear() + override fun close() { - // TODO, nop for now, should stop the processing + trackedTransmitterQueue.clear() + trackedTransmitterQueue.offer(FmFullThread.EMPTY_TASK) } override fun getFormat(): AudioFormat { return format } - override fun read(size: Int): ByteBuffer { - val channelList = ChannelList(size + 8192) - - while (this.read(channelList) && channelList.currentBufferSize < size) { + override fun read(channelList: FloatConsumer): Boolean { + val buffer = bufferQueue.poll() ?: skipBuffer + while (buffer.hasRemaining()) { + channelList.accept(buffer.get()) } - - return channelList.buffer - } - - private fun read(channelList: FloatConsumer): Boolean { - channelList.accept(0f) - channelList.accept(0f) return true } } \ No newline at end of file |