Added continuous SSTV decoding of manually selected type

This commit is contained in:
Arty Bishop committed 2026-06-06 19:20:35 +01:00
1 parent 39b786ae82
commit e5033cb2e9
10 files changed
+381 -214

No files matched your search

@@ -47,11 +47,12 @@ class SstvDecoder(
onBufferOverflow = BufferOverflow.DROP_OLDEST
)
val frames: SharedFlow<SstvFrame> = _frames
val supportedModes: List<String> = buildList {
add("Raw"); add("HF Fax"); decoder.allModes.mapTo(this) { it.name }
}
val supportedModes: List<String> = decoder.allModes.map { it.name }
suspend fun feedSamples(samples: FloatArray) = withContext(Dispatchers.Default) {
// Normalize to a fixed RMS before processing so that both direct audio
// coupling and air-coupled microphone input decode with equal reliability.
normalise(samples)
val hasNewLines = decoder.process(samples, channelSelect)
if (hasNewLines) emitFrame()
}
@@ -64,12 +65,31 @@ class SstvDecoder(
}
private fun emitFrame() {
val imagePixels = if (imageBuffer.line > 0) imageBuffer.pixels.copyOf() else null
val imageWidth = imageBuffer.width
val imageHeight = imageBuffer.height
// Copy only the active image region — imageBuffer.pixels is pre-allocated
// at the maximum possible size (PD-290: 800×616), so we must not copyOf()
// the entire array and send padding pixels to the observer.
val imagePixels = if (imageBuffer.line > 0) imageBuffer.pixels.copyOf(imageWidth * imageHeight) else null
val modeName = decoder.currentMode.name
_frames.tryEmit(SstvFrame(imagePixels, imageWidth, imageHeight, modeName))
}
// Target RMS level for the normalizer. 0.25 leaves headroom while keeping the
// FM demodulator well above its noise floor regardless of input gain.
private val targetRms = 0.25f
// Bring the buffer to a fixed RMS so that microphone and direct-coupled inputs
// both decode reliably. The guard prevents amplifying pure silence into noise.
private fun normalise(buffer: FloatArray) {
var sumSq = 0f
for (s in buffer) sumSq += s * s
val rms = sqrt(sumSq / buffer.size)
if (rms > 1e-6f) {
val gain = targetRms / rms
for (i in buffer.indices) buffer[i] *= gain
}
}
}
internal enum class SyncPulseWidth { FiveMs, NineMs, TwentyMs }
@@ -136,6 +156,10 @@ internal class SyncPulseDetector(sampleRate: Int) {
fun process(buffer: FloatArray, channelSelect: Int): Boolean {
var detected = false
val channels = if (channelSelect > 0) 2 else 1
// NOTE: buffer[i] is overwritten in-place with the FM-demodulated frequency
// value for every mono sample (channelSelect == 0). DecoderEngine.process()
// reads back these values to populate scanLineBuffer. Callers must not reuse
// the buffer after this call.
for (i in 0 until buffer.size / channels) {
when (channelSelect) {
1 -> baseBand.set(buffer[2 * i])
@@ -200,7 +224,6 @@ internal class DecoderEngine(
private val visBitLen: Int
private val visLen: Int
private val rawMode: SstvMode
private val hfFaxMode: SstvMode
private val modes5ms: ArrayList<SstvMode>
private val modes9ms: ArrayList<SstvMode>
private val modes20ms: ArrayList<SstvMode>
@@ -217,6 +240,10 @@ internal class DecoderEngine(
init {
imageBuffer.line = -1
// Pre-allocate for the largest possible mode (PD-290: 800×616 = 492 800 ints)
// so that handleHeader/processPulse can reuse the array with a fill(0) instead
// of allocating a fresh IntArray on every new image, reducing GC pressure.
imageBuffer.pixels = IntArray(800 * 616)
val pfLen = round(0.0025 * sampleRate).toInt() or 1
pulseFilterDelay = (pfLen - 1) / 2
pulseFilter = MovingAverage(pfLen)
@@ -231,7 +258,6 @@ internal class DecoderEngine(
syncTolerance = round(0.03 * sampleRate).toInt()
lineTolerance = round(0.001 * sampleRate).toInt()
rawMode = RawMode(rawName, sampleRate)
hfFaxMode = HfFaxMode(sampleRate)
val robot36 = Robot36Mode(sampleRate)
currentMode = robot36
curLineSamples = robot36.scanLineSamples
@@ -301,11 +327,7 @@ internal class DecoderEngine(
}
fun setMode(name: String) {
if (rawMode.name == name) {
lockMode = true; imageBuffer.line = -1; currentMode = rawMode; return
}
var mode = allModes.firstOrNull { it.name == name }
if (mode == null && hfFaxMode.name == name) mode = hfFaxMode
val mode = allModes.firstOrNull { it.name == name }
if (mode == currentMode) {
lockMode = true; return
}
@@ -335,6 +357,10 @@ internal class DecoderEngine(
return best
}
// scopeBuffer is twice the display height. Each decoded scan line is written
// to both the current rolling position (top half, wraps at height/2) and the
// same row offset in the bottom half. The UI displays a window that always
// spans the half-height boundary, giving a seamless non-wrapping scroll effect.
private fun copyUnscaled() {
val w = minOf(scopeBuffer.width, pixelBuffer.width)
for (row in 0 until pixelBuffer.height) {
@@ -424,6 +450,9 @@ internal class DecoderEngine(
if ((amount <= 0) || (amount > sample)) return
sample -= amount; leaderBreak -= amount; lastSync -= amount
adjust(sync5ms, amount); adjust(sync9ms, amount); adjust(sync20ms, amount)
// Discard already-decoded samples by sliding the live region back to index 0.
// System.arraycopy handles the overlapping regions correctly and is a native
// memcpy on JVM, so this is fast despite moving the full remaining window.
scanLineBuffer.copyInto(scanLineBuffer, 0, amount, amount + sample)
}
@@ -494,7 +523,7 @@ internal class DecoderEngine(
if (lockMode && mode != currentMode) return false
mode.resetState()
imageBuffer.width = mode.width; imageBuffer.height = mode.height
imageBuffer.pixels = IntArray(mode.width * mode.height); imageBuffer.line = 0
imageBuffer.pixels.fill(0, 0, mode.width * mode.height); imageBuffer.line = 0
currentMode = mode
lastSync = sIdx + mode.firstSyncPulseIndex; curLineSamples = mode.scanLineSamples; lastOffset = ldrOffset
var oldest = lastSync - (pulses.size - 1) * curLineSamples
@@ -527,6 +556,15 @@ internal class DecoderEngine(
var changed = false
if (lockMode || imageBuffer.line in 0 until imageBuffer.height) {
if (currentMode != rawMode && abs(lineSamples - currentMode.scanLineSamples) > lineTolerance) return false
// Try continuous decoding
if (lockMode && imageBuffer.line == -1 && currentMode != rawMode) {
currentMode.resetState()
imageBuffer.width = currentMode.width
imageBuffer.height = currentMode.height
imageBuffer.pixels.fill(0, 0, currentMode.width * currentMode.height)
imageBuffer.line = 0
drawLines(0xff000000.toInt(), 10); drawLines(0xffffff00.toInt(), 8); drawLines(0xff000000.toInt(), 10)
}
} else {
val prev = currentMode; currentMode = detectMode(modes, lineSamples)
changed =
@@ -18,7 +18,6 @@
package com.rtbishop.look4sat.core.domain.sstv
import kotlin.math.PI
import kotlin.math.atan2
import kotlin.math.cos
import kotlin.math.pow
import kotlin.math.round
@@ -34,7 +33,6 @@ internal class Complex(var real: Float = 0f, var imag: Float = 0f) {
fun set(real: Float): Complex = set(real, 0f)
fun abs(): Float = sqrt(real * real + imag * imag)
fun arg(): Float = atan2(imag, real)
fun mul(other: Complex): Complex {
val tmp = real * other.real - imag * other.imag
@@ -74,21 +72,21 @@ internal object WindowFunctions {
}
}
// O(1) ring buffer — simpler and faster than a segment tree for the short window
// lengths used here (≤512 samples). Float32 accumulated drift over such windows
// is ~6e-5, negligible for audio-frequency processing.
internal open class MovingSum(val length: Int) {
private val tree = FloatArray(2 * length)
private var leaf = length
private val buf = FloatArray(length)
private var pos = 0
private var runningSum = 0f
fun add(input: Float) {
tree[leaf] = input
var child = leaf
var parent = leaf / 2
while (parent > 0) {
tree[parent] = tree[child] + tree[child xor 1]; child = parent; parent /= 2
}
if (++leaf >= tree.size) leaf = length
runningSum += input - buf[pos]
buf[pos] = input
if (++pos >= length) pos = 0
}
fun sum(): Float = tree[1]
fun sum(): Float = runningSum
fun sum(input: Float): Float {
add(input); return sum()
}
@@ -134,8 +132,15 @@ internal class Phasor(freq: Double, rate: Double) {
val omega = 2 * PI * freq / rate
Complex(cos(omega).toFloat(), sin(omega).toFloat())
}
private var count = 0
fun rotate(): Complex = value.div(value.mul(delta).abs())
// Renormalize every 512 rotations to prevent magnitude drift accumulation,
// eliminating the per-sample sqrt without sacrificing demodulation accuracy.
fun rotate(): Complex {
value.mul(delta)
if (++count == 512) { value.div(value.abs()); count = 0 }
return value
}
}
internal class FmDemodulator(bandwidth: Double, sampleRate: Double) {
@@ -145,11 +150,30 @@ internal class FmDemodulator(bandwidth: Double, sampleRate: Double) {
private var prev = 0f
fun demodulate(input: Complex): Float {
val phase = input.arg()
// Use fast polynomial atan2 instead of the exact trigonometric call.
// Max error ~0.005 rad translates to <1 Hz frequency error at 44100 Hz,
// well within the 50 Hz sync tolerance.
val phase = fastAtan2(input.imag, input.real)
var delta = phase - prev; prev = phase
if (delta < -pi) delta += twoPi else if (delta > pi) delta -= twoPi
return scale * delta
}
// Rajan's polynomial approximation of atan2 — avoids a transcendental call
// in the per-sample hot path (~44 k calls/s at 44100 Hz sample rate).
private fun fastAtan2(y: Float, x: Float): Float {
val absY = kotlin.math.abs(y) + 1e-10f
val r: Float
val angle: Float
if (x >= 0f) {
r = (x - absY) / (x + absY)
angle = 0.1963f * r * r * r - 0.9817f * r + pi / 4f
} else {
r = (x + absY) / (absY - x)
angle = 0.1963f * r * r * r - 0.9817f * r + 3f * pi / 4f
}
return if (y < 0f) -angle else angle
}
}
internal class ComplexFirFilter(val length: Int) {
@@ -257,9 +257,16 @@ internal class Robot36Mode(sampleRate: Int) : SstvMode {
val lPos = lumBegin + (i * lumSamples) / width
val cPos = chromBegin + (i * chromSamples) / width
if (even) {
// Even line: store Y in the red channel slot and Cr in the blue slot,
// using ColorConverter.rgb() as a convenient 3×byte packer (not RGB).
// The odd line will read these back and combine with its own Cb to
// produce the final YUV→RGB conversion for both rows.
pixelBuffer.pixels[i] = ColorConverter.rgb(scratch[lPos], 0f, scratch[cPos])
} else {
val evenYuv = pixelBuffer.pixels[i]
// Even pixel packing: 0xAARRGGBB → Y=RR, Cb=GG(unused), Cr=BB
// Odd pixel: Y=lPos, Cb=cPos, Cr=(borrowed from even's BB slot)
// Merge: take Y+Cr from even row (bits 0x00ff00ff) and Cb from odd (0x0000ff00).
val oddYuv = ColorConverter.rgb(scratch[lPos], scratch[cPos], 0f)
pixelBuffer.pixels[i] = ColorConverter.yuv2rgb((evenYuv and 0x00ff00ff) or (oddYuv and 0x0000ff00))
pixelBuffer.pixels[i + width] =
@@ -389,53 +396,6 @@ internal class PdMode(
}
}
internal class HfFaxMode(private val sampleRate: Int) : SstvMode {
override val name = "HF Fax"
override val visCode = -1
override val width = 640
override val height = 1200
override val firstPixelSampleIndex = 0
override val firstSyncPulseIndex = -1
override val scanLineSamples get() = sampleRate / 2
private val ema = Ema()
private val cumulated = FloatArray(width)
var horizontalShift = 0; private set
override fun decodeScanLine(
pixelBuffer: PixelBuffer,
scratch: FloatArray,
scanLine: FloatArray,
scopeWidth: Int,
syncPulseIndex: Int,
lineSamples: Int,
freqOffset: Float
): Boolean {
if (syncPulseIndex < 0 || syncPulseIndex + lineSamples > scanLine.size) return false
ema.setCutoff(width.toDouble(), (2 * lineSamples).toDouble(), 2); ema.reset()
for (i in 0 until lineSamples) scratch[i] = ema.process(scanLine[syncPulseIndex + i])
ema.reset()
for (i in lineSamples - 1 downTo 0) scratch[i] = freqToLevel(ema.process(scratch[i]), freqOffset)
var bestIdx = 0
var bestVal = 0f
for (i in 0 until width) {
val pos = (i * lineSamples) / width
val color = ColorConverter.gray(scratch[pos])
pixelBuffer.pixels[i] = color
// Grayscale: R==G==B, so luminance is simply the channel value normalized
val gray = (color and 0xFF) / 255f
cumulated[i] = cumulated[i] * 0.99f + gray * 0.01f
if (cumulated[i] > bestVal) {
bestIdx = i; bestVal = cumulated[i]
}
}
horizontalShift = bestIdx
pixelBuffer.width = width; pixelBuffer.height = 1
return true
}
}
internal class RawMode(override val name: String, sampleRate: Int) : SstvMode {
override val visCode = -1
override val width = -1
@@ -40,6 +40,7 @@ import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ElevatedButton
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.Icon
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
@@ -49,12 +50,21 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.hideFromAccessibility
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.TextLayoutResult
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
@@ -387,3 +397,76 @@ fun elevationColor(elevation: Double): Color {
else -> Color(0xFF66BB6A) // soft green for high elevation
}
}
@Composable
fun OutlinedText(
text: String,
modifier: Modifier = Modifier,
fillColor: Color = Color.Unspecified,
outlineColor: Color,
fontSize: TextUnit = TextUnit.Unspecified,
fontStyle: FontStyle? = null,
fontWeight: FontWeight? = null,
fontFamily: FontFamily? = null,
letterSpacing: TextUnit = TextUnit.Unspecified,
textDecoration: TextDecoration? = null,
textAlign: TextAlign? = null,
lineHeight: TextUnit = TextUnit.Unspecified,
overflow: TextOverflow = TextOverflow.Clip,
softWrap: Boolean = true,
maxLines: Int = Int.MAX_VALUE,
minLines: Int = 1,
onTextLayout: (TextLayoutResult) -> Unit = {},
style: TextStyle = LocalTextStyle.current,
outlineDrawStyle: Stroke = Stroke(width = 8f),
) {
Box(modifier = modifier) {
Text(
text = text,
modifier = Modifier.semantics { hideFromAccessibility() },
color = outlineColor,
fontSize = fontSize,
fontStyle = fontStyle,
fontWeight = fontWeight,
fontFamily = fontFamily,
letterSpacing = letterSpacing,
textDecoration = null,
textAlign = textAlign,
lineHeight = lineHeight,
overflow = overflow,
softWrap = softWrap,
maxLines = maxLines,
minLines = minLines,
onTextLayout = onTextLayout,
style = style.copy(shadow = null, drawStyle = outlineDrawStyle),
)
Text(
text = text,
color = fillColor,
fontSize = fontSize,
fontStyle = fontStyle,
fontWeight = fontWeight,
fontFamily = fontFamily,
letterSpacing = letterSpacing,
textDecoration = textDecoration,
textAlign = textAlign,
lineHeight = lineHeight,
overflow = overflow,
softWrap = softWrap,
maxLines = maxLines,
minLines = minLines,
onTextLayout = onTextLayout,
style = style,
)
}
}
// Formats a frequency in Hz as "MMM.KKK.HHH" (e.g. 145.825.000) or "---"
fun formatFrequency(frequencyHz: Long): String {
if (frequencyHz <= 0) return "---"
val mhz = frequencyHz / 1_000_000
val khz = (frequencyHz % 1_000_000) / 1_000
val hz = frequencyHz % 1_000
return String.format(Locale.ENGLISH, "%d.%03d.%03d", mhz, khz, hz)
}