init: KWS 软件唤醒 APK 工程骨架(基于 sherpa-onnx 改造)

工程结构:
- com.lila.wakeup 包名,基于 sherpa-onnx v1.13.0 SherpaOnnxKws demo 改造
- 9 个 Kotlin 模块: WakeupForegroundService / KwsStateMachine / AudioCapture
  / KwsEngine / BootReceiver / ProtocolReceiver / BroadcastSender / Config
  / MainActivity
- 5 个 Lila 中文唤醒词(你好Lila/hello Lila/Lila同学/Lila你好/小Lila)
- 仅 arm64-v8a(OrangePi CM5 / RK3588)
- 协议 v2.1: 双向 setPackage / silence_ms 3000 / 2min 兜底
- 数字人 APP 包名: com.qy.lila

构建通过项:
- Gradle Sync OK(腾讯云 Gradle 镜像 + 阿里云 Maven 镜像)
- APK 编译成功 55MB,含 25MB onnxruntime + 24MB 模型 + 9 个 Kotlin .dex
- adb install OK,Service onCreate 被调用,sherpa-onnx 加载模型

已知问题(下一 commit 修复):
- KwsEngine.kt 的 OnlineModelConfig modelType 设为 'zipformer'
  实际应为 'zipformer2',native 加载在 InitEncoder 后崩溃
- 修复方法: 用 sherpa-onnx 官方 getKwsModelConfig(0) 工厂函数

参考文档:
- 协议: ~/OrangePi_CM5_Project/docs/.../KWS唤醒协议-V2.md (v2.1)
- 设计: ~/OrangePi_CM5_Project/docs/.../KWS服务设计.md
- 评估: ~/OrangePi_CM5_Project/docs/.../KWS唤醒方案适配评估_Unity.md
This commit is contained in:
Rdzleo 2026-04-30 09:55:49 +08:00
commit 128f7ca02e
70 changed files with 3284 additions and 0 deletions

17
.gitignore vendored Normal file
View File

@ -0,0 +1,17 @@
*.iml
.gradle
/local.properties
/.idea/caches
/.idea/libraries
/.idea/modules.xml
/.idea/workspace.xml
/.idea/navEditor.xml
/.idea/assetWizardSettings.xml
.DS_Store
/build
/captures
.externalNativeBuild
.cxx
local.properties
*.bak
*.original

3
.idea/.gitignore generated vendored Normal file
View File

@ -0,0 +1,3 @@
# Default ignored files
/shelf/
/workspace.xml

6
.idea/AndroidProjectSystem.xml generated Normal file
View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="AndroidProjectSystem">
<option name="providerId" value="com.android.tools.idea.GradleProjectSystem" />
</component>
</project>

123
.idea/codeStyles/Project.xml generated Normal file
View File

@ -0,0 +1,123 @@
<component name="ProjectCodeStyleConfiguration">
<code_scheme name="Project" version="173">
<JetCodeStyleSettings>
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
</JetCodeStyleSettings>
<codeStyleSettings language="XML">
<option name="FORCE_REARRANGE_MODE" value="1" />
<indentOptions>
<option name="CONTINUATION_INDENT_SIZE" value="4" />
</indentOptions>
<arrangement>
<rules>
<section>
<rule>
<match>
<AND>
<NAME>xmlns:android</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>xmlns:.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
<order>BY_NAME</order>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*:id</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*:name</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>name</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>style</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
<order>BY_NAME</order>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
<order>ANDROID_ATTRIBUTE_ORDER</order>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>.*</XML_NAMESPACE>
</AND>
</match>
<order>BY_NAME</order>
</rule>
</section>
</rules>
</arrangement>
</codeStyleSettings>
<codeStyleSettings language="kotlin">
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
</codeStyleSettings>
</code_scheme>
</component>

5
.idea/codeStyles/codeStyleConfig.xml generated Normal file
View File

@ -0,0 +1,5 @@
<component name="ProjectCodeStyleConfiguration">
<state>
<option name="USE_PER_PROJECT_SETTINGS" value="true" />
</state>
</component>

6
.idea/compiler.xml generated Normal file
View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="CompilerConfiguration">
<bytecodeTargetLevel target="17" />
</component>
</project>

11
.idea/deploymentTargetSelector.xml generated Normal file
View File

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="deploymentTargetSelector">
<selectionStates>
<SelectionState runConfigName="app">
<option name="selectionMode" value="DROPDOWN" />
<DialogSelection />
</SelectionState>
</selectionStates>
</component>
</project>

19
.idea/gradle.xml generated Normal file
View File

@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="GradleMigrationSettings" migrationVersion="1" />
<component name="GradleSettings">
<option name="linkedExternalProjectsSettings">
<GradleProjectSettings>
<option name="testRunner" value="CHOOSE_PER_TEST" />
<option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="gradleJvm" value="#GRADLE_LOCAL_JAVA_HOME" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$" />
<option value="$PROJECT_DIR$/app" />
</set>
</option>
</GradleProjectSettings>
</option>
</component>
</project>

10
.idea/migrations.xml generated Normal file
View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectMigrations">
<option name="MigrateToGradleLocalJavaHome">
<set>
<option value="$PROJECT_DIR$" />
</set>
</option>
</component>
</project>

9
.idea/misc.xml generated Normal file
View File

@ -0,0 +1,9 @@
<project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="ProjectRootManager" version="2" languageLevel="JDK_17" default="true" project-jdk-name="jbr-17" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/build/classes" />
</component>
<component name="ProjectType">
<option name="id" value="Android" />
</component>
</project>

17
.idea/runConfigurations.xml generated Normal file
View File

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="RunConfigurationProducerService">
<option name="ignoredProducers">
<set>
<option value="com.intellij.execution.junit.AbstractAllInDirectoryConfigurationProducer" />
<option value="com.intellij.execution.junit.AllInPackageConfigurationProducer" />
<option value="com.intellij.execution.junit.PatternConfigurationProducer" />
<option value="com.intellij.execution.junit.TestInClassConfigurationProducer" />
<option value="com.intellij.execution.junit.UniqueIdConfigurationProducer" />
<option value="com.intellij.execution.junit.testDiscovery.JUnitTestDiscoveryConfigurationProducer" />
<option value="org.jetbrains.kotlin.idea.junit.KotlinJUnitRunConfigurationProducer" />
<option value="org.jetbrains.kotlin.idea.junit.KotlinPatternConfigurationProducer" />
</set>
</option>
</component>
</project>

224
CLAUDE.md Normal file
View File

