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