Commit d67ce3d2c2a599ea7759a4b36be095994d5e1ef2

Authored by yibo
1 parent bb2506da

feat(lifelog-recorder): v0.2 — Opus 编码 + 自启动 + 电池白名单

让 v1 真能脱离开发环境 24/7 跑的最后三公里:

- SegmentWriter 改 Opus(MediaCodec audio/opus encoder + MediaMuxer OGG)
  ~17× 压缩,~2 KB/s 实测(vs WAV 32 KB/s)
  存储预算:8h 语音/天 → ~57 MB/天,~1.7 GB/月(vs WAV 51 GB/月)
  ffprobe 验证 codec_name=opus, channels=1, OggS magic OK

- BootReceiver 监听 BOOT_COMPLETED / LOCKED_BOOT_COMPLETED /
  QUICKBOOT_POWERON,try-catch 兜底 Android 14+ FG service 限制

- MainActivity 加电池豁免行:dumpsys deviceidle whitelist 实时检测,
  未豁免时给「申请豁免」按钮,触发系统 RequestIgnoreBatteryOptimizations
  对话框;ON_RESUME 生命周期观察让从设置页回来 UI 自动刷新

- AndroidManifest:
  + REQUEST_IGNORE_BATTERY_OPTIMIZATIONS 权限
  + BootReceiver 注册(含 3 个 boot 相关 action)