@ -0,0 +1,224 @@
# Lila Wakeup APK 工程上下文com.lila.wakeup
> **本文件用于 Claude Code 插件加载,自动获取项目背景。**
> 本工程是 OrangePi CM5 数字人产品的 KWS 软件唤醒服务(独立 APK与第三方 Unity 数字人 APP包名 `com.qy.lila`)通过 Android Broadcast 协议联动。
---
## 一、项目本质(一句话)
基于 **sherpa-onnx** 引擎的 Android 13 KWSKeyword Spotting / 关键词唤醒APK监听用户喊"你好 Lila"等唤醒词后通过 Broadcast 通知数字人 APP 显示数字人 + 接通火山 RTC。
替代原方案的"ESP32 + 离线语音芯片 + USB 串口 SO_WAKEUP1"硬件方案,省 BOM 成本 + 更灵活。
---
## 二、核心元信息(写代码前必读)
| 项 | 值 |
|---|---|
| 本 APK 包名 | `com.lila.wakeup` |
| 数字人 APP 包名 | `com.qy.lila`(已确认,来源 LTY_Project ProjectSettings.asset |
| 目标系统 | Android 13 (API 33) |
| 目标架构 | arm64-v8a仅打包此架构节省 APK 体积) |
| 目标设备 | OrangePi CM5Rockchip RK3588rk3588s_s |
| 协议版本 | **v2.1**(详见上游 `docs/OrangePi_CM5/MD_Document/KWS唤醒协议-V2.md` |
| KWS 引擎 | sherpa-onnx Zipformer wenetspeech 3.3M(中文,已预编译 native|
| 集成方式 | **基于 sherpa-onnx 官方 SherpaOnnxKws demo 改造**(不是 Maven 依赖,因 Android arm64-v8a .so + Kotlin Wrapper 在 Maven 上没发布完整包) |
| 当前阶段 | **方案 A普通 APK**(量产前升级到方案 B系统签名 + /system/priv-app/|
---
## 三、协议规约v2.1 核心摘要)
### 三个 Broadcast Action双向 setPackage 强制要求)
| 方向 | Action | 字段 |
|---|---|---|
| **WAKEUP**(本 → APP| `com.lila.intent.action.WAKEUP` | `keyword: String` / `timestamp: long` / `confidence: float` |
| **KWS_PAUSE**APP → 本)| `com.lila.intent.action.KWS_PAUSE` | `silence_ms: long`(默认 **3000**Networking 场景 60000/ `reason: String` |
| **KWS_RESUME**APP → 本)| `com.lila.intent.action.KWS_RESUME` | `reason: String` |
### 关键设计约束(不要破坏)
1. **silence_ms 二次保险**:收到 PAUSE 后即使收到 RESUME 也要等到 silence 满才真正 resume —— 防 AI 自我唤醒
2. **2 分钟兜底超时**APP 漏发 RESUME 时 KWS 自动恢复(防永久哑火)
3. **后验平滑**:连续 N 帧(默认 2置信度过阈值才发 WAKEUP —— 降误唤醒
4. **AudioRecord 暂停时完全 release**:把麦克风让给 RTC SDK避免资源冲突
5. **WAKEUP 命中后不自动 PAUSE 自己**:必须由 APP 主动发 PAUSE —— 防漏命中导致的死锁
---
## 四、工程结构(基于 sherpa-onnx demo 改造后)
```
工程根/(原 sherpa-onnx/android/SherpaOnnxKws
├── CLAUDE.md ← 本文件
├── app/
│ ├── build.gradle.kts ← 已改:包名 + arm64-v8a only + targetSdk 33
│ └── src/main/
│ ├── AndroidManifest.xml ← 已改:完整权限 + 4 个组件声明
│ ├── assets/
│ │ └── sherpa-onnx-kws-zipformer-wenetspeech-3.3M-2024-01-01/
│ │ ├── *.onnx ← 原 demo 自带(不动)
│ │ ├── tokens.txt ← 原 demo 自带(不动)
│ │ └── keywords.txt ← 已改5 个 Lila 唤醒词
│ └── java/com/lila/wakeup/ ← 已改:包名 + 9 个新文件
│ ├── Config.kt ← 协议常量、阈值、模型路径
│ ├── MainActivity.kt ← 状态查看 UI + 权限申请(开发期辅助)
│ ├── BootReceiver.kt ← 开机自启
│ ├── WakeupForegroundService.kt ← 前台 Service 主体
│ ├── kws/
│ │ ├── AudioCapture.kt ← AudioRecord 16kHz mono PCM16
│ │ ├── KwsEngine.kt ← sherpa-onnx KWS 封装
│ │ └── KwsStateMachine.kt ← LISTENING/PAUSED 状态机 + silence_ms
│ ├── protocol/
│ │ ├── BroadcastSender.kt ← 发 WAKEUP 给 com.qy.lila
│ │ └── ProtocolReceiver.kt ← 收 PAUSE/RESUME
│ └── sherpa-onnx 自带的 Wrapper 类保留,包名 com.k2fsa.sherpa.onnx 不动)
└── build.gradle.kts (项目级) ← 不动
```
### 9 个我们写的 Kotlin 文件代码量
| 文件 | 行数 | 职责 |
|---|---|---|
| Config.kt | 108 | 全局常量 |
| MainActivity.kt | 117 | 状态 UI + 权限申请 |
| BootReceiver.kt | 50 | 开机自启 |
| WakeupForegroundService.kt | 142 | Service 主体 + 通知栏 |
| AudioCapture.kt | 112 | AudioRecord 封装 |
| KwsEngine.kt | 119 | sherpa-onnx 封装 |
| KwsStateMachine.kt | 163 | 状态机 + silence_ms + 兜底 |
| BroadcastSender.kt | 36 | 发 WAKEUP |
| ProtocolReceiver.kt | 63 | 收 PAUSE/RESUME |
---
## 五、关键参考文档(在主仓库 OrangePi_CM5_Project 中)
> **这些文档是工程的"上游契约"**,本工程实现必须与之一致。改协议要先去主仓库改文档。
| 文档 | 路径 | 作用 |
|---|---|---|
| 协议规约 v2.1 | `/Volumes/LinuxDev/OrangePi_CM5_Project/docs/OrangePi_CM5/MD_Document/KWS唤醒协议-V2.md` | 双方对接契约(最权威)|
| 服务设计 | `/Volumes/LinuxDev/OrangePi_CM5_Project/docs/OrangePi_CM5/MD_Document/KWS服务设计.md` | 我侧实现指南 |
| 第三方评估 | `/Volumes/LinuxDev/OrangePi_CM5_Project/docs/OrangePi_CM5/MD_Document/KWS唤醒方案适配评估_Unity.md` | Unity APP 团队的对接评估(含 v2 微调建议)|
| 工程骨架 README | `/Volumes/LinuxDev/OrangePi_CM5_Project/docs/OrangePi_CM5/MD_Document/KWS-APK-工程骨架/README.md` | 9 步实施指南(含路线图、坑提示)|
---
## 六、用户偏好与代码风格(继承自主项目)
- **语言**:所有沟通用中文,代码注释用中文
- **代码风格**snake_case 变量、UPPER_SNAKE_CASE 常量、_t 后缀类型Kotlin 这边按 Kotlin 标准CamelCase 类名 / camelCase 变量),但**注释一律中文**
- **嵌入式原则**:节省 RAM/Flash > 时序优化 > 模块化(虽然这是 Android 但 OrangePi 资源仍宝贵)
- **不主动加冗余校验**CLAUDE.md 全局规则)
- **破坏性操作先确认**git push --force / rm 等)
- **优先静态分配**:避免频繁 malloc/free字符串处理用固定缓冲区
---
## 七、当前任务路线图
| 阶段 | 内容 | 状态 |
|---|---|---|
| 0. 算法验证 | sherpa-onnx 预编译 KWS APK 装到板子,测识别率 | ✅ 已完成5/8 命中)|
| 1. 工程骨架 | 9 个 Kotlin 类 + Manifest + gradle | ✅ 已完成(在主仓库 KWS-APK-工程骨架/|
| 2. AS 装齐组件 | API 33 / Build-Tools 33 / NDK 25 / CMake 3.22 | 🔄 进行中 |
| 3. 克隆 sherpa-onnx + 改造 | git clone + Refactor 包名 + 拷骨架文件 | 📋 待做 |
| 4. Build → adb install 到 OrangePi CM5 | 验证唤醒效果 | 📋 待做 |
| 5. 协议联调(与 Unity APP| 按协议 v2.1 §八 联调清单 | 📋 待 APP 团队 |
| 6. 方案 B 升级 | 系统签名 + 集成进 update.img | 📋 联调通过后 |
| 7. 长稳测试 | 72h 连续运行 | 📋 量产前 |
---
## 八、关键技术决策记录(不要重新讨论)
| 决策 | 结论 | 理由(一句话)|
|---|---|---|
| 用 Maven 依赖还是克隆 demo | **克隆 demo** | Maven 上 sherpa-onnx 的 Android arm64-v8a .so + Kotlin Wrapper 不全 |
| 方案 A / B / C 怎么走? | **A 跑通 → B 升级**(不走 C| C 改 AOSP system_server 收益不抵成本 |
| silence_ms 默认值 | **3000ms** | 第三方实测 RTC 远端音频回灌 800-1500ms + AI TTS 触发2000ms 不够 |
| Networking 场景是否唤醒? | **不允许**APP 进入发 PAUSE| 配网中被唤醒会要求 APP 新增响应逻辑 + 决策跳哪里 |
| BroadcastReceiver 与 Service 通信方式 | **Locator 模式** | 比 startService / AIDL 简单,性能好 |
| AudioRecord 暂停时是 stop 还是 release | **完全 release** | 释放麦克风给 RTC SDK避免资源冲突 |
---
## 九、绝对不要做的事
1. ❌ **不要改包名 `com.qy.lila`**APP 团队已确认,改了协议失效
2. ❌ **不要改 Action 名 `com.lila.intent.action.*`**:双方协议契约
3. ❌ **不要把 sherpa-onnx 源码包名 `com.k2fsa.sherpa.onnx` 改掉**JNI native 库 binding 的硬编码包名
4. ❌ **不要在前台 Service 里调耗时操作vTaskDelay 类)阻塞 main looper**StateMachine 的 timeout 用 Handler.postDelayed
5. ❌ **不要在 AudioCapture 线程里同步调网络 / 文件 IO**:会丢音频帧
6. ❌ **不要禁用 START_STICKY**Service 异常重启依赖此标志
7. ❌ **不要把 RECORD_AUDIO 申请放在 Service**:必须在 Activity 里申请Android 限制)
---
## 十、与主项目OrangePi_CM5_Project的协作
主项目的 Claude Code 会话负责:
- 协议规约文档KWS唤醒协议-V2.md演进
- 服务设计文档KWS服务设计.md演进
- 与第三方 APP 团队对接
- 系统侧kernel / dts / Audio HAL相关修改
- 整机集成(方案 B 升级)
本工程sherpa-onnx 改造)的 Claude Code 会话负责:
- 9 个 Kotlin 文件的具体实现
- Gradle / Manifest 配置
- Android Studio Build / Run 调试
- KWS 引擎参数调优threshold、N 帧平滑)
- 真机测试(与 OrangePi CM5 联调)
**协议改动必须先在主项目改文档,再同步到本工程。**
---
## 十一、典型工作请求示例(让 Claude Code 上手)
新来 Claude Code 插件可以处理的典型任务:
- "帮我改 [`KwsStateMachine.kt`](app/src/main/java/com/lila/wakeup/kws/KwsStateMachine.kt) 的后验平滑窗口从 2 帧改为 3 帧"
- "把 [`build.gradle.kts`](app/build.gradle.kts) 的 minSdk 从 26 改为 24"
- "新增一个 keywords 重载接口,让 [`KwsEngine.kt`](app/src/main/java/com/lila/wakeup/kws/KwsEngine.kt) 支持热更新唤醒词"
- "[`MainActivity.kt`](app/src/main/java/com/lila/wakeup/MainActivity.kt) 状态展示太丑,帮我用 Compose 重写"
- "Build 报错 `Unresolved reference: KeywordSpotter`,怎么改?"
- "帮我加一个 logcat 过滤器,只看 KWS 命中日志"
需要主项目协调的请求(**不要自己改本工程**
- "改协议字段名" → 让用户回主项目讨论
- "升级方案 B" → 涉及 SDK 集成,回主项目
- "对接第三方新需求" → 协议要先改
---
## 十二、运行时验证
```bash
# 装到板子
adb install -r -g ./app/build/outputs/apk/debug/app-debug.apk
# 看日志
adb logcat -s KwsService.Svc:* KwsService.Engine:* KwsService.State:* KwsService.Audio:* KwsService.Send:* KwsService.Proto:* KwsService.Boot:*
# 关键日志关键字
[Engine] init done ← 引擎加载成功
[State] -> LISTENING ← 进入监听
[KWS] HIT confirmed ← 命中
[Broadcast] WAKEUP -> com.qy.lila ← 发广播给 APP
# 测试 APP 端 PAUSE/RESUME不依赖 Unity APP用 adb 模拟)
adb shell am broadcast -a com.lila.intent.action.KWS_PAUSE \
-n com.lila.wakeup/.protocol.ProtocolReceiver \
--el silence_ms 3000 --es reason "test_pause"
adb shell am broadcast -a com.lila.intent.action.KWS_RESUME \
-n com.lila.wakeup/.protocol.ProtocolReceiver \
--es reason "test_resume"
```

1
app/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
/build

45
app/build.gradle Normal file
View File

@ -0,0 +1,45 @@
plugins {
id 'com.android.application'
id 'org.jetbrains.kotlin.android'
}
android {
namespace 'com.lila.wakeup'
compileSdk 33
defaultConfig {
applicationId "com.lila.wakeup"
minSdk 26
targetSdk 33
versionCode 1
versionName "0.1.0-alpha"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
ndk { abiFilters "arm64-v8a" }
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = '1.8'
}
}
dependencies {
implementation 'androidx.core:core-ktx:1.7.0'
implementation 'androidx.appcompat:appcompat:1.5.1'
implementation 'com.google.android.material:material:1.7.0'
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
testImplementation 'junit:junit:4.13.2'
androidTestImplementation 'androidx.test.ext:junit:1.1.4'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.0'
}

21
app/proguard-rules.pro vendored Normal file
View File

@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile

View File

@ -0,0 +1,24 @@
package com.k2fsa.sherpa.onnx
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.Assert.*
/**
* Instrumented test, which will execute on an Android device.
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
@RunWith(AndroidJUnit4::class)
class ExampleInstrumentedTest {
@Test
fun useAppContext() {
// Context of the app under test.
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
assertEquals("com.k2fsa.sherpa.onnx", appContext.packageName)
}
}

View File

@ -0,0 +1,85 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
KWS APK 清单文件com.lila.wakeup
协议版本v2.1
目标系统Android 13 (API 33) / arm64-v8a
-->
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<!-- 录音(核心权限) -->
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<!-- 开机自启(接收 BOOT_COMPLETED 广播) -->
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<!-- 前台 ServiceAndroid 9+ 必须) -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<!-- 前台 Service 麦克风类型Android 14 (API 34)+ 强制声明类型13 可选但建议加上) -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
<!-- 防止系统休眠时音频采集中断 -->
<uses-permission android:name="android.permission.WAKE_LOCK" />
<!-- 通知栏权限Android 13+ 强制运行时申请) -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application
android:allowBackup="false"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.LilaWakeup"
tools:targetApi="33">
<!-- 前台 Service常驻录音 + KWS 推理 -->
<service
android:name=".WakeupForegroundService"
android:enabled="true"
android:exported="false"
android:foregroundServiceType="microphone"
android:stopWithTask="false" />
<!-- 开机自启接收器 -->
<receiver
android:name=".BootReceiver"
android:enabled="true"
android:exported="true"
tools:ignore="ExportedReceiver">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
<action android:name="android.intent.action.LOCKED_BOOT_COMPLETED" />
<action android:name="android.intent.action.QUICKBOOT_POWERON" />
</intent-filter>
</receiver>
<!-- 协议接收器:监听 APP 端发来的 KWS_PAUSE / KWS_RESUME -->
<receiver
android:name=".protocol.ProtocolReceiver"
android:enabled="true"
android:exported="true"
tools:ignore="ExportedReceiver">
<intent-filter>
<action android:name="com.lila.intent.action.KWS_PAUSE" />
<action android:name="com.lila.intent.action.KWS_RESUME" />
</intent-filter>
</receiver>
<!-- 状态查看 Activity开发期辅助 + 首次启动权限申请入口) -->
<activity
android:name=".MainActivity"
android:exported="true"
android:label="@string/app_name"
android:theme="@style/Theme.LilaWakeup">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>

View File

View File

@ -0,0 +1,5 @@
n ǐ h ǎo l ì l ā @你好Lila
h è l ōu l ì l ā @hello Lila
l ì l ā t óng x ué @Lila同学
l ì l ā n ǐ h ǎo @Lila你好
x i ǎo l ì l ā @小Lila

View File

@ -0,0 +1,227 @@
<blk> 0
<sos/eos> 1
<unk> 2
A 3
B 4
S 5
I 6
f 7
ù 8
ǔ 9
zh 10
y 11
ī 12
sh 13
ēng 14
uè 15
p 16
iàn 17
K 18
L 19
P 20
R 21
T 22
M 23
j 24
W 25
q 26
s 27
ān 28
b 29
ā 30
l 31
íng 32
iǔ 33
ch 34
ǐ 35
ì 36
èr 37
w 38
r 39
án 40
én 41
ó 42
g 43
uǎn 44
c 45
āng 46
ǎn 47
x 48
iān 49
ōng 50
àng 51
òng 52
d 53
ài 54
iù 55
ìng 56
ū 57
m 58
àn 59
ǎ 60
í 61
z 62
k 63
ǒu 64
ūn 65
uó 66
èng 67
òu 68
ú 69
éng 70
à 71
iào 72
āo 73
ào 74
h 75
uáng 76
t 77
iáo 78
è 79
iǎn 80
er 81
n 82
èi 83
ǐng 84
uā 85
ēi 86
ǎo 87
iú 88
uàng 89
uí 90
ǜ 91
āi 92
óu 93
ià 94
ǎng 95
īn 96
uà 97
uǐ 98
ái 99
ò 100
ē 101
óng 102
uàn 103
é 104
ìn 105
ùn 106
uò 107
ún 108
uì 109
uān 110
ǐn 111
iāo 112
ōu 113
uán 114
iǎo 115
á 116
ér 117
èn 118
C 119
D 120
E 121
O 122
N 123
Z 124
ěi 125
ián 126
ēn 127
iā 128
ǚ 129
áng 130
iě 131
ǒng 132
éi 133
iāng 134
J 135
V 136
ín 137
ié 138
īng 139
ō 140
ěr 141
F 142
ué 143
iǎng 144
uō 145
G 146
ǎi 147
ěn 148
H 149
en 150
X 151
i 152
ǒ 153
uǒ 154
iè 155
iū 156
U 157
iàng 158
uāng 159
uá 160
Q 161
ě 162
e 163
iǎ 164
iáng 165
Y 166
○ 167
iē 168
uī 169
ěng 170
uài 171
áo 172
a 173
ou 174
ǔn 175
uái 176
uāi 177
ióng 178
ei 179
ń 180
iá 181
iōng 182
üè 183
uē 184
uǎng 185
uǎ 186
o 187
uǎi 188
uě 189
ǘ 190
uo 191
iǒng 192
ang 193
ia 194
u 195
üě 196
#0 197
#1 198
#2 199
#3 200
#4 201
#5 202
#6 203
#7 204
#8 205
#9 206
#10 207
#11 208
#12 209
#13 210
#14 211
#15 212
#16 213
#17 214
#18 215
#19 216
#20 217
#21 218
#22 219
#23 220
#24 221
#25 222
#26 223
#27 224
#28 225
#29 226

View File

@ -0,0 +1,11 @@
package com.k2fsa.sherpa.onnx
data class FeatureConfig(
var sampleRate: Int = 16000,
var featureDim: Int = 80,
var dither: Float = 0.0f
)
fun getFeatureConfig(sampleRate: Int, featureDim: Int): FeatureConfig {
return FeatureConfig(sampleRate = sampleRate, featureDim = featureDim)
}

View File

@ -0,0 +1,7 @@
package com.k2fsa.sherpa.onnx
data class HomophoneReplacerConfig(
var dictDir: String = "", // unused
var lexicon: String = "",
var ruleFsts: String = "",
)

View File

@ -0,0 +1,156 @@
// Copyright (c) 2024 Xiaomi Corporation
package com.k2fsa.sherpa.onnx
import android.content.res.AssetManager
data class KeywordSpotterConfig(
var featConfig: FeatureConfig = FeatureConfig(),
var modelConfig: OnlineModelConfig = OnlineModelConfig(),
var maxActivePaths: Int = 4,
var keywordsFile: String = "keywords.txt",
var keywordsScore: Float = 1.5f,
var keywordsThreshold: Float = 0.25f,
var numTrailingBlanks: Int = 2,
)
data class KeywordSpotterResult(
val keyword: String,
val tokens: Array<String>,
val timestamps: FloatArray,
// TODO(fangjun): Add more fields
) {
override fun toString(): String {
val tokensStr = tokens.joinToString(", ")
val timestampsStr = timestamps.joinToString(", ") { "%.2f".format(it) }
return "Keyword: $keyword\nTokens: [$tokensStr]\nTimestamps: [$timestampsStr]"
}
}
class KeywordSpotter(
assetManager: AssetManager? = null,
val config: KeywordSpotterConfig,
) {
private var ptr: Long
init {
ptr = if (assetManager != null) {
newFromAsset(assetManager, config)
} else {
newFromFile(config)
}
}
protected fun finalize() {
if (ptr != 0L) {
delete(ptr)
ptr = 0
}
}
fun release() = finalize()
fun createStream(keywords: String = ""): OnlineStream {
val p = createStream(ptr, keywords)
return OnlineStream(p)
}
fun decode(stream: OnlineStream) = decode(ptr, stream.ptr)
fun reset(stream: OnlineStream) = reset(ptr, stream.ptr)
fun isReady(stream: OnlineStream) = isReady(ptr, stream.ptr)
fun getResult(stream: OnlineStream): KeywordSpotterResult {
return getResult(ptr, stream.ptr)
}
private external fun delete(ptr: Long)
private external fun newFromAsset(
assetManager: AssetManager,
config: KeywordSpotterConfig,
): Long
private external fun newFromFile(
config: KeywordSpotterConfig,
): Long
private external fun createStream(ptr: Long, keywords: String): Long
private external fun isReady(ptr: Long, streamPtr: Long): Boolean
private external fun decode(ptr: Long, streamPtr: Long)
private external fun reset(ptr: Long, streamPtr: Long)
private external fun getResult(ptr: Long, streamPtr: Long): KeywordSpotterResult
companion object {
init {
System.loadLibrary("sherpa-onnx-jni")
}
}
}
/*
Please see
https://k2-fsa.github.io/sherpa/onnx/kws/pretrained_models/index.html
for a list of pre-trained models.
We only add a few here. Please change the following code
to add your own. (It should be straightforward to add a new model
by following the code)
@param type
0 - sherpa-onnx-kws-zipformer-wenetspeech-3.3M-2024-01-01 (Chinese)
https://www.modelscope.cn/models/pkufool/sherpa-onnx-kws-zipformer-wenetspeech-3.3M-2024-01-01/summary
1 - sherpa-onnx-kws-zipformer-gigaspeech-3.3M-2024-01-01 (English)
https://www.modelscope.cn/models/pkufool/sherpa-onnx-kws-zipformer-gigaspeech-3.3M-2024-01-01/summary
*/
fun getKwsModelConfig(type: Int): OnlineModelConfig? {
when (type) {
0 -> {
val modelDir = "sherpa-onnx-kws-zipformer-wenetspeech-3.3M-2024-01-01"
return OnlineModelConfig(
transducer = OnlineTransducerModelConfig(
encoder = "$modelDir/encoder-epoch-12-avg-2-chunk-16-left-64.onnx",
decoder = "$modelDir/decoder-epoch-12-avg-2-chunk-16-left-64.onnx",
joiner = "$modelDir/joiner-epoch-12-avg-2-chunk-16-left-64.onnx",
),
tokens = "$modelDir/tokens.txt",
modelType = "zipformer2",
)
}
1 -> {
val modelDir = "sherpa-onnx-kws-zipformer-gigaspeech-3.3M-2024-01-01"
return OnlineModelConfig(
transducer = OnlineTransducerModelConfig(
encoder = "$modelDir/encoder-epoch-12-avg-2-chunk-16-left-64.onnx",
decoder = "$modelDir/decoder-epoch-12-avg-2-chunk-16-left-64.onnx",
joiner = "$modelDir/joiner-epoch-12-avg-2-chunk-16-left-64.onnx",
),
tokens = "$modelDir/tokens.txt",
modelType = "zipformer2",
)
}
}
return null
}
/*
* Get the default keywords for each model.
* Caution: The types and modelDir should be the same as those in getModelConfig
* function above.
*/
fun getKeywordsFile(type: Int): String {
when (type) {
0 -> {
val modelDir = "sherpa-onnx-kws-zipformer-wenetspeech-3.3M-2024-01-01"
return "$modelDir/keywords.txt"
}
1 -> {
val modelDir = "sherpa-onnx-kws-zipformer-gigaspeech-3.3M-2024-01-01"
return "$modelDir/keywords.txt"
}
}
return ""
}

View File

@ -0,0 +1,669 @@
package com.k2fsa.sherpa.onnx
import android.content.res.AssetManager
data class EndpointRule(
var mustContainNonSilence: Boolean,
var minTrailingSilence: Float,
var minUtteranceLength: Float,
)
data class EndpointConfig(
var rule1: EndpointRule = EndpointRule(false, 2.4f, 0.0f),
var rule2: EndpointRule = EndpointRule(true, 1.4f, 0.0f),
var rule3: EndpointRule = EndpointRule(false, 0.0f, 20.0f)
)
data class OnlineTransducerModelConfig(
var encoder: String = "",
var decoder: String = "",
var joiner: String = "",
)
data class OnlineParaformerModelConfig(
var encoder: String = "",
var decoder: String = "",
)
data class OnlineZipformer2CtcModelConfig(
var model: String = "",
)
data class OnlineNeMoCtcModelConfig(
var model: String = "",
)
data class OnlineToneCtcModelConfig(
var model: String = "",
)
data class OnlineModelConfig(
var transducer: OnlineTransducerModelConfig = OnlineTransducerModelConfig(),
var paraformer: OnlineParaformerModelConfig = OnlineParaformerModelConfig(),
var zipformer2Ctc: OnlineZipformer2CtcModelConfig = OnlineZipformer2CtcModelConfig(),
var neMoCtc: OnlineNeMoCtcModelConfig = OnlineNeMoCtcModelConfig(),
var toneCtc: OnlineToneCtcModelConfig = OnlineToneCtcModelConfig(),
var tokens: String = "",
var numThreads: Int = 1,
var debug: Boolean = false,
var provider: String = "cpu",
var modelType: String = "",
var modelingUnit: String = "",
var bpeVocab: String = "",
)
data class OnlineLMConfig(
var model: String = "",
var scale: Float = 0.5f,
)
data class OnlineCtcFstDecoderConfig(
var graph: String = "",
var maxActive: Int = 3000,
)
data class OnlineRecognizerConfig(
var featConfig: FeatureConfig = FeatureConfig(),
var modelConfig: OnlineModelConfig = OnlineModelConfig(),
var lmConfig: OnlineLMConfig = OnlineLMConfig(),
var ctcFstDecoderConfig: OnlineCtcFstDecoderConfig = OnlineCtcFstDecoderConfig(),
var hr: HomophoneReplacerConfig = HomophoneReplacerConfig(),
var endpointConfig: EndpointConfig = EndpointConfig(),
var enableEndpoint: Boolean = true,
var decodingMethod: String = "greedy_search",
var maxActivePaths: Int = 4,
var hotwordsFile: String = "",
var hotwordsScore: Float = 1.5f,
var ruleFsts: String = "",
var ruleFars: String = "",
var blankPenalty: Float = 0.0f,
)
data class OnlineRecognizerResult(
val text: String,
val tokens: Array<String>,
val timestamps: FloatArray,
val ysProbs: FloatArray,
// TODO(fangjun): Add more fields
)
class OnlineRecognizer(
assetManager: AssetManager? = null,
val config: OnlineRecognizerConfig,
) {
private var ptr: Long
init {
ptr = if (assetManager != null) {
newFromAsset(assetManager, config)
} else {
newFromFile(config)
}
}
protected fun finalize() {
if (ptr != 0L) {
delete(ptr)
ptr = 0
}
}
fun release() = finalize()
fun createStream(hotwords: String = ""): OnlineStream {
val p = createStream(ptr, hotwords)
return OnlineStream(p)
}
fun reset(stream: OnlineStream) = reset(ptr, stream.ptr)
fun decode(stream: OnlineStream) = decode(ptr, stream.ptr)
fun isEndpoint(stream: OnlineStream) = isEndpoint(ptr, stream.ptr)
fun isReady(stream: OnlineStream) = isReady(ptr, stream.ptr)
fun getResult(stream: OnlineStream): OnlineRecognizerResult {
return getResult(ptr, stream.ptr)
}
private external fun delete(ptr: Long)
private external fun newFromAsset(
assetManager: AssetManager,
config: OnlineRecognizerConfig,
): Long
private external fun newFromFile(
config: OnlineRecognizerConfig,
): Long
private external fun createStream(ptr: Long, hotwords: String): Long
private external fun reset(ptr: Long, streamPtr: Long)
private external fun decode(ptr: Long, streamPtr: Long)
private external fun isEndpoint(ptr: Long, streamPtr: Long): Boolean
private external fun isReady(ptr: Long, streamPtr: Long): Boolean
private external fun getResult(ptr: Long, streamPtr: Long): OnlineRecognizerResult
companion object {
init {
System.loadLibrary("sherpa-onnx-jni")
}
}
}
/*
Please see
https://k2-fsa.github.io/sherpa/onnx/pretrained_models/index.html
for a list of pre-trained models.
We only add a few here. Please change the following code
to add your own. (It should be straightforward to add a new model
by following the code)
@param type
0 - sherpa-onnx-streaming-zipformer-bilingual-zh-en-2023-02-20 (Bilingual, Chinese + English)
https://k2-fsa.github.io/sherpa/onnx/pretrained_models/zipformer-transducer-models.html#sherpa-onnx-streaming-zipformer-bilingual-zh-en-2023-02-20-bilingual-chinese-english
1 - csukuangfj/sherpa-onnx-lstm-zh-2023-02-20 (Chinese)
https://k2-fsa.github.io/sherpa/onnx/pretrained_models/lstm-transducer-models.html#csukuangfj-sherpa-onnx-lstm-zh-2023-02-20-chinese
2 - csukuangfj/sherpa-onnx-lstm-en-2023-02-17 (English)
https://k2-fsa.github.io/sherpa/onnx/pretrained_models/lstm-transducer-models.html#csukuangfj-sherpa-onnx-lstm-en-2023-02-17-english
3,4 - pkufool/icefall-asr-zipformer-streaming-wenetspeech-20230615
https://huggingface.co/pkufool/icefall-asr-zipformer-streaming-wenetspeech-20230615
3 - int8 encoder
4 - float32 encoder
5 - csukuangfj/sherpa-onnx-streaming-paraformer-bilingual-zh-en
https://huggingface.co/csukuangfj/sherpa-onnx-streaming-paraformer-bilingual-zh-en
6 - sherpa-onnx-streaming-zipformer-en-2023-06-26
https://huggingface.co/csukuangfj/sherpa-onnx-streaming-zipformer-en-2023-06-26
7 - shaojieli/sherpa-onnx-streaming-zipformer-fr-2023-04-14 (French)
https://huggingface.co/shaojieli/sherpa-onnx-streaming-zipformer-fr-2023-04-14
8 - csukuangfj/sherpa-onnx-streaming-zipformer-bilingual-zh-en-2023-02-20 (Bilingual, Chinese + English)
https://huggingface.co/csukuangfj/sherpa-onnx-streaming-zipformer-bilingual-zh-en-2023-02-20
encoder int8, decoder/joiner float32
*/
fun getModelConfig(type: Int): OnlineModelConfig? {
when (type) {
0 -> {
val modelDir = "sherpa-onnx-streaming-zipformer-bilingual-zh-en-2023-02-20"
return OnlineModelConfig(
transducer = OnlineTransducerModelConfig(
encoder = "$modelDir/encoder-epoch-99-avg-1.onnx",
decoder = "$modelDir/decoder-epoch-99-avg-1.onnx",
joiner = "$modelDir/joiner-epoch-99-avg-1.onnx",
),
tokens = "$modelDir/tokens.txt",
modelType = "zipformer",
)
}
1 -> {
val modelDir = "sherpa-onnx-lstm-zh-2023-02-20"
return OnlineModelConfig(
transducer = OnlineTransducerModelConfig(
encoder = "$modelDir/encoder-epoch-11-avg-1.onnx",
decoder = "$modelDir/decoder-epoch-11-avg-1.onnx",
joiner = "$modelDir/joiner-epoch-11-avg-1.onnx",
),
tokens = "$modelDir/tokens.txt",
modelType = "lstm",
)
}
2 -> {
val modelDir = "sherpa-onnx-lstm-en-2023-02-17"
return OnlineModelConfig(
transducer = OnlineTransducerModelConfig(
encoder = "$modelDir/encoder-epoch-99-avg-1.onnx",
decoder = "$modelDir/decoder-epoch-99-avg-1.onnx",
joiner = "$modelDir/joiner-epoch-99-avg-1.onnx",
),
tokens = "$modelDir/tokens.txt",
modelType = "lstm",
)
}
3 -> {
val modelDir = "icefall-asr-zipformer-streaming-wenetspeech-20230615"
return OnlineModelConfig(
transducer = OnlineTransducerModelConfig(
encoder = "$modelDir/exp/encoder-epoch-12-avg-4-chunk-16-left-128.int8.onnx",
decoder = "$modelDir/exp/decoder-epoch-12-avg-4-chunk-16-left-128.onnx",
joiner = "$modelDir/exp/joiner-epoch-12-avg-4-chunk-16-left-128.onnx",
),
tokens = "$modelDir/data/lang_char/tokens.txt",
modelType = "zipformer2",
)
}
4 -> {
val modelDir = "icefall-asr-zipformer-streaming-wenetspeech-20230615"
return OnlineModelConfig(
transducer = OnlineTransducerModelConfig(
encoder = "$modelDir/exp/encoder-epoch-12-avg-4-chunk-16-left-128.onnx",
decoder = "$modelDir/exp/decoder-epoch-12-avg-4-chunk-16-left-128.onnx",
joiner = "$modelDir/exp/joiner-epoch-12-avg-4-chunk-16-left-128.onnx",
),
tokens = "$modelDir/data/lang_char/tokens.txt",
modelType = "zipformer2",
)
}
5 -> {
val modelDir = "sherpa-onnx-streaming-paraformer-bilingual-zh-en"
return OnlineModelConfig(
paraformer = OnlineParaformerModelConfig(
encoder = "$modelDir/encoder.int8.onnx",
decoder = "$modelDir/decoder.int8.onnx",
),
tokens = "$modelDir/tokens.txt",
modelType = "paraformer",
)
}
6 -> {
val modelDir = "sherpa-onnx-streaming-zipformer-en-2023-06-26"
return OnlineModelConfig(
transducer = OnlineTransducerModelConfig(
encoder = "$modelDir/encoder-epoch-99-avg-1-chunk-16-left-128.int8.onnx",
decoder = "$modelDir/decoder-epoch-99-avg-1-chunk-16-left-128.onnx",
joiner = "$modelDir/joiner-epoch-99-avg-1-chunk-16-left-128.onnx",
),
tokens = "$modelDir/tokens.txt",
modelType = "zipformer2",
)
}
7 -> {
val modelDir = "sherpa-onnx-streaming-zipformer-fr-2023-04-14"
return OnlineModelConfig(
transducer = OnlineTransducerModelConfig(
encoder = "$modelDir/encoder-epoch-29-avg-9-with-averaged-model.int8.onnx",
decoder = "$modelDir/decoder-epoch-29-avg-9-with-averaged-model.onnx",
joiner = "$modelDir/joiner-epoch-29-avg-9-with-averaged-model.onnx",
),
tokens = "$modelDir/tokens.txt",
modelType = "zipformer",
)
}
8 -> {
val modelDir = "sherpa-onnx-streaming-zipformer-bilingual-zh-en-2023-02-20"
return OnlineModelConfig(
transducer = OnlineTransducerModelConfig(
encoder = "$modelDir/encoder-epoch-99-avg-1.int8.onnx",
decoder = "$modelDir/decoder-epoch-99-avg-1.onnx",
joiner = "$modelDir/joiner-epoch-99-avg-1.int8.onnx",
),
tokens = "$modelDir/tokens.txt",
modelType = "zipformer",
)
}
9 -> {
val modelDir = "sherpa-onnx-streaming-zipformer-zh-14M-2023-02-23"
return OnlineModelConfig(
transducer = OnlineTransducerModelConfig(
encoder = "$modelDir/encoder-epoch-99-avg-1.int8.onnx",
decoder = "$modelDir/decoder-epoch-99-avg-1.onnx",
joiner = "$modelDir/joiner-epoch-99-avg-1.int8.onnx",
),
tokens = "$modelDir/tokens.txt",
modelType = "zipformer",
)
}
10 -> {
val modelDir = "sherpa-onnx-streaming-zipformer-en-20M-2023-02-17"
return OnlineModelConfig(
transducer = OnlineTransducerModelConfig(
encoder = "$modelDir/encoder-epoch-99-avg-1.int8.onnx",
decoder = "$modelDir/decoder-epoch-99-avg-1.onnx",
joiner = "$modelDir/joiner-epoch-99-avg-1.int8.onnx",
),
tokens = "$modelDir/tokens.txt",
modelType = "zipformer",
)
}
11 -> {
val modelDir = "sherpa-onnx-nemo-streaming-fast-conformer-ctc-en-80ms"
return OnlineModelConfig(
neMoCtc = OnlineNeMoCtcModelConfig(
model = "$modelDir/model.onnx",
),
tokens = "$modelDir/tokens.txt",
)
}
12 -> {
val modelDir = "sherpa-onnx-nemo-streaming-fast-conformer-ctc-en-480ms"
return OnlineModelConfig(
neMoCtc = OnlineNeMoCtcModelConfig(
model = "$modelDir/model.onnx",
),
tokens = "$modelDir/tokens.txt",
)
}
13 -> {
val modelDir = "sherpa-onnx-nemo-streaming-fast-conformer-ctc-en-1040ms"
return OnlineModelConfig(
neMoCtc = OnlineNeMoCtcModelConfig(
model = "$modelDir/model.onnx",
),
tokens = "$modelDir/tokens.txt",
)
}
14 -> {
val modelDir = "sherpa-onnx-streaming-zipformer-korean-2024-06-16"
return OnlineModelConfig(
transducer = OnlineTransducerModelConfig(
encoder = "$modelDir/encoder-epoch-99-avg-1.int8.onnx",
decoder = "$modelDir/decoder-epoch-99-avg-1.onnx",
joiner = "$modelDir/joiner-epoch-99-avg-1.int8.onnx",
),
tokens = "$modelDir/tokens.txt",
modelType = "zipformer",
)
}
15 -> {
val modelDir = "sherpa-onnx-streaming-zipformer-small-ctc-zh-int8-2025-04-01"
return OnlineModelConfig(
zipformer2Ctc = OnlineZipformer2CtcModelConfig(
model = "$modelDir/model.int8.onnx",
),
tokens = "$modelDir/tokens.txt",
)
}
16 -> {
val modelDir = "sherpa-onnx-streaming-zipformer-small-ctc-zh-2025-04-01"
return OnlineModelConfig(
zipformer2Ctc = OnlineZipformer2CtcModelConfig(
model = "$modelDir/model.onnx",
),
tokens = "$modelDir/tokens.txt",
)
}
17 -> {
val modelDir = "sherpa-onnx-streaming-zipformer-ctc-zh-int8-2025-06-30"
return OnlineModelConfig(
zipformer2Ctc = OnlineZipformer2CtcModelConfig(
model = "$modelDir/model.int8.onnx",
),
tokens = "$modelDir/tokens.txt",
)
}
18 -> {
val modelDir = "sherpa-onnx-streaming-zipformer-ctc-zh-2025-06-30"
return OnlineModelConfig(
zipformer2Ctc = OnlineZipformer2CtcModelConfig(
model = "$modelDir/model.onnx",
),
tokens = "$modelDir/tokens.txt",
modelType = "zipformer2",
)
}
19 -> {
val modelDir = "sherpa-onnx-streaming-zipformer-ctc-zh-fp16-2025-06-30"
return OnlineModelConfig(
zipformer2Ctc = OnlineZipformer2CtcModelConfig(
model = "$modelDir/model.fp16.onnx",
),
tokens = "$modelDir/tokens.txt",
modelType = "zipformer2",
)
}
20 -> {
val modelDir = "sherpa-onnx-streaming-zipformer-zh-int8-2025-06-30"
return OnlineModelConfig(
transducer = OnlineTransducerModelConfig(
encoder = "$modelDir/encoder.int8.onnx",
decoder = "$modelDir/decoder.onnx",
joiner = "$modelDir/joiner.int8.onnx",
),
tokens = "$modelDir/tokens.txt",
modelType = "zipformer2",
)
}
21 -> {
val modelDir = "sherpa-onnx-streaming-zipformer-en-kroko-2025-08-06"
return OnlineModelConfig(
transducer = OnlineTransducerModelConfig(
encoder = "$modelDir/encoder.onnx",
decoder = "$modelDir/decoder.onnx",
joiner = "$modelDir/joiner.onnx",
),
tokens = "$modelDir/tokens.txt",
modelType = "zipformer2",
)
}
22 -> {
val modelDir = "sherpa-onnx-streaming-zipformer-es-kroko-2025-08-06"
return OnlineModelConfig(
transducer = OnlineTransducerModelConfig(
encoder = "$modelDir/encoder.onnx",
decoder = "$modelDir/decoder.onnx",
joiner = "$modelDir/joiner.onnx",
),
tokens = "$modelDir/tokens.txt",
modelType = "zipformer2",
)
}
23 -> {
val modelDir = "sherpa-onnx-streaming-zipformer-fr-kroko-2025-08-06"
return OnlineModelConfig(
transducer = OnlineTransducerModelConfig(
encoder = "$modelDir/encoder.onnx",
decoder = "$modelDir/decoder.onnx",
joiner = "$modelDir/joiner.onnx",
),
tokens = "$modelDir/tokens.txt",
modelType = "zipformer2",
)
}
24 -> {
val modelDir = "sherpa-onnx-streaming-zipformer-de-kroko-2025-08-06"
return OnlineModelConfig(
transducer = OnlineTransducerModelConfig(
encoder = "$modelDir/encoder.onnx",
decoder = "$modelDir/decoder.onnx",
joiner = "$modelDir/joiner.onnx",
),
tokens = "$modelDir/tokens.txt",
modelType = "zipformer2",
)
}
25 -> {
val modelDir = "sherpa-onnx-streaming-zipformer-small-ru-vosk-int8-2025-08-16"
return OnlineModelConfig(
transducer = OnlineTransducerModelConfig(
encoder = "$modelDir/encoder.int8.onnx",
decoder = "$modelDir/decoder.onnx",
joiner = "$modelDir/joiner.int8.onnx",
),
tokens = "$modelDir/tokens.txt",
modelType = "zipformer2",
)
}
26 -> {
val modelDir = "sherpa-onnx-streaming-zipformer-small-ru-vosk-2025-08-16"
return OnlineModelConfig(
transducer = OnlineTransducerModelConfig(
encoder = "$modelDir/encoder.onnx",
decoder = "$modelDir/decoder.onnx",
joiner = "$modelDir/joiner.onnx",
),
tokens = "$modelDir/tokens.txt",
modelType = "zipformer2",
)
}
27 -> {
val modelDir = "sherpa-onnx-streaming-t-one-russian-2025-09-08"
return OnlineModelConfig(
toneCtc = OnlineToneCtcModelConfig(
model = "$modelDir/model.onnx",
),
tokens = "$modelDir/tokens.txt",
)
}
28 -> {
val modelDir = "sherpa-onnx-nemotron-speech-streaming-en-0.6b-int8-2026-01-14"
return OnlineModelConfig(
transducer = OnlineTransducerModelConfig(
encoder = "$modelDir/encoder.int8.onnx",
decoder = "$modelDir/decoder.int8.onnx",
joiner = "$modelDir/joiner.int8.onnx",
),
tokens = "$modelDir/tokens.txt",
)
}
29 -> {
val modelDir = "sherpa-onnx-streaming-zipformer-bn-vosk-2026-02-09"
return OnlineModelConfig(
transducer = OnlineTransducerModelConfig(
encoder = "$modelDir/encoder.onnx",
decoder = "$modelDir/decoder.onnx",
joiner = "$modelDir/joiner.onnx",
),
tokens = "$modelDir/tokens.txt",
modelType = "zipformer2",
)
}
30 -> {
val modelDir = "sherpa-onnx-nemotron-speech-streaming-en-0.6b-80ms-int8-2026-04-25"
return OnlineModelConfig(
transducer = OnlineTransducerModelConfig(
encoder = "$modelDir/encoder.int8.onnx",
decoder = "$modelDir/decoder.int8.onnx",
joiner = "$modelDir/joiner.int8.onnx",
),
tokens = "$modelDir/tokens.txt",
)
}
31 -> {
val modelDir = "sherpa-onnx-nemotron-speech-streaming-en-0.6b-160ms-int8-2026-04-25"
return OnlineModelConfig(
transducer = OnlineTransducerModelConfig(
encoder = "$modelDir/encoder.int8.onnx",
decoder = "$modelDir/decoder.int8.onnx",
joiner = "$modelDir/joiner.int8.onnx",
),
tokens = "$modelDir/tokens.txt",
)
}
32 -> {
val modelDir = "sherpa-onnx-nemotron-speech-streaming-en-0.6b-560ms-int8-2026-04-25"
return OnlineModelConfig(
transducer = OnlineTransducerModelConfig(
encoder = "$modelDir/encoder.int8.onnx",
decoder = "$modelDir/decoder.int8.onnx",
joiner = "$modelDir/joiner.int8.onnx",
),
tokens = "$modelDir/tokens.txt",
)
}
33 -> {
val modelDir = "sherpa-onnx-nemotron-speech-streaming-en-0.6b-1120ms-int8-2026-04-25"
return OnlineModelConfig(
transducer = OnlineTransducerModelConfig(
encoder = "$modelDir/encoder.int8.onnx",
decoder = "$modelDir/decoder.int8.onnx",
joiner = "$modelDir/joiner.int8.onnx",
),
tokens = "$modelDir/tokens.txt",
)
}
1000 -> {
val modelDir = "sherpa-onnx-rk3588-streaming-zipformer-bilingual-zh-en-2023-02-20"
return OnlineModelConfig(
transducer = OnlineTransducerModelConfig(
encoder = "$modelDir/encoder.rknn",
decoder = "$modelDir/decoder.rknn",
joiner = "$modelDir/joiner.rknn",
),
tokens = "$modelDir/tokens.txt",
modelType = "zipformer",
provider = "rknn",
)
}
1001 -> {
val modelDir = "sherpa-onnx-rk3588-streaming-zipformer-small-bilingual-zh-en-2023-02-16"
return OnlineModelConfig(
transducer = OnlineTransducerModelConfig(
encoder = "$modelDir/encoder.rknn",
decoder = "$modelDir/decoder.rknn",
joiner = "$modelDir/joiner.rknn",
),
tokens = "$modelDir/tokens.txt",
modelType = "zipformer",
provider = "rknn",
)
}
}
return null
}
/*
Please see
https://k2-fsa.github.io/sherpa/onnx/pretrained_models/index.html
for a list of pre-trained models.
We only add a few here. Please change the following code
to add your own LM model. (It should be straightforward to train a new NN LM model
by following the code, https://github.com/k2-fsa/icefall/blob/master/icefall/rnn_lm/train.py)
@param type
0 - sherpa-onnx-streaming-zipformer-bilingual-zh-en-2023-02-20 (Bilingual, Chinese + English)
https://k2-fsa.github.io/sherpa/onnx/pretrained_models/zipformer-transducer-models.html#sherpa-onnx-streaming-zipformer-bilingual-zh-en-2023-02-20-bilingual-chinese-english
*/
fun getOnlineLMConfig(type: Int): OnlineLMConfig {
when (type) {
0 -> {
val modelDir = "sherpa-onnx-streaming-zipformer-bilingual-zh-en-2023-02-20"
return OnlineLMConfig(
model = "$modelDir/with-state-epoch-99-avg-1.int8.onnx",
scale = 0.5f,
)
}
}
return OnlineLMConfig()
}
fun getEndpointConfig(): EndpointConfig {
return EndpointConfig(
rule1 = EndpointRule(false, 2.4f, 0.0f),
rule2 = EndpointRule(true, 1.4f, 0.0f),
rule3 = EndpointRule(false, 0.0f, 20.0f)
)
}

View File

@ -0,0 +1,42 @@
package com.k2fsa.sherpa.onnx
class OnlineStream(var ptr: Long = 0) {
fun acceptWaveform(samples: FloatArray, sampleRate: Int) =
acceptWaveform(ptr, samples, sampleRate)
fun inputFinished() = inputFinished(ptr)
fun setOption(key: String, value: String) = setOption(ptr, key, value)
fun getOption(key: String): String = getOption(ptr, key)
protected fun finalize() {
if (ptr != 0L) {
delete(ptr)
ptr = 0
}
}
fun release() = finalize()
fun use(block: (OnlineStream) -> Unit) {
try {
block(this)
} finally {
release()
}
}
private external fun acceptWaveform(ptr: Long, samples: FloatArray, sampleRate: Int)
private external fun inputFinished(ptr: Long)
private external fun setOption(ptr: Long, key: String, value: String)
private external fun getOption(ptr: Long, key: String): String
private external fun delete(ptr: Long)
companion object {
init {
System.loadLibrary("sherpa-onnx-jni")
}
}
}

View File

@ -0,0 +1,50 @@
package com.lila.wakeup
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.os.Build
import android.util.Log
/**
* 开机自启接收器
*
* 监听 BOOT_COMPLETED / LOCKED_BOOT_COMPLETED / QUICKBOOT_POWERON 三种开机广播
* 收到后启动 [WakeupForegroundService] 进入监听状态
*
* Android 限制
* - 普通 APK 必须用户手动启动过一次 APP BootReceiver 才会生效系统安全限制
* - 升级到方案 B系统签名 + /system/priv-app/后此限制取消
*/
class BootReceiver : BroadcastReceiver() {
companion object {
private const val TAG = "KwsService.Boot"
}
override fun onReceive(context: Context, intent: Intent) {
val action = intent.action
Log.i(TAG, "Received boot broadcast: $action")
when (action) {
Intent.ACTION_BOOT_COMPLETED,
Intent.ACTION_LOCKED_BOOT_COMPLETED,
"android.intent.action.QUICKBOOT_POWERON" -> {
startWakeupService(context)
}
else -> {
Log.w(TAG, "Ignored unknown action: $action")
}
}
}
private fun startWakeupService(context: Context) {
val serviceIntent = Intent(context, WakeupForegroundService::class.java)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
context.startForegroundService(serviceIntent)
} else {
context.startService(serviceIntent)
}
Log.i(TAG, "WakeupForegroundService started after boot")
}
}

