refactor(cw)!: replace legacy CW engine with DeepCW in both entry points
DeepCW 成为唯一 CW 解码内核, 不保留旧引擎作兜底 (用户决定: 完全移植)。 删除旧内核 (1298 行): - CwDecoder.kt / CwBayesianDecoder.kt / CwChannelTracker.kt / CwSpectrogram.kt - CwDsp / CwFFT / CwSTFFT / CwGoertzel / CwFilter / CwResampler.kt - CwDecoderTest.kt 旧内核的自动定频与 squelch 门控由 DeepCW 的 400-1200Hz 固定频窗替代 —— 模型自带定频, 不再需要频谱峰值跟踪和噪声门。 两个 CW 入口都改接 DeepCW: 1. feature:cw 独立整页 CwDecodeScreen.kt 重写为纯 Compose (瀑布图 -> 实时行 -> 历史区), 移除 AndroidView/LayoutInflater 和被删的 activity_main 布局; 删除 CwSettingsDialog.kt (旧引擎的手动音调/带宽面板, DeepCW 全自动无需) 2. feature:radar 内嵌可折叠面板改走 ViewModel 的 CW state/action, 移除 Morse Expert 控制器与 cw_panel_main 布局 (该面板此前注释写明 "no longer feeds it", ViewModel 里的 CW 通路是死代码) 新增 CwWaterfall.kt: 复用 CwDeepSpectrogram 绘制瀑布图, 显示的正是模型 分析的 400-1200Hz 频段与同一批幅值。 接口接线: - IMainContainer.provideCwDecoder() 提供 ICwDecoder (实现需 Context 读 assets) - RadarViewModel 构造新增 cwDecoderFactory, onCleared() 中 close() 释放 OrtSession 避免原生内存泄漏 - 删除已无调用方的 RadarAction.CwSetToneFreq (DeepCW 定频固定, 无参数可调); CwSubState.cwToneFreq 语义改为只读显示检测到的音调 依赖清理: - feature:radar 不再依赖 feature:cw 与 constraintlayout - feature:cw 不再依赖 constraintlayout 字符串: feature:cw 清理 Morse Expert 遗留串 (premium/rate/help/ tap_back_again_to_close/purple_500), 按 en/zh/tr/in/id 五语补全新串; core:presentation 补 radar_cw_tone / radar_cw_waiting 五语。 验证: ./gradlew :core:domain:test => 4 个测试类全绿 ./gradlew :feature:cw:compileDebugKotlin => BUILD SUCCESSFUL ./gradlew :feature:radar:compileDebugKotlin => BUILD SUCCESSFUL ./gradlew :core:data:compileDebugKotlin => BUILD SUCCESSFUL
This commit is contained in:
1 parent
57762613a8
commit
9b0d543f12
30 files changed
+430
-1805
No files matched your search
@@ -1,29 +1,28 @@
|
||||
/*
|
||||
* CwDecodeScreen.kt - Look4Sat CW decode page (Compose shell).
|
||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||
*
|
||||
* Ported from Morse Expert 1.15 com.ve3nea.morse_expert.MainActivity (converted to a plain controller class):
|
||||
* - AndroidView embeds the original activity_main.xml (ConstraintLayout root, with decodedTextView/scaleView/
|
||||
* statusTextView/verticalLayout/waterfallView);
|
||||
* - Lifecycle onCreate/onResume/onPause/onDestroy fully delegated to the controller, same order as the original Activity
|
||||
* (onCreate must run before onResume; onDispose runs onPause then onDestroy);
|
||||
* - Mic permission (RECORD_AUDIO) is managed on the Compose side; onPermissionGranted() (= v()) is called once granted,
|
||||
* an initialized flag guarantees it runs only once (v() news an a() which overwrites f11036E);
|
||||
* - The original options_menu Clear/Pause/Save/Record/Settings became a top button row (icons from the module's original drawables).
|
||||
* 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.
|
||||
*
|
||||
* Note: setImmersive(window, false) inside the controller's onCreate restores the host window to
|
||||
* decorFitsSystemWindows(true) (ported original behavior; affects the whole host Activity).
|
||||
* 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.cw
|
||||
|
||||
import android.Manifest
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.Uri
|
||||
import android.provider.Settings
|
||||
import android.view.LayoutInflater
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.background
|
||||
@@ -36,169 +35,233 @@ 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.systemBarsPadding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
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.graphics.Color
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.constraintlayout.widget.ConstraintLayout
|
||||
import com.ve3nea.morse_expert.MainActivity
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
|
||||
import com.rtbishop.look4sat.core.presentation.R as CoreR
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Full-page CW decoder backed by DeepCW.
|
||||
*
|
||||
* Layout follows the DeepCW reference app: waterfall on top, the live line
|
||||
* under it, then the scrolling history. Pause/clear controls and the microphone
|
||||
* permission flow carry over from the previous page.
|
||||
*
|
||||
* There is no settings dialog any more: the model analyses a fixed
|
||||
* 400-1200 Hz window and tracks speed on its own, so there is nothing to tune.
|
||||
*/
|
||||
@Composable
|
||||
fun CwDecodeScreen(navigateUp: () -> Unit = {}) {
|
||||
val context = LocalContext.current
|
||||
val activity = remember(context) {
|
||||
context as? Activity ?: error("CwDecodeScreen must be hosted in an Activity")
|
||||
}
|
||||
// Ported controller (plain class, not an Activity); new instance per page entry
|
||||
val controller = remember { MainActivity() }
|
||||
// Inflate the original layout early so AndroidView and the controller's onCreate share one root view
|
||||
val rootView = remember(context) {
|
||||
LayoutInflater.from(context).inflate(R.layout.activity_main, null) as ConstraintLayout
|
||||
}
|
||||
val container = remember { (context.applicationContext as IContainerProvider).getMainContainer() }
|
||||
val decoder = remember { container.provideCwDecoder() }
|
||||
val audioCapture = remember { container.provideAudioCapture() }
|
||||
val waterfall = remember { CwWaterfallState() }
|
||||
|
||||
var permissionGranted by remember {
|
||||
mutableStateOf(
|
||||
context.checkSelfPermission(Manifest.permission.RECORD_AUDIO) ==
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
)
|
||||
}
|
||||
var permanentlyDenied by remember { mutableStateOf(false) }
|
||||
// v() news an a() which overwrites f11036E; must run exactly once
|
||||
var initialized by remember { mutableStateOf(false) }
|
||||
var showSettings by remember { mutableStateOf(false) }
|
||||
var isListening by remember { mutableStateOf(false) }
|
||||
|
||||
// Lifecycle: onCreate before onResume; onDispose runs onPause then onDestroy
|
||||
DisposableEffect(Unit) {
|
||||
controller.onCreate(activity, rootView)
|
||||
controller.onResume()
|
||||
onDispose {
|
||||
controller.onPause()
|
||||
controller.onDestroy()
|
||||
}
|
||||
}
|
||||
|
||||
// Start the decode core after permission granted; both "already granted on entry" and "granted via dialog" go here
|
||||
LaunchedEffect(permissionGranted) {
|
||||
if (permissionGranted && !initialized) {
|
||||
controller.onPermissionGranted() // = v(): 创建音频采集 + 解码核心
|
||||
// v() only creates the core; the page is already resumed, so run onResume again to start recording immediately
|
||||
// (AudioRecord is freshly created so startRecording won't double-start; GLSurfaceView.onResume is idempotent)
|
||||
controller.onResume()
|
||||
initialized = true
|
||||
}
|
||||
}
|
||||
val decodedText by decoder.decodedText.collectAsState()
|
||||
val estimatedPitch by decoder.estimatedPitch.collectAsState()
|
||||
val signalStrength by decoder.signalStrength.collectAsState()
|
||||
val inferenceMs by decoder.lastInferenceMs.collectAsState()
|
||||
val errorMessage by decoder.errorMessage.collectAsState()
|
||||
|
||||
val permissionLauncher = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.RequestPermission()
|
||||
) { granted ->
|
||||
permissionGranted = granted
|
||||
permanentlyDenied = !granted &&
|
||||
val activity = context as? Activity
|
||||
permanentlyDenied = !granted && activity != null &&
|
||||
!activity.shouldShowRequestPermissionRationale(Manifest.permission.RECORD_AUDIO)
|
||||
if (granted) isListening = true
|
||||
}
|
||||
|
||||
// Original tap_back_again_to_close: single Back shows a hint; press again within 2 s to exit
|
||||
BackHandler {
|
||||
if (!controller.handleBackPress()) navigateUp()
|
||||
// Capture runs only while listening; cancelling the effect stops the mic.
|
||||
LaunchedEffect(isListening, permissionGranted) {
|
||||
if (!isListening || !permissionGranted) return@LaunchedEffect
|
||||
launch {
|
||||
audioCapture.audioFlow().collect { chunk ->
|
||||
decoder.processBuffer(chunk, audioCapture.sampleRate)
|
||||
waterfall.pushSamples(chunk, audioCapture.sampleRate)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showSettings) {
|
||||
CwSettingsDialog(controller = controller, onDismiss = { showSettings = false })
|
||||
LaunchedEffect(permissionGranted) {
|
||||
if (permissionGranted) isListening = true
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.systemBarsPadding()
|
||||
) {
|
||||
// Original options_menu: Pause/Clear/Save (original icons) + Record/Settings (text buttons)
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
// OrtSession holds native memory and must be released explicitly.
|
||||
decoder.close()
|
||||
}
|
||||
}
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceEvenly
|
||||
.padding(horizontal = 4.dp, vertical = 2.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
IconButton(onClick = { controller.togglePause() }) {
|
||||
IconButton(onClick = { isListening = false; navigateUp() }) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_baseline_pause_24),
|
||||
contentDescription = stringResource(R.string.pause)
|
||||
painter = painterResource(CoreR.drawable.ic_back),
|
||||
contentDescription = stringResource(R.string.cw_back)
|
||||
)
|
||||
}
|
||||
IconButton(onClick = { controller.clearDecoded() }) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_baseline_delete_24),
|
||||
contentDescription = stringResource(R.string.clear)
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = stringResource(CoreR.string.nav_cw),
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
)
|
||||
Text(
|
||||
text = if (estimatedPitch != null) {
|
||||
stringResource(R.string.cw_status_tone, estimatedPitch!!.toInt(), inferenceMs)
|
||||
} else {
|
||||
stringResource(R.string.cw_status_listening)
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
IconButton(onClick = { controller.saveText() }) {
|
||||
IconButton(onClick = { isListening = !isListening }) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_baseline_save_24),
|
||||
contentDescription = stringResource(R.string.save_text)
|
||||
painter = painterResource(
|
||||
if (isListening) CoreR.drawable.ic_pause else CoreR.drawable.ic_play
|
||||
),
|
||||
contentDescription = stringResource(
|
||||
if (isListening) R.string.cw_pause else R.string.cw_resume
|
||||
)
|
||||
)
|
||||
}
|
||||
// recordSignals needs the decode core (f11037F); disabled until initialized
|
||||
TextButton(
|
||||
onClick = { if (initialized) controller.recordSignals() },
|
||||
enabled = initialized
|
||||
) {
|
||||
Text(stringResource(R.string.record_signals))
|
||||
}
|
||||
TextButton(onClick = { showSettings = true }) {
|
||||
Text(stringResource(R.string.settings))
|
||||
IconButton(onClick = { decoder.reset(); waterfall.clear() }) {
|
||||
Icon(
|
||||
painter = painterResource(CoreR.drawable.ic_delete),
|
||||
contentDescription = stringResource(R.string.cw_clear)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Box(modifier = Modifier.weight(1f).fillMaxWidth()) {
|
||||
AndroidView(
|
||||
factory = { rootView },
|
||||
modifier = Modifier.fillMaxSize()
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(150.dp)
|
||||
.padding(horizontal = 8.dp)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
) {
|
||||
CwWaterfallView(state = waterfall, signalStrength = signalStrength)
|
||||
}
|
||||
|
||||
Text(
|
||||
text = decodedText.takeLast(64).ifEmpty { "…" },
|
||||
fontSize = 20.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
maxLines = 1,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 6.dp)
|
||||
)
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f))
|
||||
) {
|
||||
Text(
|
||||
text = decodedText,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(8.dp),
|
||||
fontSize = 16.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
if (!permissionGranted) {
|
||||
// Mic permission not granted: overlay + hint text + request button
|
||||
|
||||
errorMessage?.let { message ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color(0x99000000)),
|
||||
.background(MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.9f)),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(
|
||||
text = message,
|
||||
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(16.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (!permissionGranted) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.scrim.copy(alpha = 0.6f)),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.cw_mic_permission),
|
||||
color = Color.White,
|
||||
color = MaterialTheme.colorScheme.inverseOnSurface,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(horizontal = 24.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
Button(onClick = {
|
||||
permissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
|
||||
}) {
|
||||
Text(stringResource(R.string.cw_grant_permission))
|
||||
}
|
||||
if (permanentlyDenied) {
|
||||
// "Never ask again" checked: guide user to system settings
|
||||
TextButton(onClick = {
|
||||
activity.startActivity(
|
||||
context.startActivity(
|
||||
Intent(
|
||||
Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
|
||||
Uri.parse("package:${activity.packageName}")
|
||||
Uri.fromParts("package", context.packageName, null)
|
||||
)
|
||||
)
|
||||
}) {
|
||||
@@ -209,5 +272,7 @@ fun CwDecodeScreen(navigateUp: () -> Unit = {}) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
}
|
||||
}
|
||||
@@ -1,218 +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.
|
||||
*/
|
||||
package com.rtbishop.look4sat.feature.cw
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
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.Spacer
|
||||
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.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.RadioButton
|
||||
import androidx.compose.material3.Slider
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
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.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.ve3nea.morse_expert.MainActivity
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/** Keys/defaults ported from the original Morse Expert (root_preferences.xml + SettingsActivity logic). */
|
||||
private const val KEY_MESSAGE_TYPE = "message_type"
|
||||
private const val KEY_TEXT_FONT_SIZE = "text_font_size"
|
||||
private const val DEFAULT_MESSAGE_TYPE = "general_text"
|
||||
private const val DEFAULT_TEXT_FONT_SIZE = 18
|
||||
private const val MIN_FONT_SIZE = 7
|
||||
private const val MAX_FONT_SIZE = 99
|
||||
|
||||
/** Preset palette (9 colors, 3x3 grid); the original ColorPreferenceCompat used a third-party colorpicker, not imported. */
|
||||
private val PaletteColors = listOf(
|
||||
0xFFD0F0F0.toInt(), 0xFF000000.toInt(), 0xFFFFFFFF.toInt(),
|
||||
0xFFAAAAAA.toInt(), 0xFFFF0000.toInt(), 0xFF00FF00.toInt(),
|
||||
0xFF0000FF.toInt(), 0xFFFFFF00.toInt(), 0xFFFF00FF.toInt(),
|
||||
)
|
||||
|
||||
/**
|
||||
* CW settings dialog (replaces the original Morse Expert SettingsActivity).
|
||||
* Items and keys ported from the original APK: message_type / text_font_size / 9 colors
|
||||
* (I2.b.f663b keys, I2.b.f664d defaults, I2.b.c English titles).
|
||||
* Storage: SharedPreferences(getPackageName() + "_preferences").
|
||||
* OK saves and calls controller.onResume() to take effect at once; Cancel/outside-tap only closes without saving.
|
||||
*/
|
||||
@Composable
|
||||
fun CwSettingsDialog(controller: MainActivity, onDismiss: () -> Unit) {
|
||||
val activity = controller.mActivity ?: return
|
||||
val prefs = remember(activity) {
|
||||
activity.getSharedPreferences(activity.packageName + "_preferences", Context.MODE_PRIVATE)
|
||||
}
|
||||
|
||||
var messageType by remember {
|
||||
mutableStateOf(prefs.getString(KEY_MESSAGE_TYPE, DEFAULT_MESSAGE_TYPE) ?: DEFAULT_MESSAGE_TYPE)
|
||||
}
|
||||
var fontSize by remember {
|
||||
mutableStateOf(
|
||||
prefs.getInt(KEY_TEXT_FONT_SIZE, DEFAULT_TEXT_FONT_SIZE)
|
||||
.toFloat().coerceIn(MIN_FONT_SIZE.toFloat(), MAX_FONT_SIZE.toFloat())
|
||||
)
|
||||
}
|
||||
var colorValues by remember {
|
||||
mutableStateOf(IntArray(9) { i -> prefs.getInt(I2.b.f663b[i], I2.b.f664d[i]) })
|
||||
}
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("CW Settings") },
|
||||
text = {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(top = 4.dp)
|
||||
) {
|
||||
Text("Message type", style = MaterialTheme.typography.titleSmall)
|
||||
MessageTypeRow("general_text", "General Text", messageType) { messageType = it }
|
||||
MessageTypeRow("ham_radio_qso", "Ham Radio QSO", messageType) { messageType = it }
|
||||
HorizontalDivider(Modifier.padding(vertical = 8.dp))
|
||||
|
||||
Text("Text font size", style = MaterialTheme.typography.titleSmall)
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Slider(
|
||||
value = fontSize,
|
||||
onValueChange = { fontSize = it },
|
||||
valueRange = MIN_FONT_SIZE.toFloat()..MAX_FONT_SIZE.toFloat(),
|
||||
steps = MAX_FONT_SIZE - MIN_FONT_SIZE - 1,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Text(
|
||||
text = "${fontSize.roundToInt()}",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
textAlign = TextAlign.End,
|
||||
modifier = Modifier.width(44.dp),
|
||||
)
|
||||
}
|
||||
HorizontalDivider(Modifier.padding(vertical = 8.dp))
|
||||
|
||||
Text("Colors", style = MaterialTheme.typography.titleSmall)
|
||||
for (i in 0 until 9) {
|
||||
ColorSettingRow(
|
||||
title = I2.b.c[i],
|
||||
value = colorValues[i],
|
||||
onSelect = { selected ->
|
||||
colorValues = colorValues.copyOf().also { it[i] = selected }
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
val editor = prefs.edit()
|
||||
editor.putString(KEY_MESSAGE_TYPE, messageType)
|
||||
editor.putInt(KEY_TEXT_FONT_SIZE, fontSize.roundToInt())
|
||||
for (i in 0 until 9) {
|
||||
editor.putInt(I2.b.f663b[i], colorValues[i])
|
||||
}
|
||||
editor.apply()
|
||||
// The original re-read all settings in MainActivity.onResume() when SettingsActivity returned; ported logic in place
|
||||
controller.onResume()
|
||||
onDismiss()
|
||||
}) { Text("OK") }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) { Text("Cancel") }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MessageTypeRow(option: String, label: String, selected: String, onSelect: (String) -> Unit) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(6.dp))
|
||||
.clickable { onSelect(option) }
|
||||
.padding(vertical = 2.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
RadioButton(selected = option == selected, onClick = { onSelect(option) })
|
||||
Text(label, style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ColorSettingRow(title: String, value: Int, onSelect: (Int) -> Unit) {
|
||||
Column(Modifier.fillMaxWidth().padding(vertical = 6.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(title, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.weight(1f))
|
||||
Box(
|
||||
Modifier
|
||||
.size(20.dp)
|
||||
.clip(RoundedCornerShape(4.dp))
|
||||
.background(Color(value)),
|
||||
)
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text(
|
||||
text = String.format("#%06X", value and 0xFFFFFF),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(6.dp))
|
||||
PaletteGrid(selected = value, onSelect = onSelect)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PaletteGrid(selected: Int, onSelect: (Int) -> Unit) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
PaletteColors.chunked(3).forEach { rowColors ->
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
rowColors.forEach { color ->
|
||||
val isSelected = color == selected
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(36.dp)
|
||||
.border(
|
||||
width = if (isSelected) 3.dp else 1.dp,
|
||||
color = if (isSelected) MaterialTheme.colorScheme.primary
|
||||
else MaterialTheme.colorScheme.outlineVariant,
|
||||
shape = RoundedCornerShape(6.dp),
|
||||
)
|
||||
.clip(RoundedCornerShape(6.dp))
|
||||
.background(Color(color))
|
||||
.clickable { onSelect(color) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
/*
|
||||
* 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.cw
|
||||
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import com.rtbishop.look4sat.core.domain.cw.CwDeepSpectrogram
|
||||
|
||||
/**
|
||||
* Rolling spectrogram history for the waterfall display.
|
||||
*
|
||||
* Reuses [CwDeepSpectrogram] so the picture shows exactly the 400-1200 Hz band
|
||||
* and the same magnitudes the model sees — what you look at is what it decodes.
|
||||
*/
|
||||
class CwWaterfallState(private val historyRows: Int = 96) {
|
||||
|
||||
private val rows = ArrayDeque<FloatArray>(historyRows)
|
||||
private val pending = ArrayList<Float>(CwDeepSpectrogram.SAMPLE_RATE)
|
||||
|
||||
/** Bumped on every change so Compose knows to redraw. */
|
||||
val revision = mutableIntStateOf(0)
|
||||
|
||||
/** Snapshot for drawing, oldest row first. */
|
||||
fun snapshot(): List<FloatArray> = rows.toList()
|
||||
|
||||
fun pushSamples(chunk: FloatArray, sampleRate: Int) {
|
||||
if (chunk.isEmpty()) return
|
||||
val resampled = CwDeepSpectrogram.resampleLinear(
|
||||
chunk, sampleRate, CwDeepSpectrogram.SAMPLE_RATE
|
||||
)
|
||||
pending.ensureCapacity(pending.size + resampled.size)
|
||||
for (sample in resampled) pending.add(sample)
|
||||
|
||||
// Need at least one FFT window before a row can be produced.
|
||||
if (pending.size < CwDeepSpectrogram.FFT_LENGTH) return
|
||||
|
||||
val audio = FloatArray(pending.size) { pending[it] }
|
||||
pending.clear()
|
||||
|
||||
for (row in CwDeepSpectrogram.compute(audio)) {
|
||||
if (rows.size >= historyRows) rows.removeFirst()
|
||||
rows.addLast(row)
|
||||
}
|
||||
revision.intValue++
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
rows.clear()
|
||||
pending.clear()
|
||||
revision.intValue++
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Draws the waterfall newest-row-last, one pixel column per frequency bin.
|
||||
* Colour ramp goes dark blue -> cyan -> yellow with magnitude.
|
||||
*/
|
||||
@Composable
|
||||
internal fun CwWaterfallView(
|
||||
state: CwWaterfallState,
|
||||
signalStrength: Float,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
// Reading the revision inside the draw scope is what triggers redraws as
|
||||
// new spectra arrive; without it the canvas would render only once.
|
||||
val revision = state.revision.intValue
|
||||
|
||||
Canvas(modifier = modifier.fillMaxSize()) {
|
||||
drawRect(color = Color(0xFF00060F), size = size)
|
||||
|
||||
val rows = state.snapshot()
|
||||
if (rows.isEmpty()) return@Canvas
|
||||
|
||||
// Scale to the loudest value on screen so quiet signals stay visible.
|
||||
var peak = 0f
|
||||
for (row in rows) for (v in row) if (v > peak) peak = v
|
||||
if (peak <= 0f) return@Canvas
|
||||
|
||||
val rowHeight = size.height / rows.size
|
||||
val binWidth = size.width / CwDeepSpectrogram.FREQUENCY_BINS
|
||||
|
||||
for ((index, row) in rows.withIndex()) {
|
||||
val y = index * rowHeight
|
||||
for (bin in row.indices) {
|
||||
val magnitude = (row[bin] / peak).coerceIn(0f, 1f)
|
||||
if (magnitude < 0.06f) continue
|
||||
drawRect(
|
||||
color = rampColor(magnitude),
|
||||
topLeft = Offset(bin * binWidth, y),
|
||||
size = Size(binWidth + 1f, rowHeight + 1f)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (signalStrength > 0f) {
|
||||
drawRect(
|
||||
color = Color(0xFF4CD964).copy(alpha = 0.8f),
|
||||
topLeft = Offset(0f, size.height - 3f),
|
||||
size = Size(size.width * signalStrength.coerceIn(0f, 1f), 3f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun rampColor(magnitude: Float): Color = when {
|
||||
magnitude < 0.5f -> {
|
||||
val t = magnitude / 0.5f
|
||||
Color(red = 0f, green = 0.35f * t, blue = 0.35f + 0.55f * t)
|
||||
}
|
||||
else -> {
|
||||
val t = (magnitude - 0.5f) / 0.5f
|
||||
Color(red = t, green = 0.35f + 0.6f * t, blue = 0.9f - 0.8f * t)
|
||||
}
|
||||
}
|
||||
@@ -1,22 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="purple_500">#ff6200ee</color>
|
||||
<integer name="default_font_size">18</integer>
|
||||
<string name="idle">Diam</string>
|
||||
<string name="waterfall_display">Tampilan waterfall</string>
|
||||
<string name="frequency_scale">Skala frekuensi</string>
|
||||
<string name="decoded_text">Teks terdekode</string>
|
||||
<string name="clear">Bersihkan</string>
|
||||
<string name="pause">Jeda Dekode</string>
|
||||
<string name="save_text">Simpan Teks</string>
|
||||
<string name="record_signals">Rekam Sinyal</string>
|
||||
<string name="settings">Pengaturan</string>
|
||||
<string name="help">Bantuan Daring</string>
|
||||
<string name="rate">Nilai Aplikasi Ini</string>
|
||||
<string name="premium">Dapatkan Versi Premium</string>
|
||||
<string name="tap_back_again_to_close">Ketuk Kembali lagi untuk menutup aplikasi</string>
|
||||
<string name="message_type">Tipe Pesan</string>
|
||||
<string name="cw_mic_permission">Izin mikrofon diperlukan untuk dekode CW</string>
|
||||
<string name="cw_grant_permission">Beri izin</string>
|
||||
<string name="cw_back">Kembali</string>
|
||||
<string name="cw_pause">Jeda pendekodean</string>
|
||||
<string name="cw_resume">Lanjutkan pendekodean</string>
|
||||
<string name="cw_clear">Hapus teks hasil dekode</string>
|
||||
<string name="cw_status_listening">Mendengarkan…</string>
|
||||
<string name="cw_status_tone">%1$d Hz · %2$d ms</string>
|
||||
<string name="cw_mic_permission">Izin mikrofon diperlukan untuk pendekodean CW</string>
|
||||
<string name="cw_grant_permission">Berikan izin</string>
|
||||
<string name="cw_open_settings">Buka pengaturan aplikasi</string>
|
||||
</resources>
|
||||
@@ -1,22 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="purple_500">#ff6200ee</color>
|
||||
<integer name="default_font_size">18</integer>
|
||||
<string name="idle">Diam</string>
|
||||
<string name="waterfall_display">Tampilan waterfall</string>
|
||||
<string name="frequency_scale">Skala frekuensi</string>
|
||||
<string name="decoded_text">Teks terdekode</string>
|
||||
<string name="clear">Bersihkan</string>
|
||||
<string name="pause">Jeda Dekode</string>
|
||||
<string name="save_text">Simpan Teks</string>
|
||||
<string name="record_signals">Rekam Sinyal</string>
|
||||
<string name="settings">Pengaturan</string>
|
||||
<string name="help">Bantuan Daring</string>
|
||||
<string name="rate">Nilai Aplikasi Ini</string>
|
||||
<string name="premium">Dapatkan Versi Premium</string>
|
||||
<string name="tap_back_again_to_close">Ketuk Kembali lagi untuk menutup aplikasi</string>
|
||||
<string name="message_type">Tipe Pesan</string>
|
||||
<string name="cw_mic_permission">Izin mikrofon diperlukan untuk dekode CW</string>
|
||||
<string name="cw_grant_permission">Beri izin</string>
|
||||
<string name="cw_back">Kembali</string>
|
||||
<string name="cw_pause">Jeda pendekodean</string>
|
||||
<string name="cw_resume">Lanjutkan pendekodean</string>
|
||||
<string name="cw_clear">Hapus teks hasil dekode</string>
|
||||
<string name="cw_status_listening">Mendengarkan…</string>
|
||||
<string name="cw_status_tone">%1$d Hz · %2$d ms</string>
|
||||
<string name="cw_mic_permission">Izin mikrofon diperlukan untuk pendekodean CW</string>
|
||||
<string name="cw_grant_permission">Berikan izin</string>
|
||||
<string name="cw_open_settings">Buka pengaturan aplikasi</string>
|
||||
</resources>
|
||||
@@ -1,22 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="purple_500">#ff6200ee</color>
|
||||
<integer name="default_font_size">18</integer>
|
||||
<string name="idle">Beklemede</string>
|
||||
<string name="waterfall_display">Şelale görüntüsü</string>
|
||||
<string name="frequency_scale">Frekans ölçeği</string>
|
||||
<string name="decoded_text">Çözülen metin</string>
|
||||
<string name="clear">Temizle</string>
|
||||
<string name="pause">Çözmeyi Duraklat</string>
|
||||
<string name="save_text">Metni Kaydet</string>
|
||||
<string name="record_signals">Sinyalleri Kaydet</string>
|
||||
<string name="settings">Ayarlar</string>
|
||||
<string name="help">Çevrimiçi Yardım</string>
|
||||
<string name="rate">Bu Uygulamayı Değerlendir</string>
|
||||
<string name="premium">Premium Sürümü Al</string>
|
||||
<string name="tap_back_again_to_close">Kapatmak için Geriye tekrar dokunun</string>
|
||||
<string name="message_type">Mesaj Türü</string>
|
||||
<string name="cw_mic_permission">CW çözme için mikrofon izni gereklidir</string>
|
||||
<string name="cw_back">Geri</string>
|
||||
<string name="cw_pause">Çözmeyi duraklat</string>
|
||||
<string name="cw_resume">Çözmeyi sürdür</string>
|
||||
<string name="cw_clear">Çözülen metni temizle</string>
|
||||
<string name="cw_status_listening">Dinleniyor…</string>
|
||||
<string name="cw_status_tone">%1$d Hz · %2$d ms</string>
|
||||
<string name="cw_mic_permission">CW çözümü için mikrofon izni gerekli</string>
|
||||
<string name="cw_grant_permission">İzin ver</string>
|
||||
<string name="cw_open_settings">Uygulama ayarlarını aç</string>
|
||||
</resources>
|
||||
@@ -1,21 +1,11 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="purple_500">#ff6200ee</color>
|
||||
<integer name="default_font_size">18</integer>
|
||||
<string name="idle">空闲</string>
|
||||
<string name="waterfall_display">瀑布图</string>
|
||||
<string name="frequency_scale">频率刻度</string>
|
||||
<string name="decoded_text">解码文本</string>
|
||||
<string name="clear">清除</string>
|
||||
<string name="pause">暂停解码</string>
|
||||
<string name="save_text">保存文本</string>
|
||||
<string name="record_signals">录音信号</string>
|
||||
<string name="settings">设置</string>
|
||||
<string name="help">在线帮助</string>
|
||||
<string name="rate">评价此应用</string>
|
||||
<string name="premium">获取高级版</string>
|
||||
<string name="tap_back_again_to_close">再次按返回键退出应用</string>
|
||||
<string name="message_type">消息类型</string>
|
||||
<string name="cw_back">返回</string>
|
||||
<string name="cw_pause">暂停解码</string>
|
||||
<string name="cw_resume">继续解码</string>
|
||||
<string name="cw_clear">清空解码文本</string>
|
||||
<string name="cw_status_listening">正在监听…</string>
|
||||
<string name="cw_status_tone">%1$d Hz · %2$d 毫秒</string>
|
||||
<string name="cw_mic_permission">CW 解码需要麦克风权限</string>
|
||||
<string name="cw_grant_permission">授予权限</string>
|
||||
<string name="cw_open_settings">打开应用设置</string>
|
||||
|
||||
@@ -1,21 +1,11 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="purple_500">#ff6200ee</color>
|
||||
<integer name="default_font_size">18</integer>
|
||||
<string name="idle">Idle</string>
|
||||
<string name="waterfall_display">Waterfall display</string>
|
||||
<string name="frequency_scale">Frequency scale</string>
|
||||
<string name="decoded_text">Decoded text</string>
|
||||
<string name="clear">Clear</string>
|
||||
<string name="pause">Pause Decoding</string>
|
||||
<string name="save_text">Save Text</string>
|
||||
<string name="record_signals">Record Signals</string>
|
||||
<string name="settings">Settings</string>
|
||||
<string name="help">Online Help</string>
|
||||
<string name="rate">Rate this App</string>
|
||||
<string name="premium">Get Premium Version</string>
|
||||
<string name="tap_back_again_to_close">Tap Back again to close the app</string>
|
||||
<string name="message_type">Message Type</string>
|
||||
<string name="cw_back">Back</string>
|
||||
<string name="cw_pause">Pause decoding</string>
|
||||
<string name="cw_resume">Resume decoding</string>
|
||||
<string name="cw_clear">Clear decoded text</string>
|
||||
<string name="cw_status_listening">Listening…</string>
|
||||
<string name="cw_status_tone">%1$d Hz · %2$d ms</string>
|
||||
<string name="cw_mic_permission">Microphone permission is required for CW decoding</string>
|
||||
<string name="cw_grant_permission">Grant permission</string>
|
||||
<string name="cw_open_settings">Open app settings</string>
|
||||
|
||||
Reference in new issue
Block a user