From fee7157d84c3ce887a540be82dc7a7d2e0c8e368 Mon Sep 17 00:00:00 2001 From: SoniEx2 Date: Sat, 8 Mar 2025 11:32:33 -0300 Subject: Move things around --- .../autistic/radio/client/antenna/AntennaModel.kt | 6 +- .../radio/client/antenna/ConstAntennaModel.kt | 13 ++ .../autistic/radio/client/antenna/NullModel.kt | 13 -- .../radio/client/antenna/WasmAntennaFactory.kt | 6 +- .../autistic/radio/client/cli/OfflineSimulator.kt | 237 +++++++++++++++++++++ .../space/autistic/radio/client/complex/Complex.kt | 32 +++ .../autistic/radio/client/dsp/Biquad1stOrder.kt | 11 + .../autistic/radio/client/fmsim/FmFullConstants.kt | 114 ++++++++++ .../radio/client/fmsim/FmFullDemodulator.kt | 162 ++++++++++++++ .../autistic/radio/client/fmsim/FmFullMixer.kt | 4 + .../autistic/radio/client/fmsim/FmFullModulator.kt | 170 +++++++++++++++ .../kotlin/space/autistic/radio/client/irc/IRC.kt | 7 + .../autistic/radio/client/opus/OpusDecoder.kt | 77 +++++++ .../autistic/radio/client/opus/OpusFactory.kt | 26 +++ .../radio/client/reflection/MemoryReflection.kt | 14 ++ .../space/autistic/radio/cli/OfflineSimulator.kt | 237 --------------------- .../kotlin/space/autistic/radio/complex/Complex.kt | 32 --- .../space/autistic/radio/dsp/Biquad1stOrder.kt | 11 - .../space/autistic/radio/fmsim/FmFullConstants.kt | 114 ---------- .../autistic/radio/fmsim/FmFullDemodulator.kt | 158 -------------- .../space/autistic/radio/fmsim/FmFullMixer.kt | 4 - .../space/autistic/radio/fmsim/FmFullModulator.kt | 171 --------------- .../space/autistic/radio/opus/OpusDecoder.kt | 77 ------- .../space/autistic/radio/opus/OpusFactory.kt | 26 --- .../autistic/radio/reflection/MemoryReflection.kt | 14 -- 25 files changed, 873 insertions(+), 863 deletions(-) create mode 100644 src/client/kotlin/space/autistic/radio/client/antenna/ConstAntennaModel.kt delete mode 100644 src/client/kotlin/space/autistic/radio/client/antenna/NullModel.kt create mode 100644 src/client/kotlin/space/autistic/radio/client/cli/OfflineSimulator.kt create mode 100644 src/client/kotlin/space/autistic/radio/client/complex/Complex.kt create mode 100644 src/client/kotlin/space/autistic/radio/client/dsp/Biquad1stOrder.kt create mode 100644 src/client/kotlin/space/autistic/radio/client/fmsim/FmFullConstants.kt create mode 100644 src/client/kotlin/space/autistic/radio/client/fmsim/FmFullDemodulator.kt create mode 100644 src/client/kotlin/space/autistic/radio/client/fmsim/FmFullMixer.kt create mode 100644 src/client/kotlin/space/autistic/radio/client/fmsim/FmFullModulator.kt create mode 100644 src/client/kotlin/space/autistic/radio/client/irc/IRC.kt create mode 100644 src/client/kotlin/space/autistic/radio/client/opus/OpusDecoder.kt create mode 100644 src/client/kotlin/space/autistic/radio/client/opus/OpusFactory.kt create mode 100644 src/client/kotlin/space/autistic/radio/client/reflection/MemoryReflection.kt delete mode 100644 src/main/kotlin/space/autistic/radio/cli/OfflineSimulator.kt delete mode 100644 src/main/kotlin/space/autistic/radio/complex/Complex.kt delete mode 100644 src/main/kotlin/space/autistic/radio/dsp/Biquad1stOrder.kt delete mode 100644 src/main/kotlin/space/autistic/radio/fmsim/FmFullConstants.kt delete mode 100644 src/main/kotlin/space/autistic/radio/fmsim/FmFullDemodulator.kt delete mode 100644 src/main/kotlin/space/autistic/radio/fmsim/FmFullMixer.kt delete mode 100644 src/main/kotlin/space/autistic/radio/fmsim/FmFullModulator.kt delete mode 100644 src/main/kotlin/space/autistic/radio/opus/OpusDecoder.kt delete mode 100644 src/main/kotlin/space/autistic/radio/opus/OpusFactory.kt delete mode 100644 src/main/kotlin/space/autistic/radio/reflection/MemoryReflection.kt 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/ConstAntennaModel.kt b/src/client/kotlin/space/autistic/radio/client/antenna/ConstAntennaModel.kt new file mode 100644 index 0000000..fc531d2 --- /dev/null +++ b/src/client/kotlin/space/autistic/radio/client/antenna/ConstAntennaModel.kt @@ -0,0 +1,13 @@ +package space.autistic.radio.client.antenna + +import org.joml.Vector3d + +class ConstAntennaModel(private val level: Float) : AntennaModel { + override fun apply(position: Vector3d): Float { + return level + } + + override fun shouldAttenuate(): Boolean { + return false + } +} diff --git a/src/client/kotlin/space/autistic/radio/client/antenna/NullModel.kt b/src/client/kotlin/space/autistic/radio/client/antenna/NullModel.kt deleted file mode 100644 index 3c188b6..0000000 --- a/src/client/kotlin/space/autistic/radio/client/antenna/NullModel.kt +++ /dev/null @@ -1,13 +0,0 @@ -package space.autistic.radio.client.antenna - -import org.joml.Vector3d - -class NullModel : AntennaModel { - override fun apply(position: Vector3d): Float { - return 0f - } - - override fun shouldAttenuate(): Boolean { - return false - } -} 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..51743dd 100644 --- a/src/client/kotlin/space/autistic/radio/client/antenna/WasmAntennaFactory.kt +++ b/src/client/kotlin/space/autistic/radio/client/antenna/WasmAntennaFactory.kt @@ -33,7 +33,7 @@ class WasmAntennaFactory(moduleBytes: ByteArray) : AntennaModelFactory { override fun create(orientation: Quaterniond): AntennaModel { if (failing) { - return NullModel() + return ConstAntennaModel(0f) } try { val instance = instanceBuilder!!.build() @@ -57,7 +57,7 @@ class WasmAntennaFactory(moduleBytes: ByteArray) : AntennaModelFactory { Level.SEVERE, "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") @@ -86,7 +86,7 @@ class WasmAntennaFactory(moduleBytes: ByteArray) : AntennaModelFactory { } catch (e: ChicoryException) { logger.log(Level.SEVERE, "Error while trying to initialize antenna model.", e) failing = true - return NullModel() + return ConstAntennaModel(0f) } } 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..68c4719 --- /dev/null +++ b/src/client/kotlin/space/autistic/radio/client/cli/OfflineSimulator.kt @@ -0,0 +1,237 @@ +package space.autistic.radio.client.cli + +import org.joml.Vector2f +import space.autistic.radio.client.complex.cmul +import space.autistic.radio.client.fmsim.FmFullConstants +import space.autistic.radio.client.fmsim.FmFullDemodulator +import space.autistic.radio.client.fmsim.FmFullModulator +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) { + 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 = 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 { + 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, floatView.get(i) + 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, floatView.get(i) + 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/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) { + 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) { + 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/FmFullMixer.kt b/src/client/kotlin/space/autistic/radio/client/fmsim/FmFullMixer.kt new file mode 100644 index 0000000..567d93f --- /dev/null +++ b/src/client/kotlin/space/autistic/radio/client/fmsim/FmFullMixer.kt @@ -0,0 +1,4 @@ +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/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) { + 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) { + 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/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..fe78393 --- /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.client.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 { 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/reflection/MemoryReflection.kt b/src/client/kotlin/space/autistic/radio/client/reflection/MemoryReflection.kt new file mode 100644 index 0000000..b1da224 --- /dev/null +++ b/src/client/kotlin/space/autistic/radio/client/reflection/MemoryReflection.kt @@ -0,0 +1,14 @@ +package space.autistic.radio.client.reflection + +import com.dylibso.chicory.runtime.ByteBufferMemory +import java.lang.invoke.MethodHandles +import java.nio.ByteBuffer + +fun ByteBufferMemory.getBuffer(): ByteBuffer { + return MemoryReflection.buffer.get(this) as ByteBuffer +} + +object MemoryReflection { + val buffer = MethodHandles.privateLookupIn(ByteBufferMemory::class.java, MethodHandles.lookup()) + .findVarHandle(ByteBufferMemory::class.java, "buffer", ByteBuffer::class.java) +} \ No newline at end of file diff --git a/src/main/kotlin/space/autistic/radio/cli/OfflineSimulator.kt b/src/main/kotlin/space/autistic/radio/cli/OfflineSimulator.kt deleted file mode 100644 index bc16814..0000000 --- a/src/main/kotlin/space/autistic/radio/cli/OfflineSimulator.kt +++ /dev/null @@ -1,237 +0,0 @@ -package space.autistic.radio.cli - -import org.joml.Vector2f -import space.autistic.radio.complex.cmul -import space.autistic.radio.fmsim.FmFullConstants -import space.autistic.radio.fmsim.FmFullDemodulator -import space.autistic.radio.fmsim.FmFullModulator -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) { - 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 = 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 { - 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, floatView.get(i) + 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, floatView.get(i) + 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/main/kotlin/space/autistic/radio/complex/Complex.kt b/src/main/kotlin/space/autistic/radio/complex/Complex.kt deleted file mode 100644 index 918dac2..0000000 --- a/src/main/kotlin/space/autistic/radio/complex/Complex.kt +++ /dev/null @@ -1,32 +0,0 @@ -package space.autistic.radio.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/main/kotlin/space/autistic/radio/dsp/Biquad1stOrder.kt b/src/main/kotlin/space/autistic/radio/dsp/Biquad1stOrder.kt deleted file mode 100644 index 8f86218..0000000 --- a/src/main/kotlin/space/autistic/radio/dsp/Biquad1stOrder.kt +++ /dev/null @@ -1,11 +0,0 @@ -package space.autistic.radio.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/main/kotlin/space/autistic/radio/fmsim/FmFullConstants.kt b/src/main/kotlin/space/autistic/radio/fmsim/FmFullConstants.kt deleted file mode 100644 index 6b92328..0000000 --- a/src/main/kotlin/space/autistic/radio/fmsim/FmFullConstants.kt +++ /dev/null @@ -1,114 +0,0 @@ -package space.autistic.radio.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/main/kotlin/space/autistic/radio/fmsim/FmFullDemodulator.kt b/src/main/kotlin/space/autistic/radio/fmsim/FmFullDemodulator.kt deleted file mode 100644 index de44e69..0000000 --- a/src/main/kotlin/space/autistic/radio/fmsim/FmFullDemodulator.kt +++ /dev/null @@ -1,158 +0,0 @@ -package space.autistic.radio.fmsim - -import org.joml.Vector2f -import org.jtransforms.fft.FloatFFT_1D -import space.autistic.radio.complex.I -import space.autistic.radio.complex.cmul -import space.autistic.radio.complex.conjugate -import space.autistic.radio.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) { - 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) - } - } - } - - 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/main/kotlin/space/autistic/radio/fmsim/FmFullMixer.kt b/src/main/kotlin/space/autistic/radio/fmsim/FmFullMixer.kt deleted file mode 100644 index 654d50f..0000000 --- a/src/main/kotlin/space/autistic/radio/fmsim/FmFullMixer.kt +++ /dev/null @@ -1,4 +0,0 @@ -package space.autistic.radio.fmsim - -class FmFullMixer { -} \ No newline at end of file diff --git a/src/main/kotlin/space/autistic/radio/fmsim/FmFullModulator.kt b/src/main/kotlin/space/autistic/radio/fmsim/FmFullModulator.kt deleted file mode 100644 index 1f3849e..0000000 --- a/src/main/kotlin/space/autistic/radio/fmsim/FmFullModulator.kt +++ /dev/null @@ -1,171 +0,0 @@ -package space.autistic.radio.fmsim - -import org.joml.Vector2f -import space.autistic.radio.complex.cmul -import space.autistic.radio.complex.conjugate -import space.autistic.radio.dsp.Biquad1stOrder -import java.nio.FloatBuffer -import java.util.function.Consumer -import org.jtransforms.fft.FloatFFT_1D -import space.autistic.radio.complex.I -import kotlin.math.max -import kotlin.math.min -import kotlin.math.sqrt - -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) { - 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) { - 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/main/kotlin/space/autistic/radio/opus/OpusDecoder.kt b/src/main/kotlin/space/autistic/radio/opus/OpusDecoder.kt deleted file mode 100644 index 56fce2b..0000000 --- a/src/main/kotlin/space/autistic/radio/opus/OpusDecoder.kt +++ /dev/null @@ -1,77 +0,0 @@ -package space.autistic.radio.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/main/kotlin/space/autistic/radio/opus/OpusFactory.kt b/src/main/kotlin/space/autistic/radio/opus/OpusFactory.kt deleted file mode 100644 index 70e0c3c..0000000 --- a/src/main/kotlin/space/autistic/radio/opus/OpusFactory.kt +++ /dev/null @@ -1,26 +0,0 @@ -package space.autistic.radio.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 { it.toFile().inputStream() }.orElseGet { - this.javaClass.getResourceAsStream("/opus.wasm") - } - } -} \ No newline at end of file diff --git a/src/main/kotlin/space/autistic/radio/reflection/MemoryReflection.kt b/src/main/kotlin/space/autistic/radio/reflection/MemoryReflection.kt deleted file mode 100644 index 78961da..0000000 --- a/src/main/kotlin/space/autistic/radio/reflection/MemoryReflection.kt +++ /dev/null @@ -1,14 +0,0 @@ -package space.autistic.radio.reflection - -import com.dylibso.chicory.runtime.ByteBufferMemory -import java.lang.invoke.MethodHandles -import java.nio.ByteBuffer - -fun ByteBufferMemory.getBuffer(): ByteBuffer { - return MemoryReflection.buffer.get(this) as ByteBuffer -} - -object MemoryReflection { - val buffer = MethodHandles.privateLookupIn(ByteBufferMemory::class.java, MethodHandles.lookup()) - .findVarHandle(ByteBufferMemory::class.java, "buffer", ByteBuffer::class.java) -} \ No newline at end of file -- cgit 1.4.1