View File

@ -0,0 +1,108 @@
package com.lila.wakeup
import android.media.AudioFormat
/**
* KWS APK 全局配置常量
*
* 协议版本v2.1详见 KWS唤醒协议-V2.md
* 涵盖三类常量
* - 协议常量Action 对端包名字段键
* - 引擎参数阈值平滑超时采样率
* - 模型路径assets 内相对路径
*/
object Config {
// ============================================================
// 一、协议常量
// ============================================================
/** 本 APK 包名(自身) */
const val SELF_PACKAGE = "com.lila.wakeup"
/** 数字人 APP 包名(已确认 v2.1 */
const val APP_PACKAGE = "com.qy.lila"
/** 唤醒事件广播 Action本 → APP */
const val ACTION_WAKEUP = "com.lila.intent.action.WAKEUP"
/** 暂停 KWS 广播 ActionAPP → 本) */
const val ACTION_KWS_PAUSE = "com.lila.intent.action.KWS_PAUSE"
/** 恢复 KWS 广播 ActionAPP → 本) */
const val ACTION_KWS_RESUME = "com.lila.intent.action.KWS_RESUME"
/** WAKEUP Extras: 命中的唤醒词文本 */
const val EXTRA_KEYWORD = "keyword"
/** WAKEUP Extras: 命中时间戳(毫秒) */
const val EXTRA_TIMESTAMP = "timestamp"
/** WAKEUP Extras: 置信度 [0,1] */
const val EXTRA_CONFIDENCE = "confidence"
/** PAUSE Extras: 静默期毫秒数 */
const val EXTRA_SILENCE_MS = "silence_ms"
/** PAUSE/RESUME Extras: 调用原因(透传日志) */
const val EXTRA_REASON = "reason"
// ============================================================
// 二、引擎参数
// ============================================================
/** PAUSE 默认静默期v2.1 微调3000ms 覆盖 RTC 远端音频回灌 + AI TTS 触发延迟) */
const val DEFAULT_SILENCE_MS = 3000L
/** APP 漏发 RESUME 时的兜底超时2 分钟) */
const val PAUSE_TIMEOUT_MS = 2 * 60 * 1000L
/** KWS 主阈值("你好Lila" 主词) */
const val KWS_THRESHOLD_PRIMARY = 0.85f
/** KWS 次阈值(其他变体词) */
const val KWS_THRESHOLD_SECONDARY = 0.80f
/** 后验平滑:连续 N 帧 confidence > 阈值才算命中 */
const val SMOOTH_FRAMES = 2
// ============================================================
// 三、AudioRecord 配置
// ============================================================
/** 采样率sherpa-onnx 模型固定 16kHz */
const val SAMPLE_RATE = 16_000
/** 单声道 */
const val CHANNEL_CONFIG = AudioFormat.CHANNEL_IN_MONO
/** 16-bit PCM */
const val ENCODING = AudioFormat.ENCODING_PCM_16BIT
/** 单帧采样点数10ms 帧 @ 16kHz = 160 samples */
const val FRAME_SAMPLES = SAMPLE_RATE / 100
// ============================================================
// 四、模型路径assets 内)
// ============================================================
/**
* 模型目录 sherpa-onnx 官方 demo 一致
* 切换模型时改这一行 + 对应的 keywords.txt
*/
const val MODEL_DIR = "sherpa-onnx-kws-zipformer-wenetspeech-3.3M-2024-01-01"
const val MODEL_ENCODER = "$MODEL_DIR/encoder-epoch-12-avg-2-chunk-16-left-64.onnx"
const val MODEL_DECODER = "$MODEL_DIR/decoder-epoch-12-avg-2-chunk-16-left-64.onnx"
const val MODEL_JOINER = "$MODEL_DIR/joiner-epoch-12-avg-2-chunk-16-left-64.onnx"
const val MODEL_TOKENS = "$MODEL_DIR/tokens.txt"
const val MODEL_KEYWORDS = "$MODEL_DIR/keywords.txt"
// ============================================================
// 五、通知栏
// ============================================================
const val NOTIF_CHANNEL_ID = "lila_wakeup_kws"
const val NOTIF_CHANNEL_NAME = "Lila 语音唤醒"
const val NOTIF_ID = 1
}

