Added a few tweaks to SSTV sensitivity and reception

This commit is contained in:
Arty Bishop committed 2026-07-30 12:01:54 +02:00
1 parent 9a52fdfde5
commit e86bc2c700
4 files changed
+308 -26

No files matched your search

@@ -20,39 +20,94 @@ package com.rtbishop.look4sat.core.domain.sstv
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlin.math.PI
import kotlin.math.abs import kotlin.math.abs
import kotlin.math.round import kotlin.math.round
import kotlin.math.sqrt import kotlin.math.sqrt
class SstvFrame( class SstvFrame(
val scopePixels: IntArray?,
val scopeWidth: Int,
val scopeHeight: Int,
val imagePixels: IntArray?, val imagePixels: IntArray?,
val imageWidth: Int, val imageWidth: Int,
val imageHeight: Int, val imageHeight: Int,
val modeName: String val modeName: String,
val imageComplete: Boolean,
val inputRms: Float,
val appliedGain: Float,
val syncHitRate: Float,
val predictedLineBursts: Int,
val maxPredictedStreak: Int,
val timingErrorSamples: Int
)
/**
* Decoder quality metrics for diagnostics and logging. Useful for profiling decode
* performance on noisy recordings.
*/
data class SstvQualityMetrics(
val syncHitRate: Float,
val predictedLineBursts: Int,
val maxPredictedStreak: Int,
val timingErrorSamples: Int
)
enum class LineRecoveryStrategy {
Look4SatLimited,
Robot36Compatible
}
class SstvDiagnosticsHandle internal constructor(
val enabled: Boolean,
val metrics: StateFlow<SstvQualityMetrics?>
) )
class SstvDecoder( class SstvDecoder(
sampleRate: Int = 44100, sampleRate: Int = 44100,
scopeWidth: Int = 320, scopeWidth: Int = 320,
scopeHeight: Int = 256, scopeHeight: Int = 256,
private val channelSelect: Int = 0 private val channelSelect: Int = 0,
targetRmsLevel: Float = 0.25f,
private val includeScopeData: Boolean = false,
private val enableRmsNormalization: Boolean = true,
preFilterCutoffHz: Double = 500.0,
enablePreFilter: Boolean = true,
private val enableDiagnosticsHandle: Boolean = false,
lineRecoveryStrategy: LineRecoveryStrategy = LineRecoveryStrategy.Look4SatLimited
) { ) {
private val scopeBuffer = PixelBuffer(scopeWidth, scopeHeight * 2) private val scopeBuffer = PixelBuffer(scopeWidth, scopeHeight * 2)
private val imageBuffer = PixelBuffer(scopeWidth, scopeHeight) private val imageBuffer = PixelBuffer(scopeWidth, scopeHeight)
private val decoder = DecoderEngine(scopeBuffer, imageBuffer, "Raw", sampleRate) private val decoder = DecoderEngine(scopeBuffer, imageBuffer, "Raw", sampleRate, lineRecoveryStrategy)
private val _frames = MutableSharedFlow<SstvFrame>( private val _frames = MutableSharedFlow<SstvFrame>(
replay = 1, replay = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST onBufferOverflow = BufferOverflow.DROP_OLDEST
) )
private var lastInputRms = 0f
private var lastAppliedGain = 1f
private val _qualityMetrics = MutableStateFlow<SstvQualityMetrics?>(null)
private val preFilter = if (enablePreFilter) HighPassFilter(preFilterCutoffHz, sampleRate.toDouble()) else null
private val diagnosticsHandle = SstvDiagnosticsHandle(enableDiagnosticsHandle, _qualityMetrics)
val frames: SharedFlow<SstvFrame> = _frames val frames: SharedFlow<SstvFrame> = _frames
val supportedModes: List<String> = decoder.allModes.map { it.name } val supportedModes: List<String> = decoder.allModes.map { it.name }
suspend fun feedSamples(samples: FloatArray) = withContext(Dispatchers.Default) { suspend fun feedSamples(samples: FloatArray) = withContext(Dispatchers.Default) {
// Normalize to a fixed RMS before processing so that both direct audio // Optional pre-filtering: remove DC offset and subsonic noise that can mask
// coupling and air-coupled microphone input decode with equal reliability. // weak signals and corrupt the RMS normalization baseline.
normalise(samples) preFilter?.apply(samples)
// Optional RMS normalization: bring input to a consistent level so the
// FM demodulator operates in a predictable region. However, this amplifies
// noise proportionally. Aggressive RMS targets (e.g., 0.25) can hurt weak
// signals by boosting noise floor. Safer defaults: 0.35-0.50 for noisy inputs.
// Disable entirely for direct line-level inputs (e.g., receiver discriminator).
val gain = if (enableRmsNormalization) normalise(samples) else GainInfo(0f, 1f)
lastInputRms = gain.inputRms
lastAppliedGain = gain.appliedGain
val hasNewLines = decoder.process(samples, channelSelect) val hasNewLines = decoder.process(samples, channelSelect)
if (hasNewLines) emitFrame() if (hasNewLines) emitFrame()
} }
@@ -62,36 +117,134 @@ class SstvDecoder(
fun clearPixels() { fun clearPixels() {
imageBuffer.line = -1 imageBuffer.line = -1
imageBuffer.pixels.fill(0) imageBuffer.pixels.fill(0)
decoder.resetQuality()
if (enableDiagnosticsHandle) _qualityMetrics.value = null
}
fun getDiagnosticsHandle(): SstvDiagnosticsHandle? = diagnosticsHandle.takeIf { it.enabled }
/**
* Export quality metrics for logging/diagnostics. Useful for profiling decode
* performance on noisy recordings. Returns null before first frame is emitted.
*/
@Suppress("unused")
fun getQualityMetrics(): SstvQualityMetrics? {
if (enableDiagnosticsHandle) return _qualityMetrics.value
val q = decoder.quality()
return SstvQualityMetrics(
syncHitRate = q.syncHitRate,
predictedLineBursts = q.predictedLineBursts,
maxPredictedStreak = q.maxPredictedStreak,
timingErrorSamples = q.timingErrorSamples
)
} }
private fun emitFrame() { private fun emitFrame() {
val imageWidth = imageBuffer.width val imageWidth = imageBuffer.width
val imageHeight = imageBuffer.height val imageHeight = imageBuffer.height
val quality = decoder.quality()
if (enableDiagnosticsHandle) {
_qualityMetrics.value = SstvQualityMetrics(
syncHitRate = quality.syncHitRate,
predictedLineBursts = quality.predictedLineBursts,
maxPredictedStreak = quality.maxPredictedStreak,
timingErrorSamples = quality.timingErrorSamples
)
}
val imageComplete = imageBuffer.line >= imageHeight && imageBuffer.line > 0
// Copy only the active image region — imageBuffer.pixels is pre-allocated // 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() // at the maximum possible size (PD-290: 800×616), so we must not copyOf()
// the entire array and send padding pixels to the observer. // the entire array and send padding pixels to the observer.
val imagePixels = if (imageBuffer.line > 0) imageBuffer.pixels.copyOf(imageWidth * imageHeight) else null val imagePixels = if (imageBuffer.line > 0) imageBuffer.pixels.copyOf(imageWidth * imageHeight) else null
val modeName = decoder.currentMode.name val modeName = decoder.currentMode.name
_frames.tryEmit(SstvFrame(imagePixels, imageWidth, imageHeight, modeName)) val scopePixels = if (includeScopeData) scopeBuffer.pixels.copyOf() else null
val scopeWidth = if (includeScopeData) scopeBuffer.width else 0
val scopeHeight = if (includeScopeData) scopeBuffer.height else 0
_frames.tryEmit(
SstvFrame(
scopePixels = scopePixels,
scopeWidth = scopeWidth,
scopeHeight = scopeHeight,
imagePixels = imagePixels,
imageWidth = imageWidth,
imageHeight = imageHeight,
modeName = modeName,
imageComplete = imageComplete,
inputRms = lastInputRms,
appliedGain = lastAppliedGain,
syncHitRate = quality.syncHitRate,
predictedLineBursts = quality.predictedLineBursts,
maxPredictedStreak = quality.maxPredictedStreak,
timingErrorSamples = quality.timingErrorSamples
)
)
} }
// Target RMS level for the normalizer. 0.25 leaves headroom while keeping the // Target RMS level for the normalizer. 0.25 leaves headroom while keeping the
// FM demodulator well above its noise floor regardless of input gain. // FM demodulator well above its noise floor regardless of input gain.
private val targetRms = 0.25f // TUNING GUIDE:
// - 0.20-0.25: Aggressive, best for clean direct-coupled inputs, worst for mic noise
// - 0.35-0.40: Moderate, good balance for typical phone/mic inputs (RECOMMENDED)
// - 0.50-0.60: Conservative, best for noisy environments, reduces amplitude resolution
// Disable RMS normalization entirely if using a professional receiver discriminator output.
private val targetRms = targetRmsLevel.coerceIn(0.05f, 0.8f)
private class GainInfo(
val inputRms: Float,
val appliedGain: Float
)
// Bring the buffer to a fixed RMS so that microphone and direct-coupled inputs // 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. // both decode reliably. The guard prevents amplifying pure silence into noise.
private fun normalise(buffer: FloatArray) { private fun normalise(buffer: FloatArray): GainInfo {
var sumSq = 0f var sumSq = 0f
for (s in buffer) sumSq += s * s for (s in buffer) sumSq += s * s
val rms = sqrt(sumSq / buffer.size) val rms = sqrt(sumSq / buffer.size)
if (rms > 1e-6f) { if (rms > 1e-6f) {
val gain = targetRms / rms val gain = targetRms / rms
for (i in buffer.indices) buffer[i] *= gain for (i in buffer.indices) buffer[i] *= gain
return GainInfo(rms, gain)
}
return GainInfo(rms, 1f)
}
}
/**
* Simple high-pass filter to remove DC offset and subsonic interference before RMS
* normalization. Improves noise floor estimation and prevents low-freq noise from
* corrupting the gain calculation.
*
* Design: First-order butterworth (pole at cutoff frequency). Fast, minimal latency,
* suitable for real-time preprocessing.
*/
internal class HighPassFilter(cutoffHz: Double, sampleRateHz: Double) {
private val alpha: Float
private var prevInput = 0f
private var prevOutput = 0f
init {
// First-order pole placement: alpha = wc / (wc + ws) where wc = 2*pi*fc, ws = 2*pi*fs
val omega = 2f * PI.toFloat() * (cutoffHz / sampleRateHz).toFloat()
alpha = omega / (omega + 1f)
}
fun apply(buffer: FloatArray) {
for (i in buffer.indices) {
val input = buffer[i]
prevOutput = alpha * (prevOutput + input - prevInput)
buffer[i] = prevOutput
prevInput = input
} }
} }
} }
internal class DecoderQuality(
val syncHitRate: Float,
val predictedLineBursts: Int,
val maxPredictedStreak: Int,
val timingErrorSamples: Int
)
internal enum class SyncPulseWidth { FiveMs, NineMs, TwentyMs } internal enum class SyncPulseWidth { FiveMs, NineMs, TwentyMs }
internal class SyncPulseDetector(sampleRate: Int) { internal class SyncPulseDetector(sampleRate: Int) {
@@ -197,7 +350,8 @@ internal class DecoderEngine(
private val scopeBuffer: PixelBuffer, private val scopeBuffer: PixelBuffer,
private val imageBuffer: PixelBuffer, private val imageBuffer: PixelBuffer,
rawName: String, rawName: String,
sampleRate: Int sampleRate: Int,
lineRecoveryStrategy: LineRecoveryStrategy
) { ) {
private val pixelBuffer = PixelBuffer(800, 2) private val pixelBuffer = PixelBuffer(800, 2)
private val detector = SyncPulseDetector(sampleRate) private val detector = SyncPulseDetector(sampleRate)
@@ -237,6 +391,19 @@ internal class DecoderEngine(
private var lastSync = 0 private var lastSync = 0
private var curLineSamples: Int private var curLineSamples: Int
private var lastOffset = 0f private var lastOffset = 0f
private var syncChecks = 0
private var syncHits = 0
private var predictedLineBursts = 0
private var predictedStreak = 0
private var maxPredictedStreak = 0
private var timingErrorSamples = 0
// Look4Sat strategy limits synthetic lines to avoid visible vertical collapse.
// Robot36 strategy preserves legacy behavior by allowing unlimited synthesis.
private val maxConsecutivePredictedLines = when (lineRecoveryStrategy) {
LineRecoveryStrategy.Look4SatLimited -> 2
LineRecoveryStrategy.Robot36Compatible -> Int.MAX_VALUE
}
init { init {
imageBuffer.line = -1 imageBuffer.line = -1
@@ -286,6 +453,11 @@ internal class DecoderEngine(
fun process(recordBuffer: FloatArray, channelSelect: Int): Boolean { fun process(recordBuffer: FloatArray, channelSelect: Int): Boolean {
var newLines = false var newLines = false
val detected = detector.process(recordBuffer, channelSelect) val detected = detector.process(recordBuffer, channelSelect)
syncChecks++
if (detected) {
syncHits++
predictedStreak = 0
}
var syncIdx = sample + detector.pulseOffset var syncIdx = sample + detector.pulseOffset
val channels = if (channelSelect > 0) 2 else 1 val channels = if (channelSelect > 0) 2 else 1
for (j in 0 until recordBuffer.size / channels) { for (j in 0 until recordBuffer.size / channels) {
@@ -308,20 +480,10 @@ internal class DecoderEngine(
} }
} }
} else if (handleHeader()) { } else if (handleHeader()) {
predictedStreak = 0
newLines = true newLines = true
} else if (sample > lastSync + (curLineSamples * 5) / 4) { } else if (sample > lastSync + (curLineSamples * 5) / 4) {
copyLines( newLines = decodePredictedLine()
currentMode.decodeScanLine(
pixelBuffer,
scratch,
scanLineBuffer,
scopeBuffer.width,
lastSync,
curLineSamples,
lastOffset
)
)
lastSync += curLineSamples; newLines = true
} }
return newLines return newLines
} }
@@ -337,6 +499,25 @@ internal class DecoderEngine(
lockMode = false lockMode = false
} }
fun quality(): DecoderQuality {
val hitRate = if (syncChecks > 0) syncHits.toFloat() / syncChecks else 0f
return DecoderQuality(
syncHitRate = hitRate,
predictedLineBursts = predictedLineBursts,
maxPredictedStreak = maxPredictedStreak,
timingErrorSamples = timingErrorSamples
)
}
fun resetQuality() {
syncChecks = 0
syncHits = 0
predictedLineBursts = 0
predictedStreak = 0
maxPredictedStreak = 0
timingErrorSamples = 0
}
private fun mean(a: IntArray): Double = a.sumOf { it.toDouble() } / a.size private fun mean(a: IntArray): Double = a.sumOf { it.toDouble() } / a.size
private fun stdDev(a: IntArray, m: Double): Double { private fun stdDev(a: IntArray, m: Double): Double {
var s = 0.0; for (v in a) s += (v - m) * (v - m); return sqrt(s / a.size) var s = 0.0; for (v in a) s += (v - m) * (v - m); return sqrt(s / a.size)
@@ -426,6 +607,29 @@ internal class DecoderEngine(
if (finish) drawLines(0xff000000.toInt(), 10) if (finish) drawLines(0xff000000.toInt(), 10)
} }
private fun decodePredictedLine(): Boolean {
// Avoid long streaks of synthetic lines; once we exceed the cap we wait for
// real sync to reduce visible vertical compression on weak/noisy signals.
if (predictedStreak >= maxConsecutivePredictedLines) return false
val expectedSync = lastSync + curLineSamples
timingErrorSamples = sample - expectedSync
val decoded = currentMode.decodeScanLine(
pixelBuffer,
scratch,
scanLineBuffer,
scopeBuffer.width,
lastSync,
curLineSamples,
lastOffset
)
copyLines(decoded)
lastSync = expectedSync
predictedLineBursts++
predictedStreak++
if (predictedStreak > maxPredictedStreak) maxPredictedStreak = predictedStreak
return decoded
}
private fun drawLines(color: Int, count: Int) { private fun drawLines(color: Int, count: Int) {
repeat(count) { repeat(count) {
scopeBuffer.pixels.fill( scopeBuffer.pixels.fill(
@@ -542,6 +746,7 @@ internal class DecoderEngine(
lineLen: IntArray, lineLen: IntArray,
latest: Int latest: Int
): Boolean { ): Boolean {
predictedStreak = 0
for (i in 1 until syncPulses.size) syncPulses[i - 1] = syncPulses[i] for (i in 1 until syncPulses.size) syncPulses[i - 1] = syncPulses[i]
syncPulses[syncPulses.size - 1] = latest syncPulses[syncPulses.size - 1] = latest
for (i in 1 until lineLen.size) lineLen[i - 1] = lineLen[i] for (i in 1 until lineLen.size) lineLen[i - 1] = lineLen[i]
@@ -22,6 +22,7 @@ import com.rtbishop.look4sat.core.domain.predict.CelestialComputer
import com.rtbishop.look4sat.core.domain.predict.OrbitalPass import com.rtbishop.look4sat.core.domain.predict.OrbitalPass
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
import com.rtbishop.look4sat.core.domain.sstv.SstvFrame import com.rtbishop.look4sat.core.domain.sstv.SstvFrame
import com.rtbishop.look4sat.core.domain.sstv.SstvQualityMetrics
data class RadioPanelState( data class RadioPanelState(
val label: String = "", val label: String = "",
@@ -72,7 +73,8 @@ data class SstvSubState(
val hasPermission: Boolean = false, val hasPermission: Boolean = false,
val selectedMode: String = "Auto", val selectedMode: String = "Auto",
val supportedModes: List<String> = emptyList(), val supportedModes: List<String> = emptyList(),
val currentFrame: SstvFrame? = null val currentFrame: SstvFrame? = null,
val diagnosticsMetrics: SstvQualityMetrics? = null
) )
sealed interface RadarAction { sealed interface RadarAction {
@@ -32,6 +32,7 @@ import com.rtbishop.look4sat.core.domain.repository.IReporter
import com.rtbishop.look4sat.core.domain.repository.ISatelliteRepo import com.rtbishop.look4sat.core.domain.repository.ISatelliteRepo
import com.rtbishop.look4sat.core.domain.repository.ISensorsRepo import com.rtbishop.look4sat.core.domain.repository.ISensorsRepo
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
import com.rtbishop.look4sat.core.domain.sstv.LineRecoveryStrategy
import com.rtbishop.look4sat.core.domain.sstv.SstvDecoder import com.rtbishop.look4sat.core.domain.sstv.SstvDecoder
import com.rtbishop.look4sat.core.domain.usecase.IAudioCapture import com.rtbishop.look4sat.core.domain.usecase.IAudioCapture
import com.rtbishop.look4sat.core.domain.usecase.IAddToCalendar import com.rtbishop.look4sat.core.domain.usecase.IAddToCalendar
@@ -213,7 +214,6 @@ class RadarViewModel(
override fun onCleared() { override fun onCleared() {
sensorsRepo.disableSensor() sensorsRepo.disableSensor()
super.onCleared()
} }
fun onAction(action: RadarAction) { fun onAction(action: RadarAction) {
@@ -344,13 +344,32 @@ class RadarViewModel(
private fun initSstvDecoder() { private fun initSstvDecoder() {
if (sstvDecoder == null) { if (sstvDecoder == null) {
val decoder = SstvDecoder(sampleRate = audioCapture.sampleRate) val decoder = SstvDecoder(
sampleRate = audioCapture.sampleRate,
targetRmsLevel = SSTV_TARGET_RMS,
includeScopeData = SSTV_INCLUDE_SCOPE,
enableRmsNormalization = SSTV_ENABLE_RMS_NORMALIZATION,
preFilterCutoffHz = SSTV_PREFILTER_CUTOFF_HZ,
enablePreFilter = SSTV_ENABLE_PREFILTER,
enableDiagnosticsHandle = SSTV_ENABLE_DIAGNOSTICS_HANDLE,
lineRecoveryStrategy = SSTV_LINE_RECOVERY_STRATEGY
)
sstvDecoder = decoder sstvDecoder = decoder
decoder.lockMode(_uiState.value.sstv.selectedMode) decoder.lockMode(_uiState.value.sstv.selectedMode)
_uiState.update { it.copy(sstv = it.sstv.copy(supportedModes = decoder.supportedModes)) } _uiState.update { it.copy(sstv = it.sstv.copy(supportedModes = decoder.supportedModes)) }
viewModelScope.launch { viewModelScope.launch {
decoder.frames.collect { frame -> decoder.frames.collect { frame ->
_uiState.update { it.copy(sstv = it.sstv.copy(currentFrame = frame)) } val metrics = decoder.getQualityMetrics()
_uiState.update {
it.copy(sstv = it.sstv.copy(currentFrame = frame, diagnosticsMetrics = metrics))
}
}
}
decoder.getDiagnosticsHandle()?.let { handle ->
viewModelScope.launch {
handle.metrics.collectLatest { metrics ->
_uiState.update { it.copy(sstv = it.sstv.copy(diagnosticsMetrics = metrics)) }
}
} }
} }
} }
@@ -374,6 +393,39 @@ class RadarViewModel(
} }
companion object { companion object {
// SSTV Decoder Tuning Parameters
// ==============================
// SSTV_TARGET_RMS: Normalized signal level before FM demodulation.
// - 0.25: Aggressive (original default), amplifies noise; use only for clean inputs
// - 0.35-0.40: RECOMMENDED for typical phone/mic inputs
// - 0.50-0.60: Conservative, best for noisy environments
private const val SSTV_TARGET_RMS = 0.40f // Changed from 0.25 to safer default
// Enable/disable RMS normalization entirely. Set false for direct receiver outputs.
private const val SSTV_ENABLE_RMS_NORMALIZATION = true
// High-pass pre-filter to remove DC offset and subsonic noise before RMS normalization.
// Improves weak signal robustness by cleaning the noise floor estimate.
private const val SSTV_PREFILTER_CUTOFF_HZ = 500.0 // Removes everything below 500 Hz
// Enable pre-filtering. Set false to skip (minimal CPU cost if disabled).
private const val SSTV_ENABLE_PREFILTER = true
// Diagnostics handle can be enabled temporarily to capture field reports.
// Interpretation notes for noisy recordings:
// - syncHitRate < 0.30: weak/noisy signal path or mistuned gain
// - predictedLineBursts rising quickly: frequent sync loss and likely vertical collapse
// - maxPredictedStreak > 3 in Robot36 mode: noisy path and synthetic line flooding
private const val SSTV_ENABLE_DIAGNOSTICS_HANDLE = false
// Line recovery policy:
// - Look4SatLimited: caps predicted lines to reduce visible vertical collapse.
// - Robot36Compatible: unlimited predicted lines (legacy Robot36 behavior).
// Ask users to compare both if they report line-skipping/compression artifacts.
private val SSTV_LINE_RECOVERY_STRATEGY = LineRecoveryStrategy.Look4SatLimited
// Debug: Return scope visualization in frames (increases per-frame memory usage).
private const val SSTV_INCLUDE_SCOPE = false
val CTCSS_TONES = listOf( val CTCSS_TONES = listOf(
67.0, 69.3, 71.9, 74.4, 77.0, 79.7, 82.5, 85.4, 88.5, 91.5, 67.0, 69.3, 71.9, 74.4, 77.0, 79.7, 82.5, 85.4, 88.5, 91.5,
@@ -58,6 +58,7 @@ import com.rtbishop.look4sat.core.presentation.IconCard
import com.rtbishop.look4sat.core.presentation.OutlinedText import com.rtbishop.look4sat.core.presentation.OutlinedText
import com.rtbishop.look4sat.core.presentation.R import com.rtbishop.look4sat.core.presentation.R
import com.rtbishop.look4sat.core.presentation.infiniteMarquee import com.rtbishop.look4sat.core.presentation.infiniteMarquee
import kotlin.math.roundToInt
@Composable @Composable
internal fun SstvPage( internal fun SstvPage(
@@ -181,6 +182,19 @@ internal fun SstvPage(
modifier = Modifier.align(Alignment.Center) modifier = Modifier.align(Alignment.Center)
) )
} }
val qualityText = frame?.let {
val syncPct = (it.syncHitRate * 100f).roundToInt()
val gainX10 = (it.appliedGain * 10f).roundToInt() / 10f
val inputRmsX1000 = (it.inputRms * 1000f).roundToInt() / 1000f
val scope = if (it.scopePixels != null) " | Scope ${it.scopeWidth}x${it.scopeHeight}" else ""
val complete = if (it.imageComplete) " | Complete" else ""
"RMS $inputRmsX1000 | Gain x$gainX10 | Sync $syncPct% | Pred ${it.predictedLineBursts}/${it.maxPredictedStreak} | Err ${it.timingErrorSamples}$scope$complete"
} ?: sstv.diagnosticsMetrics?.let {
val syncPct = (it.syncHitRate * 100f).roundToInt()
"Sync $syncPct% | Pred ${it.predictedLineBursts}/${it.maxPredictedStreak} | Err ${it.timingErrorSamples}"
}
// Bottom control bar — dark, blends with the black canvas // Bottom control bar — dark, blends with the black canvas
Column( Column(
modifier = Modifier modifier = Modifier
@@ -198,6 +212,15 @@ internal fun SstvPage(
outlineColor = MaterialTheme.colorScheme.background, outlineColor = MaterialTheme.colorScheme.background,
modifier = Modifier.align(Alignment.CenterHorizontally) modifier = Modifier.align(Alignment.CenterHorizontally)
) )
if (qualityText != null) {
OutlinedText(
text = qualityText,
fontSize = 12.sp,
fillColor = MaterialTheme.colorScheme.onSurface,
outlineColor = MaterialTheme.colorScheme.background,
modifier = Modifier.align(Alignment.CenterHorizontally)
)
}
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),