7 Commits
Author SHA1 Message Date
feie9454 80a847d652 家庭连结 2.0 2026-07-19 18:12:24 +08:00
feie9454 0b6ba4be7e 添加批量测试数字人功能 2026-04-18 22:40:16 +08:00
feie9454 f31df36bf4 添加知识库与子代理功能。添加 debug 模式。 2026-04-18 19:10:17 +08:00
feie9454 3f84c111d9 更新 readme 2026-04-18 11:42:08 +08:00
feie9454 1ad70525c1 添加家属留言处理功能,支持主动播报和消息确认 2026-04-17 15:08:40 +08:00
feie9454 b177a7ae77 修改图标 2026-04-17 13:44:49 +08:00
feie9454 61183e0c9a 添加家人绑定 2026-04-17 11:32:01 +08:00
79 changed files with 25481 additions and 1233 deletions
+353 -354
View File
@@ -1,405 +1,404 @@
# Duix Mobile for Android SDK Documentation
# 安智伴 · AI 数字人陪护终端(Android)
English | [中文](./README_zh.md)
## 1. Product Overview
`Duix Mobile for Android` is a lightweight, fully offline 2D digital human solution for Android, supporting real-time rendering of digital avatars driven by voice audio.
### 1.1 Application Scenarios
- **Low deployment cost**: Suitable for unattended scenarios such as large-screen terminals, government halls, and banks.
- **Minimal network dependency**: Runs entirely locally, no internet required, stable operation in subways and remote areas.
- **Diverse functionality**: Can serve as a guide, Q&A customer service, intelligent companion, and more.
### 1.2 Core Features
- Customizable digital avatar and local rendering
- Real-time voice-driven playback (supports WAV playback and PCM streaming)
- Motion playback control (specific or random actions)
- Automatic resource download management
> 本项目与 家属监护 Web 端配合使用,共同构成完整的"AI 数字人 + 家庭远程关怀"解决方案。
---
## 2. Terminology
## 目录
| Term | Meaning |
|--------------------|----------------------------------------------------------------------------|
| PCM | Pulse-Code Modulation, raw audio stream with 16kHz sample rate, 16-bit depth, Mono channel |
| WAV | An audio file format that supports PCM encoding, suitable for short voice playback |
| RenderSink | Rendering data reception interface, implemented by the SDK, can be used for custom rendering or default display |
| DUIX | Main control object of the digital human, integrates model loading, rendering, broadcasting, and motion control |
| GLES | OpenGL ES, a graphics interface for rendering images on Android |
| SpecialAction | A JSON file attached to the model that marks action intervals (e.g., greetings, waving) |
- [需求分析](#需求分析)
- [设计思路](#设计思路)
- [技术架构](#技术架构)
- [模块说明](#模块说明)
- [全链路数据流](#全链路数据流)
- [通信协议](#通信协议)
- [集成要求](#集成要求)
- [SDK 接口文档](#sdk-接口文档)
- [构建与运行](#构建与运行)
---
## 3. SDK Access
## 需求分析
### 3.1 Module Reference (Recommended)
### 背景
1. Obtain the complete source package, unzip it, and copy the `duix-sdk` directory to the project root directory.
2. In the project `settings.gradle`, add:
中国老龄化进程加速,独居或空巢老人的心理陪伴和安全监护成为刚需。子女因工作或地理距离无法陪伴,需要一种低门槛、有温度的技术方案来弥合亲情距离。
```gradle
include ':duix-sdk'
### 核心需求
| 需求维度 | 描述 |
|---------|------|
| **AI 陪伴对话** | 老人可随时与屏幕上的"数字人"自然语音对话,获得情感陪伴和生活助理服务 |
| **家庭留言桥梁** | 子女通过 Web 端发送关怀留言 → 数字人主动转达;老人口述留言 → 子女在 Web 端收到 |
| **使用行为上报** | 设备自动向云端上报使用事件(开屏、对话时长等),供家属了解老人日常 |
| **离线数字人渲染** | 数字人形象的 2D 渲染完全在本地完成(NCNN 推理 + OpenGL),无需 GPU 服务器 |
| **低门槛部署** | 老人无需任何操作,设备预装即可使用;家属扫码绑定即可监护 |
### 用户角色
| 角色 | 使用场景 |
|------|---------|
| **老人**(设备端) | 与数字人语音聊天、听取家人留言、口述回复 |
| **家属**(Web 端) | 发送留言、查看老人使用动态、接收推送通知 |
---
## 设计思路
### 1. 端云分离、各司其职
- **端侧(本项目)**:承担数字人渲染(完全离线)+ 实时 AI 对话(通过 Qwen Omni-Realtime 云端模型)+ 家庭消息同步
- **云侧(Monitor Web)**:承担家属身份管理、设备绑定、消息存储与分发、使用数据可视化
### 2. AI 对话与数字人解耦
DUIX SDK 仅负责"给定 PCM 音频 → 渲染对应口型/表情动画",不关心音频来源。音频可来自:
- Qwen Omni-Realtime 模型的实时语音回复
- 本地 TTS 合成
- 任意 WAV 文件
这种解耦使得 AI 模型可独立升级,不影响渲染管线。
### 3. 工具调用 + 实现双向留言
通过 Qwen 的 Function Calling 能力,在 AI 对话中注册三个工具函数:
- `consult_psychology_knowledge_base`:Omni 提炼老人问题 → Android 端调用 qwen3.6-plus,并把 WHO ICOPE 2024 全文作为上下文 → 返回权威说明
- `leave_message_for_family`:老人口述 → AI 提取内容 → 调用工具 → 消息存入云端
- `fetch_family_messages`:老人想听留言 → AI 调用工具 → 拉取消息 → 口播转达
AI 自主判断何时调用工具,无需老人学习任何操作指令。涉及权威照护依据时,实时语音模型不会直接吞入整份知识库上下文,而是把问题转交给长上下文子代理处理。
### 4. 面向老人的交互设计
- **语义 VAD**:采用 `semantic_vad` 而非传统声学 VAD,能过滤电视背景音、咳嗽等噪音,仅在老人真正说话时响应
- **主动播报**:对话开始时若有未读家人留言,AI 会主动口播转达,无需老人主动查询
- **温和人设**:系统 Prompt 设定为"面向中国老年群体的心理陪伴 AI",使用温和、简短、生活化的中文回复
---
## 技术架构
### 系统全景
```
┌──────────────────────────────────────────────────────────────────┐
│ Android 设备端 │
│ │
│ ┌─────────────┐ PCM音频 ┌──────────────────────────────┐ │
│ │ Qwen Omni │ ────────────→ │ DUIX SDK │ │
│ │ Realtime │ │ ┌────────────────────────┐ │ │
│ │ (WebSocket) │ AI回复语音 │ │ NCNN 推理引擎 │ │ │
│ │ │ │ │ (口型/表情/动作 推理) │ │ │
│ └──────┬──────┘ │ └───────────┬────────────┘ │ │
│ │ │ │ ImageFrame │ │
│ 语音 │ 文本 │ ┌───────────▼────────────┐ │ │
│ 输入 │ +语音 │ │ OpenGL ES 2.0 渲染 │ │ │
│ │ │ │ (DUIXRenderer) │ │ │
│ ┌──────▼──────┐ │ └───────────┬────────────┘ │ │
│ │ 麦克风录音 │ │ │ │ │
│ │ (16kHz PCM) │ │ ┌───────────▼────────────┐ │ │
│ └─────────────┘ │ │ GLTextureView (UI) │ │ │
│ └──────────────────────────────┘ │
│ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ FamilyBridgeService (HTTP) │ │
│ │ • 设备注册 → POST /api/device/register │ │
│ │ • 拉取留言 → GET /api/device/messages │ │
│ │ • 标记已读 → POST /api/device/messages/read │ │
│ │ • 上报事件 → POST /api/device/usage │ │
│ │ • 对话存档 → POST /api/device/conversations │ │
│ │ • 工具记录 → POST /api/device/tool-call │ │
│ └──────────────────────────────┬───────────────────────────┘ │
└─────────────────────────────────┼────────────────────────────────┘
│ HTTPS
▼
┌──────────────────────────────────────────────────────────────────┐
│ digital-human-monitor-v2 (Next.js 云端) │
│ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────────┐ │
│ │ 设备管理 │ │ 消息存储 │ │ 行为分析 │ │ Web Push 推送 │ │
│ └──────────┘ └──────────┘ └──────────┘ └──────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────┐ │
│ │ PostgreSQL │ │
│ └──────────────┘ │
└──────────────────────────────────────────────────────────────────┘
```
3. In the module's `build.gradle`, add the dependency:
### 技术栈
```gradle
dependencies {
api project(":duix-sdk")
}
| 层级 | 技术 |
|------|------|
| **数字人渲染** | NCNN (端侧推理) + OpenCV 4.6 + OpenGL ES 2.0 + ONNX Runtime |
| **AI 对话** | 千问 Qwen 3.5 Omni Plus Realtime (WebSocket) + Qwen 3.6 Plus 知识子代理 (HTTP) |
| **音频处理** | AudioRecord (16kHz) → PCM → Base64 → WebSocket;AudioTrack 播放 |
| **网络通信** | OkHttp 4 (HTTP API / qwen3.6-plus) + OkHttp WebSocket (Qwen Realtime) |
| **本地存储** | SharedPreferences (设备 UUID、配置) |
| **构建系统** | Gradle 8.13.2 + AGP 8.9.1 + JDK 17 (Temurin) |
| **语言** | Kotlin (应用层) + Java (SDK 层) + C++ (Native 推理层) |
---
## 模块说明
### 项目结构
```
dh_aigc_android/
├── duix-sdk/ # 核心 2D 数字人渲染 SDK(库模块)
│ ├── src/main/java/ # Java SDK 接口层
│ │ └── ai/guiji/duix/
│ │ ├── sdk/client/ # DUIX 主控、渲染、回调、工具类
│ │ └── duix/DuixNcnn.java # JNI 接口
│ └── src/main/cpp/ # C++ Native 推理引擎
│ ├── dhcore/ # 核心数字人推理
│ ├── dhmfcc/ # 音频 MFCC 特征提取
│ ├── dhunet/ # 口型网络模型
│ └── third/ # 预编译依赖 (NCNN, OpenCV, ONNX, libjpeg)
│
├── test/ # 演示应用(应用模块)
│ └── src/main/
│ ├── java/ai/guiji/duix/test/
│ │ ├── ui/activity/
│ │ │ ├── MainActivity.kt # 主页:模型选择与下载管理
│ │ │ ├── CallActivity.kt # 核心:数字人渲染 + AI 对话 + 留言桥梁 + 知识子代理
│ │ │ ├── SettingsActivity.kt # 设备设置
│ │ │ └── VoiceCloneSetupActivity.kt # 声音克隆配置
│ │ ├── realtime/
│ │ │ ├── OmniRealtimeClient.java # Qwen Omni WebSocket 客户端
│ │ │ └── QwenTtsRealtimeClient.kt # Qwen TTS 客户端
│ │ ├── service/
│ │ │ ├── FamilyBridgeService.kt # 家庭监护 API 客户端
│ │ │ ├── PsychologyKnowledgeAgentService.kt # qwen3.6-plus 长上下文知识子代理
│ │ │ ├── VoiceCloneService.kt # 声音克隆服务
│ │ │ └── DashScopeConfig.java # API 配置常量
│ │ ├── audio/
│ │ │ ├── AudioResampler.java # PCM 重采样
│ │ │ └── StreamingAudioRecorder.java # 实时麦克风录音
│ │ └── camera/
│ │ └── CameraFrameCapture.java # 摄像头帧采集
│ └── assets/knowledge/
│ └── who_icope_2024.txt # WHO ICOPE 2024 权威知识库全文
│
└── qwen-docs/ # 千问 Omni-Realtime API 参考文档
├── main.md # 协议总览
├── client_events.md # 客户端事件定义
├── server_events.md # 服务端事件定义
└── function_calling.md # 工具调用机制
```
### 3.2 AAR Reference (Optional)
### duix-sdk — 数字人渲染引擎
1. Place the compiled `duix-sdk-release.aar` module into the `libs/` directory.
2. Add the dependency:
| 类 | 职责 |
|----|------|
| `DUIX` | 主控对象:管理初始化、渲染循环、音频驱动、生命周期 |
| `RenderThread` | 独立渲染线程:逐帧推理 + 音频同步 |
| `DUIXRenderer` | OpenGL ES 2.0 渲染器:将 ImageFrame 渲染到 TextureView |
| `DUIXTextureView` | 自定义 TextureView:提供 GL 渲染表面 |
| `VirtualModelUtil` | 模型资源管理:下载、校验、解压 |
| `DuixNcnn` | JNI 桥接:调用 C++ 层 NCNN 推理 |
```gradle
dependencies {
api fileTree(include: ['*.jar', '*.aar'], dir: 'libs')
}
**渲染流水线**:
```
pushPcm(音频) → MFCC特征提取 → NCNN口型网络推理
→ 获取ImageFrame(图像+掩码) → OpenGL混合渲染 → TextureView显示
```
### test — 应用集成层
| 类 | 职责 |
|----|------|
| `CallActivity` | 整合 DUIX 渲染 + Qwen 对话 + 留言桥梁的核心页面 |
| `OmniRealtimeClient` | Qwen Omni-Realtime WebSocket 客户端,处理实时语音对话 |
| `FamilyBridgeService` | 家庭监护云 API 客户端,负责设备注册、消息同步、事件上报 |
| `MainActivity` | 模型选择与下载管理入口 |
---
## 全链路数据流
### 1. AI 实时对话
```
老人说话 → 麦克风录音 (16kHz PCM)
→ Base64 编码 → input_audio_buffer.append (WebSocket)
→ Qwen Omni 模型推理
→ response.audio.delta (Base64 PCM 回传)
→ 解码为 PCM → DUIX.pushPcm()
→ NCNN 推理口型动画 → OpenGL 渲染
→ 同时 AudioTrack 播放语音
```
### 2. 留言传递(老人 → 家属)
```
老人对 AI 说 "帮我给儿子带个话,说我挺好的"
→ Qwen 识别意图 → 触发 leave_message_for_family 工具
→ 提取 recipientRelation="儿子", message="我挺好的"
→ FamilyBridgeService → POST /api/device/conversations
→ Monitor Web 存入数据库
→ Web Push 通知家属
→ AI 回复老人 "好的,已经帮您告诉儿子了"
```
### 3. 留言传递(家属 → 老人)
```
家属在 Web 端发送留言 → 存入 FamilyMessage 表
→ 老人设备启动 AI 对话
→ FamilyBridgeService 拉取未读消息
→ 注入 AI 系统 Prompt: "家里人刚留了新留言,请主动转告..."
→ AI 在第一轮回复中口播: "您儿子给您捎了个话——记得吃药"
```
### 4. 使用行为上报
```
设备事件 (APP_OPEN / AI_SESSION_STARTED / TOOL_CALLED / ...)
→ FamilyBridgeService → POST /api/device/usage
→ Monitor Web 存入 UsageEvent 表
→ 家属在"陪伴动态"页面查看趋势图
```
---
## 4. Integration Requirements
## 通信协议
| Item | Description |
|----------------|-----------------------------------------------------------------|
| System | Supports Android 10+ systems. |
| CPU Architecture | armeabi-v7a, arm64-v8a |
| Hardware Requirements | Device CPU with 8 or more cores (Snapdragon 8 Gen 2), 8GB or more memory, available storage space of 1GB or more |
| Network | None (Fully local operation) |
| Development IDE | Android Studio Giraffe 2022.3.1 Patch 2 |
| Memory Requirements | Minimum 800MB memory available for the digital human |
### Qwen Omni-Realtime WebSocket
| 方向 | 事件 | 用途 |
|------|------|------|
| C→S | `session.update` | 设置模态(text+audio)、语音、VAD、系统 Prompt、工具列表 |
| C→S | `input_audio_buffer.append` | 推送用户麦克风 PCM (Base64) |
| S→C | `response.audio.delta` | 模型语音回复增量块 (Base64 PCM) |
| S→C | `response.text.done` | 模型文本回复完成 |
| S→C | `response.function_call_arguments.done` | 工具调用请求 |
| C→S | `conversation.item.create` | 回传工具执行结果 |
| C→S | `response.create` | 触发模型基于工具结果生成最终回复 |
### 注册的 AI 工具
| 工具名 | 参数 | 用途 |
|--------|------|------|
| `consult_psychology_knowledge_base` | `question` (核心问题), `focus` (聚焦点,可选) | 调用 qwen3.6-plus + WHO ICOPE 全文,返回权威照护说明 |
| `leave_message_for_family` | `recipientRelation` (称谓), `message` (内容), `importance` (优先级) | 老人留言给家人 |
| `fetch_family_messages` | `count` (数量 1-5) | 拉取家人留言 |
### FamilyBridge HTTP API
| 端点 | 方法 | 用途 |
|------|------|------|
| `/api/device/register` | POST | 设备注册,返回 `deviceUuid` + `bindUrl` |
| `/api/device/messages` | GET | 拉取待送达消息 |
| `/api/device/messages/read` | POST | 标记消息已读 |
| `/api/device/usage` | POST | 上报使用事件 |
| `/api/device/conversations` | POST | 上传对话轮次 |
| `/api/device/tool-call` | POST | 记录工具调用日志 |
---
## 5. Usage Flow Overview
## 集成要求
```mermaid
graph TD
A[Check Configuration and Models] --> B[Build DUIX Instance]
B --> C[Call init to Initialize]
C --> D[Display Avatar / Render]
D --> E[PCM or WAV Audio Driving]
E --> F[Playback Control & Motion Triggering]
F --> G[Resource Release]
```
| 项目 | 要求 |
|------|------|
| 系统版本 | Android 10+ (API 24+) |
| CPU 架构 | arm64-v8a, armeabi-v7a |
| 硬件 | 8 核 CPU (骁龙 8 Gen2), 8GB+ 内存, 1GB+ 存储 |
| 内存 | 数字人运行需 ≥ 800MB 可用内存 |
| 网络 | 数字人渲染:无需网络;AI 对话 + 监护同步:需联网 |
| 开发 IDE | Android Studio Giraffe 2022.3.1+ |
| JDK | OpenJDK 17 (Temurin) |
| Gradle | 8.13.2 (使用项目自带 wrapper) |
---
## 6. Key Interfaces and Example Calls
## SDK 接口文档
### 6.1 Model Check and Download
### 术语
Before using the rendering service, ensure that the basic configuration and model files are synchronized to local storage. The SDK provides a simple demonstration of the model download and decompression process using `VirtualModelUtil`. If model download is slow or fails, developers can choose to cache the model package to their own storage service.
| 术语 | 含义 |
|------|------|
| PCM | 16kHz 采样率、16bit 位深、Mono 单通道的原始音频流 |
| WAV | 支持 PCM 编码的音频文件格式 |
| RenderSink | 渲染数据接收接口,SDK 提供默认实现 |
| DUIX | 数字人主控对象 |
| ImageFrame | 包含 rawBuffer (图像) + maskBuffer (掩码) + 宽高的渲染帧 |
> Function Definition: `ai.guiji.duix.sdk.client.VirtualModelUtil`
```
// Check if base configuration is downloaded
boolean checkBaseConfig(Context context)
// Check if the model is downloaded
boolean checkModel(Context context, String name)
// Base configuration download
void baseConfigDownload(Context context, String url, ModelDownloadCallback callback)
// Model download
void modelDownload(Context context, String modelUrl, ModelDownloadCallback callback)
```
`ModelDownloadCallback` includes progress, completion, failure callbacks, etc., as defined in the SDK.
```
interface ModelDownloadCallback {
// Download progress
void onDownloadProgress(String url, long current, long total);
// Unzip progress
void onUnzipProgress(String url, long current, long total);
// Download and unzip complete
void onDownloadComplete(String url, File dir);
// Download and unzip failed
void onDownloadFail(String url, int code, String msg);
}
```
**Call Example**:
### 模型检查与下载
```kotlin
if (!VirtualModelUtil.checkBaseConfig(mContext)){
VirtualModelUtil.baseConfigDownload(mContext, baseConfigUrl, callback)
}
// 检查基础配置
VirtualModelUtil.checkBaseConfig(context): Boolean
// 下载基础配置
VirtualModelUtil.baseConfigDownload(context, url, callback)
// 检查模型
VirtualModelUtil.checkModel(context, modelUrl): Boolean
// 下载模型
VirtualModelUtil.modelDownload(context, modelUrl, callback)
```
### 初始化与渲染
```kotlin
if (!VirtualModelUtil.checkModel(mContext, modelUrl)){
VirtualModelUtil.modelDownload(mContext, modelUrl, callback)
}
```
---
### 6.2 Initialization and Rendering Start
In the `onCreate()` stage of the rendering page, build the DUIX object and call the init interface.
> Function Definition: `ai.guiji.duix.sdk.client.DUIX`
```
// Build DUIX object
public DUIX(Context context, String modelName, RenderSink sink, Callback callback)
// Initialize DUIX service
void init()
```
**DUIX Object Construction Explanation**:
| Parameter | Type | Description |
|---------------|-----------|----------------------------------------------------------------|
| context | Context | System context |
| modelName | String | Can pass the model download URL (if downloaded) or cached filename |
| render | RenderSink| Rendering data interface, SDK provides a default rendering component inheriting from this interface, or you can implement it yourself |
| callback | Callback | Various callback events handled by the SDK |
Where **Callback** is defined as: `ai.guiji.duix.sdk.client.Callback`
```
interface Callback {
void onEvent(String event, String msg, Object info);
}
```
**Call Example**:
```kotlin
duix = DUIX(mContext, modelUrl, mDUIXRender) { event, msg, info ->
// 构建 DUIX 实例
val duix = DUIX(context, modelUrl, renderSink) { event, msg, info ->
when (event) {
ai.guiji.duix.sdk.client.Constant.CALLBACK_EVENT_INIT_READY -> {
initOK()
}
ai.guiji.duix.sdk.client.Constant.CALLBACK_EVENT_INIT_ERROR -> {
initError()
}
// ...
Constant.CALLBACK_EVENT_INIT_READY -> { /* 初始化完成 */ }
Constant.CALLBACK_EVENT_INIT_ERROR -> { /* 初始化失败 */ }
Constant.CALLBACK_EVENT_PLAY_START -> { /* 播放开始 */ }
Constant.CALLBACK_EVENT_PLAY_END -> { /* 播放结束 */ }
}
}
// Asynchronous callback result
duix?.init()
duix.init()
```
In the `init` callback, confirm the initialization result.
### 音频驱动
```kotlin
// PCM 流式推送(用于实时 AI 语音)
duix.pushPcm(pcmBytes)
// WAV 文件播放
duix.playWav(wavFilePath)
```
### 资源释放
```kotlin
// 在 Activity onDestroy 中调用
duix.release()
```
---
### 6.3 Digital Human Avatar Display
## 构建与运行
Use the SDK-provided `DUIXRenderer` and `DUIXTextureView` to quickly implement rendering with transparency support. Alternatively, you can implement the `RenderSink` interface to customize the rendering logic.
### 环境准备
The **RenderSink** definition is as follows: `ai.guiji.duix.sdk.client.render.RenderSink`
1. 安装 Android Studio Giraffe 2022.3.1+
2. 配置 JDK 17:`File → Settings → Build → Gradle → Gradle JDK → 选择 JDK 17`
3. 连接 Android 设备(需满足硬件要求)
### 编译安装
```bash
# 使用 Gradle Wrapper 编译安装 test 应用
./gradlew :test:installDebug
```
### 配置说明
API 密钥和服务地址在 `DashScopeConfig.java` 中配置:
```java
/**
* Rendering pipeline, returns rendering data through this interface
*/
public interface RenderSink {
// The frame's buffer data is arranged in BGR order
void onVideoFrame(ImageFrame imageFrame);
}
```
**Call Example**:
Use `DUIXRenderer` and `DUIXTextureView` to quickly implement rendering. These controls support transparency and can freely set the background and foreground.
```kotlin
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// ...
mDUIXRender =
DUIXRenderer(
mContext,
binding.glTextureView
)
binding.glTextureView.setEGLContextClientVersion(GL_CONTEXT_VERSION)
binding.glTextureView.setEGLConfigChooser(8, 8, 8, 8, 16, 0) // Transparency
binding.glTextureView.isOpaque = false // Transparency
binding.glTextureView.setRenderer(mDUIXRender)
binding.glTextureView.renderMode =
GLSurfaceView.RENDERMODE_WHEN_DIRTY // Must be called after setting the renderer
duix = DUIX(mContext, modelUrl, mDUIXRender) { event, msg, _ ->
}
// ...
}
```
---
### 6.4 Broadcasting Control
#### Use Streaming PCM to Drive Digital Human Broadcasting
**PCM Format: 16kHz sample rate, single channel, 16-bit depth**
> Function Definition: `ai.guiji.duix.sdk.client.DUIX`
```
// Notify service to start pushing audio
void startPush()
// Push PCM data
void pushPcm(byte[] buffer)
// Finish a segment of audio push (Call this after the audio push is complete, not after playback finishes)
void stopPush()
```
`startPush`, `pushPcm`, and `stopPush` need to be called in pairs. `pushPcm` should not be too long. After pushing the entire audio, call `stopPush` to end the session. Use `startPush` again for the next audio.
**The audio data between each startPush and stopPush segment should be at least 1 second (32000 bytes), otherwise the mouth shape driver cannot be triggered, and blank frames can be used to fill in.**
**Call Example**:
```kotlin
val thread = Thread {
duix?.startPush()
val inputStream = assets.open("pcm/2.pcm")
val buffer = ByteArray(320)
var length = 0
while (inputStream.read(buffer).also { length = it } > 0){
val data = buffer.copyOfRange(0, length)
duix?.pushPcm(data)
}
duix?.stopPush()
inputStream.close()
}
thread.start()
```
---
### 6.5 Motion Control
#### Play Specific Motion Interval
The model supports new motion intervals marked in `SpecialAction.json`
> Function Definition: `ai.guiji.duix.sdk.client.DUIX`
```
/**
* Play specific motion interval
* @param name The motion interval name, which can be obtained from @{ModelInfo.getSilenceRegion()} after init callback
* @param now Whether to play immediately: true: play now; false: wait for current silent or motion interval to finish
*/
void startMotion(String name, boolean now)
```
**Call Example**:
```kotlin
duix?.startMotion("Greeting", true)
```
#### Randomly Play Motion Interval
> Function Definition: `ai.guiji.duix.sdk.client.DUIX`
```
/**
* Randomly play a motion interval
* @param now Whether to play immediately: true: play now; false: wait for current silent or motion interval to finish
*/
void startRandomMotion(boolean now);
```
**Call Example**:
```kotlin
duix?.startRandomMotion(true)
```
---
## 7. Proguard Configuration
If using obfuscation, add the following in `proguard-rules.pro`:
```proguard
-keep class ai.guiji.duix.DuixNcnn{*; }
```
---
## 8. Precautions
1. Ensure that the base configuration file and model are downloaded to the specified location before driving rendering initialization.
2. PCM audio should not be too long, as PCM buffers are cached in memory; long audio streams may cause memory overflow.
3. To replace the preview model, modify the `modelUrl` value in `MainActivity.kt` and use the SDK's built-in file download and decompression management to obtain the complete model files.
4. Audio driving format: 16kHz sample rate, single channel, 16-bit depth.
5. Insufficient device performance may result in the audio feature extraction speed not matching the playback speed. You can use `duix?.setReporter()` to monitor frame rendering information.
---
## 9. FAQ and Troubleshooting Guide
| Issue | Possible Cause | Solution |
|---------------------------------|------------------------------|------------------------------|
| init callback failed | Model path error or model not downloaded | Use `checkModel` to check model status |
| Rendering black screen | EGL configuration or texture view error | Use SDK-provided example settings |
| No PCM playback effect | Incorrect format or `startPush` not called | Ensure audio format is correct and call push method |
| Model download slow | Unstable network or restricted CDN | Support self-hosted model file storage service |
---
## 10. Version History
**<a>4.0.1</a>**
1. Supports PCM audio stream driving the digital human, improving audio playback response speed.
2. Optimized motion interval playback, allowing specific motion intervals based on model configuration.
3. Custom audio player, removed Exoplayer playback dependency.
4. Provided simplified model download synchronization management tools.
5. The audio data between each startPush and stopPush segment should be at least 1 second (32000 bytes), otherwise the mouth shape driver cannot be triggered, and blank frames can be used to fill in.
**<a>3.0.5</a>**
```text
1. Updated arm32 CPU libonnxruntime.so version to fix compatibility issues.
2. Modified motion interval playback function, supports random and sequential playback, requires manual call to stop playback to return to silent interval.
```
**<a>3.0.4</a>**
```text
1. Fixed model display issue due to low float precision on some devices.
```
**<a>3.0.3</a>**
```text
1. Optimized local rendering.
```
## 11. 🔗 Open-source Dependencies
| Module | Description |
|------------------------------------------|--------------------------------|
| [onnx](https://github.com/onnx/onnx) | General AI model standard format |
| [ncnn](https://github.com/Tencent/ncnn) | High-performance neural network computing framework (Tencent) |
---
For more help, please contact the technical support team.
MONITOR_BASE_URL // 监护 Web 服务地址
REALTIME_BASE_URL // Qwen Omni WebSocket 地址
MULTIMODAL_GENERATION_URL // qwen3.6-plus HTTP 地址
DEFAULT_API_KEY // DashScope API Key
PSYCHOLOGY_SUBAGENT_MODEL // 心理学知识子代理模型
PSYCHOLOGY_KNOWLEDGE_ASSET_PATH // 内置 WHO ICOPE 知识库 asset 路径
AUDIO_SAMPLE_RATE // 音频采样率 (24000)
```
-485
View File
@@ -1,485 +0,0 @@
# Duix Mobile for Android SDK 文档
中文 | [English](./README.md)
## 一、产品介绍
`Duix Mobile for Android` 是一套轻量级、纯离线的 Android 平台 2D 虚拟人解决方案,支持通过语音音频驱动数字人形象并进行实时渲染。
### 1.1 应用场景
- **部署成本低**:适用于大屏终端、政务展厅、银行等无人值守场景。
- **网络依赖小**:完全本地运行,无需联网,可在地铁、偏远地区稳定运行。
- **功能多样化**:可服务于导览讲解、问答客服、智能陪伴等多种业务形态。
### 1.2 核心功能
- 数字人形象定制与本地渲染
- 实时语音驱动播报(支持 WAV 播放和 PCM 推送)
- 动作播放控制(指定动作、随机动作)
- 资源自动下载管理
---
## 二、术语说明
| 术语 | 含义 |
|-------------------|------------------------------------------------------------------------|
| PCM | Pulse-Code Modulation,16kHz 采样率、16bit 位深、Mono 单通道的原始音频流 |
| WAV | 一种音频文件格式,支持 PCM 编码,适合短语音播放 |
| RenderSink | 渲染数据接收接口,由 SDK 提供实现,可用于自定义渲染或默认展示 |
| DUIX | 数字人主控对象,集成了模型加载、渲染、播报、动作等能力 |
| GLES | OpenGL ES,Android 渲染图像用到的图形接口 |
| SpecialAction | 模型附带的 JSON 文件,标注动作区间(例如打招呼、挥手等) |
---
## 三、SDK 获取方式
### 3.1 Module 引用(推荐)
1. 获取完整源码包,解压后将 `duix-sdk` 目录复制到项目根目录下。
2. 在项目 `settings.gradle` 中添加:
```gradle
include ':duix-sdk'
```
3. 在模块 `build.gradle` 中添加依赖:
```gradle
dependencies {
api project(":duix-sdk")
}
```
### 3.2 AAR 引用(可选)
1. 将duix-sdk模块编译的 `duix-sdk-release.aar` 放入 `libs/` 目录。
2. 添加依赖:
```gradle
dependencies {
api fileTree(include: ['*.jar', '*.aar'], dir: 'libs')
}
```
---
## 四、集成要求
| 项目 | 描述 |
|--------|----------------------------------------------------|
| 系统 | 支持 Android 10+ 系统。 |
| CPU架构 | armeabi-v7a, arm64-v8a |
| 硬件要求 | 要求设备 CPU8 核及以上(骁龙8 Gen2),内存 8G 及以上。可用存储空间 1GB 及以上。 |
| 网络 | 无(完全本地运行) |
| 开发 IDE | Android Studio Giraffe 2022.3.1 Patch 2 |
| 内存要求 | 可用于数字人的内存 >= 800MB |
**编译项目的Gradle使用的JDK版本为17,需要在File->Setting->Build,Execution,Deployment->Grade Projects->Gradle JDK: ${选择一个17版本的JDK}**
---
## 五、使用流程概览
```mermaid
graph TD
A[检查配置与模型] --> B[构建 DUIX 实例]
B --> C[调用 init 初始化]
C --> D[展示形象 / 渲染]
D --> E[PCM 或 WAV 音频驱动]
E --> F[播放控制与动作触发]
F --> G[资源释放]
```
---
## 六、关键接口与调用示例
### 6.1. 模型检查及下载
使用渲染服务前需要将基础配置及模型文件同步到本地存储中,SDK中提供了VirtualModelUtil简单演示了模型下载解压流程。
若模型下载过慢或无法下载,开发者可以选择将模型包缓存到自己的存储服务。
> 函数定义: `ai.guiji.duix.sdk.client.VirtualModelUtil`
```
// 检查基础配置是否已下载
boolean checkBaseConfig(Context context)
// 检查模型是否已下载
boolean checkModel(Context context, String name)
// 基础配置下载
void baseConfigDownload(Context context, String url, ModelDownloadCallback callback)
// 模型下载
void modelDownload(Context context, String modelUrl, ModelDownloadCallback callback)
```
`ModelDownloadCallback` 包含进度、完成、失败等回调,详见 SDK 定义。
```
interface ModelDownloadCallback {
// 下载进度
void onDownloadProgress(String url, long current, long total);
// 解压进度
void onUnzipProgress(String url, long current, long total);
// 下载解压完成
void onDownloadComplete(String url, File dir);
// 下载解压失败
void onDownloadFail(String url, int code, String msg);
}
```
**调用示例**:
```kotlin
if (!VirtualModelUtil.checkBaseConfig(mContext)){
VirtualModelUtil.baseConfigDownload(mContext, baseConfigUrl, callback)
}
```
```kotlin
if (!VirtualModelUtil.checkModel(mContext, modelUrl)){
VirtualModelUtil.modelDownload(mContext, modelUrl, callback)
}
```
---
### 6.2. 初始化与渲染启动
在渲染页onCreate()阶段构建DUIX对象并调用init接口
> 函数定义: `ai.guiji.duix.sdk.client.DUIX`
```
// 构建DUIX对象
public DUIX(Context context, String modelName, RenderSink sink, Callback callback)
// 初始化DUIX服务
void init()
```
**DUIX对象构建说明**:
| 参数 | 类型 | 描述 |
|------------|------------|-------------------------------------|
| context | Context | 系统上下文 |
| modelName | String | 可以传递模型下载的URL(已下载完成)或缓存的文件名 |
| render | RenderSink | 渲染数据接口,sdk提供了默认的渲染组件继承自该接口,也可以自己实现 |
| callback | Callback | SDK处理的各种回调事件 |
其中**Callback**的定义: `ai.guiji.duix.sdk.client.Callback`
```
interface Callback {
void onEvent(String event, String msg, Object info);
}
```
**调用示例**:
```kotlin
duix = DUIX(mContext, modelUrl, mDUIXRender) { event, msg, info ->
when (event) {
ai.guiji.duix.sdk.client.Constant.CALLBACK_EVENT_INIT_READY -> {
initOK()
}
ai.guiji.duix.sdk.client.Constant.CALLBACK_EVENT_INIT_ERROR -> {
initError()
}
// ...
}
}
// 异步回调结果
duix?.init()
```
在init回调中确认初始化结果
---
### 6.3. 数字人形象展示
使用 SDK 提供的 `DUIXRenderer` 和 `DUIXTextureView` 可快速实现支持透明通道的渲染。也可以自己实现RenderSink接口自定义渲染逻辑。
其中**RenderSink**的定义如下: `ai.guiji.duix.sdk.client.render.RenderSink`
```java
/**
* 渲染管道,通过该接口返回渲染数据
*/
public interface RenderSink {
// frame中的buffer数据以bgr顺序排列
void onVideoFrame(ImageFrame imageFrame);
}
```
**调用示例**:
使用DUIXRenderer及DUIXTextureView控件简单实现渲染展示,该控件支持透明通道可以自由设置背景及前景
```kotlin
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// ...
mDUIXRender =
DUIXRenderer(
mContext,
binding.glTextureView
)
binding.glTextureView.setEGLContextClientVersion(GL_CONTEXT_VERSION)
binding.glTextureView.setEGLConfigChooser(8, 8, 8, 8, 16, 0) // 透明
binding.glTextureView.isOpaque = false // 透明
binding.glTextureView.setRenderer(mDUIXRender)
binding.glTextureView.renderMode =
GLSurfaceView.RENDERMODE_WHEN_DIRTY // 一定要在设置完Render之后再调用
duix = DUIX(mContext, modelUrl, mDUIXRender) { event, msg, _ ->
}
// ...
}
```
---
### 6.4 播报控制
#### 使用流式推送PCM驱动数字人播报
**PCM格式:16k采样率单通道16位深**
> 函数定义: `ai.guiji.duix.sdk.client.DUIX`
```
// 通知服务开始推送音频
void startPush()
// 推送PCM数据
void pushPcm(byte[] buffer)
// 完成一段音频推送(音频推送完就调要该函数,而不是等播放完成再调用。)
void stopPush()
```
startPush、pushPcm、stopPush需要成对调用,pushPcm不宜过长。可以在一整段音频推送完后调用stopPush结束当前会话,下一段音频再使用startPush重新开启推送。
**每段startPush到stopPush中间的音频数据最少要1秒(32000字节)否则无法触发口型驱动,可以自行使用空白帧填充。**
**调用示例**:
```kotlin
val thread = Thread {
duix?.startPush()
val inputStream = assets.open("pcm/2.pcm")
val buffer = ByteArray(320)
var length = 0
while (inputStream.read(buffer).also { length = it } > 0){
val data = buffer.copyOfRange(0, length)
duix?.pushPcm(data)
}
duix?.stopPush()
inputStream.close()
}
thread.start()
```
---
#### WAV 播放驱动
> 函数定义: `ai.guiji.duix.sdk.client.DUIX`
```
void playAudio(String wavPath)
```
**该函数兼容旧的wav驱动数字人接口,在内部实际是调用了PCM推流方式实现驱动。**
**参数说明**:
| 参数 | 类型 | 描述 |
|---------|--------|-----------------------|
| wavPath | String | 16k采样率单通道16位深的wav本地文件 |
**调用示例**:
```kotlin
duix?.playAudio(wavPath)
```
音频播放状态及进度回调:
```kotlin
object : Callback {
fun onEvent(event: String, msg: String, info: Object) {
when (event) {
// ...
"play.start" -> {
// 开始播放音频
}
"play.end" -> {
// 完成播放音频
}
"play.error" -> {
// 音频播放异常
}
}
}
}
```
---
#### 终止当前播报
当数字人正在播报时调用该接口终止播报。
> 函数定义: `ai.guiji.duix.sdk.client.DUIX`
```
boolean stopAudio();
```
**调用示例如下**:
```kotlin
duix?.stopAudio()
```
---
### 6.5. 动作控制
#### 播放指定动作区间
模型中支持新的动作区间标注(SpecialAction.json)
> 函数定义: `ai.guiji.duix.sdk.client.DUIX`
```
/**
* 播放指定动作区间
* @param name 动作区间名称,在init成功回调时,可以在@{ModelInfo.getSilenceRegion()}中获取到可用的动作区间
* @param now 是否立即播放 true: 立即播放; false: 等待当前静默区间或动作区间播放完毕后播放
*/
void startMotion(String name, boolean now)
```
**调用示例如下**:
```kotlin
duix?.startMotion("打招呼", true)
```
#### 随机播放动作区间
随机播放场景及旧的标注协议(config.json)
> 函数定义: `ai.guiji.duix.sdk.client.DUIX`
```
/**
* 随机播放一个动作区间
* @param now 是否立即播放 true: 立即播放; false: 等待当前静默区间或动作区间播放完毕后播放
*/
void startRandomMotion(boolean now);
```
**调用示例如下**:
```kotlin
duix?.startRandomMotion(true)
```
---
## 七. Proguard配置
如果代码使用了混淆,请在proguard-rules.pro中配置:
```proguard
-keep class ai.guiji.duix.DuixNcnn{*; }
```
---
## 八、注意事项
1. 驱动渲染初始化前需要确保基础配置文件及模型下载到指定位置。
2. 播放的PCM音频不宜过长,播放的PCM缓存在内存中,过长的音频流可能导致内存溢出。
3. 替换预览模型可以在MainActivity.kt文件中修改modelUrl的值,使用SDK中自带的文件下载解压管理以获得完整的模型文件。
4. 音频驱动的格式: 16k采样率单通道16位深度
5. 设备性能不足时可能导致音频特征提取的速度跟不上音频播放的速度,可以使用duix?.setReporter()函数添加一个监控观察帧渲染返回的信息。
6. 每段startPush到stopPush中间的音频数据最少要1秒(32000字节)否则无法触发口型驱动,可以自行使用空白帧填充。
---
## 九、常见问题与排查指南
| 问题现象 | 可能原因 | 解决方案 |
|---------------------|--------------------------|------------------------|
| init 回调失败 | 模型路径错误或未下载完成 | 使用 `checkModel` 检查模型状态 |
| 渲染黑屏 | EGL 配置或纹理视图设置错误 | 使用 SDK 提供示例中的设置方法 |
| PCM 无播报效果 | 格式不符或未调用 startPush | 确保音频格式正确并调用推送方法 |
| 模型下载过慢 | 网络不稳定或 CDN 受限 | 支持自建模型文件托管服务 |
---
## 十、版本记录
**<a>4.0.1</a>**
```text
1. 支持PCM音频流驱动数字人,提升音频播放响应速度。
2. 优化动作区间播放,可根据模型配置指定播放动作区间。
3. 自定义音频播放器,去除Exoplayer播放依赖
4. 提供简洁的模型下载同步管理工具
```
**<a>3.0.5</a>**
```text
1. 更新arm32位cpu的libonnxruntime.so版本以修复兼容问题。
2. 修改动作区间播放函数,可以使用随机播放和顺序播放,需要主动调用停止播放动作区间以回到静默区间。
```
**<a>3.0.4</a>**
```text
1. 修复部分设备gl默认float低精度导致无法正常显示形象问题。
```
**<a>3.0.3</a>**
```text
1. 优化本地渲染。
```
## 十一、🔗 开源依赖
| 模块 | 描述 |
|-------------------------------------------|-------------------|
| [onnx](https://github.com/onnx/onnx) | 通用AI模型标准格式 |
| [ncnn](https://github.com/Tencent/ncnn) | 高性能神经网络计算框架(腾讯) |
---
如需更多帮助,请联系技术支持团队。
+1 -1
View File
@@ -11,7 +11,7 @@ buildscript {
google()
}
dependencies {
classpath 'com.android.tools.build:gradle:8.1.2'
classpath 'com.android.tools.build:gradle:8.13.2'
classpath 'org.jetbrains.kotlin:kotlin-gradle-plugin:1.8.10'
}
}
+14 -9
View File
@@ -1,19 +1,24 @@
plugins {
id 'com.android.library'
}
def sdkVersionCode = (rootProject.ext.has('versionCode') ? rootProject.ext.versionCode : 1) as Integer
def sdkVersionName = (rootProject.ext.has('versionName') ? rootProject.ext.versionName : '1.0.0').toString()
android {
namespace 'ai.guiji.duix.sdk.client'
compileSdk 33
ndkVersion '25.1.8937393'
defaultConfig {
minSdk 24
versionCode 13
versionName '4.1.1'
externalNativeBuild {
cmake {
abiFilters 'arm64-v8a', "armeabi-v7a"
if (project.hasProperty('includeX86_64')) {
abiFilters 'arm64-v8a', 'armeabi-v7a', 'x86_64'
} else {
abiFilters 'arm64-v8a', 'armeabi-v7a'
}
cppFlags "-std=c++17", "-fexceptions"
//arguments "-DANDROID_STL=c++_shared","-DANDROID_TOOLCHAIN=clang"
}
@@ -25,20 +30,20 @@ android {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
buildConfigField("String", "VERSION_NAME", "\"${defaultConfig.versionName}\"")
buildConfigField('int', 'VERSION_CODE', "${defaultConfig.versionCode}")
buildConfigField("String", "VERSION_NAME", "\"${sdkVersionName}\"")
buildConfigField('int', 'VERSION_CODE', "${sdkVersionCode}")
}
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
buildConfigField("String", "VERSION_NAME", "\"${defaultConfig.versionName}\"")
buildConfigField('int', 'VERSION_CODE', "${defaultConfig.versionCode}")
buildConfigField("String", "VERSION_NAME", "\"${sdkVersionName}\"")
buildConfigField('int', 'VERSION_CODE', "${sdkVersionCode}")
android.libraryVariants.all { variant ->
variant.outputs.all {
outputFileName = "duix_client_sdk_${buildType.name}_${defaultConfig.versionName}.aar"
outputFileName = "duix_client_sdk_${buildType.name}_${sdkVersionName}.aar"
}
}
}
@@ -65,4 +70,4 @@ android {
dependencies {
api fileTree(include: ['*.jar', '*.aar'], dir: 'libs')
}
}
+3 -1
View File
@@ -2,6 +2,7 @@ android.enableJetifier=true
android.useAndroidX=true
org.gradle.daemon=true
org.gradle.jvmargs=-Xms2048m -Xmx6656m
org.gradle.java.home=C:/Program Files/Java/jdk-17
appcompatVersion=1.3.0
recyclerviewVersion=1.2.1
@@ -14,4 +15,5 @@ lifecycle_ktx=2.3.1
android.injected.testOnly=false
android.defaults.buildfeatures.buildconfig=true
android.nonTransitiveRClass=false
android.nonFinalResIds=false
android.nonFinalResIds=false
android.overridePathCheck=true
+1 -1
View File
@@ -3,4 +3,4 @@ distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.0-all.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-all.zip
@@ -0,0 +1,100 @@
本文介绍 Qwen-Omni-Realtime API 的客户端事件。
> 相关文档:[实时(Qwen-Omni-Realtime)](https://help.aliyun.com/zh/model-studio/realtime)。
## **session.update**
客户端建立 WebSocket 连接后,需首先发送该事件,用于更新会话的默认配置。服务端收到 `session.update` 事件后会校验参数。如果参数不合法,则返回错误;如果参数合法,则更新并返回完整的配置。
| **type** `*string*` **(必选)** 事件类型,固定为`session.update`。 | ``` { "event_id": "event_ToPZqeobitzUJnt3QqtWg", "type": "session.update", "session": { "modalities": [ "text", "audio" ], "voice": "Chelsie", "input_audio_format": "pcm", "output_audio_format": "pcm", "instructions": "你是某五星级酒店的AI客服专员,请准确且友好地解答客户关于房型、设施、价格、预订政策的咨询。请始终以专业和乐于助人的态度回应,杜绝提供未经证实或超出酒店服务范围的信息。", "turn_detection": { "type": "server_vad", "threshold": 0.5, "silence_duration_ms": 800 }, "enable_search": true, "search_options": { "enable_source": true }, "tools": [ { "type": "function", "function": { "name": "get_current_weather", "description": "当你想查询指定城市的天气时非常有用。", "parameters": { "type": "object", "properties": { "location": { "type": "string", "description": "城市或县区,比如北京市、杭州市、余杭区等。" } }, "required": ["location"] } } } ], "seed": 1314, "max_tokens": 16384, "repetition_penalty": 1.05, "presence_penalty": 0.0, "top_k": 50, "top_p": 1.0, "temperature": 0.9 } } ``` |
| --- | --- |
| **session** `*object*` (可选) 会话配置。 **属性** **modalities** `*array*` (可选) 模型输出模态设置,可选值: - \\["text"\\] 仅输出文本。 - \\["text","audio"\\](默认值) 输出文本与音频。 **voice** `*string*` (可选) 模型生成音频的音色,支持的音色参见[音色列表](https://help.aliyun.com/zh/model-studio/realtime#f9c68d860a3rs)。 默认音色: - Qwen3.5-Omni-Realtime系列:`Tina` - Qwen3-Omni-Flash-Realtime:`Cherry` - Qwen-Omni-Turbo-Realtime:`Chelsie` **input\\_audio\\_format** `*string*` (可选) 用户输入音频格式,当前仅支持设为`pcm`。 **output\\_audio\\_format** `*string*` (可选) 模型输出音频的格式,当前仅支持设为`pcm`。 **instructions** `*string*` (可选) 系统消息,用于设定模型的目标或角色。 **turn\\_detection** `*object*` (可选) 语音活动检测(VAD)的配置。设置为 `null` 表示禁用 VAD,由用户手动触发模型响应。若未提供本字段,系统将使用以下默认参数启用 VAD。 **属性** **type** `*string*` (可选) VAD类型,取值如下: - `server_vad`(默认值):基于声学特征检测用户语音结束。 - `semantic_vad`:基于语义有效性检测用户语音结束,可过滤无意义语音(如回应语、背景音)。仅`qwen3.5-omni-realtime`模型支持。 **threshold** `*float*` (可选) VAD的灵敏度。值越低,VAD越敏感,更容易将微弱声音(包括背景噪音)识别为语音;值越高,越不敏感,需要更清晰、音量更大的语音才能触发。 取值范围在`[-1.0, 1.0]`,默认值为 0.5。 **silence\\_duration\\_ms** `*integer*` (可选) 语音结束后需保持静音的最短时间,超时即触发模型响应。值越低,响应越快,但可能在语音短暂停顿时误触发模型响应。 默认值为800,参数范围\\[200, 6000\\]。 **enable\\_search** `*boolean*` (可选) **仅在使用 Qwen3.5-Omni-Realtime 模型时生效。** 是否启用联网搜索功能。设置为 `true` 启用,默认为 `false`。启用后,模型可自主判断是否需要搜索来回应用户的即时问题。 > 工具调用(tools)和联网搜索(enable\\_search)不兼容,不可同时开启。 **search\\_options** `*object*` (可选) 联网搜索选项配置。需启用 `enable_search` 后才生效。 **属性** **enable\\_source** `*boolean*` (可选) 是否返回搜索结果来源列表。设置为 `true` 启用。 **tools** `*array*` (可选) 工具定义列表。配置后模型可根据用户输入自主决定是否调用工具。 **属性** **type** `*string*`(必选) 固定为 `function`。 **function.name** `*string*`(必选) 自定义的工具函数名称,建议使用与函数相同的名称,如`get_current_weather`或`get_current_time`。 **function.description** `*string*`(可选) 对工具函数功能的描述,大模型会参考该字段来选择是否使用该工具函数。 **function.parameters** `*object*`(可选) 对工具函数入参的描述,大模型会参考该字段来进行入参的提取。如果工具函数不需要输入参数,则无需指定。 **属性** **type** `*string*`(必选) 固定为 `object`。 **properties** `*object*`(可选) 描述各入参的名称、数据类型与描述。Key 值为入参的名称,Value 值为包含数据类型(`type`)与描述(`description`)的对象。 **required** `*array*`(可选) 指定哪些入参为必填项。 |
| **temperature** `*float*` (可选) 采样温度,控制模型生成内容的多样性。 temperature越高,生成的内容更多样,反之,生成的内容更确定。 取值范围: \\[0, 2) 由于temperature与top\\_p均可以控制生成内容的多样性,因此建议您只设置其中一个值。 temperature默认值: - `qwen3.5-omni-realtime`系列:0.7 - `qwen3-omni-flash-realtime`系列:0.9 - `qwen-omni-turbo-realtime`系列:1.0 > `qwen-omni-turbo` 系列模型**不支持修改**。 |
| **top\\_p** `*float*` (可选) 核采样的概率阈值,控制模型生成内容的多样性。 top\\_p越高,生成的内容更多样。反之,生成的内容更确定。 取值范围:(0,1.0\\] 由于temperature与top\\_p均可以控制生成内容的多样性,因此建议您只设置其中一个值。 top\\_p默认值: - `qwen3.5-omni-realtime`系列:0.8 - `qwen3-omni-flash-realtime`系列:1.0 - `qwen-omni-turbo-realtime`系列:0.01 > `qwen-omni-turbo` 系列模型**不支持修改**。 |
| **top\\_k** `*integer*` (可选) 生成过程中采样候选集的大小。例如,取值为50时,仅将单次生成中得分最高的50个Token组成随机采样的候选集。取值越大,生成的随机性越高;取值越小,生成的确定性越高。取值为`null`或当top\\_k大于100时,表示不启用`top_k`策略,此时仅有`top_p`策略生效。 取值需要大于或等于0。 top\\_k默认值: - `qwen3.5-omni-realtime`系列:20 - `qwen3-omni-flash-realtime`系列:50 - `qwen-omni-turbo-realtime`系列:20 > `qwen-omni-turbo` 系列模型**不支持修改**。 |
| **max\\_tokens** `*integer*` (可选) 本次请求返回的最大 Token 数。 > `max_tokens` 的设置不会影响大模型的生成过程,如果模型生成的 Token 数超过`max_tokens`,本次请求会返回截断后的内容。 默认值和最大值都是模型的最大输出长度。关于各模型的最大输出长度,请参见[模型列表](https://help.aliyun.com/zh/model-studio/models#9f8890ce29g5u)。 max\\_tokens参数适用于需要限制字数(如生成摘要、关键词)、控制成本或减少响应时间的场景。 > `qwen-omni-turbo` 系列模型**不支持修改**。 |
| **repetition\\_penalty** `*float*` (可选) 模型生成时连续序列中的重复度。提高repetition\\_penalty时可以降低模型生成的重复度,1.0表示不做惩罚。没有严格的取值范围,只要大于0即可。 repetition\\_penalty默认值: - `qwen3.5-omni-realtime`系列:1.0 - `qwen3-omni-flash-realtime`系列:1.05 - `qwen-omni-turbo-realtime`系列:1.05 > `qwen-omni-turbo` 系列模型**不支持修改**。 |
| **presence\\_penalty** `*float*` (可选) 控制模型生成内容时的重复度。 取值范围:\\[-2.0, 2.0\\]。正数会减少重复度,负数会增加重复度。 presence\\_penalty默认值: - `qwen3.5-omni-realtime`系列:1.5 - `qwen3-omni-flash-realtime`系列:0.0 - `qwen-omni-turbo-realtime`系列:0.0 适用场景: 较高的presence\\_penalty适用于要求多样性、趣味性或创造性的场景,如创意写作或头脑风暴。 较低的presence\\_penalty适用于要求一致性或专业术语的场景,如技术文档或其他正式文档。 > `qwen-omni-turbo` 系列模型**不支持修改**。 |
| **seed** `*integer*` (可选) 设置seed参数会使大模型的生成过程更具有确定性,通常用于使模型每次运行的结果一致。 在每次模型调用时传入相同的seed值(由您指定),并保持其他参数不变,模型将尽可能返回相同的结果。 取值范围:0到231−1,默认值-1。 > `qwen-omni-turbo` 系列模型**不支持修改**。 |
## **response.create**
`response.create` 事件用于指示服务端创建模型响应。在VAD模式下,服务端会自动创建模型响应,无需发送该事件。在工具调用场景中,客户端通过 `conversation.item.create` 回传工具结果后,需发送此事件触发模型生成最终响应。
服务端使用 `response.created` 事件、一个或多个项和内容事件(如 `conversation.item.created` 和 `response.content_part.added`)进行响应,最后用一个 `response.done` 事件表示响应已完成。
| **type** `*string*` **(必选)** 事件类型,固定为`response.create`。 | ``` { "type": "response.create", "event_id": "event_1718624400000" } ``` |
| --- | --- |
## **response.cancel**
客户端发送此事件用以取消正在进行的响应。如果没有任何响应可供取消,服务端将响应错误事件。
| **type** `*string*` **(必选)** 事件类型,固定为`response.cancel`。 | ``` { "event_id": "event_B4o9RHSTWobB5OQdEHLTo", "type": "response.cancel" } ``` |
| --- | --- |
## **input\_audio\_buffer.append**
用于将音频字节追加到输入音频缓冲区。
| **type** `*string*` **(必选)** 事件类型,固定为`input_audio_buffer.append`。 | ``` { "event_id": "event_B4o9RHSTWobB5OQdEHLTo", "type": "input_audio_buffer.append", "audio": "UklGR..." } ``` |
| --- | --- |
| **audio** `*string*` **(必选)** Base64 编码的音频数据。 |
## **input\_audio\_buffer.commit**
用于提交用户输入音频缓冲区,在对话中创建新的用户消息项。 如果输入的音频缓冲区为空,服务端会返回错误事件。
- [VAD 模式](https://help.aliyun.com/zh/model-studio/realtime#68d826b358q1r):客户端不需要发送此事件,服务端会自动提交音频缓冲区。
- [Manual 模式](https://help.aliyun.com/zh/model-studio/realtime#3dbb650fb3ird):客户端必须提交音频缓冲区才能创建用户消息项。
提交输入音频缓冲区不会从模型创建响应,服务端将使用 `input_audio_buffer.committed` 事件进行响应。
> 如果客户端发送过[input\_image\_buffer.append](#c28ed38410nfw)事件,input\_audio\_buffer.commit 事件会将图像缓冲区一起提交。
| **type** `*string*` **(必选)** 事件类型,固定为`input_audio_buffer.commit`。 | ``` { "event_id": "event_B4o9RHSTWobB5OQdEHLTo", "type": "input_audio_buffer.commit" } ``` |
| --- | --- |
## **input\_audio\_buffer.clear**
用于清除缓冲区中的音频字节。服务端发送`input_audio_buffer.cleared` 事件进行响应。
| **type** `*string*` **(必选)** 事件类型,固定为`input_audio_buffer.clear`。 | ``` { "event_id": "event_xxx", "type": "input_audio_buffer.clear" } ``` |
| --- | --- |
## **input\_image\_buffer.append**
用于将图像数据添加到图像缓冲区。图像可来自本地文件,或从视频流实时采集。
目前对图片输入有以下限制:
- 图像格式必须为 JPG 或 JPEG。建议分辨率为 480p 或 720p以获得最佳性能,最高不超过 1080p;
- 单张图片大小不大于500KB(Base64编码前);
- 图片数据需要经过Base64编码;
- 建议以 1张/秒 的频率向服务端发送图像;
- 发送 input\_image\_buffer.append 事件前,至少发送过一次 input\_audio\_buffer.append 事件。
> 图像缓冲区与音频缓冲区一起通过[input\_audio\_buffer.commit](#1cbea5fa7fkfl)事件提交。
| **type** `*string*` **(必选)** 事件类型,固定为`input_image_buffer.append`。 | ``` { "event_id": "event_xxx", "type": "input_image_buffer.append", "image": "xxx" } ``` |
| --- | --- |
| **image** `*string*` **(必选)** Base64 编码的图像数据。 |
## **conversation.item.create**
客户端发送此事件,将工具函数的执行结果回传给服务端。当模型触发工具调用后,客户端需在本地执行工具函数,然后通过此事件将结果发回,再发送 `response.create` 触发模型生成最终响应。
**说明**
当前仅支持 `function_call_output` 类型的 item。
| **type** `*string*` **(必选)** 事件类型,固定为`conversation.item.create`。 | ``` { "event_id": "event_55099cddb51b4f208cb95d1a994eef80", "type": "conversation.item.create", "item": { "id": "item_2a80d7682b4e473c9c2154da135041e9", "type": "function_call_output", "call_id": "call_62c24725afdb4c2680ac54", "output": "北京今天天气为霾转晴,气温4/-4℃,微风" } } ``` |
| --- | --- |
| **item** `*object*` **(必选)** 要创建的对话项,不能为空。 **属性** **id** `*string*`(可选) 对话项 ID。客户端可预先指定以便对齐本地状态;若未提供,由服务端生成。 **type** `*string*`(必选) 对话项类型。当前仅支持 `function_call_output`。 **call\\_id** `*string*`(必选) 对应 `response.function_call_arguments.done` 事件中返回的 `call_id`。 **output** `*string*`(必选) 工具函数的执行结果。 |
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,283 @@
本文介绍 Qwen-Omni-Realtime API 的服务端事件,包括工具调用(Function Calling)相关事件。
> 相关文档:[实时(Qwen-Omni-Realtime)](https://help.aliyun.com/zh/model-studio/realtime)。
## **error**
服务端返回的错误信息。
| **event\\_id** `*string*` 本次事件唯一标识符。 | ``` { "event_id": "event_RoUu4T8yExPMI37GKwaOC", "type": "error", "error": { "type": "invalid_request_error", "code": "invalid_value", "message": "Invalid modalities: ['audio']. Supported combinations are: ['text'] and ['audio', 'text'].", "param": "session.modalities" } } ``` |
| --- | --- |
| **type** `*string*` 事件类型,固定为`error`。 |
| **error** `*object*` 错误的详细信息。 **属性** **type** `*string*` 错误类型。 **code** `*string*` 错误码。 **message** `*string*` 错误信息。 **param** `*string*` 与错误相关的参数,如`session.modalities`。 |
## **session.created**
客户端连接后,服务端返回的第一个事件,包含本次连接的默认配置信息。
| **event\\_id** `*string*` 本次事件唯一标识符。 | ``` { "event_id": "event_RdvlSpbBb2ssyBjYrDHjt", "type": "session.created", "session": { "object": "realtime.session", "model": "qwen3-omni-flash-realtime", "modalities": [ "text", "audio" ], "voice": "Cherry", "input_audio_format": "pcm", "output_audio_format": "pcm", "input_audio_transcription": { "model": "gummy-realtime-v1" }, "turn_detection": { // 取值为server_vad或semantic_vad(仅qwen3.5-omni-realtime支持) "type": "server_vad", "threshold": 0.5, "prefix_padding_ms": 300, "silence_duration_ms": 800, "create_response": true, "interrupt_response": true }, "enable_search": false, "search_options": {}, "tools": [], "temperature": 0.8, "id": "sess_Ov7GOXoNXhNjlxXtOGKQS" } } ``` |
| --- | --- |
| **type** `*string*` 事件类型,固定为`session.created`。 |
| **session** `*object*` 会话的配置信息。 **属性** **object** `*string*` 固定为`realtime.session`。 **model** `*string*` 使用的模型。 **modalities** `*array*` 模型输出模态设置。 **voice** `*string*` 模型生成音频的音色。 **input\\_audio\\_format** `*string*` 输入音频的格式,固定为`pcm`。 **output\\_audio\\_format** `*string*` 模型输出音频的格式,固定为`pcm`。 **input\\_audio\\_transcription** `*object*` 语音转录的配置。 **属性** **model** `*string*` 语音转录模型,固定为`gummy-realtime-v1`。 **turn\\_detection** `*object*` 语音活动检测(VAD)的配置。 **属性** **type** `*string*` VAD类型。取值:`server_vad`(默认值)或 `semantic_vad`。详情请参见[客户端事件](https://help.aliyun.com/zh/model-studio/client-events#7c8172272codm)。 **threshold** `*float*` VAD检测阈值。 **silence\\_duration\\_ms** `*integer*` 检测语音停止的静音持续时间。 **enable\\_search** `*boolean*` 是否启用联网搜索功能。仅 Qwen3.5-Omni-Realtime 模型支持。 **search\\_options** `*object*` 联网搜索选项配置。 **temperature** `*float*` 模型的温度参数。 |
## **session.updated**
收到用户的 `session.update` 请求后,若处理成功,则返回此事件;若出错,则返回 `error` 事件。
| **event\\_id** `*string*` 本次事件唯一标识符。 | ``` { "event_id": "event_X1HsXS4b4uptp6yo1LgKd", "type": "session.updated", "session": { "id": "sess_Aih6vAcY5Ddt6jwFx1tCa", "object": "realtime.session", "model": "qwen3-omni-flash-realtime", "modalities": [ "text", "audio" ], "instructions": "你是个人助理小云,请你准确且友好地解答用户的问题,始终以乐于助人的态度回应。", "voice": "Cherry", "input_audio_format": "pcm", "output_audio_format": "pcm", "input_audio_transcription": { "model": "gummy-realtime-v1" }, "turn_detection": { // 取值为server_vad或semantic_vad(仅qwen3.5-omni-realtime支持) "type": "server_vad", "threshold": 0.1, "prefix_padding_ms": 500, "silence_duration_ms": 900, "create_response": true, "interrupt_response": true }, "enable_search": true, "search_options": { "enable_source": true }, "tools": [ { "type": "function", "function": { "name": "get_current_weather", "description": "当你想查询指定城市的天气时非常有用。", "parameters": { "type": "object", "properties": { "location": {"type": "string", "description": "城市名称"} }, "required": ["location"] } } } ], "temperature": 0.8, "max_response_output_token": "inf", "max_tokens": 16384, "repetition_penalty": 1.05, "presence_penalty": 0.0, "top_k": 50, "top_p": 1.0, "seed":-1 } } ``` |
| --- | --- |
| **type** `*string*` 事件类型,固定为`session.updated`。 |
| **session** `*object*` 会话的配置信息。 **属性** **temperature** `*float*` 模型的温度参数。 **modalities** `*array*` 模型输出模态设置。 **voice** `*string*` 模型生成音频的音色。 **instructions** `*string*` 模型的目标与角色。 **input\\_audio\\_format** `*string*` 输入音频的格式,当前仅支持设置为`pcm`。 **output\\_audio\\_format** `*string*` 输出音频的格式,当前仅支持设置为`pcm`。 **input\\_audio\\_transcription** `*object*` 语音转录的配置。 **属性** **model** `*string*` 语音转录模型,固定为`gummy-realtime-v1`。 **turn\\_detection** `*object*` 语音活动检测(VAD)的配置。 **属性** **type** `*string*` VAD类型。取值:`server_vad`(默认值)或 `semantic_vad`。详情请参见[客户端事件](https://help.aliyun.com/zh/model-studio/client-events#7c8172272codm)。 **threshold** `*float*` VAD检测阈值。 **silence\\_duration\\_ms** `*integer*` 检测语音停止的静音持续时间。 **enable\\_search** `*boolean*`(可选) 是否启用联网搜索功能。仅 Qwen3.5-Omni-Realtime 模型支持。 **search\\_options** `*object*`(可选) 联网搜索选项配置。 **tools** `*array*`(可选) 工具定义列表。配置后模型可根据用户输入自主决定是否调用工具。 **属性** **type** `*string*`(必选) 固定为 `function`。 **function.name** `*string*`(必选) 自定义的工具函数名称,建议使用与函数相同的名称,如`get_current_weather`或`get_current_time`。 **function.description** `*string*`(可选) 对工具函数功能的描述,大模型会参考该字段来选择是否使用该工具函数。 **function.parameters** `*object*`(可选) 对工具函数入参的描述,大模型会参考该字段来进行入参的提取。如果工具函数不需要输入参数,则无需指定。 **属性** **type** `*string*`(必选) 固定为 `object`。 **properties** `*object*`(可选) 描述各入参的名称、数据类型与描述。Key 值为入参的名称,Value 值为包含数据类型(`type`)与描述(`description`)的对象。 **required** `*array*`(可选) 指定哪些入参为必填项。 **top\\_p**`*float*` 核采样的概率阈值。 **top\\_k** `*integer*` 模型生成过程中,采样候选集的大小。 **max\\_tokens** `*integer*` 模型在本次请求返回的最大 Token 数。 **repetition\\_penalty** `*float*` 控制模型生成时,连续序列中的重复度*。* **presence\\_penalty** `*float*` 控制模型在生成内容时的重复度。 **seed** `*integer*` 模型在每次请求时,运行结果一致性程度。 |
## **input\_audio\_buffer.speech\_started**
在 VAD 模式下,当服务端在音频缓冲区中检测到语音开始时,会返回此事件。
> 若服务端尚未检测到语音,则每次向缓冲区添加音频时都可能触发此事件。
| **event\\_id** `*string*` 本次事件唯一标识符。 | ``` { "event_id": "event_Pvp8nEhsQuGCQbFJ9x58n", "type": "input_audio_buffer.speech_started", "audio_start_ms": 3647, "item_id": "item_YbAiGvK2H7YaS34o4R6Ba" } ``` |
| --- | --- |
| **type** `*string*` 事件类型,固定为`input_audio_buffer.speech_started`。 |
| **audio\\_start\\_ms** `*integer*` 从音频开始写入缓冲区到首次检测到语音所经过的毫秒数。 |
| **item\\_id** `*string*` 语音停止时将创建的用户消息项的 ID。 > 用户消息项用于将用户输入追加到对话历史,供模型后续推理与生成使用。 |
## **input\_audio\_buffer.speech\_stopped**
在 VAD 模式下,当音频缓冲区中检测到语音结束时,服务端会返回此事件。
同时,服务端还会返回一个 `conversation.item.created` 事件,以创建对应的用户消息项。
| **event\\_id** `*string*` 本次事件唯一标识符。 | ``` { "event_id": "event_UhQiqNVRsgUiq4KUS5Xb5", "type": "input_audio_buffer.speech_stopped", "audio_end_ms": 4453, "item_id": "item_YbAiGvK2H7YaS34o4R6Ba" } ``` |
| --- | --- |
| **type** `*string*` 事件类型,固定为`input_audio_buffer.speech_stopped`。 |
| **audio\\_end\\_ms** `*integer*` 语音停止时刻距会话开始经过的毫秒数。 |
| **item\\_id** `*string*` 将创建的用户消息项的 ID。 |
## **input\_audio\_buffer.committed**
当输入音频缓冲区被提交时返回此事件。
- 在VAD模式下,当检测到用户说话结束时,服务端会自动提交音频缓冲区并返回此事件。
- 在 Manual 模式下,当客户端发送`input_audio_buffer.commit`事件后,服务端返回此事件。
| **event\\_id** `*string*` 本次事件唯一标识符。 | ``` { "event_id": "event_Iy6sUzL1nmdFgshFYxJEz", "type": "input_audio_buffer.committed", "item_id": "item_YbAiGvK2H7YaS34o4R6Ba" } ``` |
| --- | --- |
| **type** `*string*` 事件类型,固定为`input_audio_buffer.committed`。 |
| **item\\_id** `*string*` 将创建的用户消息项的 ID。 |
## **input\_audio\_buffer.cleared**
客户端发送`input_audio_buffer.clear`事件后,服务端将返回此事件。
| **event\\_id** `*string*` 本次事件唯一标识符。 | ``` { "event_id": "event_RoUu4T8yExPMI37GKwaOC", "type": "input_audio_buffer.cleared" } ``` |
| --- | --- |
| **type** `*string*` 事件类型,固定为`input_audio_buffer.cleared`。 |
## **conversation.item.created**
当对话项创建时返回此事件。
| **event\\_id** `*string*` 本次事件唯一标识符。 | ``` { "event_id": "event_JEfkrr9gO3Ny7Xcv9bGVd", "type": "conversation.item.created", "item": { "id": "item_YbAiGvK2H7YaS34o4R6Ba", "object": "realtime.item", "type": "message", "status": "in_progress", "role": "assistant", "content": [ { "type": "input_audio" } ] } } // 工具调用场景 { "event_id": "event_S1hkaIQgcuQD8OEdOpGHQ", "type": "conversation.item.created", "item": { "id": "item_FEG9qJGNkPcdf4et3p7BV", "object": "realtime.item", "type": "function_call", "status": "in_progress", "call_id": "call_bc0a7fb7235840f69ecfe4", "name": "get_current_weather", "arguments": "" } } ``` |
| --- | --- |
| **type** `*string*` 事件类型,固定为`conversation.item.created`。 |
| **item** `*object*` 要添加到对话中的项。 **属性** **id** `*string*` 对话项的唯一ID。 **object** `*string*` 始终为 `realtime.item` 。 **status** `*string*` 对话项的状态。 **role** `*string*` 消息的角色。 **content** `*string*` 消息的内容。当 type 为 `message` 时存在。 **type** `*string*` 对话项的类型。可选值为 `message`(常规消息)或 `function_call`(工具调用)。 **name** `*string*` 当 type 为 `function_call` 时,被调用的函数名称。 **call\\_id** `*string*` 当 type 为 `function_call` 时,本次函数调用的唯一 ID。 **arguments** `*string*` 当 type 为 `function_call` 时,函数调用的参数(JSON 字符串)。 |
## **conversation.item.input\_audio\_transcription.completed**
此事件表示用户音频写入缓冲区后生成的转录结果。其转录由独立的语音识别模型(当前固定为 `gummy-realtime-v1`)处理。
> 语音识别模型生成的转录文本可能与 Qwen-Omni-Realtime 模型的理解存在差异,仅供参考。
| **event\\_id** `*string*` 本次事件唯一标识符。 | ``` { "event_id": "event_FrrZcxiDfTB9LD9p4pVng", "type": "conversation.item.input_audio_transcription.completed", "item_id": "item_YbAiGvK2H7YaS34o4R6Ba", "content_index": 0, "transcript": "喂,你好。" } ``` |
| --- | --- |
| **type** `*string*` 事件类型,固定为`conversation.item.input_audio_transcription.completed`。 |
| **item\\_id** `*string*` 用户消息项的 ID。 |
| **content\\_index** *integer* 当前固定为0。 |
| **transcript** `*string*` 转录的文本内容。 |
## **conversation.item.input\_audio\_transcription.failed**
启用输入音频转录后,若用户音频转录失败,服务端会返回此事件。此事件独立于 `error` 事件,便于客户端识别。
| **event\\_id** `*string*` 本次事件唯一标识符。 | ``` { "type": "conversation.item.input_audio_transcription.failed", "item_id": "<item_id>", "content_index": 0, "error": { "code": "<code>", "message": "<message>", "param": "<param>" } } ``` |
| --- | --- |
| **type** `*string*` 事件类型,固定为`conversation.item.input_audio_transcription.failed`。 |
| **item\\_id** `*string*` 用户消息项的 ID。 |
| **content\\_index** `*integer*` 当前固定为0。 |
| **error** `*object*` 错误信息。 **属性** **code** `*string*` 错误码。 **message** `*string*` 错误消息。 **param** `*string*` 错误相关的参数。 |
## **response.created**
当服务端生成新的模型响应时,会返回此事件。
| **event\\_id** `*string*` 本次事件唯一标识符。 | ``` { "event_id": "event_XuDavMzQN3KKepqGu3KRh", "type": "response.created", "response": { "id": "resp_HaVOPdbmX6vifiV5pAfJY", "object": "realtime.response", "conversation_id": "conv_FjJaccpnvwHNo9cPVuzGc", "status": "in_progress", "modalities": [ "text", "audio" ], "voice": "Cherry", "output_audio_format": "pcm", "output": [] } } ``` |
| --- | --- |
| **type** `*string*` 事件类型,固定为`response.created`。 |
| **response** `*object*` 响应对象。 **属性** **id** `*string*` 响应的唯一 ID。 **conversation\\_id** `*string*` 当前会话的唯一ID。 **object** `*string*` 对象类型,此事件下固定为`realtime.response`。 **status** `*string*` 响应的状态。在`[completed, failed, in_progress, or incomplete]`范围内。 **modalities** `*array*` 响应的模态。 **voice** `*string*` 模型生成音频的音色。 **output** `*string*` 此事件下目前为空。 |
## **response.done**
响应生成完成后,服务端会返回此事件。事件中的 `response` 对象包含除原始音频数据外的全部输出项。
| **event\\_id** `*string*` 本次事件唯一标识符。 | ``` { "event_id": "event_CSaxRRYLvbrfexDXAEuDG", "type": "response.done", "response": { "id": "resp_HaVOPdbmX6vifiV5pAfJY", "object": "realtime.response", "conversation_id": "conv_FjJaccpnvwHNo9cPVuzGc", "status": "completed", "modalities": [ "text", "audio" ], "voice": "Cherry", "output_audio_format": "pcm", "output": [ { "id": "item_Ls6MtCUWO7LM4E59QziNv", "object": "realtime.item", "type": "message", "status": "completed", "role": "assistant", "content": [ { "type": "audio", "transcript": "你好呀!有什么我可以帮你的吗?" } ] } ], "usage": { "total_tokens": 377, "input_tokens": 336, "output_tokens": 41, "input_tokens_details": { "text_tokens": 228, "audio_tokens": 108 }, "output_tokens_details": { "text_tokens": 9, "audio_tokens": 32 }, "plugins": { "search": { "count": 1, "strategy": "agent" } } } } } // 工具调用场景 { "event_id": "event_T1EFAJp43X2DWtDRmxTtx", "type": "response.done", "response": { "id": "resp_TucN5QgymL5MA8vkJvFlS", "object": "realtime.response", "conversation_id": "conv_SEDZESRlefT8WvLSmEn6E", "status": "completed", "modalities": ["text", "audio"], "voice": "Ethan", "output_audio_format": "pcm16", "output": [ { "id": "item_FEG9qJGNkPcdf4et3p7BV", "object": "realtime.item", "type": "function_call", "status": "completed", "call_id": "call_bc0a7fb7235840f69ecfe4", "name": "get_current_weather", "arguments": " {\\"location\\": \\"杭州\\"}" } ], "usage": { "total_tokens": 567, "input_tokens": 524, "output_tokens": 43, "input_tokens_details": { "text_tokens": 487, "audio_tokens": 37 }, "output_tokens_details": { "text_tokens": 43 } } } } ``` |
| --- | --- |
| **type** `*string*` 事件类型,固定为`response.done`。 |
| **response** `*object*` 响应对象。 **属性** **id** `*string*` 响应的唯一 ID。 **conversation\\_id** `*string*` 当前会话的唯一ID。 **object** `*string*` 对象类型,此事件下固定为`realtime.response`。 **status** `*string*` 响应的状态。 **modalities** `*array*` 响应的模态。 **voice** `*string*` 模型生成音频的音色。 **output** `*object*` 响应的输出。 **属性** **id** `*string*` 响应输出对应的ID。 **type** `*string*` 输出项的类型,可选值为 `message`(常规消息)或 `function_call`(工具调用)。 **object** `*string*` 输出项的对象类型,当前固定为`realtime.item`。 **status** `*string*` 输出项的状态。 **role** `*string*` 输出项的角色。 **content** `*array*` 输出项的内容。当 type 为 `message` 时存在。 **属性** **type** `*string*` 输出内容的类型。输出为纯文本时,为`text`;输出包含音频时,为`audio`。 **text** `*string*` 输出的文本内容。 **transcript** `*string*` 音频转录为文字后的内容。 **name** `*string*` 当 type 为 `function_call` 时,被调用的函数名称。 **call\\_id** `*string*` 当 type 为 `function_call` 时,函数调用的唯一 ID。 **arguments** `*string*` 当 type 为 `function_call` 时,函数调用的完整参数(JSON 字符串)。 **usage** `*object*` 本次响应的 Token 消耗信息。 **属性** **total\\_tokens** `*integer*` 本次响应消耗的总 Token 数。 **input\\_tokens** `*integer*` 输入 Token 数。 **output\\_tokens** `*integer*` 输出 Token 数。 **input\\_tokens\\_details** `*object*` 输入 Token 的分项详情,包含 `text_tokens`(文本 Token 数)和 `audio_tokens`(音频 Token 数)。 **output\\_tokens\\_details** `*object*` 输出 Token 的分项详情,包含 `text_tokens`(文本 Token 数)和 `audio_tokens`(音频 Token 数)。 **plugins** `*object*`(可选) 插件使用计量信息。启用联网搜索(`enable_search`)时返回。 **属性** **search** `*object*` 联网搜索计量信息。 **属性** **count** `*integer*` 搜索次数。 **strategy** `*string*` 搜索策略。 |
## **response.text.delta**
当输出模态仅包含文本,且模型增量生成新的文本时,服务端将返回此事件。
| **event\\_id** `*string*` 本次事件唯一标识符。 | ``` { "delta": "喂", "event_id": "event_TH49MauuPmRo1RGaMSlP7", "type": "response.text.delta", "response_id": "resp_PrRSvPVpnCExdUOGHHLuP", "item_id": "item_L8IRm9kRXFpxoOjDqDC96", "output_index": 0, "content_index": 0 } ``` |
| --- | --- |
| **type** `*string*` 事件类型,固定为`response.text.delta`。 |
| **delta** `*string*` 返回的增量文本。 |
| **response\\_id** `*string*` 回复的ID。 |
| **item\\_id** `*string*` 消息项ID,可以关联同一个消息项。 |
| **output\\_index** `*integer*` 响应中输出项的索引, 目前固定为 0。 |
| **content\\_index** `*integer*` 响应中输出项中内部部分的索引, 目前固定为 0。 |
## **response.text.done**
当输出模态仅包含文本,且模型生成的文本结束时,服务端将返回此事件。
> 当响应中断、不完整或取消时,也会返回此事件。
| **event\\_id** `*string*` 本次事件唯一标识符。 | ``` { "event_id": "event_B1lIeE2Nac33zn5V7h2mm", "type": "response.text.done", "response_id": "resp_B1lIdtjF4Noqpn5NOjznj", "item_id": "item_B1lIdJsAJlJiFs8ztWpJt", "output_index": 0, "content_index": 0, "text": "How can I assist you today?" } ``` |
| --- | --- |
| **type** `*string*` 事件类型,固定为`response.text.done`。 |
| **response\\_id** `*string*` 响应的ID。 |
| **item\\_id** `*string*` 消息项ID。 |
| **output\\_index**`*integer*` 响应输出项的索引。 |
| **content\\_index**`*integer*` 响应输出项的索引。 |
| **text** `*string*` 模型输出的完整文本。 |
## **response.audio.delta**
当输出模态包含音频,且模型增量生成新的音频数据时,服务端将返回此事件。
| **event\\_id** `*string*` 本次事件唯一标识符。 | ``` { "event_id": "event_B1osWMZBtrEQbiIwW0qHQ", "type": "response.audio.delta", "response_id": "resp_P79OOMs8LnrXVpiIHUCKR", "item_id": "item_OFaPGtzfWCPyGzxnuEX9i", "output_index": 0, "content_index": 0, "delta": "{base64 audio}" } ``` |
| --- | --- |
| **type** `*string*` 事件类型,固定为`response.audio.delta`。 |
| **response\\_id** `*string*` 响应的ID。 |
| **item\\_id** `*string*` 消息项ID。 |
| **output\\_index**`*integer*` 响应输出项的索引。 |
| **content\\_index**`*integer*` 响应输出项的索引。 |
| **delta** `*string*` 模型增量输出的音频数据,使用Base64编码。 |
## **response.audio.done**
当输出模态包含音频,且模型完成生成音频数据时,服务端将返回此事件。
> 当响应中断、不完整或取消时,也会返回此事件。
| **event\\_id** `*string*` 本次事件唯一标识符。 | ``` { "event_id": "event_Le1TDl7VfyHQxl47DtGxI", "type": "response.audio.done", "response_id": "resp_HaVOPdbmX6vifiV5pAfJY", "item_id": "item_Ls6MtCUWO7LM4E59QziNv", "output_index": 0, "content_index": 0 } ``` |
| --- | --- |
| **type** `*string*` 事件类型,固定为`response.audio.done`。 |
| **response\\_id** `*string*` 响应的ID。 |
| **item\\_id** `*string*` 消息项ID。 |
| **output\\_index**`*integer*` 响应输出项的索引。 |
| **content\\_index**`*integer*` 响应输出项的索引。 |
## **response.audio\_transcript.delta**
当输出模态包含音频,且模型增量生成新的音频对应的文本时,服务端将返回 `response.audio_transcript.delta` 事件。
| **event\\_id** `*string*` 本次事件唯一标识符。 | ``` { "event_id": "event_BksW7fOwnyavZdDxIzZYM", "type": "response.audio_transcript.delta", "response_id": "resp_HaVOPdbmX6vifiV5pAfJY", "item_id": "item_Ls6MtCUWO7LM4E59QziNv", "output_index": 0, "content_index": 0, "delta": "有什么" } ``` |
| --- | --- |
| **type** `*string*` 事件类型,固定为`response.audio_transcript.delta`。 |
| **response\\_id** `*string*` 响应的ID。 |
| **item\\_id** `*string*` 消息项ID。 |
| **output\\_index**`*integer*` 响应输出项的索引。 |
| **content\\_index**`*integer*` 响应输出项的索引。 |
| **delta** `*string*` 增量文本。 |
## **response.audio\_transcript.done**
当输出模态包含音频,且模型完成音频转录后,服务端将返回 `response.audio_transcript.done` 事件。
| **event\\_id** `*string*` 本次事件唯一标识符。 | ``` { "event_id": "event_X49tL2WerT4WjxcmH16lS", "type": "response.audio_transcript.done", "response_id": "resp_HaVOPdbmX6vifiV5pAfJY", "item_id": "item_Ls6MtCUWO7LM4E59QziNv", "output_index": 0, "content_index": 0, "transcript": "你好呀!有什么我可以帮你的吗?" } ``` |
| --- | --- |
| **type** `*string*` 事件类型,固定为`response.audio_transcript.done`。 |
| **response\\_id** `*string*` 响应的ID。 |
| **item\\_id** `*string*` 消息项ID。 |
| **output\\_index**`*integer*` 响应输出项的索引。 |
| **content\\_index**`*integer*` 响应输出项的索引。 |
| **transcript** `*string*` 完整文本。 |
## **response.function\_call\_arguments.delta**
当模型以流式方式生成函数调用的参数字符串时,每产生一段新内容,服务端推送一次本事件。客户端应按接收顺序将各事件中的 `delta` 字段拼接,得到与当前进度一致的参数文本;完整内容以随后的 `response.function_call_arguments.done` 为准。
| **event\\_id** `*string*` 本次事件唯一标识符。 | ``` { "event_id": "event_SlKoJyEbPEqLq14DSM1u5", "type": "response.function_call_arguments.delta", "response_id": "resp_JnTOsWXlFhKcFohZbtfz6", "item_id": "item_Rhcms7CauTNsQprV5S4Hr", "output_index": 0, "call_id": "call_2be200f4cafe419b9530dd", "delta": " {\\"location\\": \\"北京\\"}" } ``` |
| --- | --- |
| **type** `*string*` 事件类型,固定为`response.function_call_arguments.delta`。 |
| **response\\_id** `*string*` 响应的ID。 |
| **item\\_id** `*string*` 消息项ID。 |
| **output\\_index**`*integer*` 该响应中输出项的索引。 |
| **call\\_id** `*string*` 本次函数调用的唯一 ID,与同一轮中的 `done` 事件保持一致。 |
| **delta** `*string*` 本段新增的参数字符串片段(增量)。需按顺序拼接。 |
## **response.function\_call\_arguments.done**
函数调用参数已全部生成完毕。本事件中的 `arguments` 为完整的参数字符串。客户端可在收到本事件后解析参数并调用本地工具函数;应以本事件中的完整 `arguments` 为准,而非 `delta` 拼接结果。
| **event\\_id** `*string*` 本次事件唯一标识符。 | ``` { "event_id": "event_X6suLyuL5agdH7r6koesM", "type": "response.function_call_arguments.done", "response_id": "resp_JnTOsWXlFhKcFohZbtfz6", "item_id": "item_Rhcms7CauTNsQprV5S4Hr", "output_index": 0, "name": "get_current_weather", "call_id": "call_2be200f4cafe419b9530dd", "arguments": " {\\"location\\": \\"北京\\"}" } ``` |
| --- | --- |
| **type** `*string*` 事件类型,固定为`response.function_call_arguments.done`。 |
| **response\\_id** `*string*` 响应的ID。 |
| **item\\_id** `*string*` 消息项ID。 |
| **output\\_index**`*integer*` 该响应中输出项的索引。 |
| **call\\_id** `*string*` 本次函数调用的唯一 ID。 |
| **name** `*string*` 被调用的函数名称。 |
| **arguments** `*string*` 函数调用的完整参数,一般以 JSON 字符串形式表示。 |
## **response.output\_item.added**
在响应生成过程中创建新项目时,服务端返回此事件。项目类型可以是 `message`(常规消息)或 `function_call`(工具调用)。
| **event\\_id** `*string*` 本次事件唯一标识符。 | ``` { "event_id": "event_DsCO341DEVtiATtCB6BUY", "type": "response.output_item.added", "response_id": "resp_HaVOPdbmX6vifiV5pAfJY", "output_index": 0, "item": { "id": "item_Ls6MtCUWO7LM4E59QziNv", "object": "realtime.item", "type": "message", "status": "in_progress", "role": "assistant", "content": [] } } // 工具调用场景 { "event_id": "event_HXmKt5pGoiRtXx7Hq7zpN", "type": "response.output_item.added", "response_id": "resp_TucN5QgymL5MA8vkJvFlS", "output_index": 0, "item": { "id": "item_FEG9qJGNkPcdf4et3p7BV", "object": "realtime.item", "type": "function_call", "status": "in_progress", "call_id": "call_bc0a7fb7235840f69ecfe4", "name": "get_current_weather", "arguments": "" } } ``` |
| --- | --- |
| **type** `*string*` 事件类型,固定为`response.output_item.added`。 |
| **response\\_id** `*string*` 响应的ID。 |
| **output\\_index**`*integer*` 响应输出项的索引。 |
| **item**`*object*` 输出项信息。 **属性** **id** `*string*` 输出项的唯一ID。 **object** `*string*` 始终为 `realtime.item` 。 **status** `*string*` 输出项的状态。 **role** `*string*` 发送消息的角色。 **content** `*string*` 消息的内容。当 type 为 `message` 时存在。 **type** `*string*` 输出项的类型。可选值为 `message`(常规消息)或 `function_call`(工具调用)。 **name** `*string*` 当 type 为 `function_call` 时,被调用的函数名称。 **call\\_id** `*string*` 当 type 为 `function_call` 时,本次函数调用的唯一 ID。 **arguments** `*string*` 当 type 为 `function_call` 时,函数调用的参数(JSON 字符串)。在 added 事件中初始为空字符串。 |
## **response.output\_item.done**
当新的项目输出完成时,服务端返回此事件。
| **event\\_id** `*string*` 本次事件唯一标识符。 | ``` { "event_id": "event_MEu5nlLw1LsOguHiehIP8", "type": "response.output_item.done", "response_id": "resp_HaVOPdbmX6vifiV5pAfJY", "output_index": 0, "item": { "id": "item_Ls6MtCUWO7LM4E59QziNv", "object": "realtime.item", "type": "message", "status": "completed", "role": "assistant", "content": [ { "type": "audio", "text": "你好呀!有什么我可以帮你的吗?" } ] } } // 工具调用场景 { "event_id": "event_FHspdfAnCyjuME3mmAwSY", "type": "response.output_item.done", "response_id": "resp_TucN5QgymL5MA8vkJvFlS", "output_index": 0, "item": { "id": "item_FEG9qJGNkPcdf4et3p7BV", "object": "realtime.item", "type": "function_call", "status": "completed", "call_id": "call_bc0a7fb7235840f69ecfe4", "name": "get_current_weather", "arguments": " {\\"location\\": \\"杭州\\"}" } } ``` |
| --- | --- |
| **type** `*string*` 事件类型,固定为`response.output_item.done`。 |
| **response\\_id** `*string*` 响应的ID。 |
| **output\\_index**`*integer*` 响应输出项的索引。 |
| **item**`*object*` 输出项信息。 **属性** **id** `*string*` 输出项的唯一ID。 **object** `*string*` 始终为 `realtime.item` 。 **status** `*string*` 输出项的状态。 **role** `*string*` 发送消息的角色。 **content** `*string*` 消息的内容。当 type 为 `message` 时存在。 **type** `*string*` 输出项的类型。可选值为 `message`(常规消息)或 `function_call`(工具调用)。 **name** `*string*` 当 type 为 `function_call` 时,被调用的函数名称。 **call\\_id** `*string*` 当 type 为 `function_call` 时,本次函数调用的唯一 ID。 **arguments** `*string*` 当 type 为 `function_call` 时,函数调用的完整参数(JSON 字符串)。 |
## **response.content\_part.added**
在响应生成过程中,向助手消息项中添加新内容部分时,服务端返回此事件。
| **event\\_id** `*string*` 本次事件唯一标识符。 | ``` { "event_id": "event_AVBOmrgY3C8bjlRajfSUT", "type": "response.content_part.added", "response_id": "resp_HaVOPdbmX6vifiV5pAfJY", "item_id": "item_Ls6MtCUWO7LM4E59QziNv", "output_index": 0, "content_index": 0, "part": { "type": "audio", "text": "" } } ``` |
| --- | --- |
| **type** `*string*` 事件类型,固定为`response.content_part.added`。 |
| **response\\_id** `*string*` 响应的ID。 |
| **item\\_id** `*string*` 消息项ID。 |
| **output\\_index**`*integer*` 响应输出项的索引,目前固定为 0。 |
| **content\\_index**`*integer*` 响应输出项中内部部分的索引, 目前固定为 0。 |
| **part**`*object*` 输出项信息。 **属性** **type** `*string*` 内容部分的类型。 **text** `*string*` 内容部分的文本。 |
## **response.content\_part.done**
在助手消息项中的内容部分完成流式传输时,服务端返回此事件。
| **event\\_id** `*string*` 本次事件唯一标识符。 | ``` { "event_id": "event_Il8HD19v58Qr5IBkw7LtN", "type": "response.content_part.done", "response_id": "resp_HaVOPdbmX6vifiV5pAfJY", "item_id": "item_Ls6MtCUWO7LM4E59QziNv", "output_index": 0, "content_index": 0, "part": { "type": "audio", "text": "你好呀!有什么我可以帮你的吗?" } } ``` |
| --- | --- |
| **type** `*string*` 事件类型,固定为`response.content_part.done`。 |
| **response\\_id** `*string*` 响应的ID。 |
| **item\\_id** `*string*` 消息项ID。 |
| **output\\_index**`*integer*` 响应输出项的索引,目前固定为 0。 |
| **content\\_index**`*integer*` 该项内容数组中内容部分的索引,目前固定为 0。 |
| **part**`*object*` 输出项信息。 **属性** **type** `*string*` 内容部分的类型。 **text** `*string*` 内容部分的文本。 |
File diff suppressed because one or more lines are too long
+3
View File
@@ -10,6 +10,7 @@ static def getCurrentTime() {
android {
namespace 'ai.guiji.duix.test'
compileSdk 34
ndkVersion '25.1.8937393'
defaultConfig {
applicationId "ai.guiji.duix.test"
@@ -82,9 +83,11 @@ dependencies {
implementation 'androidx.constraintlayout:constraintlayout:2.1.1'
implementation "androidx.activity:activity:1.3.0"
implementation "androidx.fragment:fragment:1.3.0"
implementation 'androidx.documentfile:documentfile:1.0.1'
implementation 'com.github.bumptech.glide:glide:4.12.0'
implementation project(":duix-sdk")
implementation 'com.squareup.okhttp3:okhttp:4.10.0'
implementation 'com.google.zxing:core:3.5.3'
}
-22
View File
@@ -272,28 +272,6 @@
column="12"/>
</issue>
<issue
id="UnusedResources"
message="The resource `R.drawable.ic_launcher_background` appears to be unused"
errorLine1="&lt;vector xmlns:android=&quot;http://schemas.android.com/apk/res/android&quot;"
errorLine2="^">
<location
file="src/main/res/drawable/ic_launcher_background.xml"
line="2"
column="1"/>
</issue>
<issue
id="UnusedResources"
message="The resource `R.drawable.ic_launcher_foreground` appears to be unused"
errorLine1="&lt;vector xmlns:android=&quot;http://schemas.android.com/apk/res/android&quot;"
errorLine2="^">
<location
file="src/main/res/drawable/ic_launcher_foreground.xml"
line="1"
column="1"/>
</issue>
<issue
id="UnusedResources"
message="The resource `R.string.need_record_permission` appears to be unused"
+29 -2
View File
@@ -4,16 +4,19 @@
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.RECORD_AUDIO"/>
<uses-permission android:name="android.permission.CAMERA"/>
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="29" />
<uses-feature android:name="android.hardware.camera" android:required="false"/>
<uses-feature android:name="android.hardware.camera.front" android:required="false"/>
<application
android:name=".App"
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:icon="@mipmap/azb_launcher"
android:networkSecurityConfig="@xml/network_security_config"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:roundIcon="@mipmap/azb_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.DUIX.Test">
<activity
@@ -37,6 +40,30 @@
android:exported="false"
android:theme="@style/Theme.DUIX.Call">
</activity>
<activity
android:name=".ui.activity.FolderPickerActivity"
android:exported="false"
android:theme="@style/Theme.DUIX.Test">
</activity>
<activity
android:name=".ui.activity.DigitalHumanTestActivity"
android:exported="false"
android:theme="@style/Theme.DUIX.Test">
</activity>
<activity
android:name=".ui.activity.SettingsActivity"
android:exported="false"
android:theme="@style/Theme.DUIX.Test">
</activity>
<activity
android:name=".ui.activity.DeviceSettingsActivity"
android:exported="false"
android:theme="@style/Theme.DUIX.Test">
</activity>
</application>
</manifest>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,220 @@
package ai.guiji.duix.test.audio
import android.content.Context
import android.media.AudioFormat
import android.media.MediaCodec
import android.media.MediaExtractor
import android.media.MediaFormat
import android.net.Uri
import java.io.ByteArrayOutputStream
import java.util.Locale
import kotlin.math.ceil
import kotlin.math.floor
import kotlin.math.roundToInt
object AudioFileDecoder {
private const val TARGET_SAMPLE_RATE = 16_000
data class DecodedAudio(
val pcm16Mono16k: ByteArray,
val durationMs: Long,
)
fun decodeToPcm16Mono16k(context: Context, uri: Uri): DecodedAudio {
val extractor = MediaExtractor()
var decoder: MediaCodec? = null
val rawPcmOutput = ByteArrayOutputStream()
var selectedTrack = -1
var sampleRate = TARGET_SAMPLE_RATE
var channelCount = 1
try {
val scheme = uri.scheme?.lowercase(Locale.US)
if (scheme.isNullOrEmpty() || scheme == "file") {
val path = uri.path ?: throw IllegalArgumentException("无法读取所选音频路径")
extractor.setDataSource(path)
} else {
context.contentResolver.openAssetFileDescriptor(uri, "r")?.use { descriptor ->
extractor.setDataSource(descriptor.fileDescriptor, descriptor.startOffset, descriptor.length)
} ?: throw IllegalArgumentException("无法打开所选音频")
}
selectedTrack = findFirstAudioTrack(extractor)
if (selectedTrack < 0) {
throw IllegalArgumentException("没有找到可读取的音频轨道")
}
extractor.selectTrack(selectedTrack)
val inputFormat = extractor.getTrackFormat(selectedTrack)
val mime = inputFormat.getString(MediaFormat.KEY_MIME)
?: throw IllegalArgumentException("无法识别音频格式")
sampleRate = inputFormat.optInt(MediaFormat.KEY_SAMPLE_RATE, TARGET_SAMPLE_RATE)
channelCount = inputFormat.optInt(MediaFormat.KEY_CHANNEL_COUNT, 1)
decoder = MediaCodec.createDecoderByType(mime)
decoder.configure(inputFormat, null, null, 0)
decoder.start()
val bufferInfo = MediaCodec.BufferInfo()
var inputDone = false
var outputDone = false
while (!outputDone) {
if (!inputDone) {
val inputIndex = decoder.dequeueInputBuffer(TIMEOUT_US)
if (inputIndex >= 0) {
val inputBuffer = decoder.getInputBuffer(inputIndex)
?: throw IllegalStateException("解码器输入缓冲区不可用")
val sampleSize = extractor.readSampleData(inputBuffer, 0)
if (sampleSize < 0) {
decoder.queueInputBuffer(
inputIndex,
0,
0,
0L,
MediaCodec.BUFFER_FLAG_END_OF_STREAM,
)
inputDone = true
} else {
decoder.queueInputBuffer(
inputIndex,
0,
sampleSize,
extractor.sampleTime.coerceAtLeast(0L),
0,
)
extractor.advance()
}
}
}
when (val outputIndex = decoder.dequeueOutputBuffer(bufferInfo, TIMEOUT_US)) {
MediaCodec.INFO_TRY_AGAIN_LATER -> Unit
MediaCodec.INFO_OUTPUT_FORMAT_CHANGED -> {
val outputFormat = decoder.outputFormat
sampleRate = outputFormat.optInt(MediaFormat.KEY_SAMPLE_RATE, sampleRate)
channelCount = outputFormat.optInt(MediaFormat.KEY_CHANNEL_COUNT, channelCount)
val pcmEncoding = outputFormat.optInt(
MediaFormat.KEY_PCM_ENCODING,
AudioFormat.ENCODING_PCM_16BIT,
)
if (pcmEncoding != AudioFormat.ENCODING_PCM_16BIT) {
throw IllegalArgumentException("暂不支持该音频 PCM 编码")
}
}
else -> {
if (outputIndex >= 0) {
val outputBuffer = decoder.getOutputBuffer(outputIndex)
?: throw IllegalStateException("解码器输出缓冲区不可用")
if (bufferInfo.size > 0) {
val chunk = ByteArray(bufferInfo.size)
outputBuffer.position(bufferInfo.offset)
outputBuffer.limit(bufferInfo.offset + bufferInfo.size)
outputBuffer.get(chunk)
rawPcmOutput.write(chunk)
}
val endOfStream = (bufferInfo.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0
decoder.releaseOutputBuffer(outputIndex, false)
outputDone = endOfStream
}
}
}
}
val converted = convertPcm16ToMono16k(rawPcmOutput.toByteArray(), sampleRate, channelCount)
if (converted.isEmpty()) {
throw IllegalArgumentException("音频解码后没有可用数据")
}
val durationMs = ((converted.size / 2L) * 1000L / TARGET_SAMPLE_RATE).coerceAtLeast(0L)
return DecodedAudio(
pcm16Mono16k = converted,
durationMs = durationMs,
)
} finally {
runCatching { decoder?.stop() }
runCatching { decoder?.release() }
runCatching { if (selectedTrack >= 0) extractor.unselectTrack(selectedTrack) }
runCatching { extractor.release() }
runCatching { rawPcmOutput.close() }
}
}
private fun findFirstAudioTrack(extractor: MediaExtractor): Int {
for (index in 0 until extractor.trackCount) {
val mime = extractor.getTrackFormat(index).getString(MediaFormat.KEY_MIME) ?: continue
if (mime.startsWith("audio/")) {
return index
}
}
return -1
}
private fun convertPcm16ToMono16k(input: ByteArray, inputSampleRate: Int, inputChannels: Int): ByteArray {
if (input.isEmpty()) {
return ByteArray(0)
}
if (inputChannels <= 0) {
throw IllegalArgumentException("无效的声道数")
}
val frameCount = input.size / (2 * inputChannels)
if (frameCount <= 0) {
return ByteArray(0)
}
val monoSamples = FloatArray(frameCount)
var byteOffset = 0
for (frameIndex in 0 until frameCount) {
var sum = 0
for (channelIndex in 0 until inputChannels) {
sum += readShortLE(input, byteOffset).toInt()
byteOffset += 2
}
monoSamples[frameIndex] = sum.toFloat() / inputChannels.toFloat()
}
if (inputSampleRate == TARGET_SAMPLE_RATE) {
val output = ByteArray(frameCount * 2)
for (index in monoSamples.indices) {
writeShortLE(output, index * 2, monoSamples[index].roundToInt().toShort())
}
return output
}
val outputFrameCount = ceil(frameCount.toDouble() * TARGET_SAMPLE_RATE / inputSampleRate).toInt()
val output = ByteArray(outputFrameCount * 2)
val positionScale = inputSampleRate.toDouble() / TARGET_SAMPLE_RATE.toDouble()
for (outputIndex in 0 until outputFrameCount) {
val srcPosition = outputIndex * positionScale
val srcIndex0 = floor(srcPosition).toInt().coerceIn(0, monoSamples.lastIndex)
val srcIndex1 = (srcIndex0 + 1).coerceAtMost(monoSamples.lastIndex)
val fraction = srcPosition - srcIndex0
val interpolated = monoSamples[srcIndex0] +
((monoSamples[srcIndex1] - monoSamples[srcIndex0]) * fraction.toFloat())
val clamped = interpolated.roundToInt().coerceIn(Short.MIN_VALUE.toInt(), Short.MAX_VALUE.toInt())
writeShortLE(output, outputIndex * 2, clamped.toShort())
}
return output
}
private fun readShortLE(data: ByteArray, offset: Int): Short {
val low = data[offset].toInt() and 0xFF
val high = data[offset + 1].toInt() shl 8
return (low or high).toShort()
}
private fun writeShortLE(data: ByteArray, offset: Int, value: Short) {
data[offset] = (value.toInt() and 0xFF).toByte()
data[offset + 1] = ((value.toInt() ushr 8) and 0xFF).toByte()
}
private fun MediaFormat.optInt(key: String, fallback: Int): Int {
return runCatching {
if (containsKey(key)) getInteger(key) else fallback
}.getOrDefault(fallback)
}
private const val TIMEOUT_US = 10_000L
}
@@ -0,0 +1,23 @@
package ai.guiji.duix.test.model
import android.net.Uri
data class BatchAudioJob(
val sourceUri: Uri,
val sourceRelativePath: String,
val displayName: String,
val outputRelativePath: String,
val attemptCount: Int = 0,
val status: Status = Status.PENDING,
val statusText: String = "",
val detailText: String = "",
val outputUri: Uri? = null,
) {
enum class Status {
PENDING,
RUNNING,
COMPLETED,
FAILED,
SKIPPED,
}
}
@@ -9,6 +9,10 @@ import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicLong;
@@ -32,30 +36,18 @@ public class OmniRealtimeClient {
private static final String INPUT_AUDIO_TRANSCRIPTION_MODEL = "gummy-realtime-v1";
private static final int MAX_IMAGE_BYTES = 500 * 1024;
private static final long MIN_IMAGE_APPEND_INTERVAL_MS = 800L;
private static final String DEFAULT_INSTRUCTIONS = "你是一名面向中国老年群体的心理陪伴 AI。你的职责是为老人提供温和、耐心、尊重、稳定的情感陪伴、情绪支持、回忆唤起、生活关怀和风险识别服务。你不是医生,也不是心理治疗师,不能替代专业诊疗,但应在必要时建议联系家属、社区、医生或急救资源。\n"
private static final String DEFAULT_INSTRUCTIONS = "你是一名面向中国老年群体的心理陪伴 AI。你的职责是为老人提供温和、耐心、尊重、稳定的情感陪伴、情绪支持、回忆唤起、生活关怀和风险识别服务。\n"
+ "\n"
+ "请始终遵循以下原则:\n"
+ "\n"
+ "先共情,再回应问题;\n"
+ "\n"
+ "先接住情绪,再给建议;\n"
+ "\n"
+ "使用简短、朴素、生活化的中文,始终用“您”称呼;\n"
+ "\n"
+ "不说教、不训诫、不否定、不敷衍;\n"
+ "\n"
+ "不把老人当小孩,不制造虚假亲密;\n"
+ "\n"
+ "一次不提供超过两个建议;\n"
+ "\n"
+ "鼓励老人表达感受,可适度引导回忆人生经历、兴趣、家庭和过往成就;\n"
+ "\n"
+ "对孤独、丧偶、被忽视、无价值感、失眠、焦虑、怕拖累家人等议题保持高度敏感;\n"
+ "\n"
+ "若出现自杀、自伤、重度绝望、幻觉妄想、意识混乱、虐待、严重医疗异常等风险,必须立即建议联系真人帮助和专业机构;\n"
+ "\n"
+ "不编造医疗知识、政策信息、机构电话,不确定时明确说明。\n"
+ "\n"
+ "你的回复风格应温和、平稳、尊重、有耐心。每次回答尽量包含:一句共情、一句理解或复述、一句温和追问或一个微小建议。核心目标是让老人感到:我被听见了,我被尊重了,有人愿意陪我。";
private final OkHttpClient okHttpClient;
@@ -71,6 +63,10 @@ public class OmniRealtimeClient {
private final AtomicBoolean assistantTextFinalized = new AtomicBoolean(false);
private final StringBuilder assistantTextBuffer = new StringBuilder();
public static String getDefaultInstructions() {
return DEFAULT_INSTRUCTIONS;
}
public OmniRealtimeClient(SessionOptions sessionOptions, Callback callback) {
this.sessionOptions = sessionOptions;
this.callback = callback;
@@ -170,7 +166,15 @@ public class OmniRealtimeClient {
}
session.put("input_audio_format", "pcm");
session.put("instructions", options.getInstructions());
session.put("enable_search", options.isEnableSearch());
session.put("enable_search", options.isEnableSearch() && !options.hasTools());
if (options.hasTools()) {
JSONArray tools = new JSONArray();
for (ToolDefinition toolDefinition : options.getTools()) {
tools.put(toolDefinition.toJson());
}
session.put("tools", tools);
}
if (options.isEnableInputAudioTranscription()) {
JSONObject inputAudioTranscription = new JSONObject();
@@ -263,6 +267,16 @@ public class OmniRealtimeClient {
dispatchAssistantFinalText(event.optString("text", event.optString("transcript", "")));
break;
case "response.function_call_arguments.done":
String toolName = event.optString("name", "");
String callId = event.optString("call_id", "");
String arguments = event.optString("arguments", "{}");
Log.d(TAG, "Tool call: " + toolName + ", callId=" + callId + ", arguments=" + arguments);
if (callback != null) {
callback.onToolCallRequested(toolName, callId, arguments);
}
break;
case "response.output_item.done":
dispatchAssistantFinalTextIfNeeded(extractTextFromItem(event.optJSONObject("item")));
break;
@@ -391,6 +405,63 @@ public class OmniRealtimeClient {
return isConnected.get();
}
public boolean sendFunctionCallOutput(String callId, String output) {
if (callId == null || callId.trim().isEmpty()) {
return false;
}
try {
JSONObject item = new JSONObject();
item.put("type", "function_call_output");
item.put("call_id", callId);
item.put("output", output == null ? "" : output);
JSONObject event = new JSONObject();
event.put("event_id", nextEventId());
event.put("type", "conversation.item.create");
event.put("item", item);
if (!sendEvent(event)) {
return false;
}
JSONObject responseCreate = new JSONObject();
responseCreate.put("event_id", nextEventId());
responseCreate.put("type", "response.create");
return sendEvent(responseCreate);
} catch (JSONException e) {
Log.e(TAG, "Failed to send function_call_output: " + e.getMessage());
return false;
}
}
public boolean requestResponse() {
try {
JSONObject responseCreate = new JSONObject();
responseCreate.put("event_id", nextEventId());
responseCreate.put("type", "response.create");
return sendEvent(responseCreate);
} catch (JSONException e) {
Log.e(TAG, "Failed to send response.create: " + e.getMessage());
return false;
}
}
public boolean commitInputAudio() {
try {
JSONObject event = new JSONObject();
event.put("event_id", nextEventId());
event.put("type", "input_audio_buffer.commit");
boolean sent = sendEvent(event);
if (sent) {
imageWindowOpen.set(false);
}
return sent;
} catch (JSONException e) {
Log.e(TAG, "Failed to send input_audio_buffer.commit: " + e.getMessage());
return false;
}
}
private void handleServerError(JSONObject event) {
String errMsg = event.optJSONObject("error") != null
? event.optJSONObject("error").optString("message", "Unknown error")
@@ -510,7 +581,7 @@ public class OmniRealtimeClient {
}
private String nextEventId() {
return "event_" + System.currentTimeMillis();
return "event_" + UUID.randomUUID().toString().replace("-", "");
}
public static class SessionOptions {
@@ -528,29 +599,48 @@ public class OmniRealtimeClient {
private final boolean enableTurnDetection;
private final boolean enableInputAudioTranscription;
private final OutputMode outputMode;
private final List<ToolDefinition> tools;
public SessionOptions(String voice, boolean enableSearch) {
this(voice, enableSearch, OutputMode.AUDIO_AND_TEXT);
}
public SessionOptions(String voice, boolean enableSearch, OutputMode outputMode) {
this(voice, enableSearch, outputMode, null, null);
}
public SessionOptions(String voice, boolean enableSearch, OutputMode outputMode,
String instructions, List<ToolDefinition> tools) {
this(DEFAULT_API_KEY, DEFAULT_BASE_URL, DEFAULT_MODEL, voice, DEFAULT_INSTRUCTIONS,
enableSearch, true, true, outputMode);
enableSearch, true, true, outputMode, tools, instructions);
}
public SessionOptions(String apiKey, String baseUrl, String model, String voice,
String instructions, boolean enableSearch, boolean enableTurnDetection,
boolean enableInputAudioTranscription, OutputMode outputMode) {
this(apiKey, baseUrl, model, voice, instructions, enableSearch,
enableTurnDetection, enableInputAudioTranscription, outputMode, null, null);
}
public SessionOptions(String apiKey, String baseUrl, String model, String voice,
String instructions, boolean enableSearch, boolean enableTurnDetection,
boolean enableInputAudioTranscription, OutputMode outputMode,
List<ToolDefinition> tools, String ignoredInstructionsOverride) {
this.apiKey = apiKey == null ? "" : apiKey;
this.baseUrl = baseUrl == null || baseUrl.trim().isEmpty() ? DEFAULT_BASE_URL : baseUrl;
this.model = model == null || model.trim().isEmpty() ? DEFAULT_MODEL : model;
this.voice = voice == null || voice.trim().isEmpty() ? "Ethan" : voice;
this.instructions = instructions == null || instructions.trim().isEmpty()
? DEFAULT_INSTRUCTIONS : instructions;
String resolvedInstructions = ignoredInstructionsOverride;
if (resolvedInstructions == null || resolvedInstructions.trim().isEmpty()) {
resolvedInstructions = instructions;
}
this.instructions = resolvedInstructions == null || resolvedInstructions.trim().isEmpty()
? DEFAULT_INSTRUCTIONS : resolvedInstructions;
this.enableSearch = enableSearch;
this.enableTurnDetection = enableTurnDetection;
this.enableInputAudioTranscription = enableInputAudioTranscription;
this.outputMode = outputMode == null ? OutputMode.AUDIO_AND_TEXT : outputMode;
this.tools = tools == null ? Collections.emptyList() : new ArrayList<>(tools);
}
public String getApiKey() {
@@ -584,6 +674,38 @@ public class OmniRealtimeClient {
public boolean isAudioOutputEnabled() {
return outputMode == OutputMode.AUDIO_AND_TEXT;
}
public boolean hasTools() {
return !tools.isEmpty();
}
public List<ToolDefinition> getTools() {
return tools;
}
}
public static class ToolDefinition {
private final String name;
private final String description;
private final JSONObject parameters;
public ToolDefinition(String name, String description, JSONObject parameters) {
this.name = name == null ? "" : name;
this.description = description == null ? "" : description;
this.parameters = parameters == null ? new JSONObject() : parameters;
}
public JSONObject toJson() throws JSONException {
JSONObject function = new JSONObject();
function.put("name", name);
function.put("description", description);
function.put("parameters", new JSONObject(parameters.toString()));
JSONObject tool = new JSONObject();
tool.put("type", "function");
tool.put("function", function);
return tool;
}
}
public interface Callback {
@@ -603,6 +725,8 @@ public class OmniRealtimeClient {
void onAssistantTranscriptDone(String transcript);
void onToolCallRequested(String toolName, String callId, String argumentsJson);
void onAssistantAudioDelta(byte[] pcmData);
void onAssistantAudioDone();
@@ -4,9 +4,13 @@ public final class DashScopeConfig {
public static final String DEFAULT_API_KEY = "sk-7c32083ace99472e97209e611b9ecbb1";
public static final String REALTIME_BASE_URL = "wss://dashscope.aliyuncs.com/api-ws/v1/realtime";
public static final String MULTIMODAL_GENERATION_URL = "https://dashscope.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation";
public static final String PSYCHOLOGY_SUBAGENT_MODEL = "qwen3.6-plus";
public static final String PSYCHOLOGY_KNOWLEDGE_ASSET_PATH = "knowledge/who_icope_2024.txt";
public static final String VOICE_CLONE_URL = "https://dashscope.aliyuncs.com/api/v1/services/audio/tts/customization";
public static final String VOICE_CLONE_MODEL = "qwen-voice-enrollment";
public static final String VOICE_CLONE_TTS_MODEL = "qwen3-tts-vc-realtime-2026-01-15";
public static final String MONITOR_BASE_URL = "https://digital-human.xn--876a.net";
public static final int AUDIO_SAMPLE_RATE = 24000;
private DashScopeConfig() {
@@ -0,0 +1,21 @@
package ai.guiji.duix.test.service
import android.content.Context
class DebugSettingsStore(context: Context) {
private val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
fun isChatDebugEnabled(): Boolean {
return prefs.getBoolean(KEY_CHAT_DEBUG_ENABLED, false)
}
fun setChatDebugEnabled(enabled: Boolean) {
prefs.edit().putBoolean(KEY_CHAT_DEBUG_ENABLED, enabled).apply()
}
companion object {
private const val PREFS_NAME = "app_debug_settings"
private const val KEY_CHAT_DEBUG_ENABLED = "chat_debug_enabled"
}
}
@@ -0,0 +1,51 @@
package ai.guiji.duix.test.service
import android.content.Context
import android.net.Uri
import java.util.Locale
import java.util.UUID
class DeviceIdentityStore(context: Context) {
private val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
fun getOrCreateDeviceUuid(): String {
val current = prefs.getString(KEY_DEVICE_UUID, null)?.trim()?.lowercase(Locale.US)
if (!current.isNullOrBlank()) {
return current
}
val deviceUuid = UUID.randomUUID().toString().lowercase(Locale.US)
prefs.edit().putString(KEY_DEVICE_UUID, deviceUuid).apply()
return deviceUuid
}
fun getBindUrl(): String {
val deviceUuid = getOrCreateDeviceUuid()
return prefs.getString(KEY_BIND_URL, null)?.takeIf { it.isNotBlank() }
?: buildFallbackBindUrl(deviceUuid)
}
fun saveBindUrl(bindUrl: String) {
prefs.edit().putString(KEY_BIND_URL, bindUrl.trim()).apply()
}
fun getDeviceToken(): String? = prefs.getString(KEY_DEVICE_TOKEN, null)?.takeIf { it.isNotBlank() }
fun saveDeviceToken(token: String) {
prefs.edit().putString(KEY_DEVICE_TOKEN, token.trim()).apply()
}
fun buildFallbackBindUrl(deviceUuid: String = getOrCreateDeviceUuid()): String {
val normalized = deviceUuid.trim().lowercase(Locale.US)
return DashScopeConfig.MONITOR_BASE_URL.trimEnd('/') +
"/bind?deviceUuid=" + Uri.encode(normalized)
}
companion object {
private const val PREFS_NAME = "device_identity_store"
private const val KEY_DEVICE_UUID = "device_uuid"
private const val KEY_BIND_URL = "bind_url"
private const val KEY_DEVICE_TOKEN = "device_token"
}
}
@@ -0,0 +1,138 @@
package ai.guiji.duix.test.service
import android.content.Context
import android.net.Uri
import androidx.documentfile.provider.DocumentFile
import java.io.File
import java.util.Locale
object DocumentTreeAudioStore {
data class AudioEntry(
val uri: Uri,
val displayName: String,
val relativePath: String,
)
data class ScanResult(
val rootName: String,
val files: List<AudioEntry>,
)
fun scanAudioFiles(context: Context, treeUri: Uri): ScanResult {
val root = DocumentFile.fromTreeUri(context, treeUri)
?: throw IllegalArgumentException("无法读取所选文件夹")
if (!root.isDirectory) {
throw IllegalArgumentException("所选内容不是文件夹")
}
val files = mutableListOf<AudioEntry>()
traverse(root, "", files)
return ScanResult(
rootName = root.name ?: "selected-folder",
files = files.sortedBy { it.relativePath.lowercase(Locale.US) },
)
}
fun writeVideoResult(
context: Context,
treeUri: Uri,
outputRootName: String,
sourceRelativePath: String,
localFile: File,
): Uri {
val root = DocumentFile.fromTreeUri(context, treeUri)
?: throw IllegalArgumentException("无法写入所选文件夹")
if (!root.isDirectory) {
throw IllegalArgumentException("所选内容不是文件夹")
}
var currentDir = findOrCreateDirectory(root, outputRootName)
val normalizedPath = sourceRelativePath.replace('\\', '/')
val pathSegments = normalizedPath.split('/').filter { it.isNotBlank() }
if (pathSegments.isEmpty()) {
throw IllegalArgumentException("无效的源文件路径")
}
for (segment in pathSegments.dropLast(1)) {
currentDir = findOrCreateDirectory(currentDir, segment)
}
val outputFileName = replaceExtension(pathSegments.last(), "mp4")
currentDir.findFile(outputFileName)?.delete()
val outputDocument = currentDir.createFile(VIDEO_MIME_TYPE, outputFileName)
?: throw IllegalStateException("无法创建输出文件")
localFile.inputStream().use { input ->
context.contentResolver.openOutputStream(outputDocument.uri, "w")?.use { output ->
input.copyTo(output)
} ?: throw IllegalStateException("无法打开输出文件")
}
return outputDocument.uri
}
fun buildOutputRelativePath(outputRootName: String, sourceRelativePath: String): String {
val normalizedPath = sourceRelativePath.replace('\\', '/')
return outputRootName + "/" + replaceExtension(normalizedPath, "mp4")
}
private fun traverse(directory: DocumentFile, parentPath: String, output: MutableList<AudioEntry>) {
val children = directory.listFiles().sortedBy { it.name?.lowercase(Locale.US) ?: "" }
for (child in children) {
val childName = child.name ?: continue
val childRelativePath = if (parentPath.isEmpty()) childName else "$parentPath/$childName"
when {
child.isDirectory -> traverse(child, childRelativePath, output)
child.isFile && isAudioFile(child) -> {
output += AudioEntry(
uri = child.uri,
displayName = childName,
relativePath = childRelativePath,
)
}
}
}
}
private fun isAudioFile(document: DocumentFile): Boolean {
val mimeType = document.type?.lowercase(Locale.US).orEmpty()
if (mimeType.startsWith("audio/")) {
return true
}
val name = document.name?.lowercase(Locale.US).orEmpty()
return AUDIO_EXTENSIONS.any { extension -> name.endsWith(".$extension") }
}
private fun findOrCreateDirectory(parent: DocumentFile, name: String): DocumentFile {
parent.findFile(name)?.let { existing ->
if (existing.isDirectory) {
return existing
}
existing.delete()
}
return parent.createDirectory(name)
?: throw IllegalStateException("无法创建目录: $name")
}
private fun replaceExtension(name: String, extension: String): String {
val dotIndex = name.lastIndexOf('.')
val baseName = if (dotIndex > 0) name.substring(0, dotIndex) else name
return "$baseName.$extension"
}
private val AUDIO_EXTENSIONS = setOf(
"aac",
"amr",
"flac",
"m4a",
"mp3",
"ogg",
"opus",
"wav",
"webm",
"wma",
"3gp",
)
private const val VIDEO_MIME_TYPE = "video/mp4"
}
@@ -0,0 +1,328 @@
package ai.guiji.duix.test.service
import ai.guiji.duix.test.App
import ai.guiji.duix.test.BuildConfig
import android.content.Context
import android.os.Build
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONArray
import org.json.JSONObject
class FamilyBridgeService(context: Context) {
private val appContext = context.applicationContext
private val identityStore = DeviceIdentityStore(appContext)
private val jsonMediaType = "application/json; charset=utf-8".toMediaType()
fun getDeviceUuid(): String = identityStore.getOrCreateDeviceUuid()
fun getBindUrl(): String = identityStore.getBindUrl()
fun registerCurrentDevice(): DeviceRegistrationResult {
val payload = JSONObject().apply {
put("deviceUuid", getDeviceUuid())
put("displayName", buildDisplayName())
put("appVersion", BuildConfig.VERSION_NAME)
identityStore.getDeviceToken()?.let { put("deviceToken", it) }
}
val response = postJson("/api/device/register", payload, requiresAuth = false)
val bindUrl = response.optString("bindUrl").ifBlank {
identityStore.buildFallbackBindUrl(getDeviceUuid())
}
identityStore.saveBindUrl(bindUrl)
response.optString("deviceToken").takeIf { it.isNotBlank() }?.let(identityStore::saveDeviceToken)
return DeviceRegistrationResult(
deviceUuid = response.optString("deviceUuid", getDeviceUuid()),
bindUrl = bindUrl,
)
}
fun fetchFamilyMessages(take: Int = 5): FamilyMessageFetchResult {
val normalizedTake = take.coerceIn(1, 8)
val url = DashScopeConfig.MONITOR_BASE_URL.trimEnd('/') +
"/api/device/messages?deviceUuid=${getDeviceUuid()}&take=$normalizedTake"
val response = executeJsonRequest(
Request.Builder()
.url(url)
.addDeviceAuthorization()
.get()
.build()
)
val messagesJson = response.optJSONArray("messages") ?: JSONArray()
val messages = buildList {
for (index in 0 until messagesJson.length()) {
val item = messagesJson.optJSONObject(index) ?: continue
add(
FamilyMessageItem(
id = item.optString("id"),
content = item.optString("content"),
importance = item.optString("importance"),
createdAt = item.optString("createdAt"),
status = item.optString("status"),
deliveredAt = item.optString("deliveredAt").ifBlank { null },
readAt = item.optString("readAt").ifBlank { null },
)
)
}
}
return FamilyMessageFetchResult(
summaryText = response.optString("summaryText"),
unreadCount = response.optInt("unreadCount"),
messages = messages,
)
}
fun markFamilyMessagesRead(messageIds: List<String>) {
if (messageIds.isEmpty()) {
return
}
val payload = JSONObject().apply {
put("deviceUuid", getDeviceUuid())
put("messageIds", JSONArray(messageIds))
}
postJson("/api/device/messages/read", payload)
}
fun prepareConversationPrompt(take: Int = 3): ConversationPromptInjection? {
val result = fetchFamilyMessages(take)
val unreadMessages = result.messages
.filter { it.readAt.isNullOrBlank() }
.take(take.coerceIn(1, 5))
val agenda = runCatching { fetchTodayAgenda() }.getOrNull()
if (unreadMessages.isEmpty() && agenda == null) {
return null
}
return ConversationPromptInjection(
messageIds = unreadMessages.map { it.id },
promptText = buildConversationPrompt(unreadMessages, agenda),
)
}
fun fetchTodayAgenda(): TodayAgenda? {
val url = DashScopeConfig.MONITOR_BASE_URL.trimEnd('/') + "/api/device/agenda?deviceUuid=${getDeviceUuid()}"
val response = executeJsonRequest(Request.Builder().url(url).addDeviceAuthorization().get().build())
val doses = response.optJSONArray("doses") ?: JSONArray()
val checkIns = response.optJSONArray("checkIns") ?: JSONArray()
val posts = response.optJSONArray("posts") ?: JSONArray()
val memos = response.optJSONArray("memos") ?: JSONArray()
if (doses.length() == 0 && checkIns.length() == 0 && posts.length() == 0 && memos.length() == 0) return null
return TodayAgenda(response.optString("circleName"), doses, checkIns, posts, memos)
}
fun updateMedication(doseId: String, status: String, note: String? = null, snoozeMinutes: Int = 10): JSONObject {
return postJson("/api/device/events", JSONObject().apply {
put("deviceUuid", getDeviceUuid()); put("type", "MEDICATION"); put("doseId", doseId); put("status", status)
if (!note.isNullOrBlank()) put("note", note)
put("snoozeMinutes", snoozeMinutes)
})
}
fun respondCheckIn(checkInId: String, status: String, responseText: String? = null): JSONObject {
return postJson("/api/device/events", JSONObject().apply {
put("deviceUuid", getDeviceUuid()); put("type", "CHECK_IN"); put("checkInId", checkInId); put("status", status)
if (!responseText.isNullOrBlank()) put("responseText", responseText)
})
}
fun reportSafetyAlert(category: String, title: String, summary: String, level: String, contextText: String? = null): JSONObject {
return postJson("/api/device/alerts", JSONObject().apply {
put("deviceUuid", getDeviceUuid()); put("category", category); put("title", title); put("summary", summary); put("level", level)
if (!contextText.isNullOrBlank()) put("contextText", contextText)
})
}
fun acknowledgeFamilyMessages(messageIds: List<String>) {
markFamilyMessagesRead(messageIds)
}
fun recordUsageEvent(eventType: String, detailJson: JSONObject? = null) {
val payload = JSONObject().apply {
put("deviceUuid", getDeviceUuid())
put("eventType", eventType)
if (detailJson != null) {
put("detailJson", detailJson.toString())
}
}
postJson("/api/device/usage", payload)
}
fun recordToolCall(
toolName: String,
arguments: JSONObject,
outputText: String,
status: String,
callId: String? = null,
sessionId: String? = null,
) {
val payload = JSONObject().apply {
put("deviceUuid", getDeviceUuid())
put("toolName", toolName)
put("arguments", arguments)
put("outputText", outputText)
put("status", status)
if (!callId.isNullOrBlank()) {
put("callId", callId)
}
if (!sessionId.isNullOrBlank()) {
put("sessionId", sessionId)
}
}
postJson("/api/device/tool-call", payload)
}
fun uploadConversationTurns(sessionId: String?, turns: List<ConversationTurnPayload>) {
if (turns.isEmpty()) {
return
}
val payload = JSONObject().apply {
put("deviceUuid", getDeviceUuid())
if (!sessionId.isNullOrBlank()) {
put("sessionId", sessionId)
}
put(
"turns",
JSONArray().apply {
turns.forEach { turn ->
put(
JSONObject().apply {
put("role", turn.role)
put("content", turn.content)
if (!turn.metaJson.isNullOrBlank()) {
put("metaJson", turn.metaJson)
}
}
)
}
}
)
}
postJson("/api/device/conversations", payload)
}
private fun postJson(path: String, payload: JSONObject, requiresAuth: Boolean = true): JSONObject {
val builder = Request.Builder().url(DashScopeConfig.MONITOR_BASE_URL.trimEnd('/') + path)
if (requiresAuth) builder.addDeviceAuthorization()
return executeJsonRequest(
builder
.post(payload.toString().toRequestBody(jsonMediaType))
.build()
)
}
private fun Request.Builder.addDeviceAuthorization(): Request.Builder {
val token = identityStore.getDeviceToken() ?: throw IllegalStateException("设备尚未完成安全注册")
return header("Authorization", "Bearer $token")
}
private fun executeJsonRequest(request: Request): JSONObject {
App.getOkHttpClient().newCall(request).execute().use { response ->
val responseText = response.body?.string().orEmpty()
val json = responseText.takeIf { it.isNotBlank() }?.let(::JSONObject) ?: JSONObject()
if (!response.isSuccessful) {
val message = json.optString("error").ifBlank {
response.message.ifBlank { "请求失败" }
}
throw IllegalStateException(message)
}
return json
}
}
private fun buildDisplayName(): String {
val manufacturer = Build.MANUFACTURER?.trim().orEmpty()
val model = Build.MODEL?.trim().orEmpty()
return listOf(manufacturer, model)
.filter { it.isNotBlank() }
.distinct()
.joinToString(" ")
.ifBlank { "长辈陪伴终端" }
}
private fun buildConversationPrompt(messages: List<FamilyMessageItem>, agenda: TodayAgenda?): String {
return buildString {
append("当前开始一轮新对话。请自然处理下列家庭问候和今日照护事项,不要提系统提示、后台、注入、工具或接口。")
messages.forEachIndexed { index, message ->
append("\n")
append(index + 1)
append(". ")
val importanceLabel = when (message.importance.trim().uppercase()) {
"HIGH" -> "希望尽快看到:"
"URGENT" -> "需要尽快联系:"
"LOW" -> "温和提醒:"
else -> "日常问候:"
}
append(importanceLabel)
append(message.content)
}
agenda?.let {
for (index in 0 until it.posts.length()) { val item = it.posts.optJSONObject(index) ?: continue; append("\n家庭动态:${item.optString("authorName")}说:${item.optString("content")}") }
for (index in 0 until it.doses.length()) { val item = it.doses.optJSONObject(index) ?: continue; append("\n待服药[编号${item.optString("id")}]:${item.optString("name")},${item.optString("dosage")},时间${item.optString("scheduledFor")}") }
for (index in 0 until it.checkIns.length()) { val item = it.checkIns.optJSONObject(index) ?: continue; append("\n待报平安[编号${item.optString("id")}]:${item.optString("prompt", "家里人想知道您今天是否安好")}") }
}
append("\n优先温和提醒已经到时间的用药和报平安请求;关键操作必须调用对应工具确认,不能只口头答应。")
}
}
data class DeviceRegistrationResult(
val deviceUuid: String,
val bindUrl: String,
)
data class FamilyMessageItem(
val id: String,
val content: String,
val importance: String,
val createdAt: String,
val status: String,
val deliveredAt: String?,
val readAt: String?,
)
data class FamilyMessageFetchResult(
val summaryText: String,
val unreadCount: Int,
val messages: List<FamilyMessageItem>,
)
data class ConversationPromptInjection(
val messageIds: List<String>,
val promptText: String,
)
data class TodayAgenda(
val circleName: String,
val doses: JSONArray,
val checkIns: JSONArray,
val posts: JSONArray,
val memos: JSONArray,
)
data class ConversationTurnPayload(
val role: String,
val content: String,
val metaJson: String? = null,
)
companion object {
const val EVENT_APP_OPEN = "APP_OPEN"
const val EVENT_SETTINGS_OPENED = "SETTINGS_OPENED"
const val EVENT_AI_SESSION_STARTED = "AI_SESSION_STARTED"
const val EVENT_AI_SESSION_ENDED = "AI_SESSION_ENDED"
const val EVENT_CAMERA_ENABLED = "CAMERA_ENABLED"
const val EVENT_CAMERA_DISABLED = "CAMERA_DISABLED"
const val ROLE_USER = "USER"
const val ROLE_ASSISTANT = "ASSISTANT"
const val ROLE_TOOL = "TOOL"
const val STATUS_SUCCESS = "SUCCESS"
const val STATUS_FAILURE = "FAILURE"
}
}
@@ -0,0 +1,130 @@
package ai.guiji.duix.test.service
import android.net.Uri
import java.io.File
import java.util.Locale
object LocalFileAudioStore {
data class AudioEntry(
val uri: Uri,
val displayName: String,
val relativePath: String,
)
data class ScanResult(
val rootDir: File,
val rootName: String,
val files: List<AudioEntry>,
)
data class WriteResult(
val file: File,
val skipped: Boolean,
)
fun scanAudioFiles(rootDir: File): ScanResult {
val normalizedRoot = rootDir.canonicalFile
if (!normalizedRoot.exists() || !normalizedRoot.isDirectory) {
throw IllegalArgumentException("无法读取所选文件夹")
}
val files = mutableListOf<AudioEntry>()
traverse(normalizedRoot, "", files)
return ScanResult(
rootDir = normalizedRoot,
rootName = normalizedRoot.name.ifBlank { normalizedRoot.absolutePath },
files = files.sortedBy { it.relativePath.lowercase(Locale.US) },
)
}
fun writeVideoResult(
rootDir: File,
outputRootName: String,
sourceRelativePath: String,
localFile: File,
): WriteResult {
val normalizedRoot = rootDir.canonicalFile
if (!normalizedRoot.exists() || !normalizedRoot.isDirectory) {
throw IllegalArgumentException("无法写入所选文件夹")
}
val outputFile = resolveOutputFile(normalizedRoot, outputRootName, sourceRelativePath)
if (outputFile.exists()) {
return WriteResult(outputFile, skipped = true)
}
outputFile.parentFile?.let { parent ->
if (!parent.exists() && !parent.mkdirs()) {
throw IllegalStateException("无法创建输出目录")
}
}
return runCatching {
localFile.copyTo(outputFile, overwrite = false)
WriteResult(outputFile, skipped = false)
}.getOrElse { error ->
if (outputFile.exists()) {
WriteResult(outputFile, skipped = true)
} else {
throw error
}
}
}
fun resolveOutputFile(
rootDir: File,
outputRootName: String,
sourceRelativePath: String,
): File {
val normalizedRoot = rootDir.canonicalFile
return File(normalizedRoot, buildOutputRelativePath(outputRootName, sourceRelativePath))
}
fun buildOutputRelativePath(outputRootName: String, sourceRelativePath: String): String {
val normalizedPath = sourceRelativePath.replace('\\', '/')
return outputRootName + "/" + replaceExtension(normalizedPath, "mp4")
}
private fun traverse(directory: File, parentPath: String, output: MutableList<AudioEntry>) {
val children = directory.listFiles()
?.sortedBy { it.name.lowercase(Locale.US) }
.orEmpty()
for (child in children) {
val childRelativePath = if (parentPath.isEmpty()) child.name else "$parentPath/${child.name}"
when {
child.isDirectory -> traverse(child, childRelativePath, output)
child.isFile && isAudioFile(child) -> {
output += AudioEntry(
uri = Uri.fromFile(child),
displayName = child.name,
relativePath = childRelativePath,
)
}
}
}
}
private fun isAudioFile(file: File): Boolean {
val extension = file.extension.lowercase(Locale.US)
return extension in AUDIO_EXTENSIONS
}
private fun replaceExtension(name: String, extension: String): String {
val dotIndex = name.lastIndexOf('.')
val baseName = if (dotIndex > 0) name.substring(0, dotIndex) else name
return "$baseName.$extension"
}
private val AUDIO_EXTENSIONS = setOf(
"aac",
"amr",
"flac",
"m4a",
"mp3",
"ogg",
"opus",
"wav",
"webm",
"wma",
"3gp",
)
}
@@ -0,0 +1,194 @@
package ai.guiji.duix.test.service
import ai.guiji.duix.test.App
import android.content.Context
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONArray
import org.json.JSONObject
import java.util.concurrent.TimeUnit
class PsychologyKnowledgeAgentService(
context: Context,
) {
private val appContext = context.applicationContext
private val client = App.getOkHttpClient().newBuilder()
.connectTimeout(30, TimeUnit.SECONDS)
.writeTimeout(120, TimeUnit.SECONDS)
.readTimeout(180, TimeUnit.SECONDS)
.callTimeout(180, TimeUnit.SECONDS)
.build()
private val knowledgeText: String by lazy {
appContext.assets.open(DashScopeConfig.PSYCHOLOGY_KNOWLEDGE_ASSET_PATH)
.bufferedReader(Charsets.UTF_8)
.use { reader ->
reader.readText()
.replace('\u000c', '\n')
.replace("\u0000", "")
}
}
fun consultKnowledge(
question: String,
focus: String? = null,
): KnowledgeAnswer {
val normalizedQuestion = question.trim()
require(normalizedQuestion.isNotEmpty()) { "问题不能为空" }
val request = Request.Builder()
.url(DashScopeConfig.MULTIMODAL_GENERATION_URL)
.addHeader("Authorization", "Bearer " + DashScopeConfig.DEFAULT_API_KEY)
.addHeader("Content-Type", "application/json")
.post(
buildRequestPayload(
normalizedQuestion,
focus?.trim().takeIf { !it.isNullOrEmpty() },
).toString().toRequestBody(JSON_MEDIA_TYPE)
)
.build()
client.newCall(request).execute().use { response ->
val body = response.body?.string().orEmpty()
if (!response.isSuccessful) {
throw IllegalStateException(parseErrorMessage(body).ifEmpty { "心理学知识子代理调用失败" })
}
val json = JSONObject(body)
val message = json.optJSONObject("output")
?.optJSONArray("choices")
?.optJSONObject(0)
?.optJSONObject("message")
val content = extractMessageContent(message)
if (content.isBlank()) {
throw IllegalStateException("心理学知识子代理没有返回内容")
}
val usage = json.optJSONObject("usage")
return KnowledgeAnswer(
outputText = content.trim(),
model = DashScopeConfig.PSYCHOLOGY_SUBAGENT_MODEL,
requestId = json.optString("request_id").ifBlank { null },
inputTokens = usage?.optInt("input_tokens") ?: 0,
outputTokens = usage?.optInt("output_tokens") ?: 0,
)
}
}
private fun buildRequestPayload(
question: String,
focus: String?,
): JSONObject {
return JSONObject().apply {
put("model", DashScopeConfig.PSYCHOLOGY_SUBAGENT_MODEL)
put(
"input",
JSONObject().apply {
put(
"messages",
JSONArray()
.put(
JSONObject().apply {
put("role", "system")
put("content", buildSystemPrompt())
}
)
.put(
JSONObject().apply {
put("role", "user")
put("content", buildUserPrompt(question, focus))
}
)
)
}
)
put(
"parameters",
JSONObject().apply {
put("result_format", "message")
put("enable_thinking", false)
put("temperature", 0.2)
put("max_tokens", 900)
}
)
}
}
private fun buildSystemPrompt(): String {
return buildString {
append(knowledgeText)
append("以上是完整知识库全文\n")
append("你是一个面向老年陪伴场景的权威照护知识子代理。")
append("你只能依据系统里提供的 WHO ICOPE 2024 全文回答,不要编造,不要输出与原文冲突的结论。")
append("如果知识库未提供足够直接依据,要明确说明。")
append("请使用简体中文输出,提供权威心理学指示和说明。不需要包括生活化语言,你只是一个查询专家,不负责指导。直接输出无格式纯文本,答案简练不超过100字。")
}
}
private fun buildUserPrompt(
question: String,
focus: String?,
): String {
return buildString {
append("请基于上面的 WHO ICOPE 2024 全文,回答下面这个需要查询的问题。\n")
append("核心问题:")
append(question)
if (!focus.isNullOrBlank()) {
append("\n特别关注:")
append(focus)
}
append("\n请避免空泛安慰,优先给出可落地的权威说明。")
}
}
private fun extractMessageContent(message: JSONObject?): String {
if (message == null) {
return ""
}
val content = message.opt("content")
return when (content) {
is String -> content
is JSONArray -> buildString {
for (index in 0 until content.length()) {
val part = content.opt(index)
val text = when (part) {
is JSONObject -> part.optString("text")
is String -> part
else -> ""
}.trim()
if (text.isNotEmpty()) {
if (isNotEmpty()) {
append('\n')
}
append(text)
}
}
}
else -> ""
}
}
private fun parseErrorMessage(body: String): String {
return try {
val json = JSONObject(body)
json.optJSONObject("error")?.optString("message").orEmpty()
.ifBlank { json.optString("message") }
} catch (_: Exception) {
""
}
}
data class KnowledgeAnswer(
val outputText: String,
val model: String,
val requestId: String?,
val inputTokens: Int,
val outputTokens: Int,
)
companion object {
private val JSON_MEDIA_TYPE = "application/json; charset=utf-8".toMediaType()
}
}
@@ -13,6 +13,9 @@ import ai.guiji.duix.test.databinding.ActivityCallBinding
import ai.guiji.duix.test.realtime.OmniRealtimeClient
import ai.guiji.duix.test.realtime.QwenTtsRealtimeClient
import ai.guiji.duix.test.service.DashScopeConfig
import ai.guiji.duix.test.service.DebugSettingsStore
import ai.guiji.duix.test.service.FamilyBridgeService
import ai.guiji.duix.test.service.PsychologyKnowledgeAgentService
import ai.guiji.duix.test.ui.adapter.MotionAdapter
import ai.guiji.duix.test.ui.dialog.AudioRecordDialog
import ai.guiji.duix.test.ui.widget.AudioSpectrumView
@@ -29,9 +32,12 @@ import android.view.View
import android.widget.CompoundButton
import android.widget.Toast
import com.bumptech.glide.Glide
import java.io.File
import org.json.JSONArray
import org.json.JSONObject
import java.io.FileInputStream
import java.io.FileOutputStream
import java.util.UUID
import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors
class CallActivity : BaseActivity() {
@@ -49,18 +55,22 @@ class CallActivity : BaseActivity() {
private fun applyMessage(msg: String){
if (debug){
runOnUiThread {
binding.tvDebug.visibility = View.VISIBLE
if (mMessage.length > 10000){
mMessage = ""
binding.scrollDebug.visibility = View.VISIBLE
if (mMessage.length > 60000){
mMessage = mMessage.takeLast(40000)
}
mMessage = "${StringUtils.dateToStringMS4()} $msg\n$mMessage"
mMessage += "${StringUtils.dateToStringMS4()} $msg\n"
binding.tvDebug.text = mMessage
binding.scrollDebug.post {
binding.scrollDebug.fullScroll(View.FOCUS_DOWN)
}
}
}
}
private lateinit var binding: ActivityCallBinding
private val debugSettingsStore by lazy { DebugSettingsStore(applicationContext) }
private var duix: DUIX? = null
private var mDUIXRender: DUIXRenderer? = null
private var mModelInfo: ModelInfo?=null // 加载的模型信息
@@ -75,10 +85,17 @@ class CallActivity : BaseActivity() {
private var assistantTextBuffer = StringBuilder()
private var hasStartedPushForResponse = false
private val enableWebSearch = true
private val enableToolCalling = true
private var isCloneMode = false
private var clonedVoice = ""
private var cloneTtsModel = DashScopeConfig.VOICE_CLONE_TTS_MODEL
private var ttsRealtimeClient: QwenTtsRealtimeClient? = null
private val familyBridgeService by lazy { FamilyBridgeService(applicationContext) }
private val psychologyKnowledgeAgentService by lazy { PsychologyKnowledgeAgentService(applicationContext) }
private val bridgeExecutor: ExecutorService = Executors.newSingleThreadExecutor()
private var currentSessionId: String? = null
private var pendingConversationPrompt: FamilyBridgeService.ConversationPromptInjection? = null
private var waitingForInitialFamilyAnnouncement = false
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@@ -91,11 +108,15 @@ class CallActivity : BaseActivity() {
modelUrl = intent.getStringExtra("modelUrl") ?: ""
voice = intent.getStringExtra("voice") ?: "Ethan"
debug = intent.getBooleanExtra("debug", false)
debug = intent.getBooleanExtra("debug", debugSettingsStore.isChatDebugEnabled())
isCloneMode = intent.getBooleanExtra("cloneMode", false)
clonedVoice = intent.getStringExtra("clonedVoice") ?: ""
cloneTtsModel = intent.getStringExtra("cloneTtsModel") ?: DashScopeConfig.VOICE_CLONE_TTS_MODEL
enqueueBridgeTask("设备注册失败") {
familyBridgeService.registerCurrentDevice()
}
Glide.with(mContext).load("file:///android_asset/bg/bg1.png").into(binding.ivBg)
binding.glTextureView.setEGLContextClientVersion(GL_CONTEXT_VERSION)
@@ -120,16 +141,6 @@ class CallActivity : BaseActivity() {
requestPermission(arrayOf(Manifest.permission.RECORD_AUDIO), 1)
}
binding.btnPlayPCM.setOnClickListener {
applyMessage("start play pcm")
playPCMStream()
}
binding.btnPlayWAV.setOnClickListener {
applyMessage("start play wav")
playWAVFile()
}
binding.btnRandomMotion.setOnClickListener {
applyMessage("start random motion")
duix?.startRandomMotion(true)
@@ -222,16 +233,9 @@ class CallActivity : BaseActivity() {
applyMessage("init ok")
runOnUiThread {
binding.btnRecord.isEnabled = true
binding.btnPlayPCM.isEnabled = true
binding.btnPlayWAV.isEnabled = true
binding.switchMute.isEnabled = true
binding.btnAIConversation.isEnabled = true
if (debug) {
binding.btnPlayPCM.visibility = View.VISIBLE
binding.btnPlayWAV.visibility = View.VISIBLE
}
mModelInfo?.let { modelInfo ->
if (modelInfo.motionRegions.isNotEmpty()) {
val names = ArrayList<String>()
@@ -257,13 +261,132 @@ class CallActivity : BaseActivity() {
}
}
private fun buildRealtimeSessionOptions(): OmniRealtimeClient.SessionOptions {
private fun buildRealtimeSessionOptions(
conversationPrompt: FamilyBridgeService.ConversationPromptInjection? = null,
): OmniRealtimeClient.SessionOptions {
val outputMode = if (isCloneMode) {
OmniRealtimeClient.SessionOptions.OutputMode.TEXT_ONLY
} else {
OmniRealtimeClient.SessionOptions.OutputMode.AUDIO_AND_TEXT
}
return OmniRealtimeClient.SessionOptions(voice, enableWebSearch, outputMode)
return OmniRealtimeClient.SessionOptions(
voice,
enableWebSearch,
outputMode,
buildRealtimeInstructions(conversationPrompt),
buildRealtimeTools(),
)
}
private fun buildRealtimeInstructions(
conversationPrompt: FamilyBridgeService.ConversationPromptInjection? = null,
): String {
if (!enableToolCalling) {
return buildString {
append(OmniRealtimeClient.getDefaultInstructions())
if (conversationPrompt != null) {
append("\n\n")
append(conversationPrompt.promptText)
}
}
}
return buildString {
append(OmniRealtimeClient.getDefaultInstructions())
append("\n\n")
append("当用户需要关于记忆下降、情绪低落、焦虑失眠、孤独感、认知照护、老年照护路径或家属陪伴方式的权威依据时,优先调用 consult_psychology_knowledge_base 工具,不要凭经验给出确定性的专业判断。\n")
append("调用 consult_psychology_knowledge_base 时,把老人当前最核心的问题写入 question;如果你想限定查询角度,例如陪伴建议、风险判断、是否需要就医,就写入 focus。\n")
append("如果只是日常闲聊或普通安慰,不必调用 consult_psychology_knowledge_base 工具。\n")
append("当用户明确要给儿子、女儿、老伴、家里人或亲友带话、留言、报平安时,必须调用 leave_message_for_family 工具,不要假装已经转达。\n")
append("当用户问家里人有没有留言、想听家人说了什么、想看看新消息时,必须调用 fetch_family_messages 工具,不要自己编造留言。\n")
append("老人确认服药、要求稍后提醒、报告药物问题时,必须调用对应的用药工具;不要自行修改剂量或建议停药。\n")
append("老人回应报平安请求时调用 respond_to_check_in。老人明确求助、说身体明显不适、吃错药或提到自伤他伤时,先简短确认,再调用 request_family_help;说明这是通知家人关注,不是医学诊断。\n")
append("工具执行结果返回后,再用温和、简短、生活化的中文向老人说明情况。")
if (conversationPrompt != null) {
append("\n\n")
append(conversationPrompt.promptText)
}
}
}
private fun buildRealtimeTools(): List<OmniRealtimeClient.ToolDefinition> {
if (!enableToolCalling) {
return emptyList()
}
val leaveMessageParameters = JSONObject().apply {
put("type", "object")
put("properties", JSONObject().apply {
put("recipientRelation", JSONObject().apply {
put("type", "string")
put("description", "留言对象与老人的关系,例如儿子、女儿、老伴、孙女。")
})
put("message", JSONObject().apply {
put("type", "string")
put("description", "老人想让家里人知道的完整留言内容。")
})
put("importance", JSONObject().apply {
put("type", "string")
put("description", "留言紧急程度。")
put("enum", JSONArray().put("LOW").put("NORMAL").put("HIGH").put("URGENT"))
})
})
put("required", JSONArray().put("message"))
}
val fetchFamilyMessagesParameters = JSONObject().apply {
put("type", "object")
put("properties", JSONObject().apply {
put("count", JSONObject().apply {
put("type", "integer")
put("description", "想读取几条家人留言,通常 1 到 5 条。")
})
})
}
val psychologyKnowledgeParameters = JSONObject().apply {
put("type", "object")
put("properties", JSONObject().apply {
put("question", JSONObject().apply {
put("type", "string")
put("description", "需要从权威知识库中查询的核心问题,应尽量包含老人当前的真实困扰或具体疑问。")
})
put("focus", JSONObject().apply {
put("type", "string")
put("description", "希望子代理特别聚焦的角度,例如陪伴建议、风险判断、何时就医、家属沟通方式。")
})
})
put("required", JSONArray().put("question"))
}
fun parameters(vararg fields: Pair<String, String>, required: List<String> = fields.map { it.first }) = JSONObject().apply {
put("type", "object")
put("properties", JSONObject().apply { fields.forEach { (name, description) -> put(name, JSONObject().put("type", if (name == "minutes") "integer" else "string").put("description", description)) } })
put("required", JSONArray(required))
}
return listOf(
OmniRealtimeClient.ToolDefinition(
"consult_psychology_knowledge_base",
"当需要基于 WHO ICOPE 2024 等权威照护知识回答认知、情绪、睡眠、孤独、照护与家属支持问题时使用。当老人提出具体问题比如食欲不振时,必须使用此工具进行查询,而不是凭经验直接回答。",
psychologyKnowledgeParameters,
),
OmniRealtimeClient.ToolDefinition(
"leave_message_for_family",
"当老人明确要求给家里人带话、留言、报平安或请转告时使用。",
leaveMessageParameters,
),
OmniRealtimeClient.ToolDefinition(
"fetch_family_messages",
"当老人想听家人最新留言,或询问有没有新消息时使用。",
fetchFamilyMessagesParameters,
),
OmniRealtimeClient.ToolDefinition("fetch_today_medication_plan", "查询今天待处理的用药提醒。", parameters(required = emptyList())),
OmniRealtimeClient.ToolDefinition("confirm_medication_taken", "老人明确确认已经服药后使用。", parameters("doseId" to "提醒中的服药编号。")),
OmniRealtimeClient.ToolDefinition("snooze_medication_reminder", "老人要求稍后再提醒服药时使用。", parameters("doseId" to "提醒中的服药编号。", "minutes" to "延后分钟数,通常为10。")),
OmniRealtimeClient.ToolDefinition("report_medication_problem", "老人说药找不到、吃错、不确定或服药后不舒服时使用。", parameters("doseId" to "服药编号,不确定时传unknown。", "problem" to "老人描述的完整问题。", "severity" to "ORANGE或RED。")),
OmniRealtimeClient.ToolDefinition("respond_to_check_in", "老人回应家属发起的报平安请求时使用。", parameters("checkInId" to "报平安请求编号。", "status" to "SAFE、NEEDS_CONTACT或DECLINED。", "responseText" to "老人想告诉家人的回应。")),
OmniRealtimeClient.ToolDefinition("request_family_help", "老人明确求助或确认存在需要家人立即关注的风险线索时使用。", parameters("category" to "风险类别。", "summary" to "经过老人确认的情况摘要。", "level" to "RED或ORANGE。")),
)
}
private fun isImageAppendError(message: String): Boolean {
@@ -284,46 +407,7 @@ class CallActivity : BaseActivity() {
super.onDestroy()
stopAIConversation()
duix?.release()
}
private fun playPCMStream(){
val thread = Thread {
duix?.startPush()
val inputStream = assets.open("pcm/2.pcm")
val buffer = ByteArray(320)
var length: Int
while (inputStream.read(buffer).also { length = it } > 0){
val data = buffer.copyOfRange(0, length)
duix?.pushPcm(data)
}
duix?.stopPush()
inputStream.close()
}
thread.start()
}
private fun playWAVFile(){
val thread = Thread {
val wavName = "1.wav"
val wavFile = File(mContext.externalCacheDir, wavName)
if (!wavFile.exists()){
// copy assets -> sd card
val inputStream = assets.open("wav/$wavName")
if (!mContext.externalCacheDir!!.exists()){
mContext.externalCacheDir!!.mkdirs()
}
val out = FileOutputStream(wavFile)
val buffer = ByteArray(1024)
var length: Int
while ((inputStream.read(buffer).also { length = it }) > 0) {
out.write(buffer, 0, length)
}
out.close()
inputStream.close()
}
duix?.playAudio(wavFile.absolutePath)
}
thread.start()
bridgeExecutor.shutdownNow()
}
override fun permissionsGet(get: Boolean, code: Int) {
@@ -359,30 +443,66 @@ class CallActivity : BaseActivity() {
binding.audioSpectrum.clear()
assistantTextBuffer.clear()
hasStartedPushForResponse = false
currentSessionId = UUID.randomUUID().toString()
pendingConversationPrompt = null
waitingForInitialFamilyAnnouncement = false
binding.btnAIConversation.setImageResource(R.drawable.ic_mic_off)
binding.btnAIConversation.setBackgroundResource(R.drawable.shape_fab_ai_active)
omniClient = OmniRealtimeClient(buildRealtimeSessionOptions(), object : OmniRealtimeClient.Callback {
isAIConversationActive = true
enqueueBridgeTask("家属留言预取失败") {
val initialConversationPrompt = runCatching {
familyBridgeService.prepareConversationPrompt(3)
}.onFailure { error ->
applyMessage("家属留言预取失败: ${error.message}")
}.getOrNull()
mainHandler.post {
if (!isAIConversationActive || isFinishing) {
return@post
}
pendingConversationPrompt = initialConversationPrompt
waitingForInitialFamilyAnnouncement = initialConversationPrompt != null
omniClient = OmniRealtimeClient(
buildRealtimeSessionOptions(initialConversationPrompt),
object : OmniRealtimeClient.Callback {
override fun onConnected() {
mainHandler.post {
binding.tvSubtitle.text = getString(R.string.ai_call_connected)
binding.tvSubtitle.text = if (initialConversationPrompt != null) {
"我先跟您说说家里人的留言。"
} else {
getString(R.string.ai_call_connected)
}
binding.btnCameraToggle.visibility = if (isCloneMode) View.GONE else View.VISIBLE
binding.btnCameraToggle.setBackgroundResource(R.drawable.shape_fab_camera_off)
binding.btnCameraToggle.contentDescription = getString(R.string.camera_off)
isCameraEnabled = false
applyMessage("AI对话: 已连接, model=${OmniRealtimeClient.DEFAULT_MODEL}, search=on, cloneMode=$isCloneMode")
streamingRecorder = StreamingAudioRecorder(mContext, object : StreamingAudioRecorder.StreamingCallback {
override fun onAudioData(pcmData: ByteArray) {
omniClient?.appendAudio(pcmData)
mainHandler.post { binding.audioSpectrum.updateAudio(pcmData) }
applyMessage("AI对话: 已连接, model=${OmniRealtimeClient.DEFAULT_MODEL}, search=${if (enableWebSearch) "on" else "off"}, tools=${if (enableToolCalling) "on" else "off"}, cloneMode=$isCloneMode")
if (enableWebSearch && enableToolCalling) {
// applyMessage("AI对话: 当前配置请求 search=on 且 tools=on,但 Qwen Omni Realtime 协议规定两者不兼容,实际下发时将由服务端连接配置决定仅生效其中一项")
}
if (initialConversationPrompt != null) {
applyMessage("AI对话: 检测到家属留言,准备主动播报")
val requested = omniClient?.requestResponse() == true
if (!requested) {
applyMessage("AI对话: 主动播报触发失败,直接进入普通对话")
cancelInitialFamilyAnnouncementIfNeeded()
}
override fun onError(message: String) {
mainHandler.post {
Toast.makeText(mContext, message, Toast.LENGTH_SHORT).show()
}
}
})
streamingRecorder?.start()
} else {
startStreamingRecorderIfNeeded()
}
}
enqueueBridgeTask("AI会话启动记录失败") {
familyBridgeService.registerCurrentDevice()
val detail = JSONObject().apply {
put("sessionId", currentSessionId)
put("cloneMode", isCloneMode)
put("voice", voice)
}
familyBridgeService.recordUsageEvent(FamilyBridgeService.EVENT_AI_SESSION_STARTED, detail)
}
}
@@ -395,6 +515,9 @@ class CallActivity : BaseActivity() {
if (isImageAppendError(message)) {
stopCamera()
}
if (waitingForInitialFamilyAnnouncement) {
cancelInitialFamilyAnnouncementIfNeeded()
}
val friendlyMessage = toFriendlyErrorMessage(message)
Toast.makeText(mContext, friendlyMessage, Toast.LENGTH_SHORT).show()
binding.tvSubtitle.text = friendlyMessage
@@ -419,9 +542,11 @@ class CallActivity : BaseActivity() {
}
override fun onUserTranscript(transcript: String) {
applyMessage("用户语音转写: $transcript")
mainHandler.post {
binding.tvSubtitle.text = getString(R.string.ai_call_user_said, transcript)
}
uploadConversationTurnAsync(FamilyBridgeService.ROLE_USER, transcript)
}
override fun onAssistantTextDelta(delta: String) {
@@ -432,11 +557,31 @@ class CallActivity : BaseActivity() {
}
override fun onAssistantTranscriptDone(transcript: String) {
applyMessage("助手回复: $transcript")
mainHandler.post {
assistantTextBuffer.clear()
assistantTextBuffer.append(transcript)
binding.tvSubtitle.text = transcript
}
uploadConversationTurnAsync(FamilyBridgeService.ROLE_ASSISTANT, transcript)
}
override fun onToolCallRequested(toolName: String, callId: String, argumentsJson: String) {
mainHandler.post {
binding.tvSubtitle.text = when (toolName) {
"consult_psychology_knowledge_base" -> "我来帮您查一下更稳妥的照护资料。"
"leave_message_for_family" -> "我来帮您把这句话记给家里人。"
"fetch_family_messages" -> "我来帮您看看家里人的新留言。"
"fetch_today_medication_plan" -> "我来看看今天的用药安排。"
"confirm_medication_taken" -> "好的,我帮您把这次服药记下来。"
"snooze_medication_reminder" -> "好的,稍后我再提醒您。"
"report_medication_problem", "request_family_help" -> "我先确认一下,再通知家里人关注。"
"respond_to_check_in" -> "好的,我把您的平安回复告诉家里人。"
else -> getString(R.string.ai_call_thinking)
}
}
applyMessage("AI工具调用: $toolName, callId=$callId, arguments=$argumentsJson")
handleRealtimeToolCall(toolName, callId, argumentsJson)
}
override fun onAssistantAudioDelta(pcmData: ByteArray) {
@@ -475,15 +620,21 @@ class CallActivity : BaseActivity() {
hasStartedPushForResponse = false
}
assistantTextBuffer.clear()
if (!isCloneMode) {
completeInitialFamilyAnnouncementIfNeeded()
}
}
}
})
isAIConversationActive = true
omniClient?.connect()
omniClient?.connect()
}
}
}
private fun stopAIConversation() {
val endedSessionId = currentSessionId
currentSessionId = null
isAIConversationActive = false
stopCamera()
stopCloneSpeech()
@@ -494,6 +645,8 @@ class CallActivity : BaseActivity() {
duix?.stopAudio()
assistantTextBuffer.clear()
hasStartedPushForResponse = false
pendingConversationPrompt = null
waitingForInitialFamilyAnnouncement = false
mainHandler.post {
if (!isFinishing) {
@@ -507,6 +660,16 @@ class CallActivity : BaseActivity() {
applyMessage("AI对话: 已结束")
}
}
if (!endedSessionId.isNullOrBlank()) {
enqueueBridgeTask("AI会话结束记录失败") {
val detail = JSONObject().apply {
put("sessionId", endedSessionId)
put("cloneMode", isCloneMode)
}
familyBridgeService.recordUsageEvent(FamilyBridgeService.EVENT_AI_SESSION_ENDED, detail)
}
}
}
private fun speakWithClonedVoice(text: String) {
@@ -556,6 +719,9 @@ class CallActivity : BaseActivity() {
hasStartedPushForResponse = false
}
ttsRealtimeClient = null
mainHandler.post {
completeInitialFamilyAnnouncementIfNeeded()
}
}
override fun onError(message: String) {
@@ -602,6 +768,11 @@ class CallActivity : BaseActivity() {
cameraFrameCapture?.start()
binding.btnCameraToggle.setBackgroundResource(R.drawable.shape_fab_camera_on)
binding.btnCameraToggle.contentDescription = getString(R.string.camera_on)
enqueueBridgeTask("镜头开启记录失败") {
val detail = JSONObject()
currentSessionId?.let { detail.put("sessionId", it) }
familyBridgeService.recordUsageEvent(FamilyBridgeService.EVENT_CAMERA_ENABLED, detail)
}
} else {
stopCamera()
binding.btnCameraToggle.setBackgroundResource(R.drawable.shape_fab_camera_off)
@@ -610,12 +781,338 @@ class CallActivity : BaseActivity() {
}
private fun stopCamera() {
val wasEnabled = isCameraEnabled || cameraFrameCapture != null
cameraFrameCapture?.stop()
cameraFrameCapture = null
isCameraEnabled = false
binding.cameraPreview.visibility = View.GONE
if (wasEnabled) {
enqueueBridgeTask("镜头关闭记录失败") {
val detail = JSONObject()
currentSessionId?.let { detail.put("sessionId", it) }
familyBridgeService.recordUsageEvent(FamilyBridgeService.EVENT_CAMERA_DISABLED, detail)
}
}
}
private fun enqueueBridgeTask(label: String, task: () -> Unit) {
if (bridgeExecutor.isShutdown) {
return
}
bridgeExecutor.execute {
try {
task()
} catch (throwable: Throwable) {
applyMessage("$label: ${throwable.message}")
}
}
}
private fun startStreamingRecorderIfNeeded() {
if (!isAIConversationActive || streamingRecorder != null) {
return
}
streamingRecorder = StreamingAudioRecorder(mContext, object : StreamingAudioRecorder.StreamingCallback {
override fun onAudioData(pcmData: ByteArray) {
omniClient?.appendAudio(pcmData)
mainHandler.post { binding.audioSpectrum.updateAudio(pcmData) }
}
override fun onError(message: String) {
mainHandler.post {
Toast.makeText(mContext, message, Toast.LENGTH_SHORT).show()
}
}
})
streamingRecorder?.start()
}
private fun completeInitialFamilyAnnouncementIfNeeded() {
if (!waitingForInitialFamilyAnnouncement) {
return
}
val prompt = pendingConversationPrompt
pendingConversationPrompt = null
waitingForInitialFamilyAnnouncement = false
if (prompt != null && prompt.messageIds.isNotEmpty()) {
enqueueBridgeTask("家属留言已读回写失败") {
familyBridgeService.acknowledgeFamilyMessages(prompt.messageIds)
}
}
startStreamingRecorderIfNeeded()
}
private fun cancelInitialFamilyAnnouncementIfNeeded() {
if (!waitingForInitialFamilyAnnouncement) {
return
}
pendingConversationPrompt = null
waitingForInitialFamilyAnnouncement = false
startStreamingRecorderIfNeeded()
}
private fun uploadConversationTurnAsync(role: String, content: String, metaJson: JSONObject? = null) {
enqueueBridgeTask("对话记录上传失败") {
uploadConversationTurnInternal(role, content, metaJson)
}
}
private fun uploadConversationTurnInternal(role: String, content: String, metaJson: JSONObject? = null) {
val sessionId = currentSessionId
val normalizedContent = content.trim()
if (sessionId.isNullOrBlank() || normalizedContent.isEmpty()) {
return
}
familyBridgeService.uploadConversationTurns(
sessionId,
listOf(
FamilyBridgeService.ConversationTurnPayload(
role = role,
content = normalizedContent,
metaJson = metaJson?.toString(),
)
)
)
}
private fun handleRealtimeToolCall(toolName: String, callId: String, argumentsJson: String) {
enqueueBridgeTask("工具调用处理失败") {
val arguments = runCatching { JSONObject(argumentsJson) }.getOrElse { JSONObject() }
val result = executeRealtimeTool(toolName, callId, arguments)
applyMessage("AI工具输出: $toolName, callId=$callId, status=${result.status}, output=${result.outputText}")
uploadConversationTurnInternal(
FamilyBridgeService.ROLE_TOOL,
result.outputText,
JSONObject().apply {
put("toolName", toolName)
put("callId", callId)
put("status", result.status)
put("arguments", arguments)
}
)
val sent = omniClient?.sendFunctionCallOutput(callId, result.outputText) == true
if (!sent) {
applyMessage("工具结果回传失败: $toolName")
}
}
}
private fun executeRealtimeTool(
toolName: String,
callId: String,
arguments: JSONObject,
): ToolExecutionResult {
return when (toolName) {
"consult_psychology_knowledge_base" -> executePsychologyKnowledgeTool(callId, arguments)
"leave_message_for_family" -> executeLeaveMessageTool(callId, arguments)
"fetch_family_messages" -> executeFetchFamilyMessagesTool(callId, arguments)
"fetch_today_medication_plan", "confirm_medication_taken", "snooze_medication_reminder",
"report_medication_problem", "respond_to_check_in", "request_family_help" -> executeFamilyCareTool(toolName, callId, arguments)
else -> {
val outputText = "这个请求我还不能直接处理,您可以换一种说法再告诉我。"
runCatching {
familyBridgeService.recordToolCall(
toolName = toolName,
arguments = arguments,
outputText = outputText,
status = FamilyBridgeService.STATUS_FAILURE,
callId = callId,
sessionId = currentSessionId,
)
}
ToolExecutionResult(outputText, FamilyBridgeService.STATUS_FAILURE)
}
}
}
private fun executePsychologyKnowledgeTool(callId: String, arguments: JSONObject): ToolExecutionResult {
val question = arguments.optString("question").trim()
val focus = arguments.optString("focus").trim().ifBlank { null }
if (question.isEmpty()) {
val outputText = "还没有拿到要查询的具体问题,请先用一句话说明想查什么。"
runCatching {
familyBridgeService.recordToolCall(
toolName = "consult_psychology_knowledge_base",
arguments = arguments,
outputText = outputText,
status = FamilyBridgeService.STATUS_FAILURE,
callId = callId,
sessionId = currentSessionId,
)
}
return ToolExecutionResult(outputText, FamilyBridgeService.STATUS_FAILURE)
}
return runCatching {
val result = psychologyKnowledgeAgentService.consultKnowledge(question, focus)
familyBridgeService.recordToolCall(
toolName = "consult_psychology_knowledge_base",
arguments = arguments,
outputText = result.outputText,
status = FamilyBridgeService.STATUS_SUCCESS,
callId = callId,
sessionId = currentSessionId,
)
applyMessage(
"心理学子代理完成: model=${result.model}, requestId=${result.requestId ?: "-"}, inputTokens=${result.inputTokens}, outputTokens=${result.outputTokens}"
)
ToolExecutionResult(result.outputText, FamilyBridgeService.STATUS_SUCCESS)
}.getOrElse { error ->
val outputText = "权威照护资料查询暂时失败。请先基于安全原则回应:保持温和陪伴,避免给出确定性的医学结论;如果涉及持续恶化、明显痛苦、自伤他伤或急性危险,请建议尽快联系家属、医生或急救。"
runCatching {
familyBridgeService.recordToolCall(
toolName = "consult_psychology_knowledge_base",
arguments = arguments,
outputText = outputText,
status = FamilyBridgeService.STATUS_FAILURE,
callId = callId,
sessionId = currentSessionId,
)
}
applyMessage("心理学子代理失败: ${error.message}")
ToolExecutionResult(outputText, FamilyBridgeService.STATUS_FAILURE)
}
}
private fun executeLeaveMessageTool(callId: String, arguments: JSONObject): ToolExecutionResult {
val message = arguments.optString("message").trim()
val relation = arguments.optString("recipientRelation").trim().ifBlank { "家里人" }
if (message.isEmpty()) {
val outputText = "我还没听清您想转告的话,您再慢慢说一遍,我帮您记下来。"
runCatching {
familyBridgeService.recordToolCall(
toolName = "leave_message_for_family",
arguments = arguments,
outputText = outputText,
status = FamilyBridgeService.STATUS_FAILURE,
callId = callId,
sessionId = currentSessionId,
)
}
return ToolExecutionResult(outputText, FamilyBridgeService.STATUS_FAILURE)
}
return runCatching {
val outputText = "已记下要转告给${relation}的话:$message"
familyBridgeService.recordToolCall(
toolName = "leave_message_for_family",
arguments = arguments,
outputText = outputText,
status = FamilyBridgeService.STATUS_SUCCESS,
callId = callId,
sessionId = currentSessionId,
)
ToolExecutionResult(outputText, FamilyBridgeService.STATUS_SUCCESS)
}.getOrElse { error ->
val outputText = "暂时还没连上家人的留言通道,请稍后再试一次。"
runCatching {
familyBridgeService.recordToolCall(
toolName = "leave_message_for_family",
arguments = arguments,
outputText = outputText,
status = FamilyBridgeService.STATUS_FAILURE,
callId = callId,
sessionId = currentSessionId,
)
}
applyMessage("留言工具失败: ${error.message}")
ToolExecutionResult(outputText, FamilyBridgeService.STATUS_FAILURE)
}
}
private fun executeFetchFamilyMessagesTool(callId: String, arguments: JSONObject): ToolExecutionResult {
val requestedCount = arguments.optInt("count", 3).coerceIn(1, 5)
return runCatching {
val result = familyBridgeService.fetchFamilyMessages(requestedCount)
if (result.messages.isNotEmpty()) {
familyBridgeService.markFamilyMessagesRead(result.messages.map { it.id })
}
val outputText = if (result.messages.isEmpty()) {
"家里人暂时还没有新的留言。"
} else {
buildString {
append(result.summaryText.ifBlank { "我帮您看了看,家里人有新的留言。" })
result.messages.take(requestedCount).forEachIndexed { index, message ->
append(" 第${index + 1}条:${message.content}")
}
}
}
familyBridgeService.recordToolCall(
toolName = "fetch_family_messages",
arguments = arguments,
outputText = outputText,
status = FamilyBridgeService.STATUS_SUCCESS,
callId = callId,
sessionId = currentSessionId,
)
ToolExecutionResult(outputText, FamilyBridgeService.STATUS_SUCCESS)
}.getOrElse { error ->
val outputText = "暂时还没取到家人的新留言,请稍后再试一次。"
runCatching {
familyBridgeService.recordToolCall(
toolName = "fetch_family_messages",
arguments = arguments,
outputText = outputText,
status = FamilyBridgeService.STATUS_FAILURE,
callId = callId,
sessionId = currentSessionId,
)
}
applyMessage("读取家人留言失败: ${error.message}")
ToolExecutionResult(outputText, FamilyBridgeService.STATUS_FAILURE)
}
}
private fun executeFamilyCareTool(toolName: String, callId: String, arguments: JSONObject): ToolExecutionResult {
return runCatching {
val outputText = when (toolName) {
"fetch_today_medication_plan" -> {
val agenda = familyBridgeService.fetchTodayAgenda()
if (agenda == null || agenda.doses.length() == 0) "今天暂时没有待确认的用药提醒。" else buildString {
append("今天有${agenda.doses.length()}次待确认用药。")
for (index in 0 until agenda.doses.length()) { val item = agenda.doses.optJSONObject(index) ?: continue; append(" 编号${item.optString("id")}:${item.optString("name")},${item.optString("dosage")}。") }
}
}
"confirm_medication_taken" -> { familyBridgeService.updateMedication(arguments.optString("doseId"), "TAKEN", "老人语音确认已服"); "已记录这次服药。请不要重复服用;如有不适,请立即告诉我或联系家人。" }
"snooze_medication_reminder" -> { val minutes = arguments.optInt("minutes", 10).coerceIn(1, 60); familyBridgeService.updateMedication(arguments.optString("doseId"), "SNOOZED", snoozeMinutes = minutes); "好的,已延后${minutes}分钟提醒。" }
"report_medication_problem" -> {
val problem = arguments.optString("problem").ifBlank { "老人报告用药问题" }
val doseId = arguments.optString("doseId")
if (doseId.isNotBlank() && doseId != "unknown") familyBridgeService.updateMedication(doseId, "NEEDS_FAMILY_CONFIRMATION", problem)
familyBridgeService.reportSafetyAlert("MEDICATION", "用药需要家人确认", problem, if (arguments.optString("severity") == "RED") "RED" else "ORANGE")
"已经通知家里人关注这次用药问题。我不能代替医生判断;如果情况紧急,请立即联系120或请身边的人帮助。"
}
"respond_to_check_in" -> { val status = arguments.optString("status").takeIf { it in listOf("SAFE", "NEEDS_CONTACT", "DECLINED") } ?: "NEEDS_CONTACT"; familyBridgeService.respondCheckIn(arguments.optString("checkInId"), status, arguments.optString("responseText")); "已经把您的回复告诉家里人。" }
"request_family_help" -> {
val summary = arguments.optString("summary").ifBlank { "老人请求家人联系" }
familyBridgeService.reportSafetyAlert(arguments.optString("category").ifBlank { "HELP_REQUEST" }, "长辈请求家人关注", summary, if (arguments.optString("level") == "RED") "RED" else "ORANGE", summary)
"已经通知家里人关注。这个提醒不是医学诊断;如果情况紧急,请立即联系120、110或请身边的人帮助。"
}
else -> error("不支持的照护工具")
}
familyBridgeService.recordToolCall(toolName, arguments, outputText, FamilyBridgeService.STATUS_SUCCESS, callId, currentSessionId)
ToolExecutionResult(outputText, FamilyBridgeService.STATUS_SUCCESS)
}.getOrElse { error ->
val outputText = "照护信息暂时没有同步成功,请稍后再试;如果情况紧急,请直接联系家人或急救服务。"
runCatching { familyBridgeService.recordToolCall(toolName, arguments, outputText, FamilyBridgeService.STATUS_FAILURE, callId, currentSessionId) }
applyMessage("家庭照护工具失败: ${error.message}")
ToolExecutionResult(outputText, FamilyBridgeService.STATUS_FAILURE)
}
}
private data class ToolExecutionResult(
val outputText: String,
val status: String,
)
private fun showRecordDialog(){
val audioRecordDialog = AudioRecordDialog(mContext, object : AudioRecordDialog.Listener{
override fun onFinish(path: String) {
@@ -0,0 +1,80 @@
package ai.guiji.duix.test.ui.activity
import ai.guiji.duix.test.R
import ai.guiji.duix.test.databinding.ActivityDeviceSettingsBinding
import ai.guiji.duix.test.service.FamilyBridgeService
import ai.guiji.duix.test.util.QrCodeBitmapFactory
import android.os.Bundle
import android.util.TypedValue
import android.widget.Toast
import org.json.JSONObject
class DeviceSettingsActivity : BaseActivity() {
private lateinit var binding: ActivityDeviceSettingsBinding
private val familyBridgeService by lazy { FamilyBridgeService(applicationContext) }
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityDeviceSettingsBinding.inflate(layoutInflater)
setContentView(binding.root)
binding.btnBack.setOnClickListener { finish() }
binding.btnRefresh.setOnClickListener { syncDeviceRegistration(true) }
renderIdentityCard(familyBridgeService.getBindUrl())
syncDeviceRegistration(false)
Thread {
runCatching {
familyBridgeService.recordUsageEvent(
FamilyBridgeService.EVENT_SETTINGS_OPENED,
JSONObject().put("screen", "device_settings"),
)
}
}.start()
}
private fun renderIdentityCard(bindUrl: String) {
val deviceUuid = familyBridgeService.getDeviceUuid()
binding.tvDeviceUuidValue.text = deviceUuid
binding.tvBindLinkValue.text = bindUrl
val qrSize = TypedValue.applyDimension(
TypedValue.COMPLEX_UNIT_DIP,
220f,
resources.displayMetrics,
).toInt()
binding.ivQrCode.setImageBitmap(QrCodeBitmapFactory.create(bindUrl, qrSize))
}
private fun syncDeviceRegistration(showToast: Boolean) {
binding.btnRefresh.isEnabled = false
binding.tvSyncStatus.text = getString(R.string.device_settings_syncing)
Thread {
val result = runCatching { familyBridgeService.registerCurrentDevice() }
runOnUiThread {
binding.btnRefresh.isEnabled = true
result.onSuccess { registration ->
renderIdentityCard(registration.bindUrl)
binding.tvSyncStatus.text = getString(R.string.device_settings_sync_success)
if (showToast) {
Toast.makeText(mContext, R.string.device_settings_sync_success, Toast.LENGTH_SHORT).show()
}
}.onFailure { error ->
renderIdentityCard(familyBridgeService.getBindUrl())
binding.tvSyncStatus.text = getString(
R.string.device_settings_sync_failed,
error.message ?: getString(R.string.ai_call_error_retry),
)
if (showToast) {
Toast.makeText(
mContext,
error.message ?: getString(R.string.ai_call_error_retry),
Toast.LENGTH_SHORT,
).show()
}
}
}
}.start()
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,133 @@
package ai.guiji.duix.test.ui.activity
import ai.guiji.duix.test.R
import ai.guiji.duix.test.databinding.ActivityFolderPickerBinding
import ai.guiji.duix.test.ui.adapter.FolderBrowserAdapter
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.os.Environment
import android.view.View
import androidx.activity.OnBackPressedCallback
import androidx.recyclerview.widget.LinearLayoutManager
import java.io.File
class FolderPickerActivity : BaseActivity() {
private lateinit var binding: ActivityFolderPickerBinding
private lateinit var folderAdapter: FolderBrowserAdapter
private lateinit var browseRoot: File
private lateinit var currentDirectory: File
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityFolderPickerBinding.inflate(layoutInflater)
setContentView(binding.root)
browseRoot = resolveBrowseRoot()
currentDirectory = resolveInitialDirectory(intent.getStringExtra(EXTRA_INITIAL_PATH))
onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
if (canNavigateUp()) {
navigateUp()
} else {
isEnabled = false
onBackPressedDispatcher.onBackPressed()
}
}
})
setupList()
setupActions()
renderCurrentDirectory()
}
private fun setupList() {
folderAdapter = FolderBrowserAdapter { folder ->
currentDirectory = folder
renderCurrentDirectory()
}
binding.rvFolders.layoutManager = LinearLayoutManager(this)
binding.rvFolders.adapter = folderAdapter
}
private fun setupActions() {
binding.btnBack.setOnClickListener { finish() }
binding.btnNavigateUp.setOnClickListener { navigateUp() }
binding.btnSelectCurrent.setOnClickListener {
setResult(
Activity.RESULT_OK,
Intent().putExtra(EXTRA_SELECTED_PATH, currentDirectory.absolutePath),
)
finish()
}
}
private fun renderCurrentDirectory() {
val subFolders = currentDirectory.listFiles()
?.filter { it.isDirectory && it.canRead() }
?.sortedBy { it.name.lowercase() }
.orEmpty()
binding.tvCurrentFolderName.text = currentDirectory.name.ifBlank { currentDirectory.absolutePath }
binding.tvCurrentPath.text = currentDirectory.absolutePath
binding.btnNavigateUp.isEnabled = canNavigateUp()
binding.tvEmpty.visibility = if (subFolders.isEmpty()) View.VISIBLE else View.GONE
binding.rvFolders.visibility = if (subFolders.isEmpty()) View.GONE else View.VISIBLE
folderAdapter.submitList(subFolders)
}
private fun navigateUp() {
if (!canNavigateUp()) {
return
}
currentDirectory.parentFile?.let { parent ->
currentDirectory = parent
renderCurrentDirectory()
}
}
private fun canNavigateUp(): Boolean {
val rootPath = browseRoot.canonicalPath
val currentPath = currentDirectory.canonicalPath
return currentPath != rootPath && currentDirectory.parentFile != null && currentPath.startsWith(rootPath)
}
private fun resolveBrowseRoot(): File {
return Environment.getExternalStorageDirectory().canonicalFile
}
private fun resolveInitialDirectory(initialPath: String?): File {
val candidates = listOfNotNull(
initialPath?.takeIf { it.isNotBlank() }?.let(::File),
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOCUMENTS),
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_MUSIC),
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_MOVIES),
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS),
browseRoot,
)
for (candidate in candidates) {
val file = runCatching { candidate.canonicalFile }.getOrNull() ?: continue
if (!file.exists() || !file.isDirectory || !file.canRead()) {
continue
}
if (file.canonicalPath.startsWith(browseRoot.canonicalPath)) {
return file
}
}
return browseRoot
}
companion object {
const val EXTRA_INITIAL_PATH = "extra_initial_path"
const val EXTRA_SELECTED_PATH = "extra_selected_path"
fun createIntent(context: Context, initialPath: String?): Intent {
return Intent(context, FolderPickerActivity::class.java).apply {
putExtra(EXTRA_INITIAL_PATH, initialPath)
}
}
}
}
@@ -5,18 +5,23 @@ import ai.guiji.duix.test.R
import ai.guiji.duix.test.databinding.ActivityMainBinding
import ai.guiji.duix.test.model.AvatarCatalog
import ai.guiji.duix.test.model.AvatarModel
import ai.guiji.duix.test.service.DebugSettingsStore
import ai.guiji.duix.test.service.FamilyBridgeService
import ai.guiji.duix.test.ui.adapter.AvatarAdapter
import ai.guiji.duix.test.ui.dialog.LoadingDialog
import android.content.Intent
import android.os.Bundle
import android.widget.Toast
import androidx.recyclerview.widget.GridLayoutManager
import org.json.JSONObject
import java.io.File
class MainActivity : BaseActivity() {
private lateinit var binding: ActivityMainBinding
private val debugSettingsStore by lazy { DebugSettingsStore(applicationContext) }
private val familyBridgeService by lazy { FamilyBridgeService(applicationContext) }
private var mLoadingDialog: LoadingDialog? = null
private var mLastProgress = 0
@@ -32,6 +37,9 @@ class MainActivity : BaseActivity() {
binding.btnVoiceClone.setOnClickListener {
startActivity(Intent(mContext, VoiceCloneSetupActivity::class.java))
}
binding.btnSettings.setOnClickListener {
startActivity(Intent(mContext, SettingsActivity::class.java))
}
binding.rvAvatars.layoutManager = GridLayoutManager(this, 2)
binding.rvAvatars.adapter = AvatarAdapter(AvatarCatalog.models, object : AvatarAdapter.Callback {
@@ -40,6 +48,20 @@ class MainActivity : BaseActivity() {
play(model)
}
})
bootstrapFamilyBridge()
}
private fun bootstrapFamilyBridge() {
Thread {
runCatching { familyBridgeService.registerCurrentDevice() }
runCatching {
familyBridgeService.recordUsageEvent(
FamilyBridgeService.EVENT_APP_OPEN,
JSONObject().put("screen", "main"),
)
}
}.start()
}
private fun play(model: AvatarModel) {
@@ -69,7 +91,7 @@ class MainActivity : BaseActivity() {
val intent = Intent(mContext, CallActivity::class.java)
intent.putExtra("modelUrl", model.modelUrl)
intent.putExtra("voice", model.voice)
intent.putExtra("debug", false)
intent.putExtra("debug", debugSettingsStore.isChatDebugEnabled())
startActivity(intent)
}
@@ -0,0 +1,53 @@
package ai.guiji.duix.test.ui.activity
import ai.guiji.duix.test.BuildConfig
import ai.guiji.duix.test.databinding.ActivitySettingsBinding
import ai.guiji.duix.test.service.DashScopeConfig
import ai.guiji.duix.test.service.DebugSettingsStore
import ai.guiji.duix.test.service.FamilyBridgeService
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import org.json.JSONObject
class SettingsActivity : BaseActivity() {
private lateinit var binding: ActivitySettingsBinding
private val debugSettingsStore by lazy { DebugSettingsStore(applicationContext) }
private val familyBridgeService by lazy { FamilyBridgeService(applicationContext) }
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivitySettingsBinding.inflate(layoutInflater)
setContentView(binding.root)
binding.btnBack.setOnClickListener { finish() }
binding.rowDeviceCode.setOnClickListener {
startActivity(Intent(mContext, DeviceSettingsActivity::class.java))
}
binding.rowDebugMode.setOnClickListener {
binding.switchDebugMode.performClick()
}
binding.rowDigitalHumanTest.setOnClickListener {
startActivity(Intent(mContext, DigitalHumanTestActivity::class.java))
}
binding.tvVersionValue.text = "${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})"
binding.tvServerValue.text = Uri.parse(DashScopeConfig.MONITOR_BASE_URL).host
?: DashScopeConfig.MONITOR_BASE_URL
binding.tvDeviceValue.text = familyBridgeService.getDeviceUuid()
binding.switchDebugMode.isChecked = debugSettingsStore.isChatDebugEnabled()
binding.switchDebugMode.setOnCheckedChangeListener { _, isChecked ->
debugSettingsStore.setChatDebugEnabled(isChecked)
}
Thread {
runCatching {
familyBridgeService.recordUsageEvent(
FamilyBridgeService.EVENT_SETTINGS_OPENED,
JSONObject().put("screen", "settings_home"),
)
}
}.start()
}
}
@@ -7,6 +7,7 @@ import ai.guiji.duix.test.model.AvatarCatalog
import ai.guiji.duix.test.model.AvatarModel
import ai.guiji.duix.test.model.VoiceCloneProfile
import ai.guiji.duix.test.service.DashScopeConfig
import ai.guiji.duix.test.service.DebugSettingsStore
import ai.guiji.duix.test.service.VoiceCloneProfileStore
import ai.guiji.duix.test.service.VoiceCloneService
import ai.guiji.duix.test.ui.adapter.AvatarAdapter
@@ -27,6 +28,7 @@ class VoiceCloneSetupActivity : BaseActivity() {
private lateinit var binding: ActivityVoiceCloneSetupBinding
private lateinit var avatarAdapter: AvatarAdapter
private val mainHandler = Handler(Looper.getMainLooper())
private val debugSettingsStore by lazy { DebugSettingsStore(applicationContext) }
private val profileStore by lazy { VoiceCloneProfileStore(this) }
private val voiceCloneService = VoiceCloneService()
private val recorder = VoiceCloneRecorder()
@@ -222,7 +224,7 @@ class VoiceCloneSetupActivity : BaseActivity() {
val intent = Intent(mContext, CallActivity::class.java)
intent.putExtra("modelUrl", selectedAvatar.modelUrl)
intent.putExtra("voice", selectedAvatar.voice)
intent.putExtra("debug", false)
intent.putExtra("debug", debugSettingsStore.isChatDebugEnabled())
intent.putExtra("cloneMode", true)
intent.putExtra("clonedVoice", profile.voice)
intent.putExtra("cloneTtsModel", profile.targetModel)
@@ -0,0 +1,62 @@
package ai.guiji.duix.test.ui.adapter
import ai.guiji.duix.test.R
import ai.guiji.duix.test.databinding.ItemDigitalHumanBatchJobBinding
import ai.guiji.duix.test.model.BatchAudioJob
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.core.content.ContextCompat
import androidx.recyclerview.widget.RecyclerView
import java.util.Locale
class DigitalHumanBatchJobAdapter : RecyclerView.Adapter<DigitalHumanBatchJobAdapter.ViewHolder>() {
private val items = mutableListOf<BatchAudioJob>()
class ViewHolder(val binding: ItemDigitalHumanBatchJobBinding) :
RecyclerView.ViewHolder(binding.root)
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val binding = ItemDigitalHumanBatchJobBinding.inflate(
LayoutInflater.from(parent.context),
parent,
false,
)
return ViewHolder(binding)
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val item = items[position]
val context = holder.binding.root.context
holder.binding.tvIndex.text = String.format(Locale.getDefault(), "%02d", position + 1)
holder.binding.tvPath.text = item.sourceRelativePath
holder.binding.tvStatus.text = item.statusText
holder.binding.tvDetail.text = item.detailText
holder.binding.tvDetail.alpha = if (item.detailText.isBlank()) 0.55f else 1f
val statusColor = when (item.status) {
BatchAudioJob.Status.PENDING -> R.color.warm_status_pending
BatchAudioJob.Status.RUNNING -> R.color.warm_status_running
BatchAudioJob.Status.COMPLETED -> R.color.warm_status_success
BatchAudioJob.Status.FAILED -> R.color.warm_status_error
BatchAudioJob.Status.SKIPPED -> R.color.warm_status_skipped
}
holder.binding.tvStatus.setTextColor(ContextCompat.getColor(context, statusColor))
}
override fun getItemCount(): Int = items.size
fun submitList(newItems: List<BatchAudioJob>) {
items.clear()
items.addAll(newItems)
notifyDataSetChanged()
}
fun updateItem(index: Int, item: BatchAudioJob) {
if (index !in items.indices) {
return
}
items[index] = item
notifyItemChanged(index)
}
}
@@ -0,0 +1,42 @@
package ai.guiji.duix.test.ui.adapter
import ai.guiji.duix.test.databinding.ItemFolderEntryBinding
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import java.io.File
class FolderBrowserAdapter(
private val onFolderSelected: (File) -> Unit,
) : RecyclerView.Adapter<FolderBrowserAdapter.ViewHolder>() {
private val items = mutableListOf<File>()
class ViewHolder(val binding: ItemFolderEntryBinding) : RecyclerView.ViewHolder(binding.root)
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val binding = ItemFolderEntryBinding.inflate(
LayoutInflater.from(parent.context),
parent,
false,
)
return ViewHolder(binding)
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val item = items[position]
holder.binding.tvFolderName.text = item.name.ifBlank { item.absolutePath }
holder.binding.tvFolderPath.text = item.absolutePath
holder.binding.root.setOnClickListener {
onFolderSelected(item)
}
}
override fun getItemCount(): Int = items.size
fun submitList(newItems: List<File>) {
items.clear()
items.addAll(newItems)
notifyDataSetChanged()
}
}
@@ -0,0 +1,29 @@
package ai.guiji.duix.test.util
import android.graphics.Bitmap
import android.graphics.Color
import com.google.zxing.BarcodeFormat
import com.google.zxing.EncodeHintType
import com.google.zxing.qrcode.QRCodeWriter
object QrCodeBitmapFactory {
fun create(content: String, size: Int): Bitmap {
val hints = hashMapOf<EncodeHintType, Any>(
EncodeHintType.MARGIN to 1,
EncodeHintType.CHARACTER_SET to "UTF-8",
)
val matrix = QRCodeWriter().encode(content, BarcodeFormat.QR_CODE, size, size, hints)
val pixels = IntArray(size * size)
for (y in 0 until size) {
for (x in 0 until size) {
pixels[y * size + x] = if (matrix[x, y]) Color.BLACK else Color.WHITE
}
}
return Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888).apply {
setPixels(pixels, 0, size, 0, 0, size, size)
}
}
}
@@ -0,0 +1,560 @@
package ai.guiji.duix.test.video
import ai.guiji.duix.sdk.client.bean.ImageFrame
import android.media.MediaCodec
import android.media.MediaCodecInfo
import android.media.MediaFormat
import android.media.MediaMuxer
import java.io.File
import java.nio.ByteBuffer
import java.util.concurrent.CountDownLatch
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
import kotlin.math.max
import kotlin.math.min
class DigitalHumanMp4Exporter(
private val outputFile: File,
) {
data class ExportResult(
val file: File,
val durationMs: Long,
)
private data class QueuedAudio(val data: ByteArray)
private data class FramePacket(
val raw: ByteArray,
val mask: ByteArray,
val width: Int,
val height: Int,
val timestampNs: Long,
)
private data class EncodedSample(
val data: ByteArray,
val info: MediaCodec.BufferInfo,
)
private val executor = Executors.newSingleThreadExecutor()
private var startNs = -1L
private var initialized = false
private var closed = false
private var videoWidth = 0
private var videoHeight = 0
private var videoColorFormat = MediaCodecInfo.CodecCapabilities.COLOR_FormatYUV420Flexible
private var muxer: MediaMuxer? = null
private var videoEncoder: MediaCodec? = null
private var audioEncoder: MediaCodec? = null
private var videoTrackIndex = -1
private var audioTrackIndex = -1
private var muxerStarted = false
private val pendingAudioChunks = ArrayList<QueuedAudio>()
private val pendingVideoSamples = ArrayList<EncodedSample>()
private val pendingAudioSamples = ArrayList<EncodedSample>()
private var lastFramePacket: FramePacket? = null
private var lastVideoPtsUs = -1L
private var audioBytesQueued = 0L
fun recordAudioChunk(pcm24k: ByteArray, timestampNs: Long = System.nanoTime()) {
if (pcm24k.isEmpty() || closed) {
return
}
val copy = pcm24k.clone()
executor.execute {
if (closed) {
return@execute
}
ensureStarted(timestampNs)
if (!initialized) {
pendingAudioChunks.add(QueuedAudio(copy))
} else {
queueAudioInternal(copy)
}
}
}
fun recordFrame(imageFrame: ImageFrame, timestampNs: Long = System.nanoTime()) {
if (closed) {
return
}
val rawSize = imageFrame.width * imageFrame.height * RGB_CHANNELS
val rawCopy = copyBufferBytes(imageFrame.rawBuffer, rawSize)
val maskCopy = copyBufferBytes(imageFrame.maskBuffer, rawSize)
val packet = FramePacket(rawCopy, maskCopy, imageFrame.width, imageFrame.height, timestampNs)
executor.execute {
if (closed) {
return@execute
}
ensureStarted(timestampNs)
if (!initialized) {
initializeEncoders(packet.width, packet.height)
}
queueVideoInternal(packet)
lastFramePacket = packet
if (pendingAudioChunks.isNotEmpty()) {
flushPendingAudioChunks()
}
}
}
fun finish(): ExportResult {
val latch = CountDownLatch(1)
var result: ExportResult? = null
var failure: Throwable? = null
executor.execute {
try {
closed = true
if (!initialized) {
throw IllegalStateException("未收到数字人渲染帧,无法导出视频")
}
if (pendingAudioChunks.isNotEmpty()) {
flushPendingAudioChunks()
}
if (audioBytesQueued <= 0L) {
throw IllegalStateException("未收到模型音频响应")
}
val audioDurationUs = calculateAudioDurationUs()
val lastFrame = lastFramePacket
if (lastFrame != null && audioDurationUs > lastVideoPtsUs + FRAME_PADDING_US) {
queueVideoInternal(lastFrame.copy(timestampNs = startNs + (audioDurationUs * 1000L)))
}
signalAudioEndOfStream(audioDurationUs)
drainAudioEncoder(endOfStream = true)
val finalVideoPtsUs = max(lastVideoPtsUs, audioDurationUs)
signalVideoEndOfStream(finalVideoPtsUs)
drainVideoEncoder(endOfStream = true)
releaseInternal(deleteOutput = false)
result = ExportResult(
file = outputFile,
durationMs = (audioDurationUs / 1000L).coerceAtLeast(0L),
)
} catch (throwable: Throwable) {
failure = throwable
releaseInternal(deleteOutput = true)
} finally {
latch.countDown()
}
}
if (!latch.await(FINISH_TIMEOUT_SECONDS, TimeUnit.SECONDS)) {
executor.shutdownNow()
throw IllegalStateException("视频导出超时")
}
executor.shutdown()
failure?.let { throw it }
return result ?: throw IllegalStateException("视频导出失败")
}
fun cancel() {
val latch = CountDownLatch(1)
executor.execute {
try {
closed = true
releaseInternal(deleteOutput = true)
} finally {
latch.countDown()
}
}
latch.await(CANCEL_TIMEOUT_SECONDS, TimeUnit.SECONDS)
executor.shutdownNow()
}
private fun ensureStarted(timestampNs: Long) {
if (startNs < 0L) {
startNs = timestampNs
}
}
private fun initializeEncoders(width: Int, height: Int) {
if (initialized) {
return
}
if (width <= 0 || height <= 0 || width % 2 != 0 || height % 2 != 0) {
throw IllegalArgumentException("渲染帧尺寸无效: ${width}x${height}")
}
outputFile.parentFile?.mkdirs()
if (outputFile.exists()) {
outputFile.delete()
}
videoWidth = width
videoHeight = height
muxer = MediaMuxer(outputFile.absolutePath, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4)
videoEncoder = MediaCodec.createEncoderByType(VIDEO_MIME_TYPE).apply {
videoColorFormat = selectVideoColorFormat(codecInfo)
val format = MediaFormat.createVideoFormat(VIDEO_MIME_TYPE, width, height).apply {
setInteger(MediaFormat.KEY_COLOR_FORMAT, videoColorFormat)
setInteger(MediaFormat.KEY_BIT_RATE, max(width * height * VIDEO_BITRATE_FACTOR, MIN_VIDEO_BITRATE))
setInteger(MediaFormat.KEY_FRAME_RATE, VIDEO_FRAME_RATE)
setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, VIDEO_I_FRAME_INTERVAL)
}
configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE)
start()
}
audioEncoder = MediaCodec.createEncoderByType(AUDIO_MIME_TYPE).apply {
val format = MediaFormat.createAudioFormat(AUDIO_MIME_TYPE, AUDIO_SAMPLE_RATE, AUDIO_CHANNEL_COUNT).apply {
setInteger(MediaFormat.KEY_AAC_PROFILE, MediaCodecInfo.CodecProfileLevel.AACObjectLC)
setInteger(MediaFormat.KEY_BIT_RATE, AUDIO_BITRATE)
setInteger(MediaFormat.KEY_MAX_INPUT_SIZE, AUDIO_MAX_INPUT_SIZE)
}
configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE)
start()
}
initialized = true
}
private fun flushPendingAudioChunks() {
if (pendingAudioChunks.isEmpty()) {
return
}
val iterator = pendingAudioChunks.iterator()
while (iterator.hasNext()) {
queueAudioInternal(iterator.next().data)
iterator.remove()
}
}
private fun queueAudioInternal(pcm24k: ByteArray) {
var offset = 0
val encoder = audioEncoder ?: throw IllegalStateException("音频编码器尚未初始化")
while (offset < pcm24k.size) {
val inputIndex = dequeueInputBuffer(encoder)
val inputBuffer = encoder.getInputBuffer(inputIndex)
?: throw IllegalStateException("音频输入缓冲区不可用")
inputBuffer.clear()
val chunkSize = min(inputBuffer.remaining(), pcm24k.size - offset)
inputBuffer.put(pcm24k, offset, chunkSize)
val ptsUs = audioBytesQueued * MICROS_PER_SECOND / AUDIO_BYTES_PER_SECOND
encoder.queueInputBuffer(inputIndex, 0, chunkSize, ptsUs, 0)
audioBytesQueued += chunkSize.toLong()
offset += chunkSize
drainAudioEncoder(endOfStream = false)
}
}
private fun queueVideoInternal(packet: FramePacket) {
if (packet.width != videoWidth || packet.height != videoHeight) {
throw IllegalStateException("渲染帧尺寸发生变化,无法继续编码")
}
val encoder = videoEncoder ?: throw IllegalStateException("视频编码器尚未初始化")
val inputIndex = dequeueInputBuffer(encoder)
val inputBuffer = encoder.getInputBuffer(inputIndex)
?: throw IllegalStateException("视频输入缓冲区不可用")
inputBuffer.clear()
val yuv = convertFrameToYuv(packet.raw, packet.mask, packet.width, packet.height, videoColorFormat)
if (inputBuffer.remaining() < yuv.size) {
throw IllegalStateException("视频输入缓冲区容量不足")
}
inputBuffer.put(yuv)
val ptsUs = computeVideoPresentationTimeUs(packet.timestampNs)
encoder.queueInputBuffer(inputIndex, 0, yuv.size, ptsUs, 0)
lastVideoPtsUs = ptsUs
drainVideoEncoder(endOfStream = false)
}
private fun signalAudioEndOfStream(finalPtsUs: Long) {
val encoder = audioEncoder ?: return
val inputIndex = dequeueInputBuffer(encoder)
encoder.queueInputBuffer(inputIndex, 0, 0, finalPtsUs, MediaCodec.BUFFER_FLAG_END_OF_STREAM)
}
private fun signalVideoEndOfStream(finalPtsUs: Long) {
val encoder = videoEncoder ?: return
val inputIndex = dequeueInputBuffer(encoder)
encoder.queueInputBuffer(inputIndex, 0, 0, finalPtsUs, MediaCodec.BUFFER_FLAG_END_OF_STREAM)
}
private fun drainAudioEncoder(endOfStream: Boolean) {
drainEncoder(
encoder = audioEncoder,
pendingSamples = pendingAudioSamples,
getTrackIndex = { audioTrackIndex },
setTrackIndex = { audioTrackIndex = it },
endOfStream = endOfStream,
)
}
private fun drainVideoEncoder(endOfStream: Boolean) {
drainEncoder(
encoder = videoEncoder,
pendingSamples = pendingVideoSamples,
getTrackIndex = { videoTrackIndex },
setTrackIndex = { videoTrackIndex = it },
endOfStream = endOfStream,
)
}
private fun drainEncoder(
encoder: MediaCodec?,
pendingSamples: MutableList<EncodedSample>,
getTrackIndex: () -> Int,
setTrackIndex: (Int) -> Unit,
endOfStream: Boolean,
) {
if (encoder == null) {
return
}
val bufferInfo = MediaCodec.BufferInfo()
while (true) {
val outputIndex = encoder.dequeueOutputBuffer(bufferInfo, TIMEOUT_US)
when {
outputIndex == MediaCodec.INFO_TRY_AGAIN_LATER -> {
if (!endOfStream) {
return
}
}
outputIndex == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED -> {
val muxerInstance = muxer ?: throw IllegalStateException("复用器未初始化")
val trackIndex = muxerInstance.addTrack(encoder.outputFormat)
setTrackIndex(trackIndex)
startMuxerIfReady()
}
outputIndex >= 0 -> {
val outputBuffer = encoder.getOutputBuffer(outputIndex)
?: throw IllegalStateException("编码器输出缓冲区不可用")
if ((bufferInfo.flags and MediaCodec.BUFFER_FLAG_CODEC_CONFIG) != 0) {
bufferInfo.size = 0
}
if (bufferInfo.size > 0) {
val trackIndex = getTrackIndex()
if (trackIndex < 0) {
throw IllegalStateException("编码轨道尚未创建")
}
val sample = copyEncodedSample(outputBuffer, bufferInfo)
if (muxerStarted) {
writeEncodedSample(trackIndex, sample)
} else {
pendingSamples.add(sample)
}
}
val endReached = (bufferInfo.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0
encoder.releaseOutputBuffer(outputIndex, false)
if (endReached) {
return
}
}
}
}
}
private fun startMuxerIfReady() {
if (muxerStarted || videoTrackIndex < 0 || audioTrackIndex < 0) {
return
}
val muxerInstance = muxer ?: throw IllegalStateException("复用器未初始化")
muxerInstance.start()
muxerStarted = true
flushPendingSamples(videoTrackIndex, pendingVideoSamples)
flushPendingSamples(audioTrackIndex, pendingAudioSamples)
}
private fun flushPendingSamples(trackIndex: Int, pendingSamples: MutableList<EncodedSample>) {
if (pendingSamples.isEmpty()) {
return
}
pendingSamples.sortBy { it.info.presentationTimeUs }
val iterator = pendingSamples.iterator()
while (iterator.hasNext()) {
writeEncodedSample(trackIndex, iterator.next())
iterator.remove()
}
}
private fun writeEncodedSample(trackIndex: Int, sample: EncodedSample) {
val muxerInstance = muxer ?: throw IllegalStateException("复用器未初始化")
muxerInstance.writeSampleData(trackIndex, ByteBuffer.wrap(sample.data), sample.info)
}
private fun copyEncodedSample(buffer: ByteBuffer, info: MediaCodec.BufferInfo): EncodedSample {
val data = ByteArray(info.size)
buffer.position(info.offset)
buffer.limit(info.offset + info.size)
buffer.get(data)
val copiedInfo = MediaCodec.BufferInfo().apply {
offset = 0
size = data.size
presentationTimeUs = info.presentationTimeUs
flags = info.flags
}
return EncodedSample(data, copiedInfo)
}
private fun computeVideoPresentationTimeUs(timestampNs: Long): Long {
val relativeUs = ((timestampNs - startNs) / 1_000L).coerceAtLeast(0L)
return if (lastVideoPtsUs < 0L) {
relativeUs
} else {
max(relativeUs, lastVideoPtsUs + 1L)
}
}
private fun calculateAudioDurationUs(): Long {
return audioBytesQueued * MICROS_PER_SECOND / AUDIO_BYTES_PER_SECOND
}
private fun dequeueInputBuffer(encoder: MediaCodec): Int {
while (true) {
val inputIndex = encoder.dequeueInputBuffer(TIMEOUT_US)
if (inputIndex >= 0) {
return inputIndex
}
if (inputIndex != MediaCodec.INFO_TRY_AGAIN_LATER) {
throw IllegalStateException("获取编码器输入缓冲区失败")
}
if (closed) {
throw IllegalStateException("编码任务已结束")
}
}
}
private fun releaseInternal(deleteOutput: Boolean) {
runCatching {
if (muxerStarted) {
muxer?.stop()
}
}
runCatching { muxer?.release() }
runCatching { videoEncoder?.stop() }
runCatching { videoEncoder?.release() }
runCatching { audioEncoder?.stop() }
runCatching { audioEncoder?.release() }
muxer = null
videoEncoder = null
audioEncoder = null
muxerStarted = false
initialized = false
pendingAudioChunks.clear()
pendingAudioSamples.clear()
pendingVideoSamples.clear()
if (deleteOutput && outputFile.exists()) {
outputFile.delete()
}
}
private fun selectVideoColorFormat(codecInfo: MediaCodecInfo): Int {
val capabilities = codecInfo.getCapabilitiesForType(VIDEO_MIME_TYPE)
val candidates = intArrayOf(
MediaCodecInfo.CodecCapabilities.COLOR_FormatYUV420SemiPlanar,
MediaCodecInfo.CodecCapabilities.COLOR_FormatYUV420Planar,
MediaCodecInfo.CodecCapabilities.COLOR_FormatYUV420Flexible,
)
for (candidate in candidates) {
if (capabilities.colorFormats.contains(candidate)) {
return candidate
}
}
throw IllegalStateException("当前设备不支持可用的 YUV420 视频编码格式")
}
private fun convertFrameToYuv(
raw: ByteArray,
mask: ByteArray,
width: Int,
height: Int,
colorFormat: Int,
): ByteArray {
val frameSize = width * height
val output = ByteArray(frameSize + frameSize / 2)
val isSemiPlanar = colorFormat == MediaCodecInfo.CodecCapabilities.COLOR_FormatYUV420SemiPlanar
var yIndex = 0
var uIndex = frameSize
var vIndex = if (isSemiPlanar) frameSize + 1 else frameSize + frameSize / 4
for (row in 0 until height) {
for (col in 0 until width) {
val pixelIndex = row * width + col
val rgbOffset = pixelIndex * RGB_CHANNELS
val alpha = ((mask[rgbOffset].toInt() and 0xFF) +
(mask[rgbOffset + 1].toInt() and 0xFF) +
(mask[rgbOffset + 2].toInt() and 0xFF)) / RGB_CHANNELS
val blue = ((raw[rgbOffset].toInt() and 0xFF) * alpha) / 255
val green = ((raw[rgbOffset + 1].toInt() and 0xFF) * alpha) / 255
val red = ((raw[rgbOffset + 2].toInt() and 0xFF) * alpha) / 255
val y = clampToByte(((66 * red + 129 * green + 25 * blue + 128) shr 8) + 16)
val u = clampToByte(((-38 * red - 74 * green + 112 * blue + 128) shr 8) + 128)
val v = clampToByte(((112 * red - 94 * green - 18 * blue + 128) shr 8) + 128)
output[yIndex++] = y.toByte()
if (row % 2 == 0 && col % 2 == 0) {
if (isSemiPlanar) {
output[uIndex] = u.toByte()
output[vIndex] = v.toByte()
uIndex += 2
vIndex += 2
} else {
output[uIndex++] = u.toByte()
output[vIndex++] = v.toByte()
}
}
}
}
return output
}
private fun clampToByte(value: Int): Int {
return value.coerceIn(0, 255)
}
private fun copyBufferBytes(buffer: ByteBuffer, size: Int): ByteArray {
if (buffer.hasArray()) {
return buffer.array().copyOf(size)
}
val duplicate = buffer.duplicate()
duplicate.position(0)
val output = ByteArray(size)
duplicate.get(output, 0, min(size, duplicate.remaining()))
return output
}
companion object {
private const val VIDEO_MIME_TYPE = "video/avc"
private const val AUDIO_MIME_TYPE = "audio/mp4a-latm"
private const val AUDIO_SAMPLE_RATE = 24_000
private const val AUDIO_CHANNEL_COUNT = 1
private const val AUDIO_BYTES_PER_SECOND = AUDIO_SAMPLE_RATE * AUDIO_CHANNEL_COUNT * 2L
private const val AUDIO_BITRATE = 64_000
private const val AUDIO_MAX_INPUT_SIZE = 48_000
private const val VIDEO_FRAME_RATE = 24
private const val VIDEO_I_FRAME_INTERVAL = 1
private const val VIDEO_BITRATE_FACTOR = 6
private const val MIN_VIDEO_BITRATE = 1_200_000
private const val RGB_CHANNELS = 3
private const val FRAME_PADDING_US = 80_000L
private const val MICROS_PER_SECOND = 1_000_000L
private const val TIMEOUT_US = 10_000L
private const val FINISH_TIMEOUT_SECONDS = 60L
private const val CANCEL_TIMEOUT_SECONDS = 5L
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 253 KiB

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<bitmap xmlns:android="http://schemas.android.com/apk/res/android"
android:gravity="fill"
android:src="@drawable/azb_launcher_maskable" />
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="#00000000" />
</shape>
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FF5F4338"
android:pathData="M14.71,6.29a1,1 0,0 1,0 1.41L10.41,12l4.3,4.29a1,1 0,0 1,-1.42 1.42l-5,-5a1,1 0,0 1,0 -1.42l5,-5a1,1 0,0 1,1.42 0Z" />
</vector>
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FF8D6E63"
android:pathData="M9.29,6.71a1,1 0,0 1,1.41 0L15,11l-4.29,4.29a1,1 0,1 1,-1.42 -1.41L12.17,11 9.29,8.12a1,1 0,0 1,0 -1.41Z" />
</vector>
@@ -1,170 +0,0 @@
<?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>
@@ -1,30 +0,0 @@
<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>
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FF7D5D4E"
android:pathData="M19.14,12.94c0.04,-0.31 0.06,-0.63 0.06,-0.94s-0.02,-0.63 -0.06,-0.94l2.03,-1.58c0.18,-0.14 0.23,-0.4 0.12,-0.61l-1.92,-3.32c-0.12,-0.22 -0.37,-0.3 -0.6,-0.22l-2.39,0.96c-0.5,-0.38 -1.05,-0.69 -1.66,-0.94l-0.36,-2.54C14.33,2.18 14.13,2 13.88,2h-3.76c-0.25,0 -0.46,0.18 -0.49,0.42l-0.36,2.54c-0.61,0.24 -1.17,0.56 -1.66,0.94l-2.39,-0.96c-0.23,-0.09 -0.48,0 -0.6,0.22L2.7,8.48c-0.12,0.21 -0.07,0.47 0.12,0.61l2.03,1.58c-0.04,0.31 -0.06,0.64 -0.06,0.95s0.02,0.63 0.06,0.94L2.82,14.14c-0.18,0.14 -0.23,0.4 -0.12,0.61l1.92,3.32c0.12,0.22 0.37,0.3 0.6,0.22l2.39,-0.96c0.5,0.38 1.05,0.69 1.66,0.94l0.36,2.54c0.03,0.24 0.24,0.42 0.49,0.42h3.76c0.25,0 0.46,-0.18 0.49,-0.42l0.36,-2.54c0.61,-0.24 1.17,-0.56 1.66,-0.94l2.39,0.96c0.23,0.09 0.48,0 0.6,-0.22l1.92,-3.32c0.12,-0.21 0.07,-0.47 -0.12,-0.61l-2.03,-1.57ZM12,15.5c-1.93,0 -3.5,-1.57 -3.5,-3.5s1.57,-3.5 3.5,-3.5 3.5,1.57 3.5,3.5 -1.57,3.5 -3.5,3.5Z" />
</vector>
@@ -0,0 +1,7 @@
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<corners android:radius="28dp" />
<solid android:color="@color/warm_surface" />
<stroke
android:width="1dp"
android:color="#FFE4CDBD" />
</shape>
@@ -0,0 +1,7 @@
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<corners android:radius="18dp" />
<solid android:color="@color/warm_surface" />
<stroke
android:width="1dp"
android:color="#FFE3C7B3" />
</shape>
+20 -33
View File
@@ -15,16 +15,29 @@
android:layout_width="match_parent"
android:layout_height="match_parent" />
<TextView
android:id="@+id/tvDebug"
android:textSize="12sp"
<ScrollView
android:id="@+id/scrollDebug"
android:layout_width="0dp"
android:layout_height="220dp"
android:visibility="gone"
android:textColor="@color/white"
android:background="#66000000"
android:padding="6dp"
android:fadeScrollbars="false"
android:fillViewport="true"
android:scrollbars="vertical"
app:layout_constraintTop_toTopOf="parent"
android:layout_width="match_parent"
android:layout_height="200dp"/>
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent">
<TextView
android:id="@+id/tvDebug"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:fontFamily="monospace"
android:padding="8dp"
android:textColor="@color/white"
android:textIsSelectable="true"
android:textSize="12sp" />
</ScrollView>
<androidx.appcompat.widget.SwitchCompat
@@ -160,30 +173,4 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"/>
<Button
android:id="@+id/btnPlayPCM"
android:text="@string/play_pcm"
android:visibility="gone"
app:layout_constraintBottom_toTopOf="@+id/btnAIConversation"
android:background="@drawable/shape_common_btn"
android:layout_margin="6dp"
android:textSize="13sp"
android:textColor="@color/white"
android:enabled="false"
android:layout_width="match_parent"
android:layout_height="wrap_content"/>
<Button
android:id="@+id/btnPlayWAV"
android:text="@string/play_wav"
android:visibility="gone"
app:layout_constraintBottom_toTopOf="@+id/btnPlayPCM"
android:background="@drawable/shape_common_btn"
android:layout_margin="6dp"
android:textSize="13sp"
android:textColor="@color/white"
android:enabled="false"
android:layout_width="match_parent"
android:layout_height="wrap_content"/>
</androidx.constraintlayout.widget.ConstraintLayout>
@@ -0,0 +1,140 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.core.widget.NestedScrollView xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/warm_bg"
android:fillViewport="true">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:paddingStart="18dp"
android:paddingTop="28dp"
android:paddingEnd="18dp"
android:paddingBottom="24dp">
<androidx.appcompat.widget.AppCompatImageButton
android:id="@+id/btnBack"
android:layout_width="44dp"
android:layout_height="44dp"
android:background="@drawable/shape_warm_icon_button"
android:contentDescription="@string/device_settings_back"
android:padding="10dp"
android:scaleType="centerInside"
android:src="@drawable/ic_arrow_back" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="18dp"
android:text="@string/device_settings_title"
android:textColor="@color/warm_text_primary"
android:textSize="24sp"
android:textStyle="bold" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:lineSpacingExtra="4dp"
android:text="@string/device_settings_subtitle"
android:textColor="@color/warm_text_secondary"
android:textSize="15sp" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="18dp"
android:background="@drawable/shape_warm_feature_card"
android:orientation="vertical"
android:padding="18dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/device_settings_qr_title"
android:textColor="@color/warm_text_primary"
android:textSize="19sp"
android:textStyle="bold" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:lineSpacingExtra="4dp"
android:text="@string/device_settings_qr_desc"
android:textColor="@color/warm_text_secondary"
android:textSize="14sp" />
<ImageView
android:id="@+id/ivQrCode"
android:layout_width="220dp"
android:layout_height="220dp"
android:layout_gravity="center_horizontal"
android:layout_marginTop="18dp"
android:background="@drawable/shape_qr_surface"
android:padding="12dp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="18dp"
android:text="@string/device_settings_uuid_label"
android:textColor="@color/warm_text_secondary"
android:textSize="13sp" />
<TextView
android:id="@+id/tvDeviceUuidValue"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:textColor="@color/warm_text_primary"
android:textIsSelectable="true"
android:textSize="16sp"
android:textStyle="bold" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="@string/device_settings_link_label"
android:textColor="@color/warm_text_secondary"
android:textSize="13sp" />
<TextView
android:id="@+id/tvBindLinkValue"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:lineSpacingExtra="4dp"
android:textColor="@color/warm_text_primary"
android:textIsSelectable="true"
android:textSize="14sp" />
<TextView
android:id="@+id/tvSyncStatus"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:lineSpacingExtra="4dp"
android:text="@string/device_settings_syncing"
android:textColor="@color/warm_text_secondary"
android:textSize="14sp" />
<androidx.appcompat.widget.AppCompatButton
android:id="@+id/btnRefresh"
android:layout_width="match_parent"
android:layout_height="50dp"
android:layout_marginTop="16dp"
android:background="@drawable/selector_warm_primary_button"
android:elevation="0dp"
android:stateListAnimator="@null"
android:text="@string/device_settings_refresh"
android:textAllCaps="false"
android:textColor="@color/text_warm_primary_button"
android:textSize="17sp"
android:textStyle="bold" />
</LinearLayout>
</LinearLayout>
</androidx.core.widget.NestedScrollView>
@@ -0,0 +1,258 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.core.widget.NestedScrollView xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/warm_bg"
android:fillViewport="true">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:paddingStart="18dp"
android:paddingTop="28dp"
android:paddingEnd="18dp"
android:paddingBottom="28dp">
<androidx.appcompat.widget.AppCompatImageButton
android:id="@+id/btnBack"
android:layout_width="44dp"
android:layout_height="44dp"
android:background="@drawable/shape_warm_icon_button"
android:contentDescription="@string/device_settings_back"
android:padding="10dp"
android:scaleType="centerInside"
android:src="@drawable/ic_arrow_back" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="18dp"
android:text="@string/digital_human_test_title"
android:textColor="@color/warm_text_primary"
android:textSize="25sp"
android:textStyle="bold" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:lineSpacingExtra="4dp"
android:text="@string/digital_human_test_subtitle"
android:textColor="@color/warm_text_secondary"
android:textSize="15sp" />
<FrameLayout
android:layout_width="match_parent"
android:layout_height="340dp"
android:layout_marginTop="18dp"
android:background="@drawable/shape_warm_feature_card"
android:clipChildren="true"
android:clipToPadding="true">
<ImageView
android:id="@+id/ivBg"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:scaleType="centerCrop" />
<ai.guiji.duix.sdk.client.render.DUIXTextureView
android:id="@+id/glTextureView"
android:layout_width="match_parent"
android:layout_height="match_parent" />
<TextView
android:id="@+id/tvPreviewHint"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:background="#8A1A1E1F"
android:gravity="center"
android:paddingStart="16dp"
android:paddingTop="10dp"
android:paddingEnd="16dp"
android:paddingBottom="10dp"
android:textColor="@color/white"
android:textSize="16sp" />
<TextView
android:id="@+id/tvSubtitle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="bottom"
android:background="#99000000"
android:lineSpacingExtra="4dp"
android:padding="14dp"
android:textColor="@color/white"
android:textSize="16sp"
android:visibility="gone" />
</FrameLayout>
<TextView
android:id="@+id/tvStatus"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:background="@drawable/shape_warm_feature_card"
android:lineSpacingExtra="4dp"
android:padding="18dp"
android:textColor="@color/warm_text_primary"
android:textSize="17sp" />
<TextView
android:id="@+id/tvInputTranscriptLabel"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="18dp"
android:text="@string/digital_human_test_input_transcript_title"
android:textColor="@color/warm_text_primary"
android:textSize="18sp"
android:textStyle="bold"
android:visibility="gone" />
<TextView
android:id="@+id/tvInputTranscript"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:background="@drawable/shape_warm_feature_card"
android:lineSpacingExtra="4dp"
android:padding="18dp"
android:textColor="@color/warm_text_primary"
android:textSize="16sp"
android:visibility="gone" />
<LinearLayout
android:id="@+id/layoutAvatarSection"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="20dp"
android:text="@string/voice_clone_choose_avatar"
android:textColor="@color/warm_text_primary"
android:textSize="18sp"
android:textStyle="bold" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rvAvatars"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:clipToPadding="false"
android:nestedScrollingEnabled="false"
android:paddingBottom="12dp" />
</LinearLayout>
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/digital_human_test_folder_title"
android:textColor="@color/warm_text_primary"
android:textSize="18sp"
android:textStyle="bold" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:background="@drawable/shape_warm_feature_card"
android:orientation="vertical"
android:padding="18dp">
<TextView
android:id="@+id/tvFolderName"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textColor="@color/warm_text_primary"
android:textSize="17sp"
android:textStyle="bold" />
<TextView
android:id="@+id/tvFolderMeta"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:lineSpacingExtra="4dp"
android:textColor="@color/warm_text_secondary"
android:textSize="14sp" />
<androidx.appcompat.widget.AppCompatButton
android:id="@+id/btnSelectFolder"
android:layout_width="match_parent"
android:layout_height="54dp"
android:layout_marginTop="14dp"
android:background="@drawable/selector_warm_secondary_button"
android:elevation="0dp"
android:stateListAnimator="@null"
android:text="@string/digital_human_test_select_folder"
android:textAllCaps="false"
android:textColor="@color/text_warm_secondary_button"
android:textSize="17sp" />
</LinearLayout>
<LinearLayout
android:id="@+id/layoutProgressSection"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:visibility="gone">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="20dp"
android:text="@string/digital_human_test_progress_title"
android:textColor="@color/warm_text_primary"
android:textSize="18sp"
android:textStyle="bold" />
<TextView
android:id="@+id/tvProgressSummary"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:textColor="@color/warm_text_secondary"
android:textSize="14sp" />
<TextView
android:id="@+id/tvProgressEmpty"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:background="@drawable/shape_warm_feature_card"
android:lineSpacingExtra="4dp"
android:padding="18dp"
android:text="@string/digital_human_test_progress_empty"
android:textColor="@color/warm_text_secondary"
android:textSize="15sp" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rvBatchJobs"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:clipToPadding="false"
android:nestedScrollingEnabled="false"
android:paddingBottom="6dp" />
</LinearLayout>
<androidx.appcompat.widget.AppCompatButton
android:id="@+id/btnPrimaryAction"
android:layout_width="match_parent"
android:layout_height="56dp"
android:layout_marginTop="20dp"
android:background="@drawable/selector_warm_primary_button"
android:elevation="0dp"
android:stateListAnimator="@null"
android:text="@string/digital_human_test_generate"
android:textAllCaps="false"
android:textColor="@color/text_warm_primary_button"
android:textSize="18sp"
android:textStyle="bold" />
</LinearLayout>
</androidx.core.widget.NestedScrollView>
@@ -0,0 +1,132 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/warm_bg"
android:orientation="vertical"
android:paddingStart="18dp"
android:paddingTop="28dp"
android:paddingEnd="18dp"
android:paddingBottom="20dp">
<androidx.appcompat.widget.AppCompatImageButton
android:id="@+id/btnBack"
android:layout_width="44dp"
android:layout_height="44dp"
android:background="@drawable/shape_warm_icon_button"
android:contentDescription="@string/device_settings_back"
android:padding="10dp"
android:scaleType="centerInside"
android:src="@drawable/ic_arrow_back" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="18dp"
android:text="@string/folder_picker_title"
android:textColor="@color/warm_text_primary"
android:textSize="25sp"
android:textStyle="bold" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:lineSpacingExtra="4dp"
android:text="@string/folder_picker_subtitle"
android:textColor="@color/warm_text_secondary"
android:textSize="15sp" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="18dp"
android:background="@drawable/shape_warm_feature_card"
android:orientation="vertical"
android:padding="18dp">
<TextView
android:id="@+id/tvCurrentFolderName"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textColor="@color/warm_text_primary"
android:textSize="17sp"
android:textStyle="bold" />
<TextView
android:id="@+id/tvCurrentPath"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:lineSpacingExtra="4dp"
android:textColor="@color/warm_text_secondary"
android:textSize="14sp" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="14dp"
android:orientation="horizontal">
<androidx.appcompat.widget.AppCompatButton
android:id="@+id/btnNavigateUp"
android:layout_width="0dp"
android:layout_height="52dp"
android:layout_weight="1"
android:background="@drawable/selector_warm_secondary_button"
android:elevation="0dp"
android:stateListAnimator="@null"
android:text="@string/folder_picker_up"
android:textAllCaps="false"
android:textColor="@color/text_warm_secondary_button"
android:textSize="16sp" />
<androidx.appcompat.widget.AppCompatButton
android:id="@+id/btnSelectCurrent"
android:layout_width="0dp"
android:layout_height="52dp"
android:layout_marginStart="12dp"
android:layout_weight="1"
android:background="@drawable/selector_warm_primary_button"
android:elevation="0dp"
android:stateListAnimator="@null"
android:text="@string/folder_picker_select_current"
android:textAllCaps="false"
android:textColor="@color/text_warm_primary_button"
android:textSize="16sp"
android:textStyle="bold" />
</LinearLayout>
</LinearLayout>
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="20dp"
android:text="@string/folder_picker_list_title"
android:textColor="@color/warm_text_primary"
android:textSize="18sp"
android:textStyle="bold" />
<TextView
android:id="@+id/tvEmpty"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:background="@drawable/shape_warm_feature_card"
android:lineSpacingExtra="4dp"
android:padding="18dp"
android:text="@string/folder_picker_empty"
android:textColor="@color/warm_text_secondary"
android:textSize="15sp"
android:visibility="gone" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rvFolders"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_marginTop="10dp"
android:layout_weight="1"
android:clipToPadding="false"
android:nestedScrollingEnabled="false"
android:paddingBottom="8dp" />
</LinearLayout>
+40 -20
View File
@@ -12,29 +12,49 @@
android:paddingTop="40dp"
android:paddingBottom="20dp">
<TextView
android:id="@+id/tv_title"
<FrameLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center"
android:paddingStart="24dp"
android:paddingEnd="24dp"
android:text="@string/app_title"
android:textColor="@color/warm_text_primary"
android:textSize="24sp"
android:textStyle="bold" />
android:paddingStart="18dp"
android:paddingEnd="18dp">
<TextView
android:id="@+id/tv_subtitle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:gravity="center"
android:paddingStart="28dp"
android:paddingEnd="28dp"
android:text="@string/app_subtitle"
android:textColor="@color/warm_text_secondary"
android:textSize="15sp" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:paddingEnd="68dp">
<TextView
android:id="@+id/tv_title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/app_title"
android:textColor="@color/warm_text_primary"
android:textSize="24sp"
android:textStyle="bold" />
<TextView
android:id="@+id/tv_subtitle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:lineSpacingExtra="4dp"
android:text="@string/app_subtitle"
android:textColor="@color/warm_text_secondary"
android:textSize="15sp" />
</LinearLayout>
<ImageButton
android:id="@+id/btnSettings"
android:layout_width="44dp"
android:layout_height="44dp"
android:layout_gravity="top|end"
android:background="@drawable/shape_warm_icon_button"
android:contentDescription="@string/settings_open"
android:padding="10dp"
android:scaleType="centerInside"
android:src="@drawable/ic_settings" />
</FrameLayout>
<LinearLayout
android:id="@+id/cardVoiceCloneEntry"
@@ -0,0 +1,251 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.core.widget.NestedScrollView xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/warm_bg"
android:fillViewport="true">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:paddingStart="18dp"
android:paddingTop="28dp"
android:paddingEnd="18dp"
android:paddingBottom="24dp">
<androidx.appcompat.widget.AppCompatImageButton
android:id="@+id/btnBack"
android:layout_width="44dp"
android:layout_height="44dp"
android:background="@drawable/shape_warm_icon_button"
android:contentDescription="@string/device_settings_back"
android:padding="10dp"
android:scaleType="centerInside"
android:src="@drawable/ic_arrow_back" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="18dp"
android:text="@string/settings_title"
android:textColor="@color/warm_text_primary"
android:textSize="24sp"
android:textStyle="bold" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:lineSpacingExtra="4dp"
android:text="@string/settings_subtitle"
android:textColor="@color/warm_text_secondary"
android:textSize="15sp" />
<LinearLayout
android:id="@+id/rowDeviceCode"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="18dp"
android:background="@drawable/shape_warm_feature_card"
android:clickable="true"
android:focusable="true"
android:foreground="?attr/selectableItemBackground"
android:gravity="center_vertical"
android:orientation="horizontal"
android:padding="18dp">
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/settings_device_code_title"
android:textColor="@color/warm_text_primary"
android:textSize="18sp"
android:textStyle="bold" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:lineSpacingExtra="4dp"
android:text="@string/settings_device_code_desc"
android:textColor="@color/warm_text_secondary"
android:textSize="14sp" />
</LinearLayout>
<ImageView
android:layout_width="24dp"
android:layout_height="24dp"
android:layout_marginStart="12dp"
android:src="@drawable/ic_chevron_right"
android:contentDescription="@null" />
</LinearLayout>
<LinearLayout
android:id="@+id/rowDebugMode"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:background="@drawable/shape_warm_feature_card"
android:clickable="true"
android:focusable="true"
android:foreground="?attr/selectableItemBackground"
android:gravity="center_vertical"
android:orientation="horizontal"
android:padding="18dp">
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/settings_debug_title"
android:textColor="@color/warm_text_primary"
android:textSize="18sp"
android:textStyle="bold" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:lineSpacingExtra="4dp"
android:text="@string/settings_debug_desc"
android:textColor="@color/warm_text_secondary"
android:textSize="14sp" />
</LinearLayout>
<androidx.appcompat.widget.SwitchCompat
android:id="@+id/switchDebugMode"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="12dp"
android:contentDescription="@string/settings_debug_title"
android:importantForAutofill="no"
android:text="" />
</LinearLayout>
<LinearLayout
android:id="@+id/rowDigitalHumanTest"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:background="@drawable/shape_warm_feature_card"
android:clickable="true"
android:focusable="true"
android:foreground="?attr/selectableItemBackground"
android:gravity="center_vertical"
android:orientation="horizontal"
android:padding="18dp">
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/settings_digital_human_test_title"
android:textColor="@color/warm_text_primary"
android:textSize="18sp"
android:textStyle="bold" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:lineSpacingExtra="4dp"
android:text="@string/settings_digital_human_test_desc"
android:textColor="@color/warm_text_secondary"
android:textSize="14sp" />
</LinearLayout>
<ImageView
android:layout_width="24dp"
android:layout_height="24dp"
android:layout_marginStart="12dp"
android:contentDescription="@null"
android:src="@drawable/ic_chevron_right" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:background="@drawable/shape_warm_feature_card"
android:orientation="vertical"
android:padding="18dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/settings_about_title"
android:textColor="@color/warm_text_primary"
android:textSize="18sp"
android:textStyle="bold" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="@string/settings_version_label"
android:textColor="@color/warm_text_secondary"
android:textSize="13sp" />
<TextView
android:id="@+id/tvVersionValue"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:textColor="@color/warm_text_primary"
android:textSize="16sp"
android:textStyle="bold" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="@string/settings_server_label"
android:textColor="@color/warm_text_secondary"
android:textSize="13sp" />
<TextView
android:id="@+id/tvServerValue"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:textColor="@color/warm_text_primary"
android:textSize="16sp"
android:textStyle="bold" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="@string/settings_device_label"
android:textColor="@color/warm_text_secondary"
android:textSize="13sp" />
<TextView
android:id="@+id/tvDeviceValue"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:textColor="@color/warm_text_primary"
android:textIsSelectable="true"
android:textSize="15sp"
android:textStyle="bold" />
</LinearLayout>
</LinearLayout>
</androidx.core.widget.NestedScrollView>
@@ -0,0 +1,64 @@
<?xml version="1.0" encoding="utf-8"?>
<com.google.android.material.card.MaterialCardView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="10dp"
app:cardBackgroundColor="@color/warm_surface"
app:cardCornerRadius="18dp"
app:cardElevation="2dp"
app:strokeColor="@color/warm_surface_alt"
app:strokeWidth="1dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:orientation="horizontal"
android:padding="16dp">
<TextView
android:id="@+id/tvIndex"
android:layout_width="44dp"
android:layout_height="44dp"
android:background="@drawable/shape_warm_icon_button"
android:gravity="center"
android:textColor="@color/warm_text_primary"
android:textSize="15sp"
android:textStyle="bold" />
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="14dp"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:id="@+id/tvPath"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textColor="@color/warm_text_primary"
android:textSize="16sp"
android:textStyle="bold" />
<TextView
android:id="@+id/tvStatus"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:textSize="14sp"
android:textStyle="bold" />
<TextView
android:id="@+id/tvDetail"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:lineSpacingExtra="3dp"
android:textColor="@color/warm_text_secondary"
android:textSize="13sp" />
</LinearLayout>
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
@@ -0,0 +1,41 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="10dp"
android:background="@drawable/shape_warm_feature_card"
android:gravity="center_vertical"
android:orientation="horizontal"
android:padding="16dp">
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:id="@+id/tvFolderName"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textColor="@color/warm_text_primary"
android:textSize="16sp"
android:textStyle="bold" />
<TextView
android:id="@+id/tvFolderPath"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:lineSpacingExtra="3dp"
android:textColor="@color/warm_text_secondary"
android:textSize="13sp" />
</LinearLayout>
<ImageView
android:layout_width="20dp"
android:layout_height="20dp"
android:layout_marginStart="12dp"
android:contentDescription="@null"
android:src="@drawable/ic_chevron_right" />
</LinearLayout>
@@ -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/azb_launcher_adaptive_background" />
<foreground android:drawable="@drawable/azb_launcher_adaptive_foreground" />
</adaptive-icon>
@@ -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/azb_launcher_adaptive_background" />
<foreground android:drawable="@drawable/azb_launcher_adaptive_foreground" />
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

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: 5.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 982 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.6 KiB

+77 -1
View File
@@ -1,5 +1,6 @@
<resources>
<string name="app_name">DUIX Demo</string>
<string name="app_name">安智伴</string>
<string name="app_title">安智伴</string>
<string name="play">播放</string>
<string name="base_config_url">基础配置Url:</string>
<string name="model_url">模型Url:</string>
@@ -60,6 +61,81 @@
<string name="voice_clone_saved">熟悉声音已经保存好了</string>
<string name="voice_clone_create_failed">熟悉声音保存失败,请稍后再试</string>
<string name="voice_clone_unavailable">请先在熟悉声音陪伴里保存一段声音</string>
<string name="device_settings_entry_title">家人连接</string>
<string name="device_settings_entry_desc">打开设备码页面后,家人扫一扫就能把这台陪伴设备绑定到自己的账号,之后可以给您留言。</string>
<string name="device_settings_entry_action">打开设备码</string>
<string name="device_settings_back">返回</string>
<string name="device_settings_title">家人连接</string>
<string name="device_settings_subtitle">把这个二维码给家里人扫一扫,绑定后他们就能给您留言,也能看看最近的陪伴记录。</string>
<string name="device_settings_qr_title">这台设备的二维码</string>
<string name="device_settings_qr_desc">家人用网页面板扫一扫,就能把这台设备连接到自己的账号。</string>
<string name="device_settings_uuid_label">设备 UUID</string>
<string name="device_settings_link_label">绑定链接</string>
<string name="device_settings_refresh">重新同步二维码</string>
<string name="device_settings_syncing">正在为您同步设备连接信息…</string>
<string name="device_settings_sync_success">设备二维码已经准备好了</string>
<string name="device_settings_sync_failed">同步失败:%1$s</string>
<string name="settings_open">打开设置</string>
<string name="settings_title">设置</string>
<string name="settings_subtitle">把设备信息、家人连接入口和当前版本都放在这里,找起来更方便。</string>
<string name="settings_device_code_title">家人连接设备码</string>
<string name="settings_device_code_desc">打开设备二维码页面后,家里人扫一扫就能把这台设备绑定到自己的账号。</string>
<string name="settings_debug_title">显示调试详情</string>
<string name="settings_debug_desc">开启后,聊天页面会显示完整的交互日志,包括工具调用的输入和输出。</string>
<string name="settings_digital_human_test_title">数字人测试</string>
<string name="settings_digital_human_test_desc">选择一个文件夹,批量把里面的音频送给 Omni,关闭 search 和 tooluse,并按原目录结构导出数字人 mp4。</string>
<string name="settings_about_title">关于这台设备</string>
<string name="settings_version_label">应用版本</string>
<string name="settings_server_label">家属面板域名</string>
<string name="settings_device_label">当前设备 UUID</string>
<string name="digital_human_test_title">数字人测试</string>
<string name="digital_human_test_subtitle">选择一个文件夹后,应用会递归处理其中的音频,关闭 search 和 tools,并按原目录结构批量导出数字人回应 mp4。</string>
<string name="digital_human_test_status_idle">为 %1$s 选好一个文件夹后,我会按顺序处理里面的音频,并批量导出完整 mp4。</string>
<string name="digital_human_test_status_scanning_folder">正在扫描所选文件夹里的音频…</string>
<string name="digital_human_test_status_preparing_resources">正在为 %1$s 准备测试资源…</string>
<string name="digital_human_test_status_loading_avatar">正在让 %1$s 准备好批量处理…</string>
<string name="digital_human_test_status_decoding_audio">正在解析您选好的音频…</string>
<string name="digital_human_test_status_connecting">正在连接 Omni…</string>
<string name="digital_human_test_status_uploading_audio">正在把音频送给 Omni…</string>
<string name="digital_human_test_status_generating">Omni 正在回应,数字人也在跟着这段声音做同步反应。</string>
<string name="digital_human_test_status_muxing">回应已经完成,正在整理并封装最终 mp4…</string>
<string name="digital_human_test_status_done">当前这条完整 mp4 已经保存好了。</string>
<string name="digital_human_test_status_batch_ready">已准备好 %1$d 条待处理任务,点击开始后会按顺序处理。</string>
<string name="digital_human_test_status_batch_current">正在处理第 %1$d/%2$d 条:%3$s</string>
<string name="digital_human_test_status_batch_done">批处理完成,成功 %1$d 条,跳过 %2$d 条,失败 %3$d 条。</string>
<string name="digital_human_test_status_copying">正在按原目录结构保存结果…</string>
<string name="digital_human_test_status_retrying">当前任务失败,正在重试 %3$s(%1$d/%2$d)…</string>
<string name="digital_human_test_preview_idle">%1$s 会在这里依次回应当前队列里的音频。</string>
<string name="digital_human_test_preview_loading">%1$s 正在准备当前这条任务…</string>
<string name="digital_human_test_folder_title">选择输入文件夹</string>
<string name="digital_human_test_folder_none">还没有选文件夹</string>
<string name="digital_human_test_folder_hint">直接选择一个文件夹,应用会递归扫描其中的常见音频文件,并按相同目录层级写回 mp4。</string>
<string name="digital_human_test_folder_summary">已扫描 %1$d 段音频,结果会保存到 %2$s/%3$s 下,并保留原目录结构。</string>
<string name="digital_human_test_select_folder">选择文件夹</string>
<string name="digital_human_test_generate">开始批量生成</string>
<string name="digital_human_test_running">批处理中…</string>
<string name="digital_human_test_need_folder">请先选择一个文件夹</string>
<string name="digital_human_test_all_outputs_exist">目标 mp4 都已存在,当前没有需要处理的任务。</string>
<string name="digital_human_test_input_transcript_title">当前输入识别</string>
<string name="digital_human_test_input_transcript_empty">暂时还没有识别内容</string>
<string name="digital_human_test_progress_title">处理进度</string>
<string name="digital_human_test_progress_summary">总计 %1$d 条,已完成 %2$d 条,已跳过 %3$d 条,失败 %4$d 条。</string>
<string name="digital_human_test_progress_empty">点击开始后,这里会按顺序显示每条音频的处理进度。</string>
<string name="digital_human_test_job_pending">等待处理</string>
<string name="digital_human_test_job_decoding">解析音频</string>
<string name="digital_human_test_job_connecting">连接 Omni</string>
<string name="digital_human_test_job_uploading">发送音频</string>
<string name="digital_human_test_job_generating">生成响应</string>
<string name="digital_human_test_job_muxing">封装 mp4</string>
<string name="digital_human_test_job_copying">保存结果</string>
<string name="digital_human_test_job_retrying">重试中(%1$d/%2$d)</string>
<string name="digital_human_test_job_done">已完成</string>
<string name="digital_human_test_job_failed">失败</string>
<string name="digital_human_test_job_skipped">已跳过</string>
<string name="digital_human_test_job_output">输出:%1$s</string>
<string name="digital_human_test_no_audio_found">所选文件夹里没有找到可处理的音频文件</string>
<string name="digital_human_test_no_audio_response">这次 Omni 没有返回可用的响应音频</string>
<string name="digital_human_test_connection_lost">测试连接中途断开了</string>
<string name="main_download_tips">Github地址可能下载失败,您可以考虑使用代理或者将文件缓存到自己的存储服务</string>
<string name="debug_message">调试信息</string>
+5
View File
@@ -25,4 +25,9 @@
<color name="warm_button_secondary_bg_pressed">#FFF0E0D2</color>
<color name="warm_button_secondary_bg_disabled">#FFF8F1EB</color>
<color name="warm_button_secondary_stroke">#FFD7B39B</color>
<color name="warm_status_pending">#FF9C7C6E</color>
<color name="warm_status_running">#FFC16845</color>
<color name="warm_status_success">#FF5C8C5A</color>
<color name="warm_status_error">#FFB85C4A</color>
<color name="warm_status_skipped">#FF7B8A94</color>
</resources>
+86 -2
View File
@@ -1,6 +1,6 @@
<resources>
<string name="app_name">AI 情感陪护</string>
<string name="app_title">AI 情感陪护</string>
<string name="app_name">安智伴</string>
<string name="app_title">安智伴</string>
<string name="app_subtitle">选一位陪伴者,开始温暖对话</string>
<string name="choose_companion_hint">点击头像即可开始对话</string>
<string name="play">Play</string>
@@ -63,6 +63,90 @@
<string name="voice_clone_saved">The familiar voice has been saved</string>
<string name="voice_clone_create_failed">Failed to save the familiar voice, please try again later</string>
<string name="voice_clone_unavailable">Please save a familiar voice first</string>
<string name="device_settings_entry_title">Family Connection</string>
<string name="device_settings_entry_desc">Open the device code page so your family can scan and connect this companion device to their own account.</string>
<string name="device_settings_entry_action">Open Device Code</string>
<string name="device_settings_back">Back</string>
<string name="device_settings_title">Family Connection</string>
<string name="device_settings_subtitle">Show this QR code to your family. After scanning, they can leave you messages and check recent usage records.</string>
<string name="device_settings_qr_title">This device QR code</string>
<string name="device_settings_qr_desc">Family members can scan this code with the web panel to bind this device.</string>
<string name="device_settings_uuid_label">Device UUID</string>
<string name="device_settings_link_label">Binding link</string>
<string name="device_settings_refresh">Refresh QR Code</string>
<string name="device_settings_syncing">Syncing device status…</string>
<string name="device_settings_sync_success">Device QR code is ready</string>
<string name="device_settings_sync_failed">Sync failed: %1$s</string>
<string name="settings_open">Open settings</string>
<string name="settings_title">Settings</string>
<string name="settings_subtitle">Keep device information, family connection entry and current version together in one place.</string>
<string name="settings_device_code_title">Family device code</string>
<string name="settings_device_code_desc">Open the QR code page for your family to scan and bind this elder device.</string>
<string name="settings_debug_title">Show debug details</string>
<string name="settings_debug_desc">When enabled, the chat page will show full interaction logs, including tool input and output.</string>
<string name="settings_digital_human_test_title">Digital Human Test</string>
<string name="settings_digital_human_test_desc">Choose a local folder, batch-send the audio files inside to Omni, and export avatar MP4s while keeping the original directory structure.</string>
<string name="settings_about_title">About this device</string>
<string name="settings_version_label">App version</string>
<string name="settings_server_label">Family panel domain</string>
<string name="settings_device_label">Current device UUID</string>
<string name="digital_human_test_title">Digital Human Test</string>
<string name="digital_human_test_subtitle">Choose a local folder, recursively process its audio files without search or tools, and export avatar MP4s while preserving the original directory structure.</string>
<string name="digital_human_test_status_idle">Choose a local folder for %1$s, then I will process the audio files one by one and export complete MP4 responses in batch.</string>
<string name="digital_human_test_status_scanning_folder">Scanning the selected folder for audio files…</string>
<string name="digital_human_test_status_preparing_resources">Preparing %1$s for the test…</string>
<string name="digital_human_test_status_loading_avatar">Loading %1$s for the batch…</string>
<string name="digital_human_test_status_decoding_audio">Decoding the selected audio…</string>
<string name="digital_human_test_status_connecting">Connecting to Omni…</string>
<string name="digital_human_test_status_uploading_audio">Sending the selected audio to Omni…</string>
<string name="digital_human_test_status_generating">Omni is answering. The avatar is following the response audio now.</string>
<string name="digital_human_test_status_muxing">The answer is ready. Packing the final MP4…</string>
<string name="digital_human_test_status_done">The current MP4 has been saved.</string>
<string name="digital_human_test_status_batch_ready">%1$d jobs are ready to run. Tap start to process them one by one.</string>
<string name="digital_human_test_status_batch_current">Processing %1$d/%2$d: %3$s</string>
<string name="digital_human_test_status_batch_done">Batch completed. %1$d succeeded, %2$d were skipped, and %3$d failed.</string>
<string name="digital_human_test_status_copying">Saving the result with the original folder structure…</string>
<string name="digital_human_test_status_retrying">Retrying %3$s (%1$d/%2$d)…</string>
<string name="digital_human_test_preview_idle">%1$s will respond to the queued audio files here.</string>
<string name="digital_human_test_preview_loading">Getting %1$s ready for the current job…</string>
<string name="digital_human_test_folder_title">Choose an input folder</string>
<string name="digital_human_test_folder_none">No folder selected yet</string>
<string name="digital_human_test_folder_hint">Choose a local folder directly. On Android 11 and above, the app will first ask for file management access so it can avoid the system folder picker restrictions.</string>
<string name="digital_human_test_folder_summary">Found %1$d audio files. Results will be saved under %2$s/%3$s while keeping the original structure.</string>
<string name="digital_human_test_select_folder">Choose Folder</string>
<string name="digital_human_test_generate">Start Batch Export</string>
<string name="digital_human_test_running">Processing…</string>
<string name="digital_human_test_need_folder">Choose a folder first</string>
<string name="digital_human_test_all_outputs_exist">All target MP4 files already exist. There is nothing to process right now.</string>
<string name="digital_human_test_need_storage_access">Allow file management access first, then choose the input folder.</string>
<string name="digital_human_test_manage_storage_denied">File management access is still disabled, so the app cannot browse local folders yet.</string>
<string name="digital_human_test_legacy_storage_denied">Storage permission is required to browse local folders on this Android version.</string>
<string name="digital_human_test_input_transcript_title">Current recognized input</string>
<string name="digital_human_test_input_transcript_empty">No recognized transcript yet</string>
<string name="digital_human_test_progress_title">Progress</string>
<string name="digital_human_test_progress_summary">%1$d total, %2$d completed, %3$d skipped, %4$d failed.</string>
<string name="digital_human_test_progress_empty">After the batch starts, each audio job will appear here in order.</string>
<string name="digital_human_test_job_pending">Queued</string>
<string name="digital_human_test_job_decoding">Decoding audio</string>
<string name="digital_human_test_job_connecting">Connecting to Omni</string>
<string name="digital_human_test_job_uploading">Uploading audio</string>
<string name="digital_human_test_job_generating">Generating response</string>
<string name="digital_human_test_job_muxing">Packing MP4</string>
<string name="digital_human_test_job_copying">Saving result</string>
<string name="digital_human_test_job_retrying">Retrying (%1$d/%2$d)</string>
<string name="digital_human_test_job_done">Completed</string>
<string name="digital_human_test_job_failed">Failed</string>
<string name="digital_human_test_job_skipped">Skipped</string>
<string name="digital_human_test_job_output">Output: %1$s</string>
<string name="digital_human_test_no_audio_found">No supported audio files were found in the selected folder</string>
<string name="digital_human_test_no_audio_response">Omni did not return response audio this time</string>
<string name="digital_human_test_connection_lost">The test connection was interrupted</string>
<string name="folder_picker_title">Choose Folder</string>
<string name="folder_picker_subtitle">Browse the device storage directly, enter the target folder, and then use the current folder as the batch input root.</string>
<string name="folder_picker_select_current">Use Current Folder</string>
<string name="folder_picker_up">Up One Level</string>
<string name="folder_picker_list_title">Subfolders</string>
<string name="folder_picker_empty">There are no readable subfolders here. You can still use the current folder.</string>
<string name="main_download_tips">The GitHub url may fail to download, you can consider using a proxy or caching the file to your own storage service</string>
<string name="debug_message">Debug message</string>
</resources>