fix(cw): stop the waterfall crashing and cut APK size by 87MB
三个真机实测暴露的问题:
1. 闪退 (给权限后 3-4 秒必崩, 且注入日志抓不到堆栈)
CwWaterfallState 用 mutableIntStateOf 记录重绘版本号, 却从音频采集线程
写入。Compose 快照状态只能在合成线程修改, 从后台线程写会在运行时崩溃 ——
崩在 Compose 内部, 所以业务类的日志注入抓不到。
改用 MutableStateFlow (本身线程安全), Canvas 侧 collectAsState 读取。
2. 同一状态的跨线程数据竞争
pushSamples 在采集线程写 rows/pending, snapshot() 在绘制线程读, 而
ArrayDeque 非线程安全 —— 并发 removeFirst()/toList() 会抛
ConcurrentModificationException 或 IndexOutOfBoundsException。
两侧统一加锁; FFT 计算放在锁外, 只有队列追加持锁。
3. APK 从 8.4MB 暴涨到 135MB
onnxruntime-android 的 AAR 自带 4 个架构原生库: arm64 28M + armv7 20M +
x86 33M + x86_64 34M = 115MB。上次提交移除 abiFilters 时把 x86 系列也
打包了进去 (仅模拟器需要)。
重新加上 abiFilters, 保留 arm64-v8a + armeabi-v7a 两个真机 ABI,
预计降至约 43MB。注意这与上次"恢复 64 位"不冲突: 那次删的是只留
armeabi-v7a 的限制, 这次是排除 x86 系列。
另外两处加固:
- CwDeepDecoder 的模型加载从 init{} 移入惰性 ensureLoaded(): 加载会触发
ONNX Runtime 原生库装载, 失败时抛 UnsatisfiedLinkError; 在构造函数中抛出
会连带崩掉创建它的 composable, try-catch 也救不回来。移到首次使用时执行,
失败经 errorMessage 上报给 UI。
- ONNX 会话限制 intraOp 线程数为 (核数-1) 且上限 4, 给音频采集和 UI 留出
余量, 默认行为会铺满所有核心。
验证:
./gradlew :feature:cw:compileDebugKotlin :core:data:compileDebugKotlin => 通过
./gradlew :core:domain:test => 79 个测试全绿
This commit is contained in:
1 parent
37300fb2a6
commit
5f297f9233
3 files changed
+92
-30
No files matched your search
@@ -11,6 +11,14 @@ val keystoreProperties = Properties().apply {
|
||||
}
|
||||
|
||||
android {
|
||||
defaultConfig {
|
||||
// ONNX Runtime 的 AAR 自带 4 个架构共 115MB 原生库(arm64 28M / armv7 20M /
|
||||
// x86 33M / x86_64 34M)。x86 系列只有模拟器用得到, 全打包会让 APK 从 8MB
|
||||
// 涨到 135MB。仅保留真机需要的两个 ABI。
|
||||
ndk {
|
||||
abiFilters += listOf("arm64-v8a", "armeabi-v7a")
|
||||
}
|
||||
}
|
||||
androidResources {
|
||||
// 显式保留全部语言(防 shrinkResources 丢弃 in/id 印尼语配置); AGP 9 用 localeFilters
|
||||
localeFilters += listOf(
|
||||
|
||||
@@ -84,10 +84,27 @@ class CwDeepDecoder(context: Context) : ICwDecoder {
|
||||
private var inputName = "spectrogram"
|
||||
private var outputName = "log_probs"
|
||||
|
||||
init {
|
||||
private val appContext = context.applicationContext
|
||||
private var loadAttempted = false
|
||||
|
||||
/**
|
||||
* Loads metadata and the ONNX session on first use.
|
||||
*
|
||||
* Deliberately not done in `init`: loading pulls in ONNX Runtime's native
|
||||
* library, and a failure there surfaces as [UnsatisfiedLinkError]. Thrown
|
||||
* from a constructor it would take down the whole composable that created
|
||||
* the decoder, so the work happens here where it can be reported through
|
||||
* [errorMessage] instead.
|
||||
*
|
||||
* @return true when the session is ready to run.
|
||||
*/
|
||||
private fun ensureLoaded(): Boolean {
|
||||
if (session != null) return true
|
||||
if (loadAttempted) return false
|
||||
loadAttempted = true
|
||||
try {
|
||||
val metadata = JSONObject(
|
||||
context.assets.open(METADATA_ASSET).bufferedReader().use { it.readText() }
|
||||
appContext.assets.open(METADATA_ASSET).bufferedReader().use { it.readText() }
|
||||
)
|
||||
val charArray = metadata.getJSONArray("chars")
|
||||
chars = List(charArray.length()) { charArray.getString(it) }
|
||||
@@ -95,18 +112,31 @@ class CwDeepDecoder(context: Context) : ICwDecoder {
|
||||
inputName = metadata.getString("onnx_input_name")
|
||||
outputName = metadata.getString("onnx_output_name")
|
||||
|
||||
val modelBytes = context.assets.open(MODEL_ASSET).use { it.readBytes() }
|
||||
environment = OrtEnvironment.getEnvironment()
|
||||
session = environment?.createSession(modelBytes, OrtSession.SessionOptions())
|
||||
val modelBytes = appContext.assets.open(MODEL_ASSET).use { it.readBytes() }
|
||||
val env = OrtEnvironment.getEnvironment()
|
||||
environment = env
|
||||
val options = OrtSession.SessionOptions().apply {
|
||||
// Keep a core free for audio capture and the UI; the default
|
||||
// would spread inference across every core on the device.
|
||||
val threads = (Runtime.getRuntime().availableProcessors() - 1).coerceIn(1, 4)
|
||||
setIntraOpNumThreads(threads)
|
||||
}
|
||||
session = env.createSession(modelBytes, options)
|
||||
Log.i(TAG, "DeepCW ready: ${modelBytes.size} bytes, ${chars.size} classes")
|
||||
return true
|
||||
} catch (t: Throwable) {
|
||||
// Catches UnsatisfiedLinkError (missing/mismatched .so) as well as
|
||||
// asset and session failures.
|
||||
Log.e(TAG, "DeepCW model failed to load", t)
|
||||
_errorMessage.value = "CW model failed to load: ${t.message ?: t.javaClass.simpleName}"
|
||||
_errorMessage.value =
|
||||
"CW model failed to load: ${t.message ?: t.javaClass.simpleName}"
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun processBuffer(samples: FloatArray, sampleRate: Int) {
|
||||
if (session == null || samples.isEmpty()) return
|
||||
if (samples.isEmpty()) return
|
||||
if (!ensureLoaded()) return
|
||||
|
||||
val resampled = CwDeepSpectrogram.resampleLinear(
|
||||
samples, sampleRate, CwDeepSpectrogram.SAMPLE_RATE
|
||||
|
||||
@@ -20,12 +20,16 @@ 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.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
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
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
|
||||
/**
|
||||
* Rolling spectrogram history for the waterfall display.
|
||||
@@ -35,40 +39,58 @@ import com.rtbishop.look4sat.core.domain.cw.CwDeepSpectrogram
|
||||
*/
|
||||
class CwWaterfallState(private val historyRows: Int = 96) {
|
||||
|
||||
// pushSamples runs on the audio capture thread while snapshot() runs on the
|
||||
// Compose draw thread, so every touch of these two collections is guarded.
|
||||
// ArrayDeque is not thread-safe: concurrent removeFirst()/toList() throws.
|
||||
private val lock = Any()
|
||||
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)
|
||||
/**
|
||||
* Bumped on every change so Compose knows to redraw.
|
||||
*
|
||||
* A StateFlow, not `mutableIntStateOf`: this is written from the audio
|
||||
* capture thread, and Compose snapshot state must only be mutated from the
|
||||
* composition thread — doing otherwise crashes at runtime.
|
||||
*/
|
||||
private val _revision = MutableStateFlow(0)
|
||||
val revision: StateFlow<Int> = _revision.asStateFlow()
|
||||
|
||||
/** Snapshot for drawing, oldest row first. */
|
||||
fun snapshot(): List<FloatArray> = rows.toList()
|
||||
fun snapshot(): List<FloatArray> = synchronized(lock) { 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)
|
||||
val audio: FloatArray
|
||||
synchronized(lock) {
|
||||
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
|
||||
audio = FloatArray(pending.size) { pending[it] }
|
||||
pending.clear()
|
||||
}
|
||||
revision.intValue++
|
||||
|
||||
// FFT outside the lock; only the append below needs exclusivity.
|
||||
val computed = CwDeepSpectrogram.compute(audio)
|
||||
synchronized(lock) {
|
||||
for (row in computed) {
|
||||
if (rows.size >= historyRows) rows.removeFirst()
|
||||
rows.addLast(row)
|
||||
}
|
||||
}
|
||||
_revision.value += 1
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
rows.clear()
|
||||
pending.clear()
|
||||
revision.intValue++
|
||||
synchronized(lock) {
|
||||
rows.clear()
|
||||
pending.clear()
|
||||
}
|
||||
_revision.value += 1
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,11 +104,13 @@ internal fun CwWaterfallView(
|
||||
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
|
||||
val revision by state.revision.collectAsState()
|
||||
|
||||
Canvas(modifier = modifier.fillMaxSize()) {
|
||||
// Touch the revision inside the draw scope so a new spectrum triggers a
|
||||
// redraw; without this read the canvas would only ever render once.
|
||||
@Suppress("UNUSED_EXPRESSION") revision
|
||||
|
||||
drawRect(color = Color(0xFF00060F), size = size)
|
||||
|
||||
val rows = state.snapshot()
|
||||
|
||||
Reference in new issue
Block a user