View File

@ -0,0 +1,117 @@
package com.lila.wakeup
import android.Manifest
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
import android.util.Log
import android.widget.TextView
import androidx.appcompat.app.AppCompatActivity
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
/**
* 状态查看 Activity开发期辅助 + 首次启动权限申请入口
*
* 职责
* 1. 首次启动时弹麦克风权限申请
* 2. 启动 [WakeupForegroundService]
* 3. 显示当前状态开发期可选
*
* 量产时方案 B改为 launcher 隐藏 LAUNCHER intent-filter
* 用户不用进 APP 也能开机自启
*/
class MainActivity : AppCompatActivity() {
companion object {
private const val TAG = "KwsService.Main"
private const val PERMISSION_REQUEST_CODE = 1001
}
private lateinit var statusText: TextView
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// 简化:用一个 TextView 直接显示状态,不用 layout xml
statusText = TextView(this).apply {
text = "Lila 语音唤醒服务\n\n初始化中..."
textSize = 18f
setPadding(60, 60, 60, 60)
}
setContentView(statusText)
checkAndRequestPermissions()
}
private fun checkAndRequestPermissions() {
val needRequestList = mutableListOf<String>()
// 麦克风权限(核心)
if (ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO)
!= PackageManager.PERMISSION_GRANTED
) {
needRequestList.add(Manifest.permission.RECORD_AUDIO)
}
// 通知权限Android 13+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
if (ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS)
!= PackageManager.PERMISSION_GRANTED
) {
needRequestList.add(Manifest.permission.POST_NOTIFICATIONS)
}
}
if (needRequestList.isNotEmpty()) {
ActivityCompat.requestPermissions(
this,
needRequestList.toTypedArray(),
PERMISSION_REQUEST_CODE
)
} else {
startWakeupService()
}
}
override fun onRequestPermissionsResult(
requestCode: Int,
permissions: Array<out String>,
grantResults: IntArray
) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
if (requestCode != PERMISSION_REQUEST_CODE) return
val recordIdx = permissions.indexOf(Manifest.permission.RECORD_AUDIO)
if (recordIdx >= 0 && grantResults[recordIdx] == PackageManager.PERMISSION_GRANTED) {
Log.i(TAG, "RECORD_AUDIO granted")
startWakeupService()
} else {
statusText.text = "Lila 语音唤醒服务\n\n❌ 麦克风权限被拒绝,无法启动唤醒"
Log.e(TAG, "RECORD_AUDIO denied, service NOT started")
}
}
private fun startWakeupService() {
val intent = Intent(this, WakeupForegroundService::class.java)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
startForegroundService(intent)
} else {
startService(intent)
}
statusText.text = """
Lila 语音唤醒服务
服务已启动
监听唤醒词中
可关闭本界面服务会在后台持续运行
通知栏可看到运行状态
日志adb logcat -s KwsService
""".trimIndent()
Log.i(TAG, "WakeupForegroundService start requested")
}
}

