diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/sstv/SstvDecoder.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/sstv/SstvDecoder.kt index e69ddd97..5efdeabc 100644 --- a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/sstv/SstvDecoder.kt +++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/sstv/SstvDecoder.kt @@ -20,39 +20,94 @@ package com.rtbishop.look4sat.core.domain.sstv import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.withContext +import kotlin.math.PI import kotlin.math.abs import kotlin.math.round import kotlin.math.sqrt class SstvFrame( + val scopePixels: IntArray?, + val scopeWidth: Int, + val scopeHeight: Int, val imagePixels: IntArray?, val imageWidth: 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 ) class SstvDecoder( sampleRate: Int = 44100, scopeWidth: Int = 320, 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 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( replay = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST ) + private var lastInputRms = 0f + private var lastAppliedGain = 1f + private val _qualityMetrics = MutableStateFlow(null) + private val preFilter = if (enablePreFilter) HighPassFilter(preFilterCutoffHz, sampleRate.toDouble()) else null + private val diagnosticsHandle = SstvDiagnosticsHandle(enableDiagnosticsHandle, _qualityMetrics) val frames: SharedFlow = _frames val supportedModes: List = 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) + // Optional pre-filtering: remove DC offset and subsonic noise that can mask + // weak signals and corrupt the RMS normalization baseline. + 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) if (hasNewLines) emitFrame() } @@ -62,36 +117,134 @@ class SstvDecoder( fun clearPixels() { imageBuffer.line = -1 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() { val imageWidth = imageBuffer.width 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 // 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)) + 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 // 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 // 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 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 + 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 class SyncPulseDetector(sampleRate: Int) { @@ -197,7 +350,8 @@ internal class DecoderEngine( private val scopeBuffer: PixelBuffer, private val imageBuffer: PixelBuffer, rawName: String, - sampleRate: Int + sampleRate: Int, + lineRecoveryStrategy: LineRecoveryStrategy ) { private val pixelBuffer = PixelBuffer(800, 2) private val detector = SyncPulseDetector(sampleRate) @@ -237,6 +391,19 @@ internal class DecoderEngine( private var lastSync = 0 private var curLineSamples: Int 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 { imageBuffer.line = -1 @@ -286,6 +453,11 @@ internal class DecoderEngine( fun process(recordBuffer: FloatArray, channelSelect: Int): Boolean { var newLines = false val detected = detector.process(recordBuffer, channelSelect) + syncChecks++ + if (detected) { + syncHits++ + predictedStreak = 0 + } var syncIdx = sample + detector.pulseOffset val channels = if (channelSelect > 0) 2 else 1 for (j in 0 until recordBuffer.size / channels) { @@ -308,20 +480,10 @@ internal class DecoderEngine( } } } else if (handleHeader()) { + predictedStreak = 0 newLines = true } else if (sample > lastSync + (curLineSamples * 5) / 4) { - copyLines( - currentMode.decodeScanLine( - pixelBuffer, - scratch, - scanLineBuffer, - scopeBuffer.width, - lastSync, - curLineSamples, - lastOffset - ) - ) - lastSync += curLineSamples; newLines = true + newLines = decodePredictedLine() } return newLines } @@ -337,6 +499,25 @@ internal class DecoderEngine( 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 stdDev(a: IntArray, m: Double): Double { 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) } + 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) { repeat(count) { scopeBuffer.pixels.fill( @@ -542,6 +746,7 @@ internal class DecoderEngine( lineLen: IntArray, latest: Int ): Boolean { + predictedStreak = 0 for (i in 1 until syncPulses.size) syncPulses[i - 1] = syncPulses[i] syncPulses[syncPulses.size - 1] = latest for (i in 1 until lineLen.size) lineLen[i - 1] = lineLen[i] diff --git a/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarState.kt b/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarState.kt index bc91568e..bb5a0329 100644 --- a/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarState.kt +++ b/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarState.kt @@ -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.OrbitalPos import com.rtbishop.look4sat.core.domain.sstv.SstvFrame +import com.rtbishop.look4sat.core.domain.sstv.SstvQualityMetrics data class RadioPanelState( val label: String = "", @@ -72,7 +73,8 @@ data class SstvSubState( val hasPermission: Boolean = false, val selectedMode: String = "Auto", val supportedModes: List = emptyList(), - val currentFrame: SstvFrame? = null + val currentFrame: SstvFrame? = null, + val diagnosticsMetrics: SstvQualityMetrics? = null ) sealed interface RadarAction { diff --git a/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarViewModel.kt b/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarViewModel.kt index dfde1ee1..c013022f 100644 --- a/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarViewModel.kt +++ b/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarViewModel.kt @@ -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.ISensorsRepo 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.usecase.IAudioCapture import com.rtbishop.look4sat.core.domain.usecase.IAddToCalendar @@ -213,7 +214,6 @@ class RadarViewModel( override fun onCleared() { sensorsRepo.disableSensor() - super.onCleared() } fun onAction(action: RadarAction) { @@ -344,13 +344,32 @@ class RadarViewModel( private fun initSstvDecoder() { 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 decoder.lockMode(_uiState.value.sstv.selectedMode) _uiState.update { it.copy(sstv = it.sstv.copy(supportedModes = decoder.supportedModes)) } viewModelScope.launch { 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 { + // 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( 67.0, 69.3, 71.9, 74.4, 77.0, 79.7, 82.5, 85.4, 88.5, 91.5, diff --git a/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/SstvPage.kt b/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/SstvPage.kt index 36910d4b..9fa29a32 100644 --- a/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/SstvPage.kt +++ b/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/SstvPage.kt @@ -58,6 +58,7 @@ import com.rtbishop.look4sat.core.presentation.IconCard import com.rtbishop.look4sat.core.presentation.OutlinedText import com.rtbishop.look4sat.core.presentation.R import com.rtbishop.look4sat.core.presentation.infiniteMarquee +import kotlin.math.roundToInt @Composable internal fun SstvPage( @@ -181,6 +182,19 @@ internal fun SstvPage( 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 Column( modifier = Modifier @@ -198,6 +212,15 @@ internal fun SstvPage( outlineColor = MaterialTheme.colorScheme.background, 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( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp),