Commit d67ce3d2c2a599ea7759a4b36be095994d5e1ef2
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>
Showing
5 changed files
with
253 additions
and
43 deletions
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 | +} |