View File

@ -0,0 +1,140 @@
package com.lila.wakeup
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
import android.content.pm.ServiceInfo
import android.os.Build
import android.os.IBinder
import android.util.Log
import androidx.core.app.NotificationCompat
import com.lila.wakeup.kws.AudioCapture
import com.lila.wakeup.kws.KwsEngine
import com.lila.wakeup.kws.KwsStateMachine
import com.lila.wakeup.protocol.BroadcastSender
import com.lila.wakeup.protocol.WakeupServiceLocator
/**
* KWS 唤醒前台 Service 整个 APK 的运行时核心
*
* 生命周期
* - onCreate: 初始化引擎状态机采集器通知栏
* - onStartCommand: startForeground + 启动音频采集 + 进入 LISTENING
* - onDestroy: 停止采集释放引擎
*
* START_STICKY: 被系统杀死后会自动重启无音频帧丢失影响业务
*/
class WakeupForegroundService : Service() {
companion object {
private const val TAG = "KwsService.Svc"
}
private lateinit var engine: KwsEngine
private lateinit var stateMachine: KwsStateMachine
private lateinit var audioCapture: AudioCapture
private lateinit var sender: BroadcastSender
override fun onCreate() {
super.onCreate()
Log.i(TAG, "onCreate")
// 通知栏 channel 必须在 startForeground 之前创建
createNotificationChannel()
// 初始化引擎栈顺序sender → engine → state → audio
sender = BroadcastSender(applicationContext)
engine = KwsEngine(applicationContext).also { it.init() }
stateMachine = KwsStateMachine(
engine = engine,
sender = sender,
onTimeoutResume = { Log.w(TAG, "[Tmout] PAUSE 超时自动 RESUME") }
)
audioCapture = AudioCapture(onFrame = { pcm -> stateMachine.onAudioFrame(pcm) })
// 注册到全局定位器,使 ProtocolReceiver 能投递 PAUSE/RESUME 事件
WakeupServiceLocator.stateMachine = stateMachine
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
Log.i(TAG, "onStartCommand")
startInForeground()
// 进入监听态
stateMachine.transitionToListening()
audioCapture.start()
// 被杀重启时尝试恢复(不带 Intent
return START_STICKY
}
override fun onDestroy() {
Log.i(TAG, "onDestroy")
// 先解绑 locator避免 ProtocolReceiver 投递到正在销毁的 stateMachine
WakeupServiceLocator.stateMachine = null
audioCapture.stop()
engine.release()
stateMachine.shutdown()
super.onDestroy()
}
override fun onBind(intent: Intent?): IBinder? = null
// ============================================================
// 通知栏(前台 Service 强制要求)
// ============================================================
private fun startInForeground() {
val notification = buildNotification()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
startForeground(
Config.NOTIF_ID,
notification,
ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE
)
} else {
startForeground(Config.NOTIF_ID, notification)
}
}
private fun createNotificationChannel() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
val mgr = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
if (mgr.getNotificationChannel(Config.NOTIF_CHANNEL_ID) != null) return
val channel = NotificationChannel(
Config.NOTIF_CHANNEL_ID,
Config.NOTIF_CHANNEL_NAME,
NotificationManager.IMPORTANCE_MIN // 最低优先级,不打扰用户
).apply {
description = "语音唤醒服务运行状态"
setShowBadge(false)
}
mgr.createNotificationChannel(channel)
}
private fun buildNotification(): Notification {
// 点击通知打开 MainActivity状态查看
val pendingIntent = PendingIntent.getActivity(
this,
0,
Intent(this, MainActivity::class.java),
PendingIntent.FLAG_IMMUTABLE
)
return NotificationCompat.Builder(this, Config.NOTIF_CHANNEL_ID)
.setContentTitle("Lila 语音助手")
.setContentText("正在监听唤醒词")
.setSmallIcon(android.R.drawable.ic_btn_speak_now) // 用系统默认麦克风图标,方案 B 时换自家
.setOngoing(true)
.setPriority(NotificationCompat.PRIORITY_MIN)
.setContentIntent(pendingIntent)
.setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE)
.build()
}
}

