summary refs log tree commit diff stats
path: root/src/main/kotlin/space/autistic/radio
diff options
context:
space:
mode:
Diffstat (limited to 'src/main/kotlin/space/autistic/radio')
-rw-r--r--src/main/kotlin/space/autistic/radio/cli/OfflineSimulator.kt57
-rw-r--r--src/main/kotlin/space/autistic/radio/fmsim/FmFullConstants.kt19
-rw-r--r--src/main/kotlin/space/autistic/radio/fmsim/FmFullDemodulator.kt158
-rw-r--r--src/main/kotlin/space/autistic/radio/fmsim/FmFullModulator.kt167
4 files changed, 294 insertions, 107 deletions
diff --git a/src/main/kotlin/space/autistic/radio/cli/OfflineSimulator.kt b/src/main/kotlin/space/autistic/radio/cli/OfflineSimulator.kt
index 517957b..bc16814 100644
--- a/src/main/kotlin/space/autistic/radio/cli/OfflineSimulator.kt
+++ b/src/main/kotlin/space/autistic/radio/cli/OfflineSimulator.kt
@@ -3,6 +3,7 @@ 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
@@ -17,20 +18,24 @@ import kotlin.math.min
 import kotlin.system.exitProcess
 
 fun printUsage() {
-    println("Usage: OfflineSimulator -o OUTFILE.raw {[-p POWER] [-l|-h] file:///FILE.raw}")
+    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. 2x48kHz 32-bit float")
+    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, 2x200kHz 32-bit float")
+    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("        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) {
+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()
@@ -45,14 +50,16 @@ fun main(args: Array<String>) {
     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") {
+            if (arg == "-o" || arg == "-O") {
                 hasOutput = true
-                inArg = "-o"
+                inArg = arg
             } else {
                 printUsage()
                 exitProcess(1)
@@ -61,6 +68,13 @@ fun main(args: Array<String>) {
             when (inArg) {
                 "-o" -> {
                     output = arg
+                    rfOutput = true
+                    inArg = ""
+                }
+
+                "-O" -> {
+                    output = arg
+                    rfOutput = false
                     inArg = ""
                 }
 
@@ -75,15 +89,17 @@ fun main(args: Array<String>) {
 
                 "" -> {
                     if (!arg.startsWith("-")) {
-                        files.add(SimFile(power, band, arg))
+                        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()
@@ -119,14 +135,16 @@ fun main(args: Array<String>) {
         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.IFFT_DATA_BLOCK_SIZE_48K_300K) {
-                        inputFile.modulator.flush(inputFile.power) {
+                    if (inputFile.buffer.remaining() > 2 * FmFullConstants.FFT_DATA_BLOCK_SIZE_48K_300K) {
+                        inputFile.modulator.flush(inputFile.power, inputFile.stereo) {
                             inputFile.buffer.put(it)
                         }
                     }
@@ -136,18 +154,18 @@ fun main(args: Array<String>) {
                         stream.close()
                         inputFile.stream = null
                         inputFile.closed = true
-                        inputFile.modulator.flush(inputFile.power) {
+                        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.modulator.process(floats, inputFile.power, inputFile.stereo) {
                             inputFile.buffer.put(it)
                             shouldFlush = false
                         }
                         if (shouldFlush) {
-                            inputFile.modulator.flush(inputFile.power) {
+                            inputFile.modulator.flush(inputFile.power, inputFile.stereo) {
                                 inputFile.buffer.put(it)
                             }
                         }
@@ -199,7 +217,18 @@ fun main(args: Array<String>) {
                 floatView.put(i, floatView.get(i) + z.x)
                 floatView.put(i, floatView.get(i) + z.y)
             }
-            outputStream.write(outputBuffer.array())
+            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
             }
diff --git a/src/main/kotlin/space/autistic/radio/fmsim/FmFullConstants.kt b/src/main/kotlin/space/autistic/radio/fmsim/FmFullConstants.kt
index 5874166..6b92328 100644
--- a/src/main/kotlin/space/autistic/radio/fmsim/FmFullConstants.kt
+++ b/src/main/kotlin/space/autistic/radio/fmsim/FmFullConstants.kt
@@ -58,12 +58,12 @@ object FmFullConstants {
         -0.0010006913216784596f,
     )
 
-    // chosen such that we can easily do 38kHz mixing in frequency (750*38k/300k = shift of 95 bins, where 750 comes
-    // from the 4/25 ratio 48k/300k i.e. 120*25/4)
+    // 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 = 120
+    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
 
@@ -74,9 +74,9 @@ object FmFullConstants {
     const val DECIMATION_48K_300K = 4
     const val INTERPOLATION_48K_300K = 25
 
-    const val IFFT_SIZE_48K_300K = FFT_SIZE_LPF_48K_15K_3K1 * INTERPOLATION_48K_300K / DECIMATION_48K_300K
-    const val IFFT_OVERLAP_48K_300K = FFT_OVERLAP_LPF_48K_15K_3K1 * INTERPOLATION_48K_300K / DECIMATION_48K_300K
-    const val IFFT_DATA_BLOCK_SIZE_48K_300K = IFFT_SIZE_48K_300K - IFFT_OVERLAP_48K_300K
+    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)
@@ -88,12 +88,14 @@ object FmFullConstants {
     // 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 * sin(2 * PI * 19000.0 * it.toDouble() / 300000.0).toFloat()
+        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
@@ -106,4 +108,7 @@ object FmFullConstants {
             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
new file mode 100644
index 0000000..ce32c77
--- /dev/null
+++ b/src/main/kotlin/space/autistic/radio/fmsim/FmFullDemodulator.kt
@@ -0,0 +1,158 @@
+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<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 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 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/FmFullModulator.kt b/src/main/kotlin/space/autistic/radio/fmsim/FmFullModulator.kt
index 96ad186..7334c37 100644
--- a/src/main/kotlin/space/autistic/radio/fmsim/FmFullModulator.kt
+++ b/src/main/kotlin/space/autistic/radio/fmsim/FmFullModulator.kt
@@ -7,8 +7,10 @@ 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)
@@ -24,31 +26,25 @@ class FmFullModulator {
         FmFullConstants.FM_PREEMPHASIS_A1_48K
     )
     private val fft48kBuffer = FloatBuffer.allocate(FmFullConstants.FFT_SIZE_LPF_48K_15K_3K1)
-    private val fir48kLpf = FloatBuffer.allocate(FmFullConstants.FFT_SIZE_LPF_48K_15K_3K1)
-    private val mixingBuffer = FloatBuffer.allocate(FmFullConstants.IFFT_SIZE_48K_300K)
-    private val outputBuffer = FloatBuffer.allocate(2 * FmFullConstants.IFFT_DATA_BLOCK_SIZE_48K_300K)
+    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 var cycle = -1f
+    private val cycle19k = Vector2f(0f, 1f)
     private var lastSum = 0f
 
     init {
-        fir48kLpf.put(0, FmFullConstants.FIR_LPF_48K_15K_3K1)
-        Companion.fft48k.realForward(fir48kLpf.array())
-
         // pre-pad the buffers
-        while (leftPlusRight.position() < FmFullConstants.FFT_OVERLAP_LPF_48K_15K_3K1) {
-            leftPlusRight.put(0f)
-            leftMinusRight.put(0f)
-        }
+        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 and processes them for output.
+     * Takes in samples at 48kHz, interleaved stereo (even when set to MONO), and processes them for output.
      *
-     * Calls consumer with processed samples in I/Q format.
+     * Calls consumer with processed samples at 300kHz in I/Q format.
      */
-    fun process(input: FloatBuffer, power: Float, consumer: Consumer<FloatBuffer>) {
+    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)
@@ -63,7 +59,7 @@ class FmFullModulator {
                     mixingBuffer.put(i, 0f)
                 }
                 fft48kBuffer.put(0, leftPlusRight, 0, FmFullConstants.FFT_SIZE_LPF_48K_15K_3K1)
-                Companion.fft48k.realForward(fft48kBuffer.array())
+                fft48k.realForward(fft48kBuffer.array())
                 fft48kBuffer.array().forEachIndexed { index, fl ->
                     fft48kBuffer.put(
                         index,
@@ -72,90 +68,83 @@ class FmFullModulator {
                 }
                 val z = Vector2f()
                 val w = Vector2f()
-                for (i in 2 until FmFullConstants.FFT_SIZE_LPF_48K_15K_3K1 step 2) {
+                for (i in 2 until (FmFullConstants.FREQUENCY_MIXING_BINS_38K 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)
-                    fft48kBuffer.put(i, z.x)
-                    fft48kBuffer.put(i + 1, z.y)
+                    mixingBuffer.put(i, z.x)
+                    mixingBuffer.put(i + 1, z.y)
                 }
-                fft48kBuffer.put(0, fft48kBuffer.get(0) * fir48kLpf.get(0))
-                fft48kBuffer.put(1, fft48kBuffer.get(1) * fir48kLpf.get(1))
-                // copy only around 19kHz of bandwidth
-                mixingBuffer.put(0, fft48kBuffer, 0, FmFullConstants.FREQUENCY_MIXING_BINS_38K or 1)
-                // zero out nyquist frequency bucket
-                mixingBuffer.put(1, 0f)
-                fft48kBuffer.put(0, leftMinusRight, 0, FmFullConstants.FFT_SIZE_LPF_48K_15K_3K1)
-                Companion.fft48k.realForward(fft48kBuffer.array())
-                fft48kBuffer.array().forEachIndexed { index, fl ->
-                    fft48kBuffer.put(
-                        index,
-                        0.2f / FmFullConstants.FFT_SIZE_LPF_48K_15K_3K1 * fl
+                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 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 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())
                 }
-                for (i in 2 until FmFullConstants.FFT_SIZE_LPF_48K_15K_3K1 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)
-                    fft48kBuffer.put(i, z.x)
-                    fft48kBuffer.put(i + 1, z.y)
-                }
-                fft48kBuffer.put(0, fft48kBuffer.get(0) * fir48kLpf.get(0))
-                // (unnecessary)
-                //fft48kBuffer.put(1, fft48kBuffer.get(1) * fir48kLpf.get(1))
-                mixingBuffer.put(
-                    FmFullConstants.FREQUENCY_MIXING_BINS_38K * 2 + 2,
-                    fft48kBuffer,
-                    2,
-                    // number of floats to copy
-                    // bins are complex, so this halves the bins (~19kHz bandwidth)
-                    // length should be even (for an exact number of complex bins)
-                    FmFullConstants.FREQUENCY_MIXING_BINS_38K and 1.inv()
-                )
-                // the actual 38k bin is at this offset, account for jt convention (buf[0 until 3] = R0,Rn,R1)
-                mixingBuffer.put(FmFullConstants.FREQUENCY_MIXING_BINS_38K * 2, fft48kBuffer.get(0))
-                val base = FmFullConstants.FREQUENCY_MIXING_BINS_38K * 2
-                // phase correction factor (due to dropping 150 bins)
-                // TODO figure out if phase is correct
-                cycle = -cycle
-                // bandwidth we care about is about half of 38k, so just, well, half it
-                for (i in 2 until FmFullConstants.FREQUENCY_MIXING_BINS_38K step 2) {
-                    z.x = mixingBuffer.get(base + i)
-                    z.y = mixingBuffer.get(base + i + 1)
-                    // we also need the conjugate
-                    z.conjugate(w)
-                    mixingBuffer.put(base + i, z.y * -cycle)
-                    mixingBuffer.put(base + i + 1, z.x * cycle)
-                    mixingBuffer.put(base - i, mixingBuffer.get(base - i - 2) - w.y * cycle)
-                    mixingBuffer.put(base - i + 1, mixingBuffer.get(base - i - 1) + w.x * cycle)
-                }
-                // handle 38kHz itself
-                z.x = mixingBuffer.get(base)
-                z.y = mixingBuffer.get(base + 1)
-                mixingBuffer.put(base, z.y * -cycle)
-                mixingBuffer.put(base + 1, z.x * cycle)
-                // (don't need to handle nyquist)
                 // 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()
-                Companion.fft300k.realInverse(mixingBuffer.array(), false)
+                fft300k.realInverse(mixingBuffer.array(), false)
                 outputBuffer.clear()
                 var sum = lastSum
-                for (i in FmFullConstants.IFFT_OVERLAP_48K_300K until FmFullConstants.IFFT_SIZE_48K_300K) {
-                    if (!stereoPilot.hasRemaining()) {
-                        stereoPilot.clear()
-                    }
-                    val result = mixingBuffer.get(i) + stereoPilot.get()
-                    sum += result * FmFullConstants.CORRECTION_FACTOR
-                    val sin = org.joml.Math.sin(sum)
-                    outputBuffer.put(sin * power)
+                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()
@@ -165,12 +154,18 @@ class FmFullModulator {
         input.compact()
     }
 
-    fun flush(power: Float, consumer: Consumer<FloatBuffer>) {
-        process(FloatBuffer.allocate(2 * leftPlusRight.remaining()), power, consumer)
+    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.IFFT_SIZE_48K_300K.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