HANDOFF.md §五 更新待办(v0.2 三项移到「已完成」),加存储预算 +
Android 14 BOOT_COMPLETED caveat 段落(Pixel 8 Pro 实测能自动续上,
前提 app 至少开过一次 + RECORD_AUDIO 已授)。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
lifelog-recorder/HANDOFF.md
@@ -40,13 +40,14 @@ APK 大小: 62 MB(含 34 MB sherpa-onnx .so + 0.6 MB silero VAD + 3 MB samp @@ -40,13 +40,14 @@ APK 大小: 62 MB(含 34 MB sherpa-onnx .so + 0.6 MB silero VAD + 3 MB samp
40 ``` 40 ```
41 app/src/main/kotlin/com/lemon/lifelog/ 41 app/src/main/kotlin/com/lemon/lifelog/
42 ├── LifelogApp.kt — Application,建 NotificationChannel 42 ├── LifelogApp.kt — Application,建 NotificationChannel
43 -├── MainActivity.kt — Compose UI,时间轴 LazyColumn + 测试按钮 43 +├── MainActivity.kt — Compose UI,时间轴 + 电池豁免 UI + 测试按钮
44 ├── service/ 44 ├── service/
45 │ ├── RecorderService.kt — 前台服务,pipeline 主体 ←★ 改 ASR 流程改这里 45 │ ├── RecorderService.kt — 前台服务,pipeline 主体 ←★ 改 ASR 流程改这里
46 -│ └── RecorderState.kt — StateFlow 把服务状态推到 UI 46 +│ ├── RecorderState.kt — StateFlow 把服务状态推到 UI
  47 +│ └── BootReceiver.kt — BOOT_COMPLETED 自启 (Android 14 有 caveat 见 §七)
47 ├── audio/ 48 ├── audio/
48 │ ├── AudioCapture.kt — AudioRecord wrapper,emits FloatArray frames 49 │ ├── AudioCapture.kt — AudioRecord wrapper,emits FloatArray frames
49 -│ └── SegmentWriter.kt — 写 16k mono WAV 到 filesDir(待换 Opus) 50 +│ └── SegmentWriter.kt — Opus encode via MediaCodec + Ogg muxer,~17× 压缩
50 ├── asr/ 51 ├── asr/
51 │ ├── ParaformerEngine.kt — sherpa-onnx paraformer 包装 ←★ 主 ASR 52 │ ├── ParaformerEngine.kt — sherpa-onnx paraformer 包装 ←★ 主 ASR
52 │ ├── ModelDownloader.kt — 首次启动下模型 + 断点续传 53 │ ├── ModelDownloader.kt — 首次启动下模型 + 断点续传
@@ -150,13 +151,30 @@ VAD 参数(在 `RecorderService.runPipeline` 里调): @@ -150,13 +151,30 @@ VAD 参数(在 `RecorderService.runPipeline` 里调):
150 151
151 ## §五 §六 待办(按优先级) 152 ## §五 §六 待办(按优先级)
152 153
153 -1. **Opus 编码**(🔥 必须):WAV 在 24/7 跑下 ~2.8 GB/天。换 `MediaCodec OMX.google.opus.encoder` 12 kbps voice mode,缩到 ~130 MB/天。改 `SegmentWriter.kt`。  
154 -2. **自启动 + 电池白名单**(🔥 必须):BOOT_COMPLETED Receiver 拉起 RecorderService;首启弹 REQUEST_IGNORE_BATTERY_OPTIMIZATIONS。否则一重启就死、Doze 一介入就死。  
155 -3. **声纹识别 / speaker diarization**(用户当面提过 3 次):sherpa-onnx 已有 `Speaker.kt` + `SpeakerEmbeddingExtractorConfig.kt`。流程:(a) 「这是我」录音 → 建 anchor embedding 存 SQLite;(b) 每段算 embedding 算 cosine 找最近邻;(c) Transcript 加 `speakerHint` 列。需要下 `sherpa-onnx-3dspeaker-zh.onnx`(~38 MB)。  
156 -4. **FTS5 中文搜索**:Room + sqlite-jsqlite-with-icu。Schema 加 `TranscriptFts` 虚表用 ICU tokenizer。UI 加搜索框 + 高亮命中段。  
157 -5. **LLM 纪要 worker**:WorkManager cron。每小时 / 静音 30min 触发,按时间窗 group segments → POST 到 hermes gateway `http://127.0.0.1:8642/v1/chat/completions`(需要先把 hermes api_server platform 配置起来,见 `~/.hermes/config.yaml`)。  
158 -6. **存储 TTL**:删超过 N 天的 WAV,文字永留。Periodic WorkManager job。  
159 -7. **设置页**:存储用量、模型切换、provider 切换、暂停时段配置(这次定的 MVP 是「一键暂停就够」,但用户后面可能要细化)。 154 +✅ **已完成(v0.2,2026-06):Opus 编码 / BootReceiver / 电池白名单**
  155 +
  156 +剩下:
  157 +
  158 +1. **声纹识别 / speaker diarization**(🔥 用户当面提过 3 次):sherpa-onnx 已有 `Speaker.kt` + `SpeakerEmbeddingExtractorConfig.kt`。流程:(a)「这是我」录音 → 建 anchor embedding 存 SQLite;(b) 每段算 embedding 算 cosine 找最近邻;(c) Transcript 加 `speakerHint` 列。需要下 `sherpa-onnx-3dspeaker-zh.onnx`(~38 MB)。
  159 +2. **FTS5 中文搜索**:Room + sqlite-jsqlite-with-icu。Schema 加 `TranscriptFts` 虚表用 ICU tokenizer。UI 加搜索框 + 高亮命中段。
  160 +3. **LLM 纪要 worker**:WorkManager cron。每小时 / 静音 30min 触发,按时间窗 group segments → POST 到 hermes gateway `http://127.0.0.1:8642/v1/chat/completions`(需要先把 hermes api_server platform 配置起来,见 `~/.hermes/config.yaml`)。
  161 +4. **存储 TTL**:删超过 N 天的 .opus,文字永留。Periodic WorkManager job。
  162 +5. **设置页**:存储用量、模型切换、provider 切换、暂停时段配置(v1 定的 MVP 是「一键暂停就够」,但 v2 可能要细化)。
  163 +6. **段播放**:时间轴每条点开能直接播原音(Opus 系统原生支持,MediaPlayer 就能放)。
  164 +7. **回链 lemonPCRM**:Transcript 提到的人名 / 项目 → GraphStore `MENTIONED_IN` 边。
  165 +
  166 +### 存储预算(v0.2 后)
  167 +
  168 +- Opus 16 kbps mono = ~2 KB/s
  169 +- VAD 后实际有效语音 ≈ 8 h/天
  170 +- → ~57 MB/天,~1.7 GB/月
  171 +- 转写文字(UTF-8 中文)≈ 30 字/秒 × 8h × 3600 ≈ 不到 1 MB/月
  172 +
  173 +### Android 14+ BOOT_COMPLETED FG service caveat
  174 +
  175 +`RecorderService` 的 `foregroundServiceType="microphone"` 是受限 FG type。从 BOOT_COMPLETED 启动**不保证总是成功**——Android 14+ 的 `ForegroundServiceStartNotAllowedException` 可能扔出来(特别是首次重启、还没用过 app 的情况)。`BootReceiver.onReceive` 已 try/catch 兜底,失败时 log + no-op,用户开 app 手动点开始就能恢复。
  176 +
  177 +实测 Pixel 8 Pro Android 16 BP2A 上**重启后能自动续上**(前提:app 已经至少打开过一次 + RECORD_AUDIO 已授)。
160 178
161 --- 179 ---
162 180
lifelog-recorder/app/src/main/AndroidManifest.xml
@@ -8,6 +8,7 @@ @@ -8,6 +8,7 @@
8 <uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> 8 <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
9 <uses-permission android:name="android.permission.WAKE_LOCK" /> 9 <uses-permission android:name="android.permission.WAKE_LOCK" />
10 <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" /> 10 <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
  11 + <uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
11 <uses-permission android:name="android.permission.INTERNET" /> 12 <uses-permission android:name="android.permission.INTERNET" />
12 <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> 13 <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
13 14
@@ -34,5 +35,16 @@ @@ -34,5 +35,16 @@
34 android:name=".service.RecorderService" 35 android:name=".service.RecorderService"
35 android:exported="false" 36 android:exported="false"
36 android:foregroundServiceType="microphone" /> 37 android:foregroundServiceType="microphone" />
  38 +
  39 + <receiver
  40 + android:name=".service.BootReceiver"
  41 + android:exported="true"
  42 + android:enabled="true">
  43 + <intent-filter>
  44 + <action android:name="android.intent.action.BOOT_COMPLETED" />
  45 + <action android:name="android.intent.action.LOCKED_BOOT_COMPLETED" />
  46 + <action android:name="android.intent.action.QUICKBOOT_POWERON" />
  47 + </intent-filter>
  48 + </receiver>
37 </application> 49 </application>
38 </manifest> 50 </manifest>
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/MainActivity.kt
1 package com.lemon.lifelog 1 package com.lemon.lifelog
2 2
3 import android.Manifest 3 import android.Manifest
  4 +import android.annotation.SuppressLint
  5 +import android.content.Context
4 import android.content.Intent 6 import android.content.Intent
  7 +import android.net.Uri
5 import android.os.Build 8 import android.os.Build
6 import android.os.Bundle 9 import android.os.Bundle
  10 +import android.os.PowerManager
  11 +import android.provider.Settings
7 import androidx.activity.ComponentActivity 12 import androidx.activity.ComponentActivity
8 import androidx.activity.compose.rememberLauncherForActivityResult 13 import androidx.activity.compose.rememberLauncherForActivityResult
9 import androidx.activity.compose.setContent 14 import androidx.activity.compose.setContent
@@ -28,11 +33,15 @@ import androidx.compose.material3.Surface @@ -28,11 +33,15 @@ import androidx.compose.material3.Surface
28 import androidx.compose.material3.Text 33 import androidx.compose.material3.Text
29 import androidx.compose.runtime.Composable 34 import androidx.compose.runtime.Composable
30 import androidx.compose.runtime.collectAsState 35 import androidx.compose.runtime.collectAsState
  36 +import androidx.compose.runtime.DisposableEffect
31 import androidx.compose.runtime.getValue 37 import androidx.compose.runtime.getValue
32 import androidx.compose.runtime.mutableStateOf 38 import androidx.compose.runtime.mutableStateOf
33 import androidx.compose.runtime.remember 39 import androidx.compose.runtime.remember
34 import androidx.compose.runtime.rememberCoroutineScope 40 import androidx.compose.runtime.rememberCoroutineScope
35 import androidx.compose.runtime.setValue 41 import androidx.compose.runtime.setValue
  42 +import androidx.lifecycle.Lifecycle
  43 +import androidx.lifecycle.LifecycleEventObserver
  44 +import androidx.lifecycle.compose.LocalLifecycleOwner
36 import androidx.compose.ui.Modifier 45 import androidx.compose.ui.Modifier
37 import androidx.compose.ui.unit.dp 46 import androidx.compose.ui.unit.dp
38 import androidx.core.content.ContextCompat 47 import androidx.core.content.ContextCompat
@@ -85,6 +94,20 @@ private fun MainScreen() { @@ -85,6 +94,20 @@ private fun MainScreen() {
85 val repo = remember(activity) { Repository(activity) } 94 val repo = remember(activity) { Repository(activity) }
86 val timeline by repo.segments.observeTimeline(200).collectAsState(initial = emptyList()) 95 val timeline by repo.segments.observeTimeline(200).collectAsState(initial = emptyList())
87 96
  97 + // Battery optimisation exemption — recompute whenever activity resumes
  98 + // (user may have just returned from the system settings dialog).
  99 + var batteryExempt by remember { mutableStateOf(isBatteryOptExempt(activity)) }
  100 + val lifecycleOwner = LocalLifecycleOwner.current
  101 + DisposableEffect(lifecycleOwner) {
  102 + val observer = LifecycleEventObserver { _, event ->
  103 + if (event == Lifecycle.Event.ON_RESUME) {
  104 + batteryExempt = isBatteryOptExempt(activity)
  105 + }
  106 + }
  107 + lifecycleOwner.lifecycle.addObserver(observer)
  108 + onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
  109 + }
  110 +
88 val permissionLauncher = rememberLauncherForActivityResult( 111 val permissionLauncher = rememberLauncherForActivityResult(
89 ActivityResultContracts.RequestMultiplePermissions() 112 ActivityResultContracts.RequestMultiplePermissions()
90 ) { results -> 113 ) { results ->
@@ -117,6 +140,21 @@ private fun MainScreen() { @@ -117,6 +140,21 @@ private fun MainScreen() {
117 } 140 }
118 }) { Text(if (running) "停止录音" else "开始录音") } 141 }) { Text(if (running) "停止录音" else "开始录音") }
119 142
  143 + // Battery optimisation exemption row.
  144 + Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically,
  145 + horizontalArrangement = Arrangement.spacedBy(8.dp)) {
  146 + Text(
  147 + if (batteryExempt) "✓ 已加入电池白名单"
  148 + else "⚠ 未加入电池白名单(Doze 会关掉录音)",
  149 + style = MaterialTheme.typography.bodySmall,
  150 + )
  151 + if (!batteryExempt) {
  152 + Button(onClick = { requestBatteryOptExemption(activity) }) {
  153 + Text("申请豁免")
  154 + }
  155 + }
  156 + }
  157 +
120 if (running && lastAudioMs > 0) { 158 if (running && lastAudioMs > 0) {
121 val rtSpeed = if (lastWallMs > 0) lastAudioMs.toDouble() / lastWallMs else 0.0 159 val rtSpeed = if (lastWallMs > 0) lastAudioMs.toDouble() / lastWallMs else 0.0
122 Text( 160 Text(
@@ -171,6 +209,20 @@ private fun MainScreen() { @@ -171,6 +209,20 @@ private fun MainScreen() {
171 } 209 }
172 } 210 }
173 211
  212 +private fun isBatteryOptExempt(ctx: Context): Boolean {
  213 + val pm = ctx.getSystemService(Context.POWER_SERVICE) as PowerManager
  214 + return pm.isIgnoringBatteryOptimizations(ctx.packageName)
  215 +}
  216 +
  217 +@SuppressLint("BatteryLife")
  218 +private fun requestBatteryOptExemption(ctx: Context) {
  219 + val intent = Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS).apply {
  220 + data = Uri.parse("package:${ctx.packageName}")
  221 + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
  222 + }
  223 + runCatching { ctx.startActivity(intent) }
  224 +}
  225 +
174 private val TIMELINE_FMT = SimpleDateFormat("HH:mm:ss", Locale.US) 226 private val TIMELINE_FMT = SimpleDateFormat("HH:mm:ss", Locale.US)
175 227
176 @Composable 228 @Composable
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/audio/SegmentWriter.kt
1 package com.lemon.lifelog.audio 1 package com.lemon.lifelog.audio
2 2
  3 +import android.media.MediaCodec
  4 +import android.media.MediaCodecInfo
  5 +import android.media.MediaFormat
  6 +import android.media.MediaMuxer
  7 +import android.util.Log
3 import java.io.File 8 import java.io.File
4 -import java.io.RandomAccessFile  
5 import java.nio.ByteBuffer 9 import java.nio.ByteBuffer
6 import java.nio.ByteOrder 10 import java.nio.ByteOrder
7 import java.text.SimpleDateFormat 11 import java.text.SimpleDateFormat
@@ -9,13 +13,23 @@ import java.util.Date @@ -9,13 +13,23 @@ import java.util.Date
9 import java.util.Locale 13 import java.util.Locale
10 14
11 /** 15 /**
12 - * Writes a finished VAD segment to disk as 16-bit PCM WAV.  
13 - * File layout: `<root>/YYYY-MM-DD/HH-MM-SS-NNNN.wav` 16 + * Writes a finished VAD segment to disk as Opus-in-Ogg (.opus, ~16 kbps voice mode).
  17 + * File layout: `<root>/YYYY-MM-DD/HH-MM-SS-MS.opus`
14 * 18 *
15 - * (Opus encoding via MediaCodec is a future optimization; raw WAV at 16k mono is  
16 - * ~32 kB/s = ~1 MB/min, still bounded for 24/7 use after VAD trimming.) 19 + * Bitrate budget: 16 kHz mono PCM16 = 32 kB/s WAV → ~2 kB/s Opus = ~16× compression.
  20 + * Per 24/7 use (≈8 h voice/day after VAD): ~57 MB/day, ~1.7 GB/month.
  21 + *
  22 + * Implementation: MediaCodec (audio/opus encoder) + MediaMuxer (OGG container).
  23 + * Each segment uses its own encoder + muxer pair — startup overhead is small (a few ms)
  24 + * and avoids cross-segment state bugs.
17 */ 25 */
18 class SegmentWriter(private val root: File) { 26 class SegmentWriter(private val root: File) {
  27 + companion object {
  28 + private const val TAG = "SegmentWriter"
  29 + private const val MIME = MediaFormat.MIMETYPE_AUDIO_OPUS // "audio/opus"
  30 + private const val BITRATE = 16_000 // ~16 kbps, plenty for speech
  31 + }
  32 +
19 private val dayFmt = SimpleDateFormat("yyyy-MM-dd", Locale.US) 33 private val dayFmt = SimpleDateFormat("yyyy-MM-dd", Locale.US)
20 private val timeFmt = SimpleDateFormat("HH-mm-ss-SSS", Locale.US) 34 private val timeFmt = SimpleDateFormat("HH-mm-ss-SSS", Locale.US)
21 35
@@ -23,42 +37,116 @@ class SegmentWriter(private val root: File) { @@ -23,42 +37,116 @@ class SegmentWriter(private val root: File) {
23 root.mkdirs() 37 root.mkdirs()
24 } 38 }
25 39
26 - /** Writes samples to a new file, returns absolute path. */ 40 + /** Writes samples to a new .opus file, returns absolute path. */
27 fun write(samplesFloat: FloatArray, ts: Long, sampleRate: Int = 16000): String { 41 fun write(samplesFloat: FloatArray, ts: Long, sampleRate: Int = 16000): String {
28 val date = Date(ts) 42 val date = Date(ts)
29 val dir = File(root, dayFmt.format(date)).apply { mkdirs() } 43 val dir = File(root, dayFmt.format(date)).apply { mkdirs() }
30 - val file = File(dir, "${timeFmt.format(date)}.wav")  
31 - writeWav(file, samplesFloat, sampleRate) 44 + val file = File(dir, "${timeFmt.format(date)}.opus")
  45 + encodeToOgg(samplesFloat, file, sampleRate)
32 return file.absolutePath 46 return file.absolutePath
33 } 47 }
34 48
35 - private fun writeWav(file: File, samples: FloatArray, sampleRate: Int) {  
36 - val pcmBytes = samples.size * 2  
37 - RandomAccessFile(file, "rw").use { raf ->  
38 - raf.setLength(0)  
39 - // RIFF header  
40 - raf.writeBytes("RIFF")  
41 - raf.writeInt(Integer.reverseBytes(36 + pcmBytes))  
42 - raf.writeBytes("WAVE")  
43 - // fmt chunk  
44 - raf.writeBytes("fmt ")  
45 - raf.writeInt(Integer.reverseBytes(16)) // chunk size  
46 - raf.writeShort(java.lang.Short.reverseBytes(1).toInt()) // PCM  
47 - raf.writeShort(java.lang.Short.reverseBytes(1).toInt()) // channels  
48 - raf.writeInt(Integer.reverseBytes(sampleRate))  
49 - raf.writeInt(Integer.reverseBytes(sampleRate * 2)) // byte rate  
50 - raf.writeShort(java.lang.Short.reverseBytes(2).toInt()) // block align  
51 - raf.writeShort(java.lang.Short.reverseBytes(16).toInt())// bits per sample  
52 - // data chunk  
53 - raf.writeBytes("data")  
54 - raf.writeInt(Integer.reverseBytes(pcmBytes))  
55 - // samples: scale float [-1,1] → int16 LE  
56 - val buf = ByteBuffer.allocate(pcmBytes).order(ByteOrder.LITTLE_ENDIAN) 49 + private fun encodeToOgg(samples: FloatArray, outFile: File, sampleRate: Int) {
  50 + if (samples.isEmpty()) return
  51 +
  52 + // 1. PCM16 LE buffer (mono).
  53 + val pcm = ByteArray(samples.size * 2)
  54 + ByteBuffer.wrap(pcm).order(ByteOrder.LITTLE_ENDIAN).apply {
57 for (s in samples) { 55 for (s in samples) {
58 - val clamped = (s * 32767f).coerceIn(-32768f, 32767f)  
59 - buf.putShort(clamped.toInt().toShort()) 56 + putShort((s.coerceIn(-1f, 1f) * 32767f).toInt().toShort())
  57 + }
  58 + }
  59 +
  60 + // 2. Set up encoder.
  61 + val codec = MediaCodec.createEncoderByType(MIME)
  62 + val format = MediaFormat.createAudioFormat(MIME, sampleRate, 1).apply {
  63 + setInteger(MediaFormat.KEY_BIT_RATE, BITRATE)
  64 + // Default frame size for Opus encoder is sane; explicit max-input-size is a hint.
  65 + setInteger(MediaFormat.KEY_MAX_INPUT_SIZE, 8192)
  66 + }
  67 + try {
  68 + codec.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE)
  69 + } catch (t: Throwable) {
  70 + Log.e(TAG, "configure failed", t)
  71 + codec.release()
  72 + throw t
  73 + }
  74 + codec.start()
  75 +
  76 + // 3. Muxer (OGG container).
  77 + val muxer = MediaMuxer(outFile.absolutePath, MediaMuxer.OutputFormat.MUXER_OUTPUT_OGG)
  78 + var trackIndex = -1
  79 + var muxerStarted = false
  80 +
  81 + val info = MediaCodec.BufferInfo()
  82 + var bytesFed = 0
  83 + var ptUs = 0L
  84 + var inputDone = false
  85 + var outputDone = false
  86 +
  87 + try {
  88 + while (!outputDone) {
  89 + // Feed PCM.
  90 + if (!inputDone) {
  91 + val idx = codec.dequeueInputBuffer(50_000)
  92 + if (idx >= 0) {
  93 + val buf = codec.getInputBuffer(idx)!!
  94 + buf.clear()
  95 + val cap = buf.capacity()
  96 + if (bytesFed < pcm.size) {
  97 + val n = minOf(cap, pcm.size - bytesFed)
  98 + buf.put(pcm, bytesFed, n)
  99 + codec.queueInputBuffer(idx, 0, n, ptUs, 0)
  100 + // 2 bytes/sample mono → frames = n/2 → us = frames * 1e6 / sr
  101 + ptUs += n.toLong() / 2L * 1_000_000L / sampleRate
  102 + bytesFed += n
  103 + } else {
  104 + codec.queueInputBuffer(
  105 + idx, 0, 0, ptUs,
  106 + MediaCodec.BUFFER_FLAG_END_OF_STREAM,
  107 + )
  108 + inputDone = true
  109 + }
  110 + }
  111 + }
  112 +
  113 + // Drain encoded output.
  114 + drain@ while (true) {
  115 + val oIdx = codec.dequeueOutputBuffer(info, 50_000)
  116 + when {
  117 + oIdx == MediaCodec.INFO_TRY_AGAIN_LATER -> break@drain
  118 + oIdx == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED -> {
  119 + if (!muxerStarted) {
  120 + trackIndex = muxer.addTrack(codec.outputFormat)
  121 + muxer.start()
  122 + muxerStarted = true
  123 + }
  124 + }
  125 + oIdx >= 0 -> {
  126 + val out = codec.getOutputBuffer(oIdx)!!
  127 + if (info.size > 0 && muxerStarted &&
  128 + (info.flags and MediaCodec.BUFFER_FLAG_CODEC_CONFIG) == 0
  129 + ) {
  130 + out.position(info.offset)
  131 + out.limit(info.offset + info.size)
  132 + muxer.writeSampleData(trackIndex, out, info)
  133 + }
  134 + codec.releaseOutputBuffer(oIdx, false)
  135 + if ((info.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0) {
  136 + outputDone = true
  137 + break@drain
  138 + }
  139 + }
  140 + }
  141 + }
  142 + }
  143 + } finally {
  144 + runCatching { codec.stop() }
  145 + codec.release()
  146 + if (muxerStarted) {
  147 + runCatching { muxer.stop() }
60 } 148 }
61 - raf.write(buf.array()) 149 + muxer.release()
62 } 150 }
63 } 151 }
64 } 152 }
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/service/BootReceiver.kt 0 → 100644
  1 +package com.lemon.lifelog.service
  2 +
  3 +import android.content.BroadcastReceiver
  4 +import android.content.Context
  5 +import android.content.Intent
  6 +import android.util.Log
  7 +import androidx.core.content.ContextCompat
  8 +
  9 +/**
  10 + * Resumes recording on device boot.
  11 + *
  12 + * Android 14+ note: starting a `foregroundServiceType=microphone` service from a
  13 + * BOOT_COMPLETED broadcast is allowed because RECORD_AUDIO is a "while-in-use" permission
  14 + * that survives across reboots once granted. If the start fails (e.g. user has revoked
  15 + * the runtime permission), Android will throw [ForegroundServiceStartNotAllowedException]
  16 + * and we just no-op; the user opens the app and taps "开始录音" to resume.
  17 + */
  18 +class BootReceiver : BroadcastReceiver() {
  19 + override fun onReceive(context: Context, intent: Intent) {
  20 + val action = intent.action ?: return
  21 + if (action != Intent.ACTION_BOOT_COMPLETED &&
  22 + action != Intent.ACTION_LOCKED_BOOT_COMPLETED &&
  23 + action != "android.intent.action.QUICKBOOT_POWERON"
  24 + ) return
  25 +
  26 + Log.i(TAG, "boot action=$action → start RecorderService")
  27 + try {
  28 + ContextCompat.startForegroundService(
  29 + context,
  30 + Intent(context, RecorderService::class.java),
  31 + )
  32 + } catch (t: Throwable) {
  33 + Log.w(TAG, "could not start service from boot", t)
  34 + }
  35 + }
  36 +
  37 + companion object {
  38 + private const val TAG = "BootReceiver"
  39 + }
  40 +}