View File

@ -0,0 +1,112 @@
package com.lila.wakeup.kws
import android.annotation.SuppressLint
import android.media.AudioFormat
import android.media.AudioRecord
import android.media.MediaRecorder
import android.util.Log
import com.lila.wakeup.Config
/**
* 麦克风音频采集封装
*
* 设计要点
* - AudioSource VOICE_RECOGNITION自动启用 NS/AGC省去自己降噪
* - 16kHz / mono / 16-bit PCM sherpa-onnx 模型严格对齐
* - 10ms 160 sample便于和 VAD 对齐
* - 暂停时完全 release AudioRecord释放麦克风给 RTC SDK
*
* 这是常驻读取线程不是 ForegroundService 主线程
*/
class AudioCapture(
/** 每读到一帧 PCM 数据时回调,参数为 16-bit PCM short[] */
private val onFrame: (ShortArray) -> Unit
) {
companion object {
private const val TAG = "KwsService.Audio"
}
@Volatile private var record: AudioRecord? = null
@Volatile private var thread: Thread? = null
@Volatile private var running = false
@SuppressLint("MissingPermission") // 权限由 MainActivity 运行时申请
fun start() {
if (running) {
Log.w(TAG, "start() called while already running, skip")
return
}
val minBuf = AudioRecord.getMinBufferSize(
Config.SAMPLE_RATE, Config.CHANNEL_CONFIG, Config.ENCODING
)
// 至少 100ms ring buffer多于 minBuf 防止音频丢帧
val bufSize = maxOf(minBuf, Config.SAMPLE_RATE * 2 * 100 / 1000)
record = AudioRecord.Builder()
.setAudioSource(MediaRecorder.AudioSource.VOICE_RECOGNITION)
.setAudioFormat(
AudioFormat.Builder()
.setSampleRate(Config.SAMPLE_RATE)
.setChannelMask(Config.CHANNEL_CONFIG)
.setEncoding(Config.ENCODING)
.build()
)
.setBufferSizeInBytes(bufSize)
.build()
if (record?.state != AudioRecord.STATE_INITIALIZED) {
Log.e(TAG, "AudioRecord init failed, state=${record?.state}")
release()
return
}
record?.startRecording()
running = true
Log.i(TAG, "AudioRecord started, bufSize=$bufSize bytes")
thread = Thread {
val frame = ShortArray(Config.FRAME_SAMPLES)
while (running) {
val n = record?.read(frame, 0, frame.size) ?: 0
if (n > 0) {
onFrame(frame)
} else if (n < 0) {
Log.e(TAG, "AudioRecord.read error code=$n, abort")
break
}
}
Log.i(TAG, "AudioCapture thread exit")
}.apply {
name = "KwsAudioCapture"
priority = Thread.NORM_PRIORITY + 2 // 略高于普通线程
isDaemon = true
start()
}
}
fun stop() {
if (!running) return
running = false
thread?.join(500)
thread = null
release()
Log.i(TAG, "AudioRecord stopped")
}
private fun release() {
record?.let {
try {
if (it.recordingState == AudioRecord.RECORDSTATE_RECORDING) {
it.stop()
}
it.release()
} catch (e: Exception) {
Log.e(TAG, "release failed: ${e.message}")
}
}
record = null
}
fun isRunning(): Boolean = running
}

