Added SSTV image decoding functionality to RadarScreen

This commit is contained in:
Arty Bishop committed 2026-06-02 20:06:22 +01:00
1 parent f7f5a73c4c
commit 79809aaec0
24 files changed
+1991 -470

No files matched your search

+1
View File
@@ -11,6 +11,7 @@
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" /> <uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" /> <uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<application <application
android:name=".MainApplication" android:name=".MainApplication"
+1
View File
@@ -11,5 +11,6 @@
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" /> <uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" /> <uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
</manifest> </manifest>
@@ -1,3 +1,20 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.data.injection package com.rtbishop.look4sat.core.data.injection
import android.bluetooth.BluetoothManager import android.bluetooth.BluetoothManager
@@ -20,6 +37,8 @@ import com.rtbishop.look4sat.core.data.repository.SettingsRepo
import com.rtbishop.look4sat.core.data.source.LocalSource import com.rtbishop.look4sat.core.data.source.LocalSource
import com.rtbishop.look4sat.core.data.source.RemoteSource import com.rtbishop.look4sat.core.data.source.RemoteSource
import com.rtbishop.look4sat.core.data.usecase.AddToCalendar import com.rtbishop.look4sat.core.data.usecase.AddToCalendar
import com.rtbishop.look4sat.core.data.usecase.AudioCapture
import com.rtbishop.look4sat.core.data.usecase.SaveImage
import com.rtbishop.look4sat.core.data.usecase.ShowToast import com.rtbishop.look4sat.core.data.usecase.ShowToast
import com.rtbishop.look4sat.core.domain.repository.IDatabaseRepo import com.rtbishop.look4sat.core.domain.repository.IDatabaseRepo
import com.rtbishop.look4sat.core.domain.repository.IMainContainer import com.rtbishop.look4sat.core.domain.repository.IMainContainer
@@ -33,6 +52,8 @@ import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
import com.rtbishop.look4sat.core.domain.source.ILocalSource import com.rtbishop.look4sat.core.domain.source.ILocalSource
import com.rtbishop.look4sat.core.domain.source.IRemoteSource import com.rtbishop.look4sat.core.domain.source.IRemoteSource
import com.rtbishop.look4sat.core.domain.usecase.IAddToCalendar import com.rtbishop.look4sat.core.domain.usecase.IAddToCalendar
import com.rtbishop.look4sat.core.domain.usecase.IAudioCapture
import com.rtbishop.look4sat.core.domain.usecase.ISaveImage
import com.rtbishop.look4sat.core.domain.usecase.IShowToast import com.rtbishop.look4sat.core.domain.usecase.IShowToast
import com.rtbishop.look4sat.core.domain.utility.DataParser import com.rtbishop.look4sat.core.domain.utility.DataParser
import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.CoroutineExceptionHandler
@@ -59,6 +80,10 @@ class MainContainer(private val context: Context) : IMainContainer {
override fun provideShowToast(): IShowToast = ShowToast(context) override fun provideShowToast(): IShowToast = ShowToast(context)
override fun provideAudioCapture(): IAudioCapture = AudioCapture()
override fun provideSaveImage(): ISaveImage = SaveImage(context)
override fun provideBluetoothReporter(): IReporter { override fun provideBluetoothReporter(): IReporter {
val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
val rc = settingsRepo.rcSettings.value val rc = settingsRepo.rcSettings.value
@@ -81,6 +81,7 @@ class SettingsRepo(
private val keyUpdateTimestamp = "updateTimestamp" private val keyUpdateTimestamp = "updateTimestamp"
private val keyShouldSeeWarning = "shouldSeeWarning" private val keyShouldSeeWarning = "shouldSeeWarning"
private val keyShouldSeeWhatsNew = "shouldSeeWhatsNew_v$appVersionName" private val keyShouldSeeWhatsNew = "shouldSeeWhatsNew_v$appVersionName"
private val keySstvMode = "sstvMode"
private val keyUseCustomTle = "useCustomTle" private val keyUseCustomTle = "useCustomTle"
private val keyUseCustomTransceivers = "useCustomTransceivers" private val keyUseCustomTransceivers = "useCustomTransceivers"
private val keyTleUrl = "tleUrl" private val keyTleUrl = "tleUrl"
@@ -333,6 +334,7 @@ class SettingsRepo(
putBoolean(keyStateOfNightMode, new.stateOfNightMode) putBoolean(keyStateOfNightMode, new.stateOfNightMode)
putBoolean(keyShouldSeeWarning, new.shouldSeeWarning) putBoolean(keyShouldSeeWarning, new.shouldSeeWarning)
putBoolean(keyShouldSeeWhatsNew, new.shouldSeeWhatsNew) putBoolean(keyShouldSeeWhatsNew, new.shouldSeeWhatsNew)
putString(keySstvMode, new.sstvMode)
} }
new new
} }
@@ -346,7 +348,8 @@ class SettingsRepo(
stateOfLightTheme = preferences.getBoolean(keyStateOfLightTheme, false), stateOfLightTheme = preferences.getBoolean(keyStateOfLightTheme, false),
stateOfNightMode = preferences.getBoolean(keyStateOfNightMode, false), stateOfNightMode = preferences.getBoolean(keyStateOfNightMode, false),
shouldSeeWarning = preferences.getBoolean(keyShouldSeeWarning, true), shouldSeeWarning = preferences.getBoolean(keyShouldSeeWarning, true),
shouldSeeWhatsNew = preferences.getBoolean(keyShouldSeeWhatsNew, true) shouldSeeWhatsNew = preferences.getBoolean(keyShouldSeeWhatsNew, true),
sstvMode = preferences.getString(keySstvMode, null) ?: "Auto"
) )
//endregion //endregion
@@ -0,0 +1,63 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.data.usecase
import android.media.AudioFormat
import android.media.AudioRecord
import android.media.MediaRecorder
import androidx.annotation.RequiresPermission
import com.rtbishop.look4sat.core.domain.usecase.IAudioCapture
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.isActive
class AudioCapture : IAudioCapture {
override val sampleRate: Int = 44100
private val channelConfig = AudioFormat.CHANNEL_IN_MONO
private val audioFormat = AudioFormat.ENCODING_PCM_FLOAT
private val bufferSize = AudioRecord.getMinBufferSize(sampleRate, channelConfig, audioFormat)
.coerceAtLeast(sampleRate) // at least 1 second buffer
@RequiresPermission(android.Manifest.permission.RECORD_AUDIO)
override fun audioFlow(): Flow<FloatArray> = flow {
val recorder = AudioRecord(
MediaRecorder.AudioSource.MIC,
sampleRate,
channelConfig,
audioFormat,
bufferSize * 4 // bytes for float
)
try {
recorder.startRecording()
val chunkSize = sampleRate / 10 // ~100ms chunks
val buffer = FloatArray(chunkSize)
while (currentCoroutineContext().isActive) {
val read = recorder.read(buffer, 0, chunkSize, AudioRecord.READ_BLOCKING)
if (read > 0) emit(if (read == chunkSize) buffer.copyOf() else buffer.copyOfRange(0, read))
}
} finally {
recorder.stop()
recorder.release()
}
}.flowOn(Dispatchers.IO)
}
@@ -0,0 +1,84 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.data.usecase
import android.content.ContentValues
import android.content.Context
import android.graphics.Bitmap
import android.os.Build
import android.os.Environment
import android.provider.MediaStore
import com.rtbishop.look4sat.core.domain.usecase.ISaveImage
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.File
import java.io.FileOutputStream
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
//Saves images to the device gallery using MediaStore (API 29+) or direct file write (API < 29)
class SaveImage(private val context: Context) : ISaveImage {
override suspend fun invoke(pixels: IntArray, width: Int, height: Int, modeName: String): Boolean {
return withContext(Dispatchers.IO) {
try {
val bitmap = Bitmap.createBitmap(pixels, width, height, Bitmap.Config.ARGB_8888)
val timestamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date())
val filename = "SSTV_${modeName}_$timestamp.png"
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
saveWithMediaStore(bitmap, filename)
} else {
saveToExternalStorage(bitmap, filename)
}
bitmap.recycle()
true
} catch (_: Exception) {
false
}
}
}
private fun saveWithMediaStore(bitmap: Bitmap, filename: String) {
val values = ContentValues().apply {
put(MediaStore.Images.Media.DISPLAY_NAME, filename)
put(MediaStore.Images.Media.MIME_TYPE, "image/png")
put(MediaStore.Images.Media.RELATIVE_PATH, "${Environment.DIRECTORY_PICTURES}/Look4Sat")
put(MediaStore.Images.Media.IS_PENDING, 1)
}
val resolver = context.contentResolver
val uri = resolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values)
?: throw IllegalStateException("Failed to create MediaStore entry")
resolver.openOutputStream(uri)?.use { stream ->
bitmap.compress(Bitmap.CompressFormat.PNG, 100, stream)
}
values.clear()
values.put(MediaStore.Images.Media.IS_PENDING, 0)
resolver.update(uri, values, null, null)
}
private fun saveToExternalStorage(bitmap: Bitmap, filename: String) {
val picturesDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES)
val dir = File(picturesDir, "Look4Sat")
if (!dir.exists()) dir.mkdirs()
val file = File(dir, filename)
FileOutputStream(file).use { stream ->
bitmap.compress(Bitmap.CompressFormat.PNG, 100, stream)
}
}
}
@@ -56,7 +56,8 @@ data class OtherSettings(
val stateOfLightTheme: Boolean, val stateOfLightTheme: Boolean,
val stateOfNightMode: Boolean = false, val stateOfNightMode: Boolean = false,
val shouldSeeWarning: Boolean, val shouldSeeWarning: Boolean,
val shouldSeeWhatsNew: Boolean val shouldSeeWhatsNew: Boolean,
val sstvMode: String = "Auto"
) )
data class DataSourcesSettings( data class DataSourcesSettings(
@@ -1,6 +1,25 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.domain.repository package com.rtbishop.look4sat.core.domain.repository
import com.rtbishop.look4sat.core.domain.usecase.IAddToCalendar import com.rtbishop.look4sat.core.domain.usecase.IAddToCalendar
import com.rtbishop.look4sat.core.domain.usecase.IAudioCapture
import com.rtbishop.look4sat.core.domain.usecase.ISaveImage
import com.rtbishop.look4sat.core.domain.usecase.IShowToast import com.rtbishop.look4sat.core.domain.usecase.IShowToast
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@@ -10,6 +29,7 @@ interface IMainContainer {
val selectionRepo: ISelectionRepo val selectionRepo: ISelectionRepo
val satelliteRepo: ISatelliteRepo val satelliteRepo: ISatelliteRepo
val databaseRepo: IDatabaseRepo val databaseRepo: IDatabaseRepo
val radioTrackingService: IRadioTrackingService
fun provideAddToCalendar(): IAddToCalendar fun provideAddToCalendar(): IAddToCalendar
fun provideShowToast(): IShowToast fun provideShowToast(): IShowToast
fun provideBluetoothReporter(): IReporter fun provideBluetoothReporter(): IReporter
@@ -17,7 +37,8 @@ interface IMainContainer {
fun provideSensorsRepo(): ISensorsRepo fun provideSensorsRepo(): ISensorsRepo
fun provideTxRadioController(): IRadioController fun provideTxRadioController(): IRadioController
fun provideRxRadioController(): IRadioController fun provideRxRadioController(): IRadioController
val radioTrackingService: IRadioTrackingService fun provideAudioCapture(): IAudioCapture
fun provideSaveImage(): ISaveImage
} }
interface IContainerProvider { interface IContainerProvider {
@@ -0,0 +1,573 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
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.SharedFlow
import kotlinx.coroutines.withContext
import kotlin.math.abs
import kotlin.math.round
import kotlin.math.sqrt
class SstvFrame(
val imagePixels: IntArray?,
val imageWidth: Int,
val imageHeight: Int,
val modeName: String
)
class SstvDecoder(
sampleRate: Int = 44100,
scopeWidth: Int = 320,
scopeHeight: Int = 256,
private val channelSelect: Int = 0
) {
private val scopeBuffer = PixelBuffer(scopeWidth, scopeHeight * 2)
private val imageBuffer = PixelBuffer(scopeWidth, scopeHeight)
private val decoder = DecoderEngine(scopeBuffer, imageBuffer, "Raw", sampleRate)
private val _frames = MutableSharedFlow<SstvFrame>(
replay = 1,
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 }
}
suspend fun feedSamples(samples: FloatArray) = withContext(Dispatchers.Default) {
val hasNewLines = decoder.process(samples, channelSelect)
if (hasNewLines) emitFrame()
}
fun lockMode(modeName: String) = decoder.setMode(modeName)
fun clearPixels() {
imageBuffer.line = -1
imageBuffer.pixels.fill(0)
}
private fun emitFrame() {
val imagePixels = if (imageBuffer.line > 0) imageBuffer.pixels.copyOf() else null
val imageWidth = imageBuffer.width
val imageHeight = imageBuffer.height
val modeName = decoder.currentMode.name
_frames.tryEmit(SstvFrame(imagePixels, imageWidth, imageHeight, modeName))
}
}
internal enum class SyncPulseWidth { FiveMs, NineMs, TwentyMs }
internal class SyncPulseDetector(sampleRate: Int) {
companion object {
const val SYNC_FREQ = 1200.0
const val BLACK_FREQ = 1500.0
const val WHITE_FREQ = 2300.0
}
var detectedWidth: SyncPulseWidth = SyncPulseWidth.NineMs; private set
var pulseOffset: Int = 0; private set
var freqOffset: Float = 0f; private set
private val bandwidth = WHITE_FREQ - BLACK_FREQ
private val fm = FmDemodulator(bandwidth, sampleRate.toDouble())
private val min5ms: Int = round(0.0025 * sampleRate).toInt()
private val max5ms: Int = round(0.007 * sampleRate).toInt()
private val max9ms: Int = round(0.0145 * sampleRate).toInt()
private val max20ms: Int = round(0.025 * sampleRate).toInt()
private val filterDelay: Int
private val avgFilter: MovingAverage
private val delayLine: Delay
private val lowPass: ComplexFirFilter
private val oscillator: Phasor
private val syncFreqValue: Float
private val syncFreqTolerance: Float
private val trigger: SchmittTrigger
private var counter = 0
private var baseBand = Complex()
init {
val filterLen = round(0.0025 * sampleRate).toInt() or 1
filterDelay = (filterLen - 1) / 2
avgFilter = MovingAverage(filterLen)
delayLine = Delay(filterLen)
val loFreq = 1000.0
val hiFreq = 2800.0
val cutoff = (hiFreq - loFreq) / 2
val lpLen = round(0.002 * sampleRate).toInt() or 1
lowPass = ComplexFirFilter(lpLen)
for (i in 0 until lpLen)
lowPass.taps[i] = (WindowFunctions.kaiser(2.0, i, lpLen) * WindowFunctions.sinc(
cutoff,
sampleRate.toDouble(),
i,
lpLen
)).toFloat()
val center = (loFreq + hiFreq) / 2
oscillator = Phasor(-center, sampleRate.toDouble())
syncFreqValue = ((SYNC_FREQ - center) * 2 / bandwidth).toFloat()
syncFreqTolerance = (50 * 2 / bandwidth).toFloat()
val porchFreq = 1500.0
val hiThresh = (SYNC_FREQ + porchFreq) / 2
val loThresh = (SYNC_FREQ + hiThresh) / 2
trigger = SchmittTrigger(
((loThresh - center) * 2 / bandwidth).toFloat(),
((hiThresh - center) * 2 / bandwidth).toFloat()
)
}
fun process(buffer: FloatArray, channelSelect: Int): Boolean {
var detected = false
val channels = if (channelSelect > 0) 2 else 1
for (i in 0 until buffer.size / channels) {
when (channelSelect) {
1 -> baseBand.set(buffer[2 * i])
2 -> baseBand.set(buffer[2 * i + 1])
3 -> baseBand.set(buffer[2 * i] + buffer[2 * i + 1])
4 -> baseBand.set(buffer[2 * i], buffer[2 * i + 1])
else -> baseBand.set(buffer[i])
}
baseBand = lowPass.filter(baseBand.mul(oscillator.rotate()))
val freq = fm.demodulate(baseBand)
val avg = avgFilter.avg(freq)
val delayed = delayLine.push(avg)
buffer[i] = freq
if (!trigger.process(avg)) {
++counter
} else if (counter !in min5ms..max20ms || abs(delayed - syncFreqValue) > syncFreqTolerance) {
counter = 0
} else {
detectedWidth = when {
counter < max5ms -> SyncPulseWidth.FiveMs
counter < max9ms -> SyncPulseWidth.NineMs
else -> SyncPulseWidth.TwentyMs
}
pulseOffset = i - filterDelay
freqOffset = delayed - syncFreqValue
detected = true
counter = 0
}
}
return detected
}
}
internal class DecoderEngine(
private val scopeBuffer: PixelBuffer,
private val imageBuffer: PixelBuffer,
rawName: String,
sampleRate: Int
) {
private val pixelBuffer = PixelBuffer(800, 2)
private val detector = SyncPulseDetector(sampleRate)
private val pulseFilter: MovingAverage
private val pulseFilterDelay: Int
private val scanLineBuffer: FloatArray
private val scratch: FloatArray
private val sync5ms = IntArray(5)
private val sync9ms = IntArray(5)
private val sync20ms = IntArray(5)
private val lines5ms = IntArray(4)
private val lines9ms = IntArray(4)
private val lines20ms = IntArray(4)
private val offsets5ms = FloatArray(5)
private val offsets9ms = FloatArray(5)
private val offsets20ms = FloatArray(5)
private val visFreqs = FloatArray(10)
private val scanLineMin: Int
private val syncTolerance: Int
private val lineTolerance: Int
private val leaderLen: Int
private val leaderTol: Int
private val transition: Int
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>
var currentMode: SstvMode; private set
val allModes: List<SstvMode> get() = modes5ms + modes9ms + modes20ms
private var lockMode = false
private var sample = 0
private var leaderBreak = 0
private var lastSync = 0
private var curLineSamples: Int
private var lastOffset = 0f
init {
imageBuffer.line = -1
val pfLen = round(0.0025 * sampleRate).toInt() or 1
pulseFilterDelay = (pfLen - 1) / 2
pulseFilter = MovingAverage(pfLen)
scanLineBuffer = FloatArray(round(7.0 * sampleRate).toInt())
scratch = FloatArray(round(1.1 * sampleRate).toInt())
leaderLen = round(0.3 * sampleRate).toInt()
leaderTol = round(0.06 * sampleRate).toInt()
transition = round(0.0005 * sampleRate).toInt()
visBitLen = round(0.03 * sampleRate).toInt()
visLen = round(0.3 * sampleRate).toInt()
scanLineMin = round(0.05 * sampleRate).toInt()
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
modes5ms = arrayListOf(
RgbMode.wraaseSc2180(sampleRate),
RgbMode.martin("1", 44, 0.146432, sampleRate),
RgbMode.martin("2", 40, 0.073216, sampleRate)
)
modes9ms = arrayListOf(
robot36, Robot72Mode(sampleRate),
RgbMode.scottie("1", 60, 0.138240, sampleRate),
RgbMode.scottie("2", 56, 0.088064, sampleRate),
RgbMode.scottie("DX", 76, 0.3456, sampleRate)
)
modes20ms = arrayListOf(
PdMode("50", 93, 320, 256, 0.09152, sampleRate),
PdMode("90", 99, 320, 256, 0.17024, sampleRate),
PdMode("120", 95, 640, 496, 0.1216, sampleRate),
PdMode("160", 98, 512, 400, 0.195584, sampleRate),
PdMode("180", 96, 640, 496, 0.18304, sampleRate),
PdMode("240", 97, 640, 496, 0.24448, sampleRate),
PdMode("290", 94, 800, 616, 0.2288, sampleRate)
)
}
fun process(recordBuffer: FloatArray, channelSelect: Int): Boolean {
var newLines = false
val detected = detector.process(recordBuffer, channelSelect)
var syncIdx = sample + detector.pulseOffset
val channels = if (channelSelect > 0) 2 else 1
for (j in 0 until recordBuffer.size / channels) {
if (sample >= scanLineBuffer.size) {
shift(curLineSamples)
syncIdx -= curLineSamples
if (sample >= scanLineBuffer.size) sample = scanLineBuffer.size - 1
}
scanLineBuffer[sample++] = recordBuffer[j]
}
if (detected) {
when (detector.detectedWidth) {
SyncPulseWidth.FiveMs -> newLines = processPulse(modes5ms, offsets5ms, sync5ms, lines5ms, syncIdx)
SyncPulseWidth.NineMs -> {
leaderBreak = syncIdx; newLines = processPulse(modes9ms, offsets9ms, sync9ms, lines9ms, syncIdx)
}
SyncPulseWidth.TwentyMs -> {
leaderBreak = syncIdx; newLines = processPulse(modes20ms, offsets20ms, sync20ms, lines20ms, syncIdx)
}
}
} else if (handleHeader()) {
newLines = true
} else if (sample > lastSync + (curLineSamples * 5) / 4) {
copyLines(
currentMode.decodeScanLine(
pixelBuffer,
scratch,
scanLineBuffer,
scopeBuffer.width,
lastSync,
curLineSamples,
lastOffset
)
)
lastSync += curLineSamples; newLines = true
}
return newLines
}
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
if (mode == currentMode) {
lockMode = true; return
}
if (mode != null) {
lockMode = true; imageBuffer.line = -1; currentMode = mode; curLineSamples = mode.scanLineSamples; return
}
lockMode = false
}
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)
}
private fun meanF(a: FloatArray): Float {
var s = 0f; for (v in a) s += v; return s / a.size
}
private fun detectMode(modes: ArrayList<SstvMode>, samples: Int): SstvMode {
var best: SstvMode = rawMode
var bestD = Int.MAX_VALUE
for (m in modes) {
val d = abs(samples - m.scanLineSamples); if (d <= lineTolerance && d < bestD) {
bestD = d; best = m
}
}
return best
}
private fun copyUnscaled() {
val w = minOf(scopeBuffer.width, pixelBuffer.width)
for (row in 0 until pixelBuffer.height) {
val line = scopeBuffer.width * scopeBuffer.line
pixelBuffer.pixels.copyInto(scopeBuffer.pixels, line, row * pixelBuffer.width, row * pixelBuffer.width + w)
scopeBuffer.pixels.fill(0, line + w, line + scopeBuffer.width)
scopeBuffer.pixels.copyInto(
scopeBuffer.pixels,
scopeBuffer.width * (scopeBuffer.line + scopeBuffer.height / 2),
line,
line + scopeBuffer.width
)
scopeBuffer.line = (scopeBuffer.line + 1) % (scopeBuffer.height / 2)
}
}
private fun copyScaled(scale: Int) {
for (row in 0 until pixelBuffer.height) {
val line = scopeBuffer.width * scopeBuffer.line
for (col in 0 until pixelBuffer.width) for (i in 0 until scale) scopeBuffer.pixels[line + col * scale + i] =
pixelBuffer.pixels[pixelBuffer.width * row + col]
scopeBuffer.pixels.fill(0, line + pixelBuffer.width * scale, line + scopeBuffer.width)
scopeBuffer.pixels.copyInto(
scopeBuffer.pixels,
scopeBuffer.width * (scopeBuffer.line + scopeBuffer.height / 2),
line,
line + scopeBuffer.width
)
scopeBuffer.line = (scopeBuffer.line + 1) % (scopeBuffer.height / 2)
repeat(scale - 1) {
scopeBuffer.pixels.copyInto(
scopeBuffer.pixels, scopeBuffer.width * scopeBuffer.line, line, line + scopeBuffer.width
)
scopeBuffer.pixels.copyInto(
scopeBuffer.pixels,
scopeBuffer.width * (scopeBuffer.line + scopeBuffer.height / 2),
line,
line + scopeBuffer.width
)
scopeBuffer.line = (scopeBuffer.line + 1) % (scopeBuffer.height / 2)
}
}
}
private fun copyLines(ok: Boolean) {
if (!ok) return
var finish = false
if (imageBuffer.line in 0 until imageBuffer.height && imageBuffer.width == pixelBuffer.width) {
val w = imageBuffer.width
for (row in 0 until pixelBuffer.height) {
if (imageBuffer.line >= imageBuffer.height) break
pixelBuffer.pixels.copyInto(imageBuffer.pixels, imageBuffer.line * w, row * w, row * w + w)
imageBuffer.line++
}
finish = imageBuffer.line == imageBuffer.height
}
val scale = scopeBuffer.width / pixelBuffer.width
if (scale <= 1) copyUnscaled() else copyScaled(scale)
if (finish) drawLines(0xff000000.toInt(), 10)
}
private fun drawLines(color: Int, count: Int) {
repeat(count) {
scopeBuffer.pixels.fill(
color,
scopeBuffer.line * scopeBuffer.width,
(scopeBuffer.line + 1) * scopeBuffer.width
)
scopeBuffer.pixels.fill(
color,
(scopeBuffer.line + scopeBuffer.height / 2) * scopeBuffer.width,
(scopeBuffer.line + 1 + scopeBuffer.height / 2) * scopeBuffer.width
)
scopeBuffer.line = (scopeBuffer.line + 1) % (scopeBuffer.height / 2)
}
}
private fun adjust(pulses: IntArray, shift: Int) {
for (i in pulses.indices) pulses[i] -= shift
}
private fun shift(amount: Int) {
if ((amount <= 0) || (amount > sample)) return
sample -= amount; leaderBreak -= amount; lastSync -= amount
adjust(sync5ms, amount); adjust(sync9ms, amount); adjust(sync20ms, amount)
scanLineBuffer.copyInto(scanLineBuffer, 0, amount, amount + sample)
}
private fun handleHeader(): Boolean {
if (leaderBreak < visBitLen + leaderTol || sample < leaderBreak + leaderLen + leaderTol + visLen + visBitLen) return false
val bp = leaderBreak; leaderBreak = 0
var preFreq = 0f
for (i in 0 until leaderTol) preFreq += scanLineBuffer[bp - visBitLen - leaderTol + i]
val toneFreq = 1900f
val center = 1900f
val tol = 50f
val halfBw = 400f
preFreq = preFreq * halfBw / leaderTol + center
if (abs(preFreq - toneFreq) > tol) return false
var ldrFreq = 0f
for (i in transition until leaderLen - leaderTol) ldrFreq += scanLineBuffer[bp + i]
val ldrOffset = ldrFreq / (leaderLen - transition - leaderTol)
ldrFreq = ldrOffset * halfBw + center
if (abs(ldrFreq - toneFreq) > tol) return false
val stopFreq = 1200f
val pulseThr = ((stopFreq + toneFreq) / 2 - center) / halfBw
var vBegin = bp + leaderLen - leaderTol
val vEnd = bp + leaderLen + leaderTol + visBitLen
repeat(pulseFilter.length) { pulseFilter.avg(scanLineBuffer[vBegin++] - ldrOffset) }
while (++vBegin < vEnd) if (pulseFilter.avg(scanLineBuffer[vBegin] - ldrOffset) < pulseThr) break
if (vBegin >= vEnd) return false
vBegin -= pulseFilterDelay
val visEnd = vBegin + visLen
visFreqs.fill(0f)
for (j in 0 until 10) for (i in transition until visBitLen - transition) visFreqs[j] += scanLineBuffer[vBegin + visBitLen * j + i] - ldrOffset
for (i in 0 until 10) visFreqs[i] = visFreqs[i] * halfBw / (visBitLen - 2 * transition) + center
if (abs(visFreqs[0] - stopFreq) > tol || abs(visFreqs[9] - stopFreq) > tol) return false
for (i in 1 until 9) if (abs(visFreqs[i] - 1100f) > tol && abs(visFreqs[i] - 1300f) > tol) return false
var vis = 0
for (i in 0 until 8) vis = vis or ((if (visFreqs[i + 1] < stopFreq) 1 else 0) shl i)
var chk = true; for (i in 0 until 8) chk = chk xor ((vis and (1 shl i)) != 0)
vis = vis and 127; if (!chk) return false
val syncThr = ((1200f + 1500f) / 2 - center) / halfBw
var sIdx = visEnd - visBitLen
val sMax = visEnd + visBitLen
repeat(pulseFilter.length) { pulseFilter.avg(scanLineBuffer[sIdx++] - ldrOffset) }
while (++sIdx < sMax) if (pulseFilter.avg(scanLineBuffer[sIdx] - ldrOffset) > syncThr) break
if (sIdx >= sMax) return false
sIdx -= pulseFilterDelay
val mode: SstvMode
val pulses: IntArray
val lines: IntArray
val f5 = modes5ms.firstOrNull { it.visCode == vis }
val f9 = modes9ms.firstOrNull { it.visCode == vis }
val f20 = modes20ms.firstOrNull { it.visCode == vis }
when {
f5 != null -> {
mode = f5; pulses = sync5ms; lines = lines5ms
}
f9 != null -> {
mode = f9; pulses = sync9ms; lines = lines9ms
}
f20 != null -> {
mode = f20; pulses = sync20ms; lines = lines20ms
}
else -> {
if (!lockMode) drawLines(0xffff0000.toInt(), 8); return false
}
}
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
currentMode = mode
lastSync = sIdx + mode.firstSyncPulseIndex; curLineSamples = mode.scanLineSamples; lastOffset = ldrOffset
var oldest = lastSync - (pulses.size - 1) * curLineSamples
if (mode.firstSyncPulseIndex > 0) oldest -= curLineSamples
for (i in pulses.indices) pulses[i] = oldest + i * curLineSamples
lines.fill(curLineSamples)
shift(lastSync + mode.firstPixelSampleIndex)
drawLines(0xff00ff00.toInt(), 8); drawLines(0xff000000.toInt(), 10)
return true
}
private fun processPulse(
modes: ArrayList<SstvMode>,
freqOffs: FloatArray,
syncPulses: IntArray,
lineLen: IntArray,
latest: Int
): Boolean {
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]
lineLen[lineLen.size - 1] = syncPulses.last() - syncPulses[syncPulses.size - 2]
for (i in 1 until freqOffs.size) freqOffs[i - 1] = freqOffs[i]
freqOffs[freqOffs.size - 1] = detector.freqOffset
if (lineLen[0] == 0) return false
val m = mean(lineLen)
val lineSamples = round(m).toInt()
if (lineSamples < scanLineMin || lineSamples > scratch.size) return false
if (stdDev(lineLen, m) > lineTolerance) return false
var changed = false
if (lockMode || imageBuffer.line in 0 until imageBuffer.height) {
if (currentMode != rawMode && abs(lineSamples - currentMode.scanLineSamples) > lineTolerance) return false
} else {
val prev = currentMode; currentMode = detectMode(modes, lineSamples)
changed =
currentMode != prev || abs(curLineSamples - lineSamples) > lineTolerance || abs(lastSync + lineSamples - syncPulses.last()) > syncTolerance
}
if (changed) {
drawLines(0xff000000.toInt(), 10); drawLines(0xff00ffff.toInt(), 8); drawLines(0xff000000.toInt(), 10)
}
val offset = meanF(freqOffs)
if (syncPulses[0] >= lineSamples && changed) {
val end = syncPulses[0]
val extra = end / lineSamples
val first = end - extra * lineSamples
var p = first; while (p < end) {
copyLines(
currentMode.decodeScanLine(
pixelBuffer,
scratch,
scanLineBuffer,
scopeBuffer.width,
p,
lineSamples,
offset
)
); p += lineSamples
}
}
val start = if (changed) 0 else lineLen.size - 1
for (i in start until lineLen.size) copyLines(
currentMode.decodeScanLine(
pixelBuffer,
scratch,
scanLineBuffer,
scopeBuffer.width,
syncPulses[i],
lineLen[i],
offset
)
)
lastSync = syncPulses.last(); curLineSamples = lineSamples; lastOffset = offset
shift(lastSync + currentMode.firstPixelSampleIndex)
return true
}
}
@@ -0,0 +1,216 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
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
import kotlin.math.sin
import kotlin.math.sqrt
internal class Complex(var real: Float = 0f, var imag: Float = 0f) {
fun set(real: Float, imag: Float): Complex {
this.real = real; this.imag = imag; return this
}
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
imag = real * other.imag + imag * other.real
real = tmp
return this
}
fun div(value: Float): Complex {
real /= value; imag /= value; return this
}
}
internal object WindowFunctions {
fun sinc(cutoff: Double, rate: Double, n: Int, nN: Int): Double {
val f = 2 * cutoff / rate
val x = n - (nN - 1) / 2.0
val fx = f * x
return if (fx == 0.0) f else f * sin(PI * fx) / (PI * fx)
}
fun kaiser(a: Double, n: Int, nN: Int): Double {
fun square(v: Double) = v * v
fun i0(x: Double): Double {
val terms = DoubleArray(35)
terms[0] = 1.0
var v = 1.0
for (m in 1 until 35) {
v *= x / (2 * m); terms[m] = square(v)
}
terms.sort()
var sum = 0.0; for (m in 34 downTo 0) sum += terms[m]
return sum
}
return i0(PI * a * sqrt(1 - square((2.0 * n) / (nN - 1) - 1))) / i0(PI * a)
}
}
internal open class MovingSum(val length: Int) {
private val tree = FloatArray(2 * length)
private var leaf = length
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
}
fun sum(): Float = tree[1]
fun sum(input: Float): Float {
add(input); return sum()
}
}
internal class MovingAverage(length: Int) : MovingSum(length) {
fun avg(input: Float): Float = sum(input) / length
}
internal class Ema {
private var alpha: Float = 1f
private var prev: Float = 0f
fun process(input: Float): Float {
prev = prev * (1 - alpha) + alpha * input; return prev
}
fun setCutoff(freq: Double, rate: Double, order: Int = 1) {
alpha = computeAlpha(freq, rate, order)
}
fun reset() {
prev = 0f
}
companion object {
fun computeAlpha(freq: Double, rate: Double, order: Int = 1): Float {
val x = cos(2 * PI * (freq.coerceAtMost(rate * 0.499)) / rate)
val discriminant = (x * (x - 4) + 3).coerceAtLeast(0.0)
return (x - 1 + sqrt(discriminant)).coerceIn(0.0, 1.0).pow(1.0 / order).toFloat()
}
fun withCutoff(freq: Double, rate: Double, order: Int = 1): Ema {
return Ema().also { it.setCutoff(freq, rate, order) }
}
}
}
internal class Phasor(freq: Double, rate: Double) {
private val value = Complex(1f, 0f)
private val delta: Complex = run {
val omega = 2 * PI * freq / rate
Complex(cos(omega).toFloat(), sin(omega).toFloat())
}
fun rotate(): Complex = value.div(value.mul(delta).abs())
}
internal class FmDemodulator(bandwidth: Double, sampleRate: Double) {
private val scale = (sampleRate / (bandwidth * PI)).toFloat()
private val pi = PI.toFloat()
private val twoPi = (2 * PI).toFloat()
private var prev = 0f
fun demodulate(input: Complex): Float {
val phase = input.arg()
var delta = phase - prev; prev = phase
if (delta < -pi) delta += twoPi else if (delta > pi) delta -= twoPi
return scale * delta
}
}
internal class ComplexFirFilter(val length: Int) {
private val real = FloatArray(length)
private val imag = FloatArray(length)
private val sum = Complex()
private var pos = 0
val taps = FloatArray(length)
fun filter(input: Complex): Complex {
real[pos] = input.real; imag[pos] = input.imag
if (++pos >= length) pos = 0
sum.real = 0f; sum.imag = 0f
for (tap in taps) {
sum.real += tap * real[pos]; sum.imag += tap * imag[pos]; if (++pos >= length) pos = 0
}
return sum
}
}
internal class Delay(val length: Int) {
private val buf = FloatArray(length)
private var pos = 0
fun push(input: Float): Float {
val tmp = buf[pos]; buf[pos] = input; if (++pos >= length) pos = 0; return tmp
}
}
internal class SchmittTrigger(private val low: Float, private val high: Float) {
private var state = false
fun process(input: Float): Boolean {
if (state) {
if (input < low) state = false
} else {
if (input > high) state = true
}
return state
}
}
internal object ColorConverter {
private fun clamp(v: Int) = v.coerceIn(0, 255)
private fun toInt(level: Float) = clamp(round(255 * level).toInt())
private fun compress(level: Float) = toInt(sqrt(level.coerceIn(0f, 1f)))
private fun yuv2rgb(yY: Int, uU: Int, vV: Int): Int {
val y = yY - 16
val u = uU - 128
val v = vV - 128
val r = clamp((298 * y + 409 * v + 128) shr 8)
val g = clamp((298 * y - 100 * u - 208 * v + 128) shr 8)
val b = clamp((298 * y + 516 * u + 128) shr 8)
return 0xff000000.toInt() or (r shl 16) or (g shl 8) or b
}
fun gray(level: Float): Int = 0xff000000.toInt() or (0x00010101 * compress(level))
fun rgb(r: Float, g: Float, b: Float): Int = 0xff000000.toInt() or (toInt(r) shl 16) or (toInt(g) shl 8) or toInt(b)
fun yuv2rgb(yY: Float, uU: Float, vV: Float): Int = yuv2rgb(toInt(yY), toInt(uU), toInt(vV))
fun yuv2rgb(packed: Int): Int = yuv2rgb((packed shr 16) and 0xff, (packed shr 8) and 0xff, packed and 0xff)
}
@@ -0,0 +1,474 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.domain.sstv
import kotlin.math.round
internal class PixelBuffer(var width: Int, var height: Int) {
var pixels = IntArray(width * height)
var line = 0
}
internal sealed interface SstvMode {
val name: String
val visCode: Int
val width: Int
val height: Int
val firstPixelSampleIndex: Int
val firstSyncPulseIndex: Int
val scanLineSamples: Int
fun resetState() {}
fun decodeScanLine(
pixelBuffer: PixelBuffer,
scratch: FloatArray,
scanLine: FloatArray,
scopeWidth: Int,
syncPulseIndex: Int,
lineSamples: Int,
freqOffset: Float
): Boolean
}
internal class RgbMode(
override val name: String,
override val visCode: Int,
private val hPixels: Int,
private val vPixels: Int,
override val firstSyncPulseIndex: Int,
override val scanLineSamples: Int,
override val firstPixelSampleIndex: Int,
private val redBegin: Int,
private val redLen: Int,
private val greenBegin: Int,
private val greenLen: Int,
private val blueBegin: Int,
private val blueLen: Int,
private val endSamples: Int,
) : SstvMode {
override val width get() = hPixels
override val height get() = vPixels
private val ema = Ema.withCutoff(hPixels.toDouble(), (2 * greenLen).toDouble(), 2)
override fun decodeScanLine(
pixelBuffer: PixelBuffer,
scratch: FloatArray,
scanLine: FloatArray,
scopeWidth: Int,
syncPulseIndex: Int,
lineSamples: Int,
freqOffset: Float
): Boolean {
val begin = firstPixelSampleIndex
if (syncPulseIndex + begin < 0 || syncPulseIndex + endSamples > scanLine.size) return false
ema.reset()
for (i in 0 until endSamples - begin) scratch[i] = ema.process(scanLine[syncPulseIndex + begin + i])
ema.reset()
for (i in endSamples - begin - 1 downTo 0) scratch[i] = freqToLevel(ema.process(scratch[i]), freqOffset)
for (i in 0 until hPixels) {
val r = redBegin + (i * redLen) / hPixels
val g = greenBegin + (i * greenLen) / hPixels
val b = blueBegin + (i * blueLen) / hPixels
pixelBuffer.pixels[i] = ColorConverter.rgb(scratch[r], scratch[g], scratch[b])
}
pixelBuffer.width = hPixels; pixelBuffer.height = 1
return true
}
companion object {
fun martin(variant: String, code: Int, channelSec: Double, sampleRate: Int): RgbMode {
val sync = 0.004862
val sep = 0.000572
val scanLine = sync + sep + 3 * (channelSec + sep)
val gEnd = sep + channelSec
val bBegin = gEnd + sep
val bEnd = bBegin + channelSec
val rBegin = bEnd + sep
val rEnd = rBegin + channelSec
return fromSeconds(
name = "Martin $variant",
code = code,
firstSyncSec = 0.0,
scanLineSec = scanLine,
beginSec = sep,
rBeginSec = rBegin,
rEndSec = rEnd,
gBeginSec = sep,
gEndSec = gEnd,
bBeginSec = bBegin,
bEndSec = bEnd,
endSec = rEnd,
sr = sampleRate
)
}
fun scottie(variant: String, code: Int, channelSec: Double, sampleRate: Int): RgbMode {
val sync = 0.009
val sep = 0.0015
val firstSync = sync + 2 * (sep + channelSec)
val scanLine = sync + 3 * (channelSec + sep)
val bEnd = -sync
val bBegin = bEnd - channelSec
val gEnd = bBegin - sep
val gBegin = gEnd - channelSec
val rEnd = sep + channelSec
return fromSeconds(
name = "Scottie $variant",
code = code,
firstSyncSec = firstSync,
scanLineSec = scanLine,
beginSec = gBegin,
rBeginSec = sep,
rEndSec = rEnd,
gBeginSec = gBegin,
gEndSec = gEnd,
bBeginSec = bBegin,
bEndSec = bEnd,
endSec = rEnd,
sr = sampleRate
)
}
fun wraaseSc2180(sampleRate: Int): RgbMode {
val sync = 0.0055225
val porch = 0.0005
val ch = 0.235
val scanLine = sync + porch + 3 * ch
val rEnd = porch + ch
val gEnd = rEnd + ch
val bEnd = gEnd + ch
return fromSeconds(
name = "Wraase SC2-180",
code = 55,
firstSyncSec = 0.0,
scanLineSec = scanLine,
beginSec = porch,
rBeginSec = porch,
rEndSec = rEnd,
gBeginSec = rEnd,
gEndSec = gEnd,
bBeginSec = gEnd,
bEndSec = bEnd,
endSec = bEnd,
sr = sampleRate
)
}
private fun fromSeconds(
name: String, code: Int, w: Int = 320, h: Int = 256,
firstSyncSec: Double, scanLineSec: Double, beginSec: Double,
rBeginSec: Double, rEndSec: Double, gBeginSec: Double, gEndSec: Double,
bBeginSec: Double, bEndSec: Double, endSec: Double, sr: Int
): RgbMode {
val begin = round(beginSec * sr).toInt()
return RgbMode(
name = name, visCode = code, hPixels = w, vPixels = h,
firstSyncPulseIndex = round(firstSyncSec * sr).toInt(),
scanLineSamples = round(scanLineSec * sr).toInt(),
firstPixelSampleIndex = begin,
redBegin = round(rBeginSec * sr).toInt() - begin,
redLen = round((rEndSec - rBeginSec) * sr).toInt(),
greenBegin = round(gBeginSec * sr).toInt() - begin,
greenLen = round((gEndSec - gBeginSec) * sr).toInt(),
blueBegin = round(bBeginSec * sr).toInt() - begin,
blueLen = round((bEndSec - bBeginSec) * sr).toInt(),
endSamples = round(endSec * sr).toInt()
)
}
}
}
internal class Robot36Mode(sampleRate: Int) : SstvMode {
override val name = "Robot 36 Color"
override val visCode = 8
override val width = 320
override val height = 240
override val firstSyncPulseIndex = 0
override val scanLineSamples: Int
override val firstPixelSampleIndex: Int
private val lumSamples: Int
private val sepSamples: Int
private val chromSamples: Int
private val lumBegin: Int
private val sepBegin: Int
private val chromBegin: Int
private val end: Int
private val ema: Ema
private var lastEven = false
init {
val syncPorch = 0.003
val lum = 0.088
val sep = 0.0045
val porch = 0.0015
val chrom = 0.044
scanLineSamples = round((0.009 + syncPorch + lum + sep + porch + chrom) * sampleRate).toInt()
lumSamples = round(lum * sampleRate).toInt(); sepSamples = round(sep * sampleRate).toInt()
chromSamples = round(chrom * sampleRate).toInt()
lumBegin = round(syncPorch * sampleRate).toInt(); firstPixelSampleIndex = lumBegin
sepBegin = round((syncPorch + lum) * sampleRate).toInt()
chromBegin = round((syncPorch + lum + sep + porch) * sampleRate).toInt()
end = round((syncPorch + lum + sep + porch + chrom) * sampleRate).toInt()
ema = Ema.withCutoff(width.toDouble(), (2 * lumSamples).toDouble(), 2)
}
override fun resetState() {
lastEven = false
}
override fun decodeScanLine(
pixelBuffer: PixelBuffer,
scratch: FloatArray,
scanLine: FloatArray,
scopeWidth: Int,
syncPulseIndex: Int,
lineSamples: Int,
freqOffset: Float
): Boolean {
if (syncPulseIndex + firstPixelSampleIndex < 0 || syncPulseIndex + end > scanLine.size) return false
var sep = 0f
for (i in 0 until sepSamples) sep += scanLine[syncPulseIndex + sepBegin + i]
sep = sep / sepSamples - freqOffset
var even = sep < 0
if (sep < -1.1f || (sep > -0.9f && sep < 0.9f) || sep > 1.1f) even = !lastEven
lastEven = even
ema.reset()
for (i in firstPixelSampleIndex until end) scratch[i] = ema.process(scanLine[syncPulseIndex + i])
ema.reset()
for (i in end - 1 downTo firstPixelSampleIndex) scratch[i] = freqToLevel(ema.process(scratch[i]), freqOffset)
for (i in 0 until width) {
val lPos = lumBegin + (i * lumSamples) / width
val cPos = chromBegin + (i * chromSamples) / width
if (even) {
pixelBuffer.pixels[i] = ColorConverter.rgb(scratch[lPos], 0f, scratch[cPos])
} else {
val evenYuv = pixelBuffer.pixels[i]
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] =
ColorConverter.yuv2rgb((oddYuv and 0x00ffff00) or (evenYuv and 0x000000ff))
}
}
pixelBuffer.width = width; pixelBuffer.height = 2
return !even
}
}
internal class Robot72Mode(sampleRate: Int) : SstvMode {
override val name = "Robot 72 Color"
override val visCode = 12
override val width = 320
override val height = 240
override val firstSyncPulseIndex = 0
override val scanLineSamples: Int
override val firstPixelSampleIndex: Int
private val lumSamples: Int
private val chromSamples: Int
private val yBegin: Int
private val vBegin: Int
private val uBegin: Int
private val end: Int
private val ema: Ema
init {
val syncPorch = 0.003
val lum = 0.138
val sep = 0.0045
val porch = 0.0015
val chrom = 0.069
scanLineSamples = round((0.009 + syncPorch + lum + 2 * (sep + porch + chrom)) * sampleRate).toInt()
lumSamples = round(lum * sampleRate).toInt(); chromSamples = round(chrom * sampleRate).toInt()
yBegin = round(syncPorch * sampleRate).toInt(); firstPixelSampleIndex = yBegin
vBegin = round((syncPorch + lum + sep + porch) * sampleRate).toInt()
uBegin = round((syncPorch + lum + sep + porch + chrom + sep + porch) * sampleRate).toInt()
end = round((syncPorch + lum + 2 * (sep + porch + chrom)) * sampleRate).toInt()
ema = Ema.withCutoff(width.toDouble(), (2 * lumSamples).toDouble(), 2)
}
override fun decodeScanLine(
pixelBuffer: PixelBuffer,
scratch: FloatArray,
scanLine: FloatArray,
scopeWidth: Int,
syncPulseIndex: Int,
lineSamples: Int,
freqOffset: Float
): Boolean {
if (syncPulseIndex + firstPixelSampleIndex < 0 || syncPulseIndex + end > scanLine.size) return false
ema.reset()
for (i in firstPixelSampleIndex until end) scratch[i] = ema.process(scanLine[syncPulseIndex + i])
ema.reset()
for (i in end - 1 downTo firstPixelSampleIndex) scratch[i] = freqToLevel(ema.process(scratch[i]), freqOffset)
for (i in 0 until width) {
val yP = yBegin + (i * lumSamples) / width
val uP = uBegin + (i * chromSamples) / width
val vP = vBegin + (i * chromSamples) / width
pixelBuffer.pixels[i] = ColorConverter.yuv2rgb(scratch[yP], scratch[uP], scratch[vP])
}
pixelBuffer.width = width; pixelBuffer.height = 1
return true
}
}
internal class PdMode(
variant: String,
override val visCode: Int,
private val hPixels: Int,
private val vPixels: Int,
channelSec: Double,
sampleRate: Int
) : SstvMode {
override val name = "PD $variant"
override val width get() = hPixels
override val height get() = vPixels
override val firstSyncPulseIndex = 0
override val scanLineSamples: Int
override val firstPixelSampleIndex: Int
private val chSamples: Int
private val yEvenBegin: Int
private val vAvgBegin: Int
private val uAvgBegin: Int
private val yOddBegin: Int
private val end: Int
private val ema: Ema
init {
val syncPorch = 0.00208
scanLineSamples = round((0.02 + syncPorch + 4 * channelSec) * sampleRate).toInt()
chSamples = round(channelSec * sampleRate).toInt()
yEvenBegin = round(syncPorch * sampleRate).toInt(); firstPixelSampleIndex = yEvenBegin
vAvgBegin = round((syncPorch + channelSec) * sampleRate).toInt()
uAvgBegin = round((syncPorch + 2 * channelSec) * sampleRate).toInt()
yOddBegin = round((syncPorch + 3 * channelSec) * sampleRate).toInt()
end = round((syncPorch + 4 * channelSec) * sampleRate).toInt()
ema = Ema.withCutoff(hPixels.toDouble(), (2 * chSamples).toDouble(), 2)
}
override fun decodeScanLine(
pixelBuffer: PixelBuffer,
scratch: FloatArray,
scanLine: FloatArray,
scopeWidth: Int,
syncPulseIndex: Int,
lineSamples: Int,
freqOffset: Float
): Boolean {
if (syncPulseIndex + firstPixelSampleIndex < 0 || syncPulseIndex + end > scanLine.size) return false
ema.reset()
for (i in firstPixelSampleIndex until end) scratch[i] = ema.process(scanLine[syncPulseIndex + i])
ema.reset()
for (i in end - 1 downTo firstPixelSampleIndex) scratch[i] = freqToLevel(ema.process(scratch[i]), freqOffset)
for (i in 0 until hPixels) {
val pos = (i * chSamples) / hPixels
pixelBuffer.pixels[i] =
ColorConverter.yuv2rgb(scratch[pos + yEvenBegin], scratch[pos + uAvgBegin], scratch[pos + vAvgBegin])
pixelBuffer.pixels[i + hPixels] =
ColorConverter.yuv2rgb(scratch[pos + yOddBegin], scratch[pos + uAvgBegin], scratch[pos + vAvgBegin])
}
pixelBuffer.width = hPixels; pixelBuffer.height = 2
return true
}
}
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
override val height = -1
override val firstPixelSampleIndex = 0
override val firstSyncPulseIndex = -1
override val scanLineSamples = -1
private val smallMax = round(0.125 * sampleRate).toInt()
private val medMax = round(0.175 * sampleRate).toInt()
private val ema = Ema()
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
var px = scopeWidth
if (lineSamples < smallMax) px /= 2
if (lineSamples < medMax) px /= 2
ema.setCutoff(px.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)
for (i in 0 until px) pixelBuffer.pixels[i] = ColorConverter.gray(scratch[(i * lineSamples) / px])
pixelBuffer.width = px; pixelBuffer.height = 1
return true
}
}
private fun freqToLevel(frequency: Float, offset: Float): Float = 0.5f * (frequency - offset + 1f)
@@ -0,0 +1,34 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.domain.usecase
import kotlinx.coroutines.flow.Flow
/**
* Platform abstraction for microphone audio capture.
* Produces a flow of mono Float PCM buffers at the configured sample rate.
*/
interface IAudioCapture {
val sampleRate: Int
/**
* Start capturing audio. Emits FloatArray buffers continuously until the flow is canceled.
* Caller is responsible for holding RECORD_AUDIO permission before calling this.
*/
fun audioFlow(): Flow<FloatArray>
}
@@ -0,0 +1,26 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.domain.usecase
/**
* Saves a decoded SSTV image to the device gallery.
* @return true if the image was saved successfully
*/
interface ISaveImage {
suspend operator fun invoke(pixels: IntArray, width: Int, height: Int, modeName: String): Boolean
}
@@ -208,14 +208,11 @@ fun CardButton(onClick: () -> Unit, text: String, modifier: Modifier = Modifier)
} }
@Composable @Composable
fun IconCard(action: () -> Unit, resId: Int, modifier: Modifier = Modifier) { fun IconCard(action: () -> Unit, resId: Int, modifier: Modifier = Modifier, enabled: Boolean = true) {
ElevatedCard(modifier = Modifier.size(48.dp)) { ElevatedCard(modifier = Modifier.size(48.dp), enabled = enabled, onClick = action) {
Box( Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
modifier = Modifier Icon(painter = painterResource(resId), contentDescription = null, modifier = modifier)
.clickable(onClick = action) }
.fillMaxSize(),
contentAlignment = Alignment.Center
) { Icon(painter = painterResource(resId), contentDescription = null, modifier = modifier) }
} }
} }
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M6,19c0,1.1 0.9,2 2,2h8c1.1,0 2,-0.9 2,-2V7H6v12zM19,4h-3.5l-1,-1h-5l-1,1H5v2h14V4z" />
</vector>
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M6,19h4L10,5L6,5v14zM14,5v14h4L18,5h-4z" />
</vector>
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M8,5v14l11,-7z" />
</vector>
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M17,3L5,3c-1.11,0 -2,0.9 -2,2v14c0,1.1 0.89,2 2,2h14c1.1,0 2,-0.9 2,-2L21,7l-4,-4zM12,19c-1.66,0 -3,-1.34 -3,-3s1.34,-3 3,-3 3,1.34 3,3 -1.34,3 -3,3zM15,9L5,9L5,5h10v4z" />
</vector>
@@ -17,6 +17,10 @@
*/ */
package com.rtbishop.look4sat.feature.radar package com.rtbishop.look4sat.feature.radar
import android.Manifest
import android.content.pm.PackageManager
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.RepeatMode import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat import androidx.compose.animation.core.animateFloat
@@ -39,6 +43,7 @@ import androidx.compose.material3.PrimaryTabRow
import androidx.compose.material3.Tab import androidx.compose.material3.Tab
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
@@ -49,6 +54,7 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.core.content.ContextCompat
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
@@ -76,14 +82,27 @@ fun RadarDestination(navigateUp: () -> Unit) {
val container = (context.applicationContext as IContainerProvider).getMainContainer() val container = (context.applicationContext as IContainerProvider).getMainContainer()
val viewModel: RadarViewModel = viewModel(factory = RadarViewModel.factory(container)) val viewModel: RadarViewModel = viewModel(factory = RadarViewModel.factory(container))
val uiState by viewModel.uiState.collectAsStateWithLifecycle() val uiState by viewModel.uiState.collectAsStateWithLifecycle()
RadarScreen(uiState, viewModel::onAction, navigateUp) // Sync actual permission state on every recomposition so it survives screen re-entry
val hasPermission = ContextCompat.checkSelfPermission(
context, Manifest.permission.RECORD_AUDIO
) == PackageManager.PERMISSION_GRANTED
LaunchedEffect(hasPermission) {
viewModel.onAction(RadarAction.SstvPermissionResult(hasPermission))
}
val permissionLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission()
) { granted -> viewModel.onAction(RadarAction.SstvPermissionResult(granted)) }
RadarScreen(uiState, viewModel::onAction, navigateUp, requestMicPermission = {
permissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
})
} }
@Composable @Composable
private fun RadarScreen( private fun RadarScreen(
uiState: RadarState, uiState: RadarState,
onAction: (RadarAction) -> Unit, onAction: (RadarAction) -> Unit,
navigateUp: () -> Unit navigateUp: () -> Unit,
requestMicPermission: () -> Unit
) { ) {
val upcomingPass = uiState.currentPass ?: getDefaultPass() val upcomingPass = uiState.currentPass ?: getDefaultPass()
val addToCalendar: () -> Unit = { val addToCalendar: () -> Unit = {
@@ -113,11 +132,11 @@ private fun RadarScreen(
} }
if (isVertical) { if (isVertical) {
RadarCard(uiState, Modifier.weight(1f)) RadarCard(uiState, Modifier.weight(1f))
PagerCard(uiState, onAction, Modifier.weight(1f)) PagerCard(uiState, onAction, requestMicPermission, Modifier.weight(1f))
} else { } else {
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
RadarCard(uiState, Modifier.weight(1f)) RadarCard(uiState, Modifier.weight(1f))
PagerCard(uiState, onAction, Modifier.weight(1f)) PagerCard(uiState, onAction, requestMicPermission, Modifier.weight(1f))
} }
} }
} }
@@ -127,6 +146,7 @@ private fun RadarScreen(
private fun PagerCard( private fun PagerCard(
uiState: RadarState, uiState: RadarState,
onAction: (RadarAction) -> Unit, onAction: (RadarAction) -> Unit,
requestMicPermission: () -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val pages = RadarPage.entries val pages = RadarPage.entries
@@ -155,38 +175,17 @@ private fun PagerCard(
radioControl = uiState.radioControl, radioControl = uiState.radioControl,
onAction = onAction onAction = onAction
) )
RadarPage.Sstv -> SstvPlaceholderPage() RadarPage.Sstv -> SstvPage(
sstv = uiState.sstv,
onAction = onAction,
requestMicPermission = requestMicPermission
)
} }
} }
} }
} }
} }
@Composable
private fun SstvPlaceholderPage() {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.fillMaxSize()
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Text(text = "🚧", fontSize = 48.sp)
Text(
text = "SSTV Decoder",
fontSize = 20.sp,
color = MaterialTheme.colorScheme.primary
)
Text(
text = "Coming soon",
fontSize = 16.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
@Composable @Composable
private fun RadarCard(uiState: RadarState, modifier: Modifier = Modifier) { private fun RadarCard(uiState: RadarState, modifier: Modifier = Modifier) {
val satellitePos = uiState.orbitalPos val satellitePos = uiState.orbitalPos
@@ -21,6 +21,7 @@ import com.rtbishop.look4sat.core.domain.model.SatRadio
import com.rtbishop.look4sat.core.domain.predict.CelestialComputer 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
data class RadioPanelState( data class RadioPanelState(
val label: String = "", val label: String = "",
@@ -57,7 +58,18 @@ data class RadarState(
val transmitters: List<SatRadio> = emptyList(), val transmitters: List<SatRadio> = emptyList(),
val selectedTransmitterUuid: String? = null, val selectedTransmitterUuid: String? = null,
val selectedFrequency: Long? = null, val selectedFrequency: Long? = null,
val radioControl: RadioControlSubState = RadioControlSubState() val radioControl: RadioControlSubState = RadioControlSubState(),
val sstv: SstvSubState = SstvSubState()
)
enum class SstvStatus { Idle, Recording, Saving }
data class SstvSubState(
val status: SstvStatus = SstvStatus.Idle,
val hasPermission: Boolean = false,
val selectedMode: String = "Auto",
val supportedModes: List<String> = emptyList(),
val currentFrame: SstvFrame? = null
) )
sealed interface RadarAction { sealed interface RadarAction {
@@ -72,4 +84,12 @@ sealed interface RadarAction {
data object ToggleTracking : RadarAction data object ToggleTracking : RadarAction
data object ConnectRadios : RadarAction data object ConnectRadios : RadarAction
data object DisconnectRadios : RadarAction data object DisconnectRadios : RadarAction
// SSTV actions
data object SstvStartRecording : RadarAction
data object SstvStopRecording : RadarAction
data object SstvSaveImage : RadarAction
data object SstvReset : RadarAction
data class SstvSelectMode(val modeName: String) : RadarAction
data class SstvPermissionResult(val granted: Boolean) : RadarAction
} }
@@ -38,6 +38,7 @@ import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.PathEffect import androidx.compose.ui.graphics.PathEffect
import androidx.compose.ui.graphics.PathMeasure import androidx.compose.ui.graphics.PathMeasure
import androidx.compose.ui.graphics.ShaderBrush import androidx.compose.ui.graphics.ShaderBrush
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.StampedPathEffectStyle import androidx.compose.ui.graphics.StampedPathEffectStyle
import androidx.compose.ui.graphics.SweepGradientShader import androidx.compose.ui.graphics.SweepGradientShader
import androidx.compose.ui.graphics.drawscope.DrawScope import androidx.compose.ui.graphics.drawscope.DrawScope
@@ -220,7 +221,7 @@ private fun DrawScope.drawBodyIcon(
translate(pos.x - half, pos.y - half) translate(pos.x - half, pos.y - half)
}) { }) {
with(painter) { with(painter) {
draw(Size(iconSize, iconSize), colorFilter = androidx.compose.ui.graphics.ColorFilter.tint(color)) draw(Size(iconSize, iconSize), colorFilter = ColorFilter.tint(color))
} }
} }
} }
@@ -24,6 +24,7 @@ import androidx.lifecycle.viewmodel.viewModelFactory
import com.rtbishop.look4sat.core.domain.model.SatRadio import com.rtbishop.look4sat.core.domain.model.SatRadio
import com.rtbishop.look4sat.core.domain.predict.CelestialComputer import com.rtbishop.look4sat.core.domain.predict.CelestialComputer
import com.rtbishop.look4sat.core.domain.predict.OrbitalObject import com.rtbishop.look4sat.core.domain.predict.OrbitalObject
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.repository.IMainContainer import com.rtbishop.look4sat.core.domain.repository.IMainContainer
import com.rtbishop.look4sat.core.domain.repository.IRadioTrackingService import com.rtbishop.look4sat.core.domain.repository.IRadioTrackingService
@@ -31,10 +32,15 @@ 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.SstvDecoder
import com.rtbishop.look4sat.core.domain.usecase.IAudioCapture
import com.rtbishop.look4sat.core.domain.usecase.IAddToCalendar import com.rtbishop.look4sat.core.domain.usecase.IAddToCalendar
import com.rtbishop.look4sat.core.domain.usecase.ISaveImage
import com.rtbishop.look4sat.core.domain.usecase.IShowToast
import com.rtbishop.look4sat.core.domain.utility.round import com.rtbishop.look4sat.core.domain.utility.round
import com.rtbishop.look4sat.core.domain.utility.toDegrees import com.rtbishop.look4sat.core.domain.utility.toDegrees
import com.rtbishop.look4sat.core.domain.utility.toTimerString import com.rtbishop.look4sat.core.domain.utility.toTimerString
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@@ -51,91 +57,100 @@ class RadarViewModel(
private val settingsRepo: ISettingsRepo, private val settingsRepo: ISettingsRepo,
private val sensorsRepo: ISensorsRepo, private val sensorsRepo: ISensorsRepo,
private val addToCalendar: IAddToCalendar, private val addToCalendar: IAddToCalendar,
private val trackingService: IRadioTrackingService private val trackingService: IRadioTrackingService,
private val audioCapture: IAudioCapture,
private val saveImage: ISaveImage,
private val showToast: IShowToast
) : ViewModel() { ) : ViewModel() {
private val stationPos = settingsRepo.stationPosition.value private val stationPos = settingsRepo.stationPosition.value
private val magDeclination = sensorsRepo.getMagDeclination(stationPos) private val magDeclination = sensorsRepo.getMagDeclination(stationPos)
private var transponders: List<SatRadio> = emptyList() private var transponders: List<SatRadio> = emptyList()
private var sstvDecoder: SstvDecoder? = null
private var sstvRecordingJob: Job? = null
private val _uiState = MutableStateFlow( private val _uiState = MutableStateFlow(
RadarState( RadarState(
isUtc = settingsRepo.otherSettings.value.stateOfUtc, isUtc = settingsRepo.otherSettings.value.stateOfUtc,
orientationValues = sensorsRepo.orientation.value, orientationValues = sensorsRepo.orientation.value,
shouldShowSweep = settingsRepo.otherSettings.value.stateOfSweep, shouldShowSweep = settingsRepo.otherSettings.value.stateOfSweep,
shouldUseCompass = settingsRepo.otherSettings.value.stateOfSensors shouldUseCompass = settingsRepo.otherSettings.value.stateOfSensors,
sstv = SstvSubState(selectedMode = settingsRepo.otherSettings.value.sstvMode)
) )
) )
val uiState: StateFlow<RadarState> = _uiState val uiState: StateFlow<RadarState> = _uiState
init { init {
// Compass sensor collection collectCompassSensor()
if (settingsRepo.otherSettings.value.stateOfSensors) { collectSettingsChanges()
viewModelScope.launch { collectPassAndStartTickLoop()
sensorsRepo.enableSensor() collectRadioTrackingState()
sensorsRepo.orientation.collect { data -> }
val orientationValues = (data.first + magDeclination) to data.second
_uiState.update { it.copy(orientationValues = orientationValues) } private fun collectCompassSensor() {
} if (!settingsRepo.otherSettings.value.stateOfSensors) return
viewModelScope.launch {
sensorsRepo.enableSensor()
sensorsRepo.orientation.collect { data ->
val orientationValues = (data.first + magDeclination) to data.second
_uiState.update { it.copy(orientationValues = orientationValues) }
} }
} }
// React to UTC setting changes }
private fun collectSettingsChanges() {
viewModelScope.launch { viewModelScope.launch {
settingsRepo.otherSettings.collectLatest { settings -> settingsRepo.otherSettings.collectLatest { settings ->
_uiState.update { it.copy(isUtc = settings.stateOfUtc) } _uiState.update { it.copy(isUtc = settings.stateOfUtc) }
} }
} }
// Resolve which pass we're tracking and start the tick loop }
private fun collectPassAndStartTickLoop() {
viewModelScope.launch { viewModelScope.launch {
val passes = satelliteRepo.passes.value val passes = satelliteRepo.passes.value
val (catNum, aosTime) = satelliteRepo.selectedPass.value val (catNum, aosTime) = satelliteRepo.selectedPass.value
val satPass = passes.find { it.catNum == catNum && it.aosTime == aosTime } ?: passes.firstOrNull() val pass = passes.find { it.catNum == catNum && it.aosTime == aosTime } ?: passes.firstOrNull() ?: return@launch
satPass?.let { pass -> _uiState.update { it.copy(currentPass = pass) }
_uiState.update { it.copy(currentPass = pass) } val transmittersList = satelliteRepo.getRadiosWithId(pass.catNum)
val transmittersList = satelliteRepo.getRadiosWithId(pass.catNum) transponders = transmittersList.filter { it.downlinkLow != null }
// Load transponders for radio control (ones that have downlink) _uiState.update { state ->
transponders = transmittersList.filter { it.downlinkLow != null } state.copy(radioControl = state.radioControl.copy(transponders = transponders))
_uiState.update { state -> }
state.copy(radioControl = state.radioControl.copy(transponders = transponders)) if (transmittersList.isNotEmpty()) {
} val firstUuid = transmittersList.first().uuid
// Auto-expand the first item so users discover the expandable UI _uiState.update { it.copy(selectedTransmitterUuid = firstUuid) }
if (transmittersList.isNotEmpty()) { transponders.find { it.uuid == firstUuid }?.let { trackingService.setTransponder(it) }
val firstUuid = transmittersList.first().uuid }
_uiState.update { it.copy(selectedTransmitterUuid = firstUuid) } if (!pass.isDeepSpace) {
transponders.find { it.uuid == firstUuid }?.let { val track = satelliteRepo.getTrack(pass.orbitalObject, stationPos, pass.aosTime, pass.losTime)
trackingService.setTransponder(it) _uiState.update { it.copy(satTrack = track) }
} }
} while (isActive) {
// Compute track once (it doesn't change for a given pass) tickPass(pass, transmittersList)
if (!pass.isDeepSpace) { delay(1000)
val track = satelliteRepo.getTrack(pass.orbitalObject, stationPos, pass.aosTime, pass.losTime)
_uiState.update { it.copy(satTrack = track) }
}
// Tick loop — position, timer, and radio updates every second
while (isActive) {
val timeNow = System.currentTimeMillis()
val pos = satelliteRepo.getPosition(pass.orbitalObject, stationPos, timeNow)
val sunPos = CelestialComputer.getSunPosition(stationPos, timeNow)
val moonPos = CelestialComputer.getMoonPosition(stationPos, timeNow)
val (time, isAos) = computeTimer(pass.isDeepSpace, pass.aosTime, pass.losTime, timeNow)
val isLos = !pass.isDeepSpace && timeNow > pass.losTime
_uiState.update {
it.copy(
currentTime = time,
isTimeAos = isAos,
isLos = isLos,
orbitalPos = pos,
sunPosition = sunPos,
moonPosition = moonPos
)
}
processRadios(transmittersList, pass.orbitalObject, timeNow)
sendPassData(pos)
delay(1000)
}
} }
} }
// Observe radio tracking service state }
private suspend fun tickPass(pass: OrbitalPass, transmittersList: List<SatRadio>) {
val timeNow = System.currentTimeMillis()
val pos = satelliteRepo.getPosition(pass.orbitalObject, stationPos, timeNow)
val sunPos = CelestialComputer.getSunPosition(stationPos, timeNow)
val moonPos = CelestialComputer.getMoonPosition(stationPos, timeNow)
val (time, isAos) = computeTimer(pass.isDeepSpace, pass.aosTime, pass.losTime, timeNow)
val isLos = !pass.isDeepSpace && timeNow > pass.losTime
_uiState.update {
it.copy(
currentTime = time, isTimeAos = isAos, isLos = isLos,
orbitalPos = pos, sunPosition = sunPos, moonPosition = moonPos
)
}
processRadios(transmittersList, pass.orbitalObject, timeNow)
sendPassData(pos)
}
private fun collectRadioTrackingState() {
viewModelScope.launch { viewModelScope.launch {
trackingService.state.collect { svc -> trackingService.state.collect { svc ->
_uiState.update { state -> _uiState.update { state ->
@@ -210,6 +225,33 @@ class RadarViewModel(
} }
RadarAction.ConnectRadios -> viewModelScope.launch { trackingService.connectRadios() } RadarAction.ConnectRadios -> viewModelScope.launch { trackingService.connectRadios() }
RadarAction.DisconnectRadios -> viewModelScope.launch { trackingService.disconnectRadios() } RadarAction.DisconnectRadios -> viewModelScope.launch { trackingService.disconnectRadios() }
// SSTV actions
is RadarAction.SstvPermissionResult -> {
_uiState.update { it.copy(sstv = it.sstv.copy(hasPermission = action.granted)) }
if (action.granted) initSstvDecoder()
}
RadarAction.SstvStartRecording -> startSstvRecording()
RadarAction.SstvStopRecording -> stopSstvRecording()
RadarAction.SstvSaveImage -> {
val frame = _uiState.value.sstv.currentFrame ?: return
val pixels = frame.imagePixels ?: return
_uiState.update { it.copy(sstv = it.sstv.copy(status = SstvStatus.Saving)) }
viewModelScope.launch {
saveImage(pixels, frame.imageWidth, frame.imageHeight, frame.modeName)
_uiState.update { it.copy(sstv = it.sstv.copy(status = SstvStatus.Idle)) }
showToast("Image saved")
}
}
is RadarAction.SstvSelectMode -> {
sstvDecoder?.lockMode(action.modeName)
_uiState.update { it.copy(sstv = it.sstv.copy(selectedMode = action.modeName)) }
settingsRepo.updateOtherSettings { it.copy(sstvMode = action.modeName) }
}
RadarAction.SstvReset -> {
sstvDecoder?.clearPixels() // Keep decoder alive, just reset pixels
_uiState.update { it.copy(sstv = it.sstv.copy(currentFrame = null)) }
}
} }
} }
@@ -281,6 +323,36 @@ class RadarViewModel(
} }
} }
private fun initSstvDecoder() {
if (sstvDecoder == null) {
val decoder = SstvDecoder(sampleRate = audioCapture.sampleRate)
sstvDecoder = decoder
_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)) }
}
}
}
}
private fun startSstvRecording() {
if (sstvRecordingJob?.isActive == true) return
initSstvDecoder()
_uiState.update { it.copy(sstv = it.sstv.copy(status = SstvStatus.Recording)) }
sstvRecordingJob = viewModelScope.launch {
audioCapture.audioFlow().collect { buffer ->
sstvDecoder?.feedSamples(buffer)
}
}
}
private fun stopSstvRecording() {
sstvRecordingJob?.cancel()
sstvRecordingJob = null
_uiState.update { it.copy(sstv = it.sstv.copy(status = SstvStatus.Idle)) }
}
companion object { companion object {
val CTCSS_TONES = listOf( val CTCSS_TONES = listOf(
@@ -307,7 +379,10 @@ class RadarViewModel(
settingsRepo = container.settingsRepo, settingsRepo = container.settingsRepo,
sensorsRepo = container.provideSensorsRepo(), sensorsRepo = container.provideSensorsRepo(),
addToCalendar = container.provideAddToCalendar(), addToCalendar = container.provideAddToCalendar(),
trackingService = container.radioTrackingService trackingService = container.radioTrackingService,
audioCapture = container.provideAudioCapture(),
saveImage = container.provideSaveImage(),
showToast = container.provideShowToast()
) )
} }
} }
@@ -1,369 +0,0 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.feature.radar
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.FilterChip
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ExposedDropdownMenuAnchorType
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.rtbishop.look4sat.core.domain.model.SatRadio
import com.rtbishop.look4sat.core.presentation.CardButton
import java.util.Locale
@Composable
fun RadioControlPage(
radioState: RadioControlSubState,
onAction: (RadarAction) -> Unit,
modifier: Modifier = Modifier
) {
val selectedTransponder = radioState.transponders.find {
it.uuid == radioState.selectedTransponderUuid
}
Column(
modifier = modifier
.fillMaxSize()
.padding(horizontal = 6.dp, vertical = 6.dp),
verticalArrangement = Arrangement.spacedBy(6.dp)
) {
// Transponder selector (dropdown)
TransponderDropdown(
transponders = radioState.transponders,
selectedTransponder = selectedTransponder,
onAction = onAction
)
// Active transponder status (TX/RX lines)
if (selectedTransponder != null) {
ActiveTransponderStatus(
transponder = selectedTransponder,
txPanel = radioState.txPanel,
rxPanel = radioState.rxPanel
)
}
// Frequency tuner + CTCSS
if (radioState.txBaseFrequencyHz != null) {
FrequencyTunerCard(
txBaseFrequencyHz = radioState.txBaseFrequencyHz,
selectedTransponder = selectedTransponder,
ctcssTone = radioState.ctcssTone,
showCtcss = selectedTransponder?.uplinkMode?.uppercase() == "FM",
onAction = onAction
)
}
Spacer(modifier = Modifier.weight(1f))
// Error message
radioState.errorMessage?.let { msg ->
Text(
text = msg,
color = MaterialTheme.colorScheme.error,
fontSize = 13.sp,
modifier = Modifier.padding(horizontal = 4.dp)
)
}
// Control buttons
ControlButtons(
isConnected = radioState.txPanel.isConnected || radioState.rxPanel.isConnected,
isTracking = radioState.isTracking,
onAction = onAction
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun TransponderDropdown(
transponders: List<SatRadio>,
selectedTransponder: SatRadio?,
onAction: (RadarAction) -> Unit
) {
var expanded by remember { mutableStateOf(false) }
ExposedDropdownMenuBox(
expanded = expanded,
onExpandedChange = { expanded = it }
) {
OutlinedTextField(
value = selectedTransponder?.info ?: "No transponders available",
onValueChange = {},
readOnly = true,
singleLine = true,
textStyle = MaterialTheme.typography.bodyMedium,
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
modifier = Modifier
.fillMaxWidth()
.menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable)
)
ExposedDropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false }
) {
transponders.forEach { radio ->
val invLabel = if (radio.isInverted) " INV" else ""
val modeLabel = "${radio.uplinkMode ?: "--"}/${radio.downlinkMode ?: "--"}$invLabel"
DropdownMenuItem(
text = {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.fillMaxWidth()
) {
Text(
text = radio.info,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f)
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = modeLabel,
fontSize = 13.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
},
onClick = {
onAction(RadarAction.SelectTransponder(radio.uuid))
expanded = false
}
)
}
}
}
}
@Composable
private fun ActiveTransponderStatus(
transponder: SatRadio,
txPanel: RadioPanelState,
rxPanel: RadioPanelState
) {
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
// TX line (uplink — on top)
FrequencyStatusRow(
label = "TX",
frequency = txPanel.frequencyHz ?: transponder.uplinkLow,
isConnected = txPanel.isConnected,
mode = txPanel.mode
)
// RX line (downlink — below)
FrequencyStatusRow(
label = "RX",
frequency = rxPanel.frequencyHz ?: transponder.downlinkLow,
isConnected = rxPanel.isConnected,
mode = rxPanel.mode
)
}
}
}
@Composable
private fun FrequencyStatusRow(
label: String,
frequency: Long?,
isConnected: Boolean,
mode: String?
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
Text(
text = "$label:",
fontSize = 13.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.width(28.dp)
)
Text(
text = frequency?.let { RadarViewModel.formatFrequency(it) } ?: "—",
fontSize = 18.sp,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.weight(1f)
)
if (mode != null) {
Text(
text = mode,
fontSize = 12.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(end = 6.dp)
)
}
Box(
modifier = Modifier
.size(8.dp)
.clip(CircleShape)
.background(if (isConnected) Color(0xFF4CAF50) else Color(0xFFE57373))
)
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun FrequencyTunerCard(
txBaseFrequencyHz: Long,
selectedTransponder: SatRadio?,
ctcssTone: Double?,
showCtcss: Boolean,
onAction: (RadarAction) -> Unit
) {
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.padding(horizontal = 8.dp, vertical = 6.dp)
) {
// TX Base Frequency label + value on one line
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center,
modifier = Modifier.fillMaxWidth()
) {
Text(
text = "TX Base: ",
fontSize = 14.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = "${RadarViewModel.formatFrequency(txBaseFrequencyHz)} MHz",
fontSize = 18.sp,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary
)
}
// Show band range hint if it's a wide transponder
if (selectedTransponder != null) {
val upLow = selectedTransponder.uplinkLow
val upHigh = selectedTransponder.uplinkHigh
if (upLow != null && upHigh != null && upLow != upHigh) {
Text(
text = "(${RadarViewModel.formatFrequency(upLow)} – ${RadarViewModel.formatFrequency(upHigh)})",
fontSize = 11.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
Spacer(modifier = Modifier.height(4.dp))
// Adjustment buttons
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
FREQ_ADJUSTMENTS.forEach { (delta, label) ->
CardButton(
onClick = { onAction(RadarAction.AdjustTxFrequency(delta)) },
text = label,
modifier = Modifier.weight(1f)
)
}
}
// CTCSS inline (only for FM)
if (showCtcss) {
Spacer(modifier = Modifier.height(6.dp))
Text(
text = "CTCSS",
fontSize = 12.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
FlowRow(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
FilterChip(
selected = ctcssTone == null,
onClick = { onAction(RadarAction.SetCtcssTone(null)) },
label = { Text("Off", fontSize = 12.sp) }
)
RadarViewModel.CTCSS_TONES.forEach { tone ->
FilterChip(
selected = ctcssTone == tone,
onClick = { onAction(RadarAction.SetCtcssTone(tone)) },
label = { Text(String.format(Locale.ENGLISH, "%.1f", tone), fontSize = 12.sp) }
)
}
}
}
}
}
}
@Composable
private fun ControlButtons(
isConnected: Boolean,
isTracking: Boolean,
onAction: (RadarAction) -> Unit
) {
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
if (!isConnected) {
CardButton(
onClick = { onAction(RadarAction.ConnectRadios) },
text = "Connect",
modifier = Modifier.weight(1f)
)
} else {
CardButton(
onClick = { onAction(RadarAction.DisconnectRadios) },
text = "Disconnect",
modifier = Modifier.weight(1f)
)
}
CardButton(
onClick = { onAction(RadarAction.ToggleTracking) },
text = if (isTracking) "Stop" else "Track",
modifier = Modifier.weight(1f)
)
}
}
private val FREQ_ADJUSTMENTS =
listOf(-10_000L to "-10k", -1_000L to "-1k", -100L to "-100", 100L to "+100", 1_000L to "+1k", 10_000L to "+10k")
@@ -0,0 +1,240 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.feature.radar
import android.graphics.Bitmap
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Button
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
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.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import com.rtbishop.look4sat.core.presentation.IconCard
import com.rtbishop.look4sat.core.presentation.R
import com.rtbishop.look4sat.core.presentation.infiniteMarquee
@Composable
internal fun SstvPage(
sstv: SstvSubState,
onAction: (RadarAction) -> Unit,
requestMicPermission: () -> Unit
) {
if (!sstv.hasPermission) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.fillMaxSize()
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text(text = "🎙️", fontSize = 48.sp)
Text(
text = "Microphone access needed",
fontSize = 16.sp,
color = MaterialTheme.colorScheme.onSurface
)
Button(onClick = requestMicPermission) {
Text("Grant permission")
}
}
}
return
}
// Mode selection dialog
val showModeDialog = remember { mutableStateOf(false) }
if (showModeDialog.value) {
val allModes = remember(sstv.supportedModes) { listOf("Auto") + sstv.supportedModes }
Dialog(onDismissRequest = { showModeDialog.value = false }) {
ElevatedCard {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.padding(vertical = 16.dp)
) {
Text(
text = "SSTV Mode",
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(bottom = 12.dp)
)
LazyColumn(
modifier = Modifier
.fillMaxHeight(0.5f)
.background(MaterialTheme.colorScheme.background),
verticalArrangement = Arrangement.spacedBy(1.dp)
) {
items(allModes) { mode ->
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.surface)
.clickable {
onAction(RadarAction.SstvSelectMode(mode))
showModeDialog.value = false
}
) {
Text(
text = mode,
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier
.weight(1f)
.padding(start = 16.dp)
)
RadioButton(
selected = mode == sstv.selectedMode,
onClick = null,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp)
)
}
}
}
}
}
}
}
// Main SSTV view — image fills entire area, controls overlay at bottom
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black)
) {
// Decoded image or placeholder — centered in available space
val frame = sstv.currentFrame
val pixels = frame?.imagePixels
if (pixels != null && frame.imageWidth > 0 && frame.imageHeight > 0) {
val bitmap = remember(pixels, frame.imageWidth, frame.imageHeight) {
Bitmap.createBitmap(
pixels,
frame.imageWidth,
frame.imageHeight,
Bitmap.Config.ARGB_8888
).asImageBitmap()
}
Image(
bitmap = bitmap,
contentDescription = "Decoded SSTV image",
contentScale = ContentScale.Fit,
modifier = Modifier
.fillMaxSize()
.align(Alignment.Center)
)
} else {
Text(
text = if (sstv.status == SstvStatus.Recording) "Listening…" else "No signal",
color = MaterialTheme.colorScheme.onSurface,
fontSize = 16.sp,
modifier = Modifier.align(Alignment.Center)
)
}
// Bottom control bar — dark, blends with the black canvas
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier
.align(Alignment.BottomCenter)
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 8.dp)
) {
// Mode button — opens dialog
ElevatedCard(
modifier = Modifier.weight(1f).height(48.dp),
onClick = { showModeDialog.value = true },
colors = CardDefaults.elevatedCardColors(
containerColor = MaterialTheme.colorScheme.surface
)
) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text(
text = "Mode: ${sstv.selectedMode}",
fontSize = 14.sp,
maxLines = 1,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.infiniteMarquee()
)
}
}
// Reset button — clears decoder state and image
IconCard(
action = { onAction(RadarAction.SstvReset) },
resId = R.drawable.ic_delete
)
// Save button — always visible, enabled when there are pixels
IconCard(
action = { onAction(RadarAction.SstvSaveImage) },
resId = R.drawable.ic_save,
enabled = sstv.currentFrame?.imagePixels != null
)
// Record / Stop button
val playAction = {
when (sstv.status) {
SstvStatus.Idle -> onAction(RadarAction.SstvStartRecording)
SstvStatus.Recording -> onAction(RadarAction.SstvStopRecording)
SstvStatus.Saving -> {}
}
}
val playColors = CardDefaults.elevatedCardColors(
containerColor = if (sstv.status == SstvStatus.Recording) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.surface
)
val playIcon = if (sstv.status == SstvStatus.Recording) R.drawable.ic_pause else R.drawable.ic_play
ElevatedCard(modifier = Modifier.size(48.dp), onClick = playAction, colors = playColors) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Icon(painter = painterResource(playIcon), contentDescription = null)
}
}
}
}
}