From 79809aaec05f4c14c3a91887bd6ec3e7c4a9e67d Mon Sep 17 00:00:00 2001 From: Arty Bishop Date: Mon, 1 Jun 2026 21:52:12 +0100 Subject: [PATCH] Added SSTV image decoding functionality to RadarScreen --- app/src/main/AndroidManifest.xml | 1 + core/data/src/main/AndroidManifest.xml | 1 + .../core/data/injection/MainContainer.kt | 25 + .../core/data/repository/SettingsRepo.kt | 5 +- .../core/data/usecase/AudioCapture.kt | 63 ++ .../look4sat/core/data/usecase/SaveImage.kt | 84 +++ .../look4sat/core/domain/model/Settings.kt | 3 +- .../core/domain/repository/IMainContainer.kt | 23 +- .../look4sat/core/domain/sstv/SstvDecoder.kt | 573 ++++++++++++++++++ .../look4sat/core/domain/sstv/SstvDsp.kt | 216 +++++++ .../look4sat/core/domain/sstv/SstvModes.kt | 474 +++++++++++++++ .../core/domain/usecase/IAudioCapture.kt | 34 ++ .../core/domain/usecase/ISaveImage.kt | 26 + .../look4sat/core/presentation/Components.kt | 13 +- .../src/main/res/drawable/ic_delete.xml | 9 + .../src/main/res/drawable/ic_pause.xml | 9 + .../src/main/res/drawable/ic_play.xml | 9 + .../src/main/res/drawable/ic_save.xml | 9 + .../look4sat/feature/radar/RadarScreen.kt | 59 +- .../look4sat/feature/radar/RadarState.kt | 22 +- .../look4sat/feature/radar/RadarView.kt | 3 +- .../look4sat/feature/radar/RadarViewModel.kt | 191 ++++-- .../feature/radar/RadioControlPage.kt | 369 ----------- .../look4sat/feature/radar/SstvPage.kt | 240 ++++++++ 24 files changed, 1991 insertions(+), 470 deletions(-) create mode 100644 core/data/src/main/java/com/rtbishop/look4sat/core/data/usecase/AudioCapture.kt create mode 100644 core/data/src/main/java/com/rtbishop/look4sat/core/data/usecase/SaveImage.kt create mode 100644 core/domain/src/main/java/com/rtbishop/look4sat/core/domain/sstv/SstvDecoder.kt create mode 100644 core/domain/src/main/java/com/rtbishop/look4sat/core/domain/sstv/SstvDsp.kt create mode 100644 core/domain/src/main/java/com/rtbishop/look4sat/core/domain/sstv/SstvModes.kt create mode 100644 core/domain/src/main/java/com/rtbishop/look4sat/core/domain/usecase/IAudioCapture.kt create mode 100644 core/domain/src/main/java/com/rtbishop/look4sat/core/domain/usecase/ISaveImage.kt create mode 100644 core/presentation/src/main/res/drawable/ic_delete.xml create mode 100644 core/presentation/src/main/res/drawable/ic_pause.xml create mode 100644 core/presentation/src/main/res/drawable/ic_play.xml create mode 100644 core/presentation/src/main/res/drawable/ic_save.xml delete mode 100644 feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadioControlPage.kt create mode 100644 feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/SstvPage.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 938878c2..5d6b6ccc 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -11,6 +11,7 @@ + + diff --git a/core/data/src/main/java/com/rtbishop/look4sat/core/data/injection/MainContainer.kt b/core/data/src/main/java/com/rtbishop/look4sat/core/data/injection/MainContainer.kt index b9f81bd9..9af7a73d 100644 --- a/core/data/src/main/java/com/rtbishop/look4sat/core/data/injection/MainContainer.kt +++ b/core/data/src/main/java/com/rtbishop/look4sat/core/data/injection/MainContainer.kt @@ -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 . + */ package com.rtbishop.look4sat.core.data.injection 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.RemoteSource 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.domain.repository.IDatabaseRepo 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.IRemoteSource 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.utility.DataParser import kotlinx.coroutines.CoroutineExceptionHandler @@ -59,6 +80,10 @@ class MainContainer(private val context: Context) : IMainContainer { override fun provideShowToast(): IShowToast = ShowToast(context) + override fun provideAudioCapture(): IAudioCapture = AudioCapture() + + override fun provideSaveImage(): ISaveImage = SaveImage(context) + override fun provideBluetoothReporter(): IReporter { val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager val rc = settingsRepo.rcSettings.value diff --git a/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/SettingsRepo.kt b/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/SettingsRepo.kt index c9b72dc8..98a975c8 100644 --- a/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/SettingsRepo.kt +++ b/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/SettingsRepo.kt @@ -81,6 +81,7 @@ class SettingsRepo( private val keyUpdateTimestamp = "updateTimestamp" private val keyShouldSeeWarning = "shouldSeeWarning" private val keyShouldSeeWhatsNew = "shouldSeeWhatsNew_v$appVersionName" + private val keySstvMode = "sstvMode" private val keyUseCustomTle = "useCustomTle" private val keyUseCustomTransceivers = "useCustomTransceivers" private val keyTleUrl = "tleUrl" @@ -333,6 +334,7 @@ class SettingsRepo( putBoolean(keyStateOfNightMode, new.stateOfNightMode) putBoolean(keyShouldSeeWarning, new.shouldSeeWarning) putBoolean(keyShouldSeeWhatsNew, new.shouldSeeWhatsNew) + putString(keySstvMode, new.sstvMode) } new } @@ -346,7 +348,8 @@ class SettingsRepo( stateOfLightTheme = preferences.getBoolean(keyStateOfLightTheme, false), stateOfNightMode = preferences.getBoolean(keyStateOfNightMode, false), shouldSeeWarning = preferences.getBoolean(keyShouldSeeWarning, true), - shouldSeeWhatsNew = preferences.getBoolean(keyShouldSeeWhatsNew, true) + shouldSeeWhatsNew = preferences.getBoolean(keyShouldSeeWhatsNew, true), + sstvMode = preferences.getString(keySstvMode, null) ?: "Auto" ) //endregion diff --git a/core/data/src/main/java/com/rtbishop/look4sat/core/data/usecase/AudioCapture.kt b/core/data/src/main/java/com/rtbishop/look4sat/core/data/usecase/AudioCapture.kt new file mode 100644 index 00000000..98d3ce66 --- /dev/null +++ b/core/data/src/main/java/com/rtbishop/look4sat/core/data/usecase/AudioCapture.kt @@ -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 . + */ +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 = 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) +} diff --git a/core/data/src/main/java/com/rtbishop/look4sat/core/data/usecase/SaveImage.kt b/core/data/src/main/java/com/rtbishop/look4sat/core/data/usecase/SaveImage.kt new file mode 100644 index 00000000..a4f41d9d --- /dev/null +++ b/core/data/src/main/java/com/rtbishop/look4sat/core/data/usecase/SaveImage.kt @@ -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 . + */ +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) + } + } +} diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/model/Settings.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/model/Settings.kt index 509e0ecd..fcdc2667 100644 --- a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/model/Settings.kt +++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/model/Settings.kt @@ -56,7 +56,8 @@ data class OtherSettings( val stateOfLightTheme: Boolean, val stateOfNightMode: Boolean = false, val shouldSeeWarning: Boolean, - val shouldSeeWhatsNew: Boolean + val shouldSeeWhatsNew: Boolean, + val sstvMode: String = "Auto" ) data class DataSourcesSettings( diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/IMainContainer.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/IMainContainer.kt index c491ab86..a46bbaeb 100644 --- a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/IMainContainer.kt +++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/IMainContainer.kt @@ -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 . + */ package com.rtbishop.look4sat.core.domain.repository 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 kotlinx.coroutines.CoroutineScope @@ -10,6 +29,7 @@ interface IMainContainer { val selectionRepo: ISelectionRepo val satelliteRepo: ISatelliteRepo val databaseRepo: IDatabaseRepo + val radioTrackingService: IRadioTrackingService fun provideAddToCalendar(): IAddToCalendar fun provideShowToast(): IShowToast fun provideBluetoothReporter(): IReporter @@ -17,7 +37,8 @@ interface IMainContainer { fun provideSensorsRepo(): ISensorsRepo fun provideTxRadioController(): IRadioController fun provideRxRadioController(): IRadioController - val radioTrackingService: IRadioTrackingService + fun provideAudioCapture(): IAudioCapture + fun provideSaveImage(): ISaveImage } interface IContainerProvider { diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/sstv/SstvDecoder.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/sstv/SstvDecoder.kt new file mode 100644 index 00000000..0651e2e4 --- /dev/null +++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/sstv/SstvDecoder.kt @@ -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 . + */ +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( + replay = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST + ) + val frames: SharedFlow = _frames + val supportedModes: List = 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 + private val modes9ms: ArrayList + private val modes20ms: ArrayList + + var currentMode: SstvMode; private set + val allModes: List 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, 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, + 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 + } +} diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/sstv/SstvDsp.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/sstv/SstvDsp.kt new file mode 100644 index 00000000..71dd4e62 --- /dev/null +++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/sstv/SstvDsp.kt @@ -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 . + */ +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) +} diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/sstv/SstvModes.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/sstv/SstvModes.kt new file mode 100644 index 00000000..1e574018 --- /dev/null +++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/sstv/SstvModes.kt @@ -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 . + */ +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) diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/usecase/IAudioCapture.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/usecase/IAudioCapture.kt new file mode 100644 index 00000000..53a36648 --- /dev/null +++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/usecase/IAudioCapture.kt @@ -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 . + */ +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 +} diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/usecase/ISaveImage.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/usecase/ISaveImage.kt new file mode 100644 index 00000000..0c3ff4c0 --- /dev/null +++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/usecase/ISaveImage.kt @@ -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 . + */ +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 +} diff --git a/core/presentation/src/main/java/com/rtbishop/look4sat/core/presentation/Components.kt b/core/presentation/src/main/java/com/rtbishop/look4sat/core/presentation/Components.kt index 9c3b3796..36f94aad 100644 --- a/core/presentation/src/main/java/com/rtbishop/look4sat/core/presentation/Components.kt +++ b/core/presentation/src/main/java/com/rtbishop/look4sat/core/presentation/Components.kt @@ -208,14 +208,11 @@ fun CardButton(onClick: () -> Unit, text: String, modifier: Modifier = Modifier) } @Composable -fun IconCard(action: () -> Unit, resId: Int, modifier: Modifier = Modifier) { - ElevatedCard(modifier = Modifier.size(48.dp)) { - Box( - modifier = Modifier - .clickable(onClick = action) - .fillMaxSize(), - contentAlignment = Alignment.Center - ) { Icon(painter = painterResource(resId), contentDescription = null, modifier = modifier) } +fun IconCard(action: () -> Unit, resId: Int, modifier: Modifier = Modifier, enabled: Boolean = true) { + ElevatedCard(modifier = Modifier.size(48.dp), enabled = enabled, onClick = action) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Icon(painter = painterResource(resId), contentDescription = null, modifier = modifier) + } } } diff --git a/core/presentation/src/main/res/drawable/ic_delete.xml b/core/presentation/src/main/res/drawable/ic_delete.xml new file mode 100644 index 00000000..6c6f704a --- /dev/null +++ b/core/presentation/src/main/res/drawable/ic_delete.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/presentation/src/main/res/drawable/ic_pause.xml b/core/presentation/src/main/res/drawable/ic_pause.xml new file mode 100644 index 00000000..2ffb66b3 --- /dev/null +++ b/core/presentation/src/main/res/drawable/ic_pause.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/presentation/src/main/res/drawable/ic_play.xml b/core/presentation/src/main/res/drawable/ic_play.xml new file mode 100644 index 00000000..88f62917 --- /dev/null +++ b/core/presentation/src/main/res/drawable/ic_play.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/presentation/src/main/res/drawable/ic_save.xml b/core/presentation/src/main/res/drawable/ic_save.xml new file mode 100644 index 00000000..56aad040 --- /dev/null +++ b/core/presentation/src/main/res/drawable/ic_save.xml @@ -0,0 +1,9 @@ + + + diff --git a/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarScreen.kt b/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarScreen.kt index 5cf92601..73b81a90 100644 --- a/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarScreen.kt +++ b/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarScreen.kt @@ -17,6 +17,10 @@ */ 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.RepeatMode import androidx.compose.animation.core.animateFloat @@ -39,6 +43,7 @@ import androidx.compose.material3.PrimaryTabRow import androidx.compose.material3.Tab import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.rememberCoroutineScope 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.unit.dp import androidx.compose.ui.unit.sp +import androidx.core.content.ContextCompat import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import com.rtbishop.look4sat.core.domain.predict.OrbitalPos @@ -76,14 +82,27 @@ fun RadarDestination(navigateUp: () -> Unit) { val container = (context.applicationContext as IContainerProvider).getMainContainer() val viewModel: RadarViewModel = viewModel(factory = RadarViewModel.factory(container)) 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 private fun RadarScreen( uiState: RadarState, onAction: (RadarAction) -> Unit, - navigateUp: () -> Unit + navigateUp: () -> Unit, + requestMicPermission: () -> Unit ) { val upcomingPass = uiState.currentPass ?: getDefaultPass() val addToCalendar: () -> Unit = { @@ -113,11 +132,11 @@ private fun RadarScreen( } if (isVertical) { RadarCard(uiState, Modifier.weight(1f)) - PagerCard(uiState, onAction, Modifier.weight(1f)) + PagerCard(uiState, onAction, requestMicPermission, Modifier.weight(1f)) } else { Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { 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( uiState: RadarState, onAction: (RadarAction) -> Unit, + requestMicPermission: () -> Unit, modifier: Modifier = Modifier ) { val pages = RadarPage.entries @@ -155,38 +175,17 @@ private fun PagerCard( radioControl = uiState.radioControl, 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 private fun RadarCard(uiState: RadarState, modifier: Modifier = Modifier) { val satellitePos = uiState.orbitalPos diff --git a/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarState.kt b/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarState.kt index a10adf40..f36795c9 100644 --- a/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarState.kt +++ b/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarState.kt @@ -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.OrbitalPass import com.rtbishop.look4sat.core.domain.predict.OrbitalPos +import com.rtbishop.look4sat.core.domain.sstv.SstvFrame data class RadioPanelState( val label: String = "", @@ -57,7 +58,18 @@ data class RadarState( val transmitters: List = emptyList(), val selectedTransmitterUuid: String? = 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 = emptyList(), + val currentFrame: SstvFrame? = null ) sealed interface RadarAction { @@ -72,4 +84,12 @@ sealed interface RadarAction { data object ToggleTracking : RadarAction data object ConnectRadios : 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 } diff --git a/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarView.kt b/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarView.kt index 74772f18..80a06e4d 100644 --- a/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarView.kt +++ b/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarView.kt @@ -38,6 +38,7 @@ import androidx.compose.ui.graphics.Path import androidx.compose.ui.graphics.PathEffect import androidx.compose.ui.graphics.PathMeasure import androidx.compose.ui.graphics.ShaderBrush +import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.graphics.StampedPathEffectStyle import androidx.compose.ui.graphics.SweepGradientShader import androidx.compose.ui.graphics.drawscope.DrawScope @@ -220,7 +221,7 @@ private fun DrawScope.drawBodyIcon( translate(pos.x - half, pos.y - half) }) { with(painter) { - draw(Size(iconSize, iconSize), colorFilter = androidx.compose.ui.graphics.ColorFilter.tint(color)) + draw(Size(iconSize, iconSize), colorFilter = ColorFilter.tint(color)) } } } diff --git a/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarViewModel.kt b/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarViewModel.kt index 132f9642..7898e183 100644 --- a/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarViewModel.kt +++ b/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarViewModel.kt @@ -24,6 +24,7 @@ import androidx.lifecycle.viewmodel.viewModelFactory import com.rtbishop.look4sat.core.domain.model.SatRadio import com.rtbishop.look4sat.core.domain.predict.CelestialComputer 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.repository.IMainContainer 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.ISensorsRepo 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.ISaveImage +import com.rtbishop.look4sat.core.domain.usecase.IShowToast import com.rtbishop.look4sat.core.domain.utility.round import com.rtbishop.look4sat.core.domain.utility.toDegrees import com.rtbishop.look4sat.core.domain.utility.toTimerString +import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -51,91 +57,100 @@ class RadarViewModel( private val settingsRepo: ISettingsRepo, private val sensorsRepo: ISensorsRepo, 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() { private val stationPos = settingsRepo.stationPosition.value private val magDeclination = sensorsRepo.getMagDeclination(stationPos) private var transponders: List = emptyList() + private var sstvDecoder: SstvDecoder? = null + private var sstvRecordingJob: Job? = null private val _uiState = MutableStateFlow( RadarState( isUtc = settingsRepo.otherSettings.value.stateOfUtc, orientationValues = sensorsRepo.orientation.value, 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 = _uiState init { - // Compass sensor collection - if (settingsRepo.otherSettings.value.stateOfSensors) { - viewModelScope.launch { - sensorsRepo.enableSensor() - sensorsRepo.orientation.collect { data -> - val orientationValues = (data.first + magDeclination) to data.second - _uiState.update { it.copy(orientationValues = orientationValues) } - } + collectCompassSensor() + collectSettingsChanges() + collectPassAndStartTickLoop() + collectRadioTrackingState() + } + + 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 { settingsRepo.otherSettings.collectLatest { settings -> _uiState.update { it.copy(isUtc = settings.stateOfUtc) } } } - // Resolve which pass we're tracking and start the tick loop + } + + private fun collectPassAndStartTickLoop() { viewModelScope.launch { val passes = satelliteRepo.passes.value val (catNum, aosTime) = satelliteRepo.selectedPass.value - val satPass = passes.find { it.catNum == catNum && it.aosTime == aosTime } ?: passes.firstOrNull() - satPass?.let { pass -> - _uiState.update { it.copy(currentPass = pass) } - val transmittersList = satelliteRepo.getRadiosWithId(pass.catNum) - // Load transponders for radio control (ones that have downlink) - transponders = transmittersList.filter { it.downlinkLow != null } - _uiState.update { state -> - state.copy(radioControl = state.radioControl.copy(transponders = transponders)) - } - // Auto-expand the first item so users discover the expandable UI - if (transmittersList.isNotEmpty()) { - val firstUuid = transmittersList.first().uuid - _uiState.update { it.copy(selectedTransmitterUuid = firstUuid) } - transponders.find { it.uuid == firstUuid }?.let { - trackingService.setTransponder(it) - } - } - // Compute track once (it doesn't change for a given pass) - if (!pass.isDeepSpace) { - 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) - } + val pass = passes.find { it.catNum == catNum && it.aosTime == aosTime } ?: passes.firstOrNull() ?: return@launch + _uiState.update { it.copy(currentPass = pass) } + val transmittersList = satelliteRepo.getRadiosWithId(pass.catNum) + transponders = transmittersList.filter { it.downlinkLow != null } + _uiState.update { state -> + state.copy(radioControl = state.radioControl.copy(transponders = transponders)) + } + if (transmittersList.isNotEmpty()) { + val firstUuid = transmittersList.first().uuid + _uiState.update { it.copy(selectedTransmitterUuid = firstUuid) } + transponders.find { it.uuid == firstUuid }?.let { trackingService.setTransponder(it) } + } + if (!pass.isDeepSpace) { + val track = satelliteRepo.getTrack(pass.orbitalObject, stationPos, pass.aosTime, pass.losTime) + _uiState.update { it.copy(satTrack = track) } + } + while (isActive) { + tickPass(pass, transmittersList) + delay(1000) } } - // Observe radio tracking service state + } + + private suspend fun tickPass(pass: OrbitalPass, transmittersList: List) { + 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 { trackingService.state.collect { svc -> _uiState.update { state -> @@ -210,6 +225,33 @@ class RadarViewModel( } RadarAction.ConnectRadios -> viewModelScope.launch { trackingService.connectRadios() } 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 { val CTCSS_TONES = listOf( @@ -307,7 +379,10 @@ class RadarViewModel( settingsRepo = container.settingsRepo, sensorsRepo = container.provideSensorsRepo(), addToCalendar = container.provideAddToCalendar(), - trackingService = container.radioTrackingService + trackingService = container.radioTrackingService, + audioCapture = container.provideAudioCapture(), + saveImage = container.provideSaveImage(), + showToast = container.provideShowToast() ) } } diff --git a/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadioControlPage.kt b/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadioControlPage.kt deleted file mode 100644 index eea7aa99..00000000 --- a/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadioControlPage.kt +++ /dev/null @@ -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 . - */ -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, - 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") diff --git a/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/SstvPage.kt b/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/SstvPage.kt new file mode 100644 index 00000000..61c1eb9d --- /dev/null +++ b/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/SstvPage.kt @@ -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 . + */ +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) + } + } + } + } +}