View File

@ -0,0 +1,119 @@
package com.lila.wakeup.kws
import android.content.Context
import android.util.Log
import com.k2fsa.sherpa.onnx.KeywordSpotter
import com.k2fsa.sherpa.onnx.KeywordSpotterConfig
import com.k2fsa.sherpa.onnx.OnlineModelConfig
import com.k2fsa.sherpa.onnx.OnlineStream
import com.k2fsa.sherpa.onnx.OnlineTransducerModelConfig
import com.lila.wakeup.Config
/**
* sherpa-onnx KWS 引擎封装
*
* 该类是对 sherpa-onnx 官方 [KeywordSpotter] Kotlin Wrapper 的薄封装
* 隐藏底层 API 细节对外暴露简洁的 [process] 接口
*
* 模型路径 [Config.MODEL_DIR] APK assets 加载
*
* 版本兼容本骨架基于 sherpa-onnx v1.13.0+ Kotlin API
* 若克隆的 sherpa-onnx 仓库 API 有差异如类名变更仅需调整本文件
*
* 业务结果数据类
*
* @param keyword 命中的唤醒词文本 "你好Lila"未命中为空字符串
* @param confidence 置信度[0,1]
*/
data class KwsResult(
val keyword: String,
val confidence: Float
) {
val isHit: Boolean get() = keyword.isNotEmpty()
}
class KwsEngine(private val context: Context) {
companion object {
private const val TAG = "KwsService.Engine"
}
private var spotter: KeywordSpotter? = null
private var stream: OnlineStream? = null
/**
* 加载模型 + keywords.txt初始化推理引擎
* 必须在 [process] 之前调用一次
*/
fun init() {
val cfg = KeywordSpotterConfig(
featConfig = com.k2fsa.sherpa.onnx.FeatureConfig(
sampleRate = Config.SAMPLE_RATE,
featureDim = 80
),
modelConfig = OnlineModelConfig(
transducer = OnlineTransducerModelConfig(
encoder = Config.MODEL_ENCODER,
decoder = Config.MODEL_DECODER,
joiner = Config.MODEL_JOINER
),
tokens = Config.MODEL_TOKENS,
modelType = "zipformer"
),
keywordsFile = Config.MODEL_KEYWORDS,
keywordsThreshold = Config.KWS_THRESHOLD_PRIMARY,
keywordsScore = 1.5f,
numTrailingBlanks = 2
)
spotter = KeywordSpotter(assetManager = context.assets, config = cfg)
stream = spotter?.createStream()
Log.i(TAG, "[Engine] init done, keywordsFile=${Config.MODEL_KEYWORDS}")
}
/**
* 喂一帧 PCM 数据进引擎返回是否命中
*
* @param pcm 16-bit PCM short[] 数据长度任意一般 10ms = 160 samples
* @return 命中则 [KwsResult.isHit] true
*/
fun process(pcm: ShortArray): KwsResult {
val sp = spotter ?: return KwsResult("", 0f)
val st = stream ?: return KwsResult("", 0f)
// sherpa-onnx 输入要求 [-1, 1] 范围 float
val floatBuf = FloatArray(pcm.size) { pcm[it] / 32768f }
st.acceptWaveform(floatBuf, Config.SAMPLE_RATE)
while (sp.isReady(st)) {
sp.decode(st)
}
val result = sp.getResult(st)
val keyword = result.keyword
return if (keyword.isNotEmpty()) {
// 命中后必须 reset否则下次不会再触发
sp.reset(st)
Log.i(TAG, "[Engine] hit keyword=$keyword")
KwsResult(keyword, 1f) // sherpa-onnx 不直接给 confidence通过 keyword_score 间接控制
} else {
KwsResult("", 0f)
}
}
/**
* 释放 native 资源 Service.onDestroy 中调用
*/
fun release() {
try {
stream?.release()
spotter?.release()
} catch (e: Exception) {
Log.e(TAG, "release failed: ${e.message}")
}
stream = null
spotter = null
Log.i(TAG, "[Engine] released")
}
}

View File

@ -0,0 +1,163 @@
package com.lila.wakeup.kws
import android.os.Handler
import android.os.Looper
import android.util.Log
import com.lila.wakeup.Config
import com.lila.wakeup.protocol.BroadcastSender
import java.util.concurrent.atomic.AtomicReference
/**
* KWS 状态机 协议 v2.1 规则的中央控制器
*
* 三个状态
* - [State.Idle]: 服务启动中极短
* - [State.Listening]: 正在监听唤醒词命中即发 WAKEUP 广播
* - [State.Paused]: APP 主动暂停 silence_ms 二次保险窗口
*
* silence_ms 实现
* - 收到 PAUSE 立即进入 Paused记录 silenceUntilMs
* - silenceUntilMs 期间收到 RESUME 等到 silenceUntilMs 过后才真正 resume AI 自我唤醒
* - 命中事件在 Paused 期间忽略
*
* 兜底超时APP 漏发 RESUME 2 分钟自动 resume详见 Config.PAUSE_TIMEOUT_MS
*/
class KwsStateMachine(
private val engine: KwsEngine,
private val sender: BroadcastSender,
private val onTimeoutResume: () -> Unit = {}
) {
companion object {
private const val TAG = "KwsService.State"
}
sealed class State {
object Idle : State()
object Listening : State()
data class Paused(val silenceUntilMs: Long, val reason: String) : State()
}
/** 用 Atomic 做无锁状态切换,避免读写竞态 */
private val state = AtomicReference<State>(State.Idle)
private val mainHandler = Handler(Looper.getMainLooper())
private var timeoutRunnable: Runnable? = null
private var deferredResumeRunnable: Runnable? = null
/** 平滑窗口:连续 N 帧都命中才真正发 WAKEUPv2.1 后验平滑) */
private var consecutiveHits = 0
fun transitionToListening() {
state.set(State.Listening)
consecutiveHits = 0
Log.i(TAG, "[State] -> LISTENING")
}
/**
* 收到 KWS_PAUSE 广播时调用
*
* @param silenceMs 静默期毫秒默认 [Config.DEFAULT_SILENCE_MS]
* @param reason 调用原因仅日志
*/
fun onPauseReceived(silenceMs: Long, reason: String) {
val until = System.currentTimeMillis() + silenceMs
state.set(State.Paused(until, reason))
consecutiveHits = 0
Log.i(TAG, "[State] -> PAUSED reason=$reason silence_ms=$silenceMs until=$until")
// 取消上一次的 deferred resume如果有
cancelDeferredResume()
// 启动 2 分钟兜底超时
scheduleTimeoutResume()
}
/**
* 收到 KWS_RESUME 广播时调用
*/
fun onResumeReceived(reason: String) {
val current = state.get()
when (current) {
is State.Paused -> {
val now = System.currentTimeMillis()
if (now < current.silenceUntilMs) {
// silence 期间,延迟到 silence 满才真正 resume
val delay = current.silenceUntilMs - now
Log.i(TAG, "[State] RESUME deferred ${delay}ms (still in silence)")
deferredResumeRunnable = Runnable {
if (state.get() is State.Paused) {
transitionToListening()
Log.i(TAG, "[State] RESUME (silence elapsed) reason=$reason")
}
}.also { mainHandler.postDelayed(it, delay) }
} else {
transitionToListening()
Log.i(TAG, "[State] RESUME immediate reason=$reason")
}
}
is State.Listening -> {
// 幂等:本就在监听态,无副作用
Log.d(TAG, "[State] RESUME ignored, already LISTENING")
}
is State.Idle -> {
transitionToListening()
Log.i(TAG, "[State] Idle -> LISTENING via RESUME reason=$reason")
}
}
cancelTimeoutResume()
}
/**
* AudioCapture 每帧调用一次 Listening 态喂引擎并检查命中
*/
fun onAudioFrame(pcm: ShortArray) {
if (state.get() !is State.Listening) return
val result = engine.process(pcm)
if (result.isHit) {
consecutiveHits++
if (consecutiveHits >= Config.SMOOTH_FRAMES) {
// 命中且过平滑 → 发 WAKEUP 广播
Log.i(TAG, "[KWS] HIT confirmed keyword=${result.keyword} smooth=$consecutiveHits")
sender.sendWakeup(result.keyword, result.confidence)
consecutiveHits = 0
} else {
Log.d(TAG, "[KWS] hit pending smooth=$consecutiveHits/${Config.SMOOTH_FRAMES}")
}
} else {
// 未命中重置计数
if (consecutiveHits > 0) consecutiveHits = 0
}
}
fun shutdown() {
cancelTimeoutResume()
cancelDeferredResume()
state.set(State.Idle)
}
// ============================================================
// 内部:兜底定时器
// ============================================================
private fun scheduleTimeoutResume() {
cancelTimeoutResume()
timeoutRunnable = Runnable {
Log.w(TAG, "[Tmout] PAUSE 超时(${Config.PAUSE_TIMEOUT_MS / 1000}s强制 RESUME")
transitionToListening()
onTimeoutResume()
}.also {
mainHandler.postDelayed(it, Config.PAUSE_TIMEOUT_MS)
}
}
private fun cancelTimeoutResume() {
timeoutRunnable?.let { mainHandler.removeCallbacks(it) }
timeoutRunnable = null
}
private fun cancelDeferredResume() {
deferredResumeRunnable?.let { mainHandler.removeCallbacks(it) }
deferredResumeRunnable = null
}
}

View File

@ -0,0 +1,36 @@
package com.lila.wakeup.protocol
import android.content.Context
import android.content.Intent
import android.util.Log
import com.lila.wakeup.Config
/**
* 向数字人 APP 发送 WAKEUP 广播
*
* 协议 v2.1 要求
* - 必须显式 setPackage(Config.APP_PACKAGE) > "com.qy.lila"
* - 必须带 keyword / timestamp / confidence 三个 Extras
*
* Android 13 安全规范隐式广播会被丢弃setPackage 是强制要求
*/
class BroadcastSender(private val context: Context) {
companion object {
private const val TAG = "KwsService.Send"
}
fun sendWakeup(keyword: String, confidence: Float) {
val timestamp = System.currentTimeMillis()
val intent = Intent(Config.ACTION_WAKEUP).apply {
setPackage(Config.APP_PACKAGE) // v2.1 双向 setPackage 强制要求
putExtra(Config.EXTRA_KEYWORD, keyword)
putExtra(Config.EXTRA_TIMESTAMP, timestamp)
putExtra(Config.EXTRA_CONFIDENCE, confidence)
}
context.sendBroadcast(intent)
Log.i(TAG, "WAKEUP -> ${Config.APP_PACKAGE} keyword=$keyword conf=$confidence ts=$timestamp")
}
}

View File

