diff --git a/app/build.gradle.kts b/app/build.gradle.kts index fa29ee4f..b6e78cc4 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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( diff --git a/core/data/src/main/java/com/rtbishop/look4sat/core/data/cw/CwDeepDecoder.kt b/core/data/src/main/java/com/rtbishop/look4sat/core/data/cw/CwDeepDecoder.kt index 0631632c..d3134ca3 100644 --- a/core/data/src/main/java/com/rtbishop/look4sat/core/data/cw/CwDeepDecoder.kt +++ b/core/data/src/main/java/com/rtbishop/look4sat/core/data/cw/CwDeepDecoder.kt @@ -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 diff --git a/feature/cw/src/main/java/com/rtbishop/look4sat/feature/cw/CwWaterfall.kt b/feature/cw/src/main/java/com/rtbishop/look4sat/feature/cw/CwWaterfall.kt index 5886e0e3..7fcc60d1 100644 --- a/feature/cw/src/main/java/com/rtbishop/look4sat/feature/cw/CwWaterfall.kt +++ b/feature/cw/src/main/java/com/rtbishop/look4sat/feature/cw/CwWaterfall.kt @@ -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(historyRows) private val pending = ArrayList(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 = _revision.asStateFlow() /** Snapshot for drawing, oldest row first. */ - fun snapshot(): List = rows.toList() + fun snapshot(): List = 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()