mirror of
https://github.com/rt-bishop/Look4Sat.git
synced 2026-10-02 03:15:37 +00:00
Added a few tweaks to SSTV sensitivity and reception
This commit is contained in:
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),
|
||||||
|
|||||||
Reference in new issue
Block a user