@ -0,0 +1,63 @@
package com.lila.wakeup.protocol
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.util.Log
import com.lila.wakeup.Config
/**
* 接收 APP 端发来的 KWS_PAUSE / KWS_RESUME 广播
*
* 协议 v2.1 字段
* - silence_ms (long): PAUSE 默认 3000Networking 60000
* - reason (String): 透传日志便于排查
*
* 实现方式通过 [WakeupServiceLocator] 静态引用 KwsStateMachine 投递事件
* 避免 BroadcastReceiver Service 之间频繁 startService 启动开销
*/
class ProtocolReceiver : BroadcastReceiver() {
companion object {
private const val TAG = "KwsService.Proto"
}
override fun onReceive(context: Context, intent: Intent) {
val action = intent.action ?: return
// 安全检查:仅处理协议白名单 Action
when (action) {
Config.ACTION_KWS_PAUSE -> handlePause(intent)
Config.ACTION_KWS_RESUME -> handleResume(intent)
else -> Log.w(TAG, "Ignored unknown action: $action")
}
}
private fun handlePause(intent: Intent) {
val silenceMs = intent.getLongExtra(Config.EXTRA_SILENCE_MS, Config.DEFAULT_SILENCE_MS)
val reason = intent.getStringExtra(Config.EXTRA_REASON) ?: "unknown"
Log.i(TAG, "Recv PAUSE silence_ms=$silenceMs reason=$reason")
WakeupServiceLocator.stateMachine?.onPauseReceived(silenceMs, reason)
?: Log.e(TAG, "stateMachine is null! Service not running?")
}
private fun handleResume(intent: Intent) {
val reason = intent.getStringExtra(Config.EXTRA_REASON) ?: "unknown"
Log.i(TAG, "Recv RESUME reason=$reason")
WakeupServiceLocator.stateMachine?.onResumeReceived(reason)
?: Log.e(TAG, "stateMachine is null! Service not running?")
}
}
/**
* 服务定位器BroadcastReceiver Service 之间的桥接
*
* Service.onCreate 中赋值onDestroy 中置 null
* 该方式避免每次广播都 bindService开销大且不引入 AIDL 复杂度
*/
object WakeupServiceLocator {
@Volatile
var stateMachine: com.lila.wakeup.kws.KwsStateMachine? = null
}

4
app/src/main/jniLibs/.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
*.so
*.txt
*.onnx
*.wav

View File

View File

@ -0,0 +1,30 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
<aapt:attr name="android:fillColor">
<gradient
android:endX="85.84757"
android:endY="92.4963"
android:startX="42.9492"
android:startY="49.59793"
android:type="linear">
<item
android:color="#44000000"
android:offset="0.0" />
<item
android:color="#00000000"
android:offset="1.0" />
</gradient>
</aapt:attr>
</path>
<path
android:fillColor="#FFFFFF"
android:fillType="nonZero"
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
android:strokeWidth="1"
android:strokeColor="#00000000" />
</vector>

View File

@ -0,0 +1,170 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#3DDC84"
android:pathData="M0,0h108v108h-108z" />
<path
android:fillColor="#00000000"
android:pathData="M9,0L9,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,0L19,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,0L29,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,0L39,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,0L49,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,0L59,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,0L69,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,0L79,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M89,0L89,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M99,0L99,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,9L108,9"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,19L108,19"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,29L108,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,39L108,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,49L108,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,59L108,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,69L108,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,79L108,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,89L108,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,99L108,99"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,29L89,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,39L89,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,49L89,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,59L89,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,69L89,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,79L89,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,19L29,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,19L39,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,19L49,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,19L59,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,19L69,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,19L79,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
</vector>

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 982 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

View File

@ -0,0 +1,16 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<!-- Base application theme. -->
<style name="Theme.LilaWakeup" parent="Theme.MaterialComponents.DayNight.DarkActionBar">
<!-- Primary brand color. -->
<item name="colorPrimary">@color/purple_200</item>
<item name="colorPrimaryVariant">@color/purple_700</item>
<item name="colorOnPrimary">@color/black</item>
<!-- Secondary brand color. -->
<item name="colorSecondary">@color/teal_200</item>
<item name="colorSecondaryVariant">@color/teal_200</item>
<item name="colorOnSecondary">@color/black</item>
<!-- Status bar color. -->
<item name="android:statusBarColor">?attr/colorPrimaryVariant</item>
<!-- Customize your theme here. -->
</style>
</resources>

View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="purple_200">#FFBB86FC</color>
<color name="purple_500">#FF6200EE</color>
<color name="purple_700">#FF3700B3</color>
<color name="teal_200">#FF03DAC5</color>
<color name="teal_700">#FF018786</color>
<color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</color>
</resources>

View File

@ -0,0 +1,3 @@
<resources>
<string name="app_name">Lila 语音唤醒</string>
</resources>

View File

@ -0,0 +1,16 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<!-- Base application theme. -->
<style name="Theme.LilaWakeup" parent="Theme.MaterialComponents.DayNight.DarkActionBar">
<!-- Primary brand color. -->
<item name="colorPrimary">@color/purple_500</item>
<item name="colorPrimaryVariant">@color/purple_700</item>
<item name="colorOnPrimary">@color/white</item>
<!-- Secondary brand color. -->
<item name="colorSecondary">@color/teal_200</item>
<item name="colorSecondaryVariant">@color/teal_700</item>
<item name="colorOnSecondary">@color/black</item>
<!-- Status bar color. -->
<item name="android:statusBarColor">?attr/colorPrimaryVariant</item>
<!-- Customize your theme here. -->
</style>
</resources>

View File

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?><!--
Sample backup rules file; uncomment and customize as necessary.
See https://developer.android.com/guide/topics/data/autobackup
for details.
Note: This file is ignored for devices older that API 31
See https://developer.android.com/about/versions/12/backup-restore
-->
<full-backup-content>
<!--
<include domain="sharedpref" path="."/>
<exclude domain="sharedpref" path="device.xml"/>
-->
</full-backup-content>

View File

@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?><!--
Sample data extraction rules file; uncomment and customize as necessary.
See https://developer.android.com/about/versions/12/backup-restore#xml-changes
for details.
-->
<data-extraction-rules>
<cloud-backup>
<!-- TODO: Use <include> and <exclude> to control what is backed up.
<include .../>
<exclude .../>
-->
</cloud-backup>
<!--
<device-transfer>
<include .../>
<exclude .../>
</device-transfer>
-->
</data-extraction-rules>

View File

@ -0,0 +1,17 @@
package com.k2fsa.sherpa.onnx
import org.junit.Test
import org.junit.Assert.*
/**
* Example local unit test, which will execute on the development machine (host).
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
class ExampleUnitTest {
@Test
fun addition_isCorrect() {
assertEquals(4, 2 + 2)
}
}

6
build.gradle Normal file
View File

@ -0,0 +1,6 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
plugins {
id 'com.android.application' version '7.3.1' apply false
id 'com.android.library' version '7.3.1' apply false
id 'org.jetbrains.kotlin.android' version '1.7.20' apply false
}

23
gradle.properties Normal file
View File

@ -0,0 +1,23 @@
# Project-wide Gradle settings.
# IDE (e.g. Android Studio) users:
# Gradle settings configured through the IDE *will override*
# any settings specified in this file.
# For more details on how to configure your build environment visit
# http://www.gradle.org/docs/current/userguide/build_environment.html
# Specifies the JVM arguments used for the daemon process.
# The setting is particularly useful for tweaking memory settings.
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
# When configured, Gradle will run in incubating parallel mode.
# This option should only be used with decoupled projects. More details, visit
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
# org.gradle.parallel=true
# AndroidX package structure to make it clearer which packages are bundled with the
# Android operating system, and which are packaged with your app's APK
# https://developer.android.com/topic/libraries/support-library/androidx-rn
android.useAndroidX=true
# Kotlin code style for this project: "official" or "obsolete":
kotlin.code.style=official
# Enables namespacing of each library's R class so that its R class includes only the
# resources declared in the library itself and none from the library's dependencies,
# thereby reducing the size of the R class for that library
android.nonTransitiveRClass=true

BIN
gradle/wrapper/gradle-wrapper.jar vendored Normal file

Binary file not shown.

View File

@ -0,0 +1,6 @@
#Thu Feb 23 11:09:06 CST 2023
distributionBase=GRADLE_USER_HOME
distributionUrl=https\://mirrors.cloud.tencent.com/gradle/gradle-8.2-bin.zip
distributionPath=wrapper/dists
zipStorePath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME

185
gradlew vendored Executable file
View File

@ -0,0 +1,185 @@
#!/usr/bin/env sh
#
# Copyright 2015 the original author or authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
##############################################################################
##
## Gradle start up script for UN*X
##
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
PRG="$0"
# Need this for relative symlinks.
while [ -h "$PRG" ] ; do
ls=`ls -ld "$PRG"`
link=`expr "$ls" : '.*-> \(.*\)$'`
if expr "$link" : '/.*' > /dev/null; then
PRG="$link"
else
PRG=`dirname "$PRG"`"/$link"
fi
done
SAVED="`pwd`"
cd "`dirname \"$PRG\"`/" >/dev/null
APP_HOME="`pwd -P`"
cd "$SAVED" >/dev/null
APP_NAME="Gradle"
APP_BASE_NAME=`basename "$0"`
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD="maximum"
warn () {
echo "$*"
}
die () {
echo
echo "$*"
echo
exit 1
}
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "`uname`" in
CYGWIN* )
cygwin=true
;;
Darwin* )
darwin=true
;;
MINGW* )
msys=true
;;
NONSTOP* )
nonstop=true
;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD="$JAVA_HOME/jre/sh/java"
else
JAVACMD="$JAVA_HOME/bin/java"
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD="java"
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
# Increase the maximum file descriptors if we can.
if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
MAX_FD_LIMIT=`ulimit -H -n`
if [ $? -eq 0 ] ; then
if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
MAX_FD="$MAX_FD_LIMIT"
fi
ulimit -n $MAX_FD
if [ $? -ne 0 ] ; then
warn "Could not set maximum file descriptor limit: $MAX_FD"
fi
else
warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
fi
fi
# For Darwin, add options to specify how the application appears in the dock
if $darwin; then
GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
fi
# For Cygwin or MSYS, switch paths to Windows format before running java
if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
APP_HOME=`cygpath --path --mixed "$APP_HOME"`
CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
JAVACMD=`cygpath --unix "$JAVACMD"`
# We build the pattern for arguments to be converted via cygpath
ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
SEP=""
for dir in $ROOTDIRSRAW ; do
ROOTDIRS="$ROOTDIRS$SEP$dir"
SEP="|"
done
OURCYGPATTERN="(^($ROOTDIRS))"
# Add a user-defined pattern to the cygpath arguments
if [ "$GRADLE_CYGPATTERN" != "" ] ; then
OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
fi
# Now convert the arguments - kludge to limit ourselves to /bin/sh
i=0
for arg in "$@" ; do
CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
else
eval `echo args$i`="\"$arg\""
fi
i=`expr $i + 1`
done
case $i in
0) set -- ;;
1) set -- "$args0" ;;
2) set -- "$args0" "$args1" ;;
3) set -- "$args0" "$args1" "$args2" ;;
4) set -- "$args0" "$args1" "$args2" "$args3" ;;
5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
esac
fi
# Escape application args
save () {
for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
echo " "
}
APP_ARGS=`save "$@"`
# Collect all arguments for the java command, following the shell quoting and substitution rules
eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
exec "$JAVACMD" "$@"

89
gradlew.bat vendored Normal file
View File

@ -0,0 +1,89 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@if "%DEBUG%" == "" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%" == "" set DIRNAME=.
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if "%ERRORLEVEL%" == "0" goto execute
echo.
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo.
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
:end
@rem End local scope for the variables with windows NT shell
if "%ERRORLEVEL%"=="0" goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
exit /b 1
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

16
settings.gradle Normal file
View File

@ -0,0 +1,16 @@
pluginManagement {
repositories {
gradlePluginPortal()
google()
mavenCentral()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "LilaWakeup_App"
include ':app'