Compare commits

...

2 Commits

Author SHA1 Message Date
2015286595 加入频谱图 2026-03-20 19:37:10 +08:00
a44ddb6dff 优化UI,加入模型/音色选择 2026-03-19 07:47:20 +08:00
27 changed files with 538 additions and 223 deletions

View File

@ -28,7 +28,8 @@
<activity
android:name=".ui.activity.CallActivity"
android:exported="false">
android:exported="false"
android:theme="@style/Theme.DUIX.Call">
</activity>
</application>

View File

@ -0,0 +1,15 @@
package ai.guiji.duix.test.model
/**
* 数字人形象模型
* @param name 中文显示名
* @param voice 语音音色名传给 Omni Realtime
* @param modelUrl 模型资源下载地址
* @param avatarRes 头像资源 IDmipmap
*/
data class AvatarModel(
val name: String,
val voice: String,
val modelUrl: String,
val avatarRes: Int
)

View File

@ -30,10 +30,12 @@ public class OmniRealtimeClient {
private final OkHttpClient okHttpClient;
private WebSocket webSocket;
private final Callback callback;
private final String voice;
private final AtomicBoolean isConnected = new AtomicBoolean(false);
private final AtomicBoolean userSpeaking = new AtomicBoolean(false);
public OmniRealtimeClient(Callback callback) {
public OmniRealtimeClient(String voice, Callback callback) {
this.voice = voice;
this.callback = callback;
this.okHttpClient = new OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS)
@ -98,7 +100,7 @@ public class OmniRealtimeClient {
try {
JSONObject session = new JSONObject();
session.put("modalities", new org.json.JSONArray().put("text").put("audio"));
session.put("voice", "Ethan");
session.put("voice", voice);
session.put("input_audio_format", "pcm");
session.put("output_audio_format", "pcm");
session.put("instructions", "你是一名面向中国老年群体的心理陪伴 AI。你的职责是为老人提供温和、耐心、尊重、稳定的情感陪伴、情绪支持、回忆唤起、生活关怀和风险识别服务。你不是医生也不是心理治疗师不能替代专业诊疗但应在必要时建议联系家属、社区、医生或急救资源。\n" +

View File

@ -13,6 +13,7 @@ import ai.guiji.duix.test.databinding.ActivityCallBinding
import ai.guiji.duix.test.realtime.OmniRealtimeClient
import ai.guiji.duix.test.ui.adapter.MotionAdapter
import ai.guiji.duix.test.ui.dialog.AudioRecordDialog
import ai.guiji.duix.test.ui.widget.AudioSpectrumView
import ai.guiji.duix.test.util.StringUtils
import android.Manifest
import android.annotation.SuppressLint
@ -25,7 +26,6 @@ import android.util.Log
import android.view.View
import android.widget.CompoundButton
import android.widget.Toast
import androidx.core.content.ContextCompat
import com.bumptech.glide.Glide
import java.io.File
import java.io.FileInputStream
@ -39,6 +39,7 @@ class CallActivity : BaseActivity() {
}
private var modelUrl = ""
private var voice = "Ethan"
private var debug = false
private var mMessage = ""
@ -82,6 +83,7 @@ class CallActivity : BaseActivity() {
setContentView(binding.root)
modelUrl = intent.getStringExtra("modelUrl") ?: ""
voice = intent.getStringExtra("voice") ?: "Ethan"
debug = intent.getBooleanExtra("debug", false)
Glide.with(mContext).load("file:///android_asset/bg/bg1.png").into(binding.ivBg)
@ -162,11 +164,17 @@ class CallActivity : BaseActivity() {
Constant.CALLBACK_EVENT_AUDIO_PLAY_START -> {
applyMessage("callback audio play start")
Log.i(TAG, "CALLBACK_EVENT_AUDIO_PLAY_START")
if (isAIConversationActive) {
runOnUiThread { binding.audioSpectrum.setAiSpeaking(true) }
}
}
Constant.CALLBACK_EVENT_AUDIO_PLAY_END -> {
applyMessage("callback audio play end")
Log.i(TAG, "CALLBACK_EVENT_PLAY_END")
if (isAIConversationActive) {
runOnUiThread { binding.audioSpectrum.setAiSpeaking(false) }
}
}
Constant.CALLBACK_EVENT_AUDIO_PLAY_ERROR -> {
@ -311,20 +319,24 @@ class CallActivity : BaseActivity() {
applyMessage("AI对话: 连接中...")
binding.tvSubtitle.visibility = View.VISIBLE
binding.tvSubtitle.text = "正在连接..."
binding.btnAIConversation.text = getString(R.string.ai_fab_stop)
binding.audioSpectrum.visibility = View.VISIBLE
binding.audioSpectrum.clear()
binding.btnAIConversation.setImageResource(R.drawable.ic_mic_off)
binding.btnAIConversation.setBackgroundResource(R.drawable.shape_fab_ai_active)
omniClient = OmniRealtimeClient(object : OmniRealtimeClient.Callback {
omniClient = OmniRealtimeClient(voice, object : OmniRealtimeClient.Callback {
override fun onConnected() {
mainHandler.post {
binding.tvSubtitle.text = "已连接,请对着麦克风说话"
binding.btnCameraToggle.visibility = View.VISIBLE
binding.btnCameraToggle.background = ContextCompat.getDrawable(mContext, R.drawable.shape_circle_camera_off)
binding.btnCameraToggle.setBackgroundResource(R.drawable.shape_fab_camera_off)
binding.btnCameraToggle.contentDescription = getString(R.string.camera_off)
isCameraEnabled = false
applyMessage("AI对话: 已连接")
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 {
@ -425,10 +437,13 @@ class CallActivity : BaseActivity() {
mainHandler.post {
if (!isFinishing) {
binding.btnAIConversation.text = getString(R.string.ai_fab_start)
binding.btnAIConversation.setImageResource(R.drawable.ic_mic)
binding.btnAIConversation.setBackgroundResource(R.drawable.shape_fab_ai)
binding.btnCameraToggle.visibility = View.GONE
binding.tvSubtitle.visibility = View.GONE
binding.tvSubtitle.text = ""
binding.audioSpectrum.clear()
binding.audioSpectrum.visibility = View.GONE
applyMessage("AI对话: 已结束")
}
}
@ -451,11 +466,11 @@ class CallActivity : BaseActivity() {
}
}, binding.cameraPreview)
cameraFrameCapture?.start()
binding.btnCameraToggle.background = ContextCompat.getDrawable(mContext, R.drawable.shape_circle_camera_on)
binding.btnCameraToggle.setBackgroundResource(R.drawable.shape_fab_camera_on)
binding.btnCameraToggle.contentDescription = getString(R.string.camera_on)
} else {
stopCamera()
binding.btnCameraToggle.background = ContextCompat.getDrawable(mContext, R.drawable.shape_circle_camera_off)
binding.btnCameraToggle.setBackgroundResource(R.drawable.shape_fab_camera_off)
binding.btnCameraToggle.contentDescription = getString(R.string.camera_off)
}
}

View File

@ -1,113 +1,96 @@
package ai.guiji.duix.test.ui.activity
import ai.guiji.duix.sdk.client.BuildConfig
import ai.guiji.duix.sdk.client.VirtualModelUtil
import ai.guiji.duix.test.R
import ai.guiji.duix.test.databinding.ActivityMainBinding
import ai.guiji.duix.test.model.AvatarModel
import ai.guiji.duix.test.ui.adapter.AvatarAdapter
import ai.guiji.duix.test.ui.dialog.LoadingDialog
import ai.guiji.duix.test.ui.dialog.ModelSelectorDialog
import android.annotation.SuppressLint
import android.content.Intent
import android.os.Bundle
import android.text.TextUtils
import android.widget.Toast
import androidx.recyclerview.widget.GridLayoutManager
import java.io.File
class MainActivity : BaseActivity() {
private lateinit var binding: ActivityMainBinding
private var mLoadingDialog: LoadingDialog?=null
private var mLoadingDialog: LoadingDialog? = null
private var mLastProgress = 0
val models = arrayListOf(
"https://github.com/duixcom/Duix-Mobile/releases/download/v1.0.0/bendi3_20240518.zip",
"https://github.com/duixcom/Duix-Mobile/releases/download/v1.0.0/airuike_20240409.zip",
"https://github.com/duixcom/Duix-Mobile/releases/download/v1.0.0/675429759852613_7f8d9388a4213080b1820b83dd057cfb_optim_m80.zip",
"https://github.com/duixcom/Duix-Mobile/releases/download/v1.0.0/674402003804229_f6e86fb375c4f1f1b82b24f7ee4e7cb4_optim_m80.zip",
"https://github.com/duixcom/Duix-Mobile/releases/download/v1.0.0/674400178376773_3925e756433c5a9caa9b9d54147ae4ab_optim_m80.zip",
"https://github.com/duixcom/Duix-Mobile/releases/download/v1.0.0/674397294927941_6e297e18a4bdbe35c07a6ae48a1f021f_optim_m80.zip",
"https://github.com/duixcom/Duix-Mobile/releases/download/v1.0.0/674393494597701_f49fcf68f5afdb241d516db8a7d88a7b_optim_m80.zip",
"https://github.com/duixcom/Duix-Mobile/releases/download/v1.0.0/651705983152197_ccf3256b2449c76e77f94276dffcb293_optim_m80.zip",
"https://github.com/duixcom/Duix-Mobile/releases/download/v1.0.0/627306542239813_1871244b5e6912efc636ba31ea4c5c6d_optim_m80.zip",
private val baseConfigUrl = "https://dl.xn--876a.net/models/gj_dh_res.zip"
private val avatarModels = listOf(
AvatarModel("晨煦", "Ethan", "https://dl.xn--876a.net/models/bendi3_20240518.zip", R.mipmap.ethan),
AvatarModel("詹妮弗", "Jennifer", "https://dl.xn--876a.net/models/Emma.zip", R.mipmap.jennifer),
AvatarModel("月白", "Moon", "https://dl.xn--876a.net/models/Kai.zip", R.mipmap.moon),
AvatarModel("艾登", "Aiden", "https://dl.xn--876a.net/models/Leo.zip", R.mipmap.leo),
AvatarModel("四月", "Maia", "https://dl.xn--876a.net/models/Lily.zip", R.mipmap.lily),
AvatarModel("奥利弗", "Andre", "https://dl.xn--876a.net/models/Oliver.zip", R.mipmap.oliver),
AvatarModel("索菲亚", "Sonrisa", "https://dl.xn--876a.net/models/Sofia.zip", R.mipmap.sofia),
)
private var mBaseConfigUrl = ""
private var mModelUrl = ""
private var mSelectedModel: AvatarModel? = null
@SuppressLint("SetTextI18n")
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)
binding.tvSdkVersion.text = "SDK Version: ${BuildConfig.VERSION_NAME}"
binding.btnMoreModel.setOnClickListener {
val modelSelectorDialog = ModelSelectorDialog(mContext, models, object : ModelSelectorDialog.Listener{
override fun onSelect(url: String) {
binding.etUrl.setText(url)
}
})
modelSelectorDialog.show()
}
binding.btnPlay.setOnClickListener {
play()
}
binding.rvAvatars.layoutManager = GridLayoutManager(this, 2)
binding.rvAvatars.adapter = AvatarAdapter(avatarModels, object : AvatarAdapter.Callback {
override fun onSelect(model: AvatarModel) {
mSelectedModel = model
play(model)
}
})
}
private fun play(){
mBaseConfigUrl = binding.etBaseConfig.text.toString()
mModelUrl = binding.etUrl.text.toString()
if (TextUtils.isEmpty(mBaseConfigUrl)){
Toast.makeText(mContext, R.string.base_config_cannot_be_empty, Toast.LENGTH_SHORT).show()
return
}
if (TextUtils.isEmpty(mModelUrl)){
Toast.makeText(mContext, R.string.model_url_cannot_be_empty, Toast.LENGTH_SHORT).show()
return
}
private fun play(model: AvatarModel) {
mSelectedModel = model
checkBaseConfig()
}
private fun checkBaseConfig(){
if (VirtualModelUtil.checkBaseConfig(mContext)){
private fun checkBaseConfig() {
if (VirtualModelUtil.checkBaseConfig(mContext)) {
checkModel()
} else {
baseConfigDownload()
}
}
private fun checkModel(){
if (VirtualModelUtil.checkModel(mContext, mModelUrl)){
private fun checkModel() {
val model = mSelectedModel ?: return
if (VirtualModelUtil.checkModel(mContext, model.modelUrl)) {
jumpPlayPage()
} else {
modelDownload()
}
}
private fun jumpPlayPage(){
private fun jumpPlayPage() {
val model = mSelectedModel ?: return
val intent = Intent(mContext, CallActivity::class.java)
intent.putExtra("modelUrl", mModelUrl)
val debug = binding.switchDebug.isChecked
intent.putExtra("debug", debug)
intent.putExtra("modelUrl", model.modelUrl)
intent.putExtra("voice", model.voice)
intent.putExtra("debug", false)
startActivity(intent)
}
private fun baseConfigDownload(){
private fun baseConfigDownload() {
mLoadingDialog?.dismiss()
mLoadingDialog = LoadingDialog(mContext, "Start downloading")
mLoadingDialog = LoadingDialog(mContext, "正在准备资源...")
mLoadingDialog?.show()
VirtualModelUtil.baseConfigDownload(mContext, mBaseConfigUrl, object :
VirtualModelUtil.baseConfigDownload(mContext, baseConfigUrl, object :
VirtualModelUtil.ModelDownloadCallback {
override fun onDownloadProgress(url: String?, current: Long, total: Long) {
val progress = (current * 100 / total).toInt()
if (progress != mLastProgress){
if (progress != mLastProgress) {
mLastProgress = progress
runOnUiThread {
if (mLoadingDialog?.isShowing == true){
mLoadingDialog?.setContent("Config download(${progress}%)")
if (mLoadingDialog?.isShowing == true) {
mLoadingDialog?.setContent("基础配置下载中(${progress}%)")
}
}
}
@ -115,11 +98,11 @@ class MainActivity : BaseActivity() {
override fun onUnzipProgress(url: String?, current: Long, total: Long) {
val progress = (current * 100 / total).toInt()
if (progress != mLastProgress){
if (progress != mLastProgress) {
mLastProgress = progress
runOnUiThread {
if (mLoadingDialog?.isShowing == true){
mLoadingDialog?.setContent("Config unzip(${progress}%)")
if (mLoadingDialog?.isShowing == true) {
mLoadingDialog?.setContent("基础配置解压中(${progress}%)")
}
}
}
@ -135,45 +118,37 @@ class MainActivity : BaseActivity() {
override fun onDownloadFail(url: String?, code: Int, msg: String?) {
runOnUiThread {
mLoadingDialog?.dismiss()
Toast.makeText(mContext, "BaseConfig download error: $msg", Toast.LENGTH_SHORT).show()
Toast.makeText(mContext, "资源下载失败: $msg", Toast.LENGTH_SHORT).show()
}
}
})
}
private fun modelDownload(){
private fun modelDownload() {
val model = mSelectedModel ?: return
mLoadingDialog?.dismiss()
mLoadingDialog = LoadingDialog(mContext, "Start downloading")
mLoadingDialog = LoadingDialog(mContext, "正在下载 ${model.name} ...")
mLoadingDialog?.show()
VirtualModelUtil.modelDownload(mContext, mModelUrl, object : VirtualModelUtil.ModelDownloadCallback{
override fun onDownloadProgress(
url: String?,
current: Long,
total: Long,
) {
VirtualModelUtil.modelDownload(mContext, model.modelUrl, object : VirtualModelUtil.ModelDownloadCallback {
override fun onDownloadProgress(url: String?, current: Long, total: Long) {
val progress = (current * 100 / total).toInt()
if (progress != mLastProgress){
if (progress != mLastProgress) {
mLastProgress = progress
runOnUiThread {
if (mLoadingDialog?.isShowing == true){
mLoadingDialog?.setContent("Model download(${progress}%)")
if (mLoadingDialog?.isShowing == true) {
mLoadingDialog?.setContent("${model.name} 下载中(${progress}%)")
}
}
}
}
override fun onUnzipProgress(
url: String?,
current: Long,
total: Long,
) {
override fun onUnzipProgress(url: String?, current: Long, total: Long) {
val progress = (current * 100 / total).toInt()
if (progress != mLastProgress){
if (progress != mLastProgress) {
mLastProgress = progress
runOnUiThread {
if (mLoadingDialog?.isShowing == true){
mLoadingDialog?.setContent("Model unzip(${progress}%)")
if (mLoadingDialog?.isShowing == true) {
mLoadingDialog?.setContent("${model.name} 解压中(${progress}%)")
}
}
}
@ -186,18 +161,12 @@ class MainActivity : BaseActivity() {
}
}
override fun onDownloadFail(
url: String?,
code: Int,
msg: String?,
) {
override fun onDownloadFail(url: String?, code: Int, msg: String?) {
runOnUiThread {
mLoadingDialog?.dismiss()
Toast.makeText(mContext, "Model download error: $msg", Toast.LENGTH_SHORT).show()
Toast.makeText(mContext, "下载失败: $msg", Toast.LENGTH_SHORT).show()
}
}
})
}
}

View File

@ -0,0 +1,38 @@
package ai.guiji.duix.test.ui.adapter
import ai.guiji.duix.test.databinding.ItemAvatarCardBinding
import ai.guiji.duix.test.model.AvatarModel
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
class AvatarAdapter(
private val list: List<AvatarModel>,
private val callback: Callback
) : RecyclerView.Adapter<AvatarAdapter.ViewHolder>() {
class ViewHolder(val binding: ItemAvatarCardBinding) :
RecyclerView.ViewHolder(binding.root)
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val binding = ItemAvatarCardBinding.inflate(
LayoutInflater.from(parent.context), parent, false
)
return ViewHolder(binding)
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val model = list[position]
holder.binding.ivAvatar.setImageResource(model.avatarRes)
holder.binding.tvName.text = model.name
holder.binding.cardAvatar.setOnClickListener {
callback.onSelect(model)
}
}
override fun getItemCount(): Int = list.size
interface Callback {
fun onSelect(model: AvatarModel)
}
}

View File

@ -0,0 +1,215 @@
package ai.guiji.duix.test.ui.widget;
import android.content.Context;
import android.graphics.Canvas;
import android.graphics.LinearGradient;
import android.graphics.Paint;
import android.graphics.Shader;
import android.util.AttributeSet;
import android.view.View;
/**
* 淡雅音频频谱可视化 View
* 支持两种模式
* 1. 麦克风模式 用真实 PCM 数据驱动频谱柱
* 2. AI 说话模式 柔和呼吸脉动动画与数字人口型同步 SDK 回调驱动
*/
public class AudioSpectrumView extends View {
private static final int BAR_COUNT = 32;
private static final float SMOOTHING = 0.35f;
private static final float DECAY = 0.92f;
private final Paint barPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
private final Paint glowPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
private final float[] magnitudes = new float[BAR_COUNT];
private final float[] displayMagnitudes = new float[BAR_COUNT];
private LinearGradient micGradient;
private LinearGradient aiGradient;
private int lastWidth = 0;
private int lastHeight = 0;
/** AI 说话脉动状态 */
private boolean aiSpeaking = false;
private long aiSpeakStartTime = 0;
public AudioSpectrumView(Context context) {
super(context);
init();
}
public AudioSpectrumView(Context context, AttributeSet attrs) {
super(context, attrs);
init();
}
public AudioSpectrumView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
init();
}
private void init() {
barPaint.setStyle(Paint.Style.FILL);
glowPaint.setStyle(Paint.Style.FILL);
glowPaint.setAlpha(40);
}
/**
* 接收 PCM 16bit mono 音频数据麦克风提取频谱能量
*/
public void updateAudio(byte[] pcmData) {
if (pcmData == null || pcmData.length < 4) return;
if (aiSpeaking) return; // AI 说话时忽略麦克风数据
int sampleCount = pcmData.length / 2;
int samplesPerBar = Math.max(1, sampleCount / BAR_COUNT);
for (int i = 0; i < BAR_COUNT; i++) {
long sum = 0;
int start = i * samplesPerBar;
int end = Math.min(start + samplesPerBar, sampleCount);
for (int j = start; j < end; j++) {
int idx = j * 2;
if (idx + 1 >= pcmData.length) break;
short sample = (short) ((pcmData[idx] & 0xFF) | (pcmData[idx + 1] << 8));
sum += Math.abs(sample);
}
float avg = (float) sum / Math.max(1, end - start);
float normalized = Math.min(1f, avg / 8000f);
float boost = 1f + (float) i / BAR_COUNT * 1.0f;
magnitudes[i] = Math.min(1f, normalized * boost);
}
postInvalidateOnAnimation();
}
/** 通知 AI 数字人开始说话(由 DUIX SDK AUDIO_PLAY_START 回调触发) */
public void setAiSpeaking(boolean speaking) {
this.aiSpeaking = speaking;
if (speaking) {
aiSpeakStartTime = System.currentTimeMillis();
// 清除麦克风残留数据
for (int i = 0; i < BAR_COUNT; i++) {
magnitudes[i] = 0f;
}
}
postInvalidateOnAnimation();
}
public boolean isAiSpeaking() {
return aiSpeaking;
}
/** 清除频谱 */
public void clear() {
aiSpeaking = false;
for (int i = 0; i < BAR_COUNT; i++) {
magnitudes[i] = 0f;
}
postInvalidateOnAnimation();
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
int w = getWidth();
int h = getHeight();
if (w == 0 || h == 0) return;
if (micGradient == null || lastWidth != w || lastHeight != h) {
lastWidth = w;
lastHeight = h;
// 麦克风暖橙渐变
micGradient = new LinearGradient(
0, h, 0, 0,
new int[]{0x00E8926F, 0x66E8926F, 0x99F0B89A},
new float[]{0f, 0.5f, 1f},
Shader.TileMode.CLAMP
);
// AI 说话柔和蓝紫渐变
aiGradient = new LinearGradient(
0, h, 0, 0,
new int[]{0x009BB5E8, 0x669BB5E8, 0x99C4B5F0},
new float[]{0f, 0.5f, 1f},
Shader.TileMode.CLAMP
);
}
// 选择渐变色
LinearGradient activeGradient = aiSpeaking ? aiGradient : micGradient;
barPaint.setShader(activeGradient);
glowPaint.setShader(activeGradient);
float totalBarWidth = w * 0.85f;
float sideMargin = (w - totalBarWidth) / 2f;
float gap = totalBarWidth * 0.02f;
float barWidth = (totalBarWidth - gap * (BAR_COUNT - 1)) / BAR_COUNT;
float cornerRadius = barWidth / 2f;
float maxBarHeight = h * 0.96f;
float minBarHeight = h * 0.05f;
if (aiSpeaking) {
drawAiPulse(canvas, w, h, sideMargin, gap, barWidth, cornerRadius, maxBarHeight, minBarHeight);
} else {
drawMicSpectrum(canvas, h, sideMargin, gap, barWidth, cornerRadius, maxBarHeight, minBarHeight);
}
}
private void drawMicSpectrum(Canvas canvas, int h, float sideMargin, float gap,
float barWidth, float cornerRadius, float maxBarHeight, float minBarHeight) {
boolean hasActivity = false;
for (int i = 0; i < BAR_COUNT; i++) {
float target = magnitudes[i];
if (target > displayMagnitudes[i]) {
displayMagnitudes[i] += (target - displayMagnitudes[i]) * (1f - SMOOTHING);
} else {
displayMagnitudes[i] *= DECAY;
}
float barH = minBarHeight + displayMagnitudes[i] * maxBarHeight;
float left = sideMargin + i * (barWidth + gap);
float top = h - barH;
float right = left + barWidth;
canvas.drawRoundRect(left - 1, top - 2, right + 1, h, cornerRadius, cornerRadius, glowPaint);
canvas.drawRoundRect(left, top, right, h, cornerRadius, cornerRadius, barPaint);
if (displayMagnitudes[i] > 0.001f) hasActivity = true;
}
if (hasActivity) {
postInvalidateOnAnimation();
}
}
private void drawAiPulse(Canvas canvas, int w, int h, float sideMargin, float gap,
float barWidth, float cornerRadius, float maxBarHeight, float minBarHeight) {
float elapsed = (System.currentTimeMillis() - aiSpeakStartTime) / 1000f;
float center = BAR_COUNT / 2f;
for (int i = 0; i < BAR_COUNT; i++) {
// 多重正弦波叠加模拟自然呼吸感
float dist = Math.abs(i - center) / center; // 0~1, 离中心的距离
float wave1 = (float) Math.sin(elapsed * 2.5 - dist * 3.0) * 0.5f + 0.5f;
float wave2 = (float) Math.sin(elapsed * 1.7 + i * 0.4) * 0.3f + 0.5f;
float wave3 = (float) Math.sin(elapsed * 4.0 - i * 0.2) * 0.15f + 0.5f;
// 中间高两边低的包络
float envelope = 1f - dist * 0.6f;
float target = (wave1 * 0.5f + wave2 * 0.3f + wave3 * 0.2f) * envelope;
target = Math.max(0.05f, Math.min(1f, target));
// 平滑过渡
displayMagnitudes[i] += (target - displayMagnitudes[i]) * 0.15f;
float barH = minBarHeight + displayMagnitudes[i] * maxBarHeight * 0.9f;
float left = sideMargin + i * (barWidth + gap);
float top = h - barH;
float right = left + barWidth;
canvas.drawRoundRect(left - 1, top - 2, right + 1, h, cornerRadius, cornerRadius, glowPaint);
canvas.drawRoundRect(left, top, right, h, cornerRadius, cornerRadius, barPaint);
}
postInvalidateOnAnimation();
}
}

View File

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="#FFFFFF">
<path
android:fillColor="@android:color/white"
android:pathData="M12,15.2C13.77,15.2 15.2,13.77 15.2,12C15.2,10.23 13.77,8.8 12,8.8C10.23,8.8 8.8,10.23 8.8,12C8.8,13.77 10.23,15.2 12,15.2ZM9,2L7.17,4H4C2.9,4 2,4.9 2,6V18C2,19.1 2.9,20 4,20H20C21.1,20 22,19.1 22,18V6C22,4.9 21.1,4 20,4H16.83L15,2H9ZM12,17C9.24,17 7,14.76 7,12C7,9.24 9.24,7 12,7C14.76,7 17,9.24 17,12C17,14.76 14.76,17 12,17Z" />
</vector>

View File

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="#FFFFFF">
<path
android:fillColor="@android:color/white"
android:pathData="M12,14c1.66,0 3,-1.34 3,-3V5c0,-1.66 -1.34,-3 -3,-3S9,3.34 9,5v6c0,1.66 1.34,3 3,3zM17.3,11c0,3 -2.54,5.1 -5.3,5.1S6.7,14 6.7,11H5c0,3.41 2.72,6.23 6,6.72V21h2v-3.28c3.28,-0.48 6,-3.3 6,-6.72h-1.7z" />
</vector>

View File

@ -0,0 +1,14 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="#FFFFFF">
<path
android:fillColor="@android:color/white"
android:pathData="M12,14c1.66,0 3,-1.34 3,-3V5c0,-1.66 -1.34,-3 -3,-3S9,3.34 9,5v6c0,1.66 1.34,3 3,3zM17.3,11c0,3 -2.54,5.1 -5.3,5.1S6.7,14 6.7,11H5c0,3.41 2.72,6.23 6,6.72V21h2v-3.28c3.28,-0.48 6,-3.3 6,-6.72h-1.7z" />
<path
android:fillColor="@android:color/white"
android:pathData="M3.27,3.27L2,4.54l7,7v0.46c0,1.66 1.34,3 3,3c0.23,0 0.44,-0.03 0.65,-0.08l1.66,1.66C13.56,16.5 12.8,16.87 12,17.1V21h2v-3.28c0.91,-0.13 1.77,-0.45 2.54,-0.9l3.73,3.73 1.27,-1.27L3.27,3.27z"
android:strokeWidth="0" />
</vector>

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="oval">
<solid android:color="#E8926F" />
</shape>

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="oval">
<solid android:color="#CC424242" />
</shape>

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="oval">
<solid android:color="#99666666" />
</shape>

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="oval">
<solid android:color="#CC2196F3" />
</shape>

View File

@ -2,7 +2,6 @@
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:fitsSystemWindows="true"
android:layout_height="match_parent">
<ImageView
@ -84,44 +83,53 @@
android:padding="12dp"
android:layout_width="0dp"
android:layout_height="wrap_content"
app:layout_constraintBottom_toTopOf="@+id/btnAIConversation"
app:layout_constraintBottom_toTopOf="@+id/audioSpectrum"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
android:layout_marginHorizontal="24dp"
android:layout_marginBottom="8dp"
/>
<ai.guiji.duix.test.ui.widget.AudioSpectrumView
android:id="@+id/audioSpectrum"
android:layout_width="0dp"
android:layout_height="48dp"
android:visibility="gone"
app:layout_constraintBottom_toTopOf="@+id/btnAIConversation"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
android:layout_marginHorizontal="32dp"
android:layout_marginBottom="12dp"
/>
<Button
<ImageButton
android:id="@+id/btnAIConversation"
android:text="@string/ai_fab_start"
android:textSize="14sp"
android:textColor="@color/white"
android:background="@drawable/shape_circle_fab"
android:src="@drawable/ic_mic"
android:background="@drawable/shape_fab_ai"
android:elevation="8dp"
android:layout_width="56dp"
android:layout_height="56dp"
android:scaleType="center"
android:layout_width="64dp"
android:layout_height="64dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
android:layout_marginBottom="32dp"
android:layout_marginBottom="40dp"
android:contentDescription="@string/ai_conversation"
/>
<Button
<ImageButton
android:id="@+id/btnCameraToggle"
android:text="📷"
android:textSize="18sp"
android:textColor="@color/white"
android:background="@drawable/shape_circle_camera_off"
android:src="@drawable/ic_camera"
android:background="@drawable/shape_fab_camera_off"
android:elevation="8dp"
android:scaleType="center"
android:layout_width="48dp"
android:layout_height="48dp"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="@+id/btnAIConversation"
app:layout_constraintTop_toTopOf="@+id/btnAIConversation"
app:layout_constraintStart_toEndOf="@+id/btnAIConversation"
app:layout_constraintEnd_toEndOf="parent"
android:layout_marginStart="16dp"
android:layout_marginEnd="24dp"
android:contentDescription="@string/camera_off"
/>

View File

@ -3,119 +3,56 @@
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:padding="12dp">
android:fitsSystemWindows="true"
android:background="@color/warm_bg">
<!-- 顶部标题区域 -->
<TextView
android:id="@+id/tv_title"
android:layout_width="match_parent"
android:layout_height="120dp"
android:layout_height="wrap_content"
android:gravity="center"
android:text="@string/app_name"
android:textColor="@color/black"
android:textSize="18dp"
android:textStyle="italic|bold"
android:paddingTop="48dp"
android:paddingBottom="4dp"
android:text="@string/app_title"
android:textColor="@color/warm_text_primary"
android:textSize="22sp"
android:textStyle="bold"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/tv_sdk_version"
android:layout_width="wrap_content"
android:id="@+id/tv_subtitle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textSize="12sp"
app:layout_constraintEnd_toEndOf="parent"
android:gravity="center"
android:paddingBottom="16dp"
android:text="@string/app_subtitle"
android:textColor="@color/warm_text_secondary"
android:textSize="14sp"
app:layout_constraintTop_toBottomOf="@+id/tv_title" />
<ScrollView
<!-- 角色列表 -->
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rvAvatars"
android:layout_width="match_parent"
android:layout_height="0dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintTop_toBottomOf="@+id/tv_sdk_version">
android:clipToPadding="false"
android:paddingStart="10dp"
android:paddingEnd="10dp"
android:paddingBottom="16dp"
app:layout_constraintBottom_toTopOf="@+id/tvBottomHint"
app:layout_constraintTop_toBottomOf="@+id/tv_subtitle" />
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<TextView
android:id="@+id/tvDownloadTips"
app:layout_constraintTop_toTopOf="parent"
android:text="@string/main_download_tips"
android:textSize="13sp"
android:textColor="@color/black"
android:textStyle="bold"
android:layout_width="match_parent"
android:layout_height="wrap_content"/>
<TextView
android:id="@+id/tvBaseConfig"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:text="@string/base_config_url"
android:textSize="13sp"
app:layout_constraintTop_toBottomOf="@+id/tvDownloadTips"
app:layout_constraintStart_toStartOf="parent"/>
<EditText
android:id="@+id/etBaseConfig"
android:layout_marginTop="12dp"
android:layout_width="match_parent"
android:layout_height="48dp"
android:text="https://github.com/duixcom/Duix-Mobile/releases/download/v1.0.0/gj_dh_res.zip"
android:textSize="13sp"
app:layout_constraintTop_toBottomOf="@+id/tvBaseConfig"
/>
<TextView
android:id="@+id/tvModelUrl"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="32dp"
android:text="@string/model_url"
android:textSize="13sp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/etBaseConfig" />
<Button
android:id="@+id/btnMoreModel"
android:text="@string/more"
android:layout_marginTop="12dp"
android:layout_width="wrap_content"
android:layout_height="48dp"
app:layout_constraintTop_toBottomOf="@+id/tvModelUrl"
app:layout_constraintEnd_toEndOf="parent"
/>
<EditText
android:id="@+id/etUrl"
android:layout_width="0dp"
android:layout_height="48dp"
android:text="https://github.com/duixcom/Duix-Mobile/releases/download/v1.0.0/bendi3_20240518.zip"
android:textSize="13sp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="@+id/btnMoreModel"
app:layout_constraintEnd_toStartOf="@+id/btnMoreModel"
/>
<Button
android:id="@+id/btnPlay"
android:layout_width="match_parent"
android:layout_height="48dp"
android:layout_marginTop="20dp"
android:text="@string/play"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@+id/etUrl" />
<androidx.appcompat.widget.SwitchCompat
android:id="@+id/switchDebug"
android:text="@string/debug_message"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="13sp"
android:layout_margin="12dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@+id/btnPlay"
/>
</androidx.constraintlayout.widget.ConstraintLayout>
</ScrollView>
<TextView
android:id="@+id/tvBottomHint"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center"
android:paddingTop="8dp"
android:paddingBottom="16dp"
android:text="@string/choose_companion_hint"
android:textColor="@color/warm_text_hint"
android:textSize="12sp"
app:layout_constraintBottom_toBottomOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -0,0 +1,42 @@
<?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:id="@+id/cardAvatar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="6dp"
android:clickable="true"
android:focusable="true"
android:foreground="?android:attr/selectableItemBackground"
app:cardCornerRadius="16dp"
app:cardElevation="2dp"
app:strokeWidth="0dp">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@color/white">
<ImageView
android:id="@+id/ivAvatar"
android:layout_width="match_parent"
android:layout_height="0dp"
android:scaleType="centerCrop"
app:layout_constraintDimensionRatio="3:4"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/tvName"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center"
android:paddingTop="10dp"
android:paddingBottom="12dp"
android:textColor="#333333"
android:textSize="15sp"
android:textStyle="bold"
app:layout_constraintTop_toBottomOf="@+id/ivAvatar" />
</androidx.constraintlayout.widget.ConstraintLayout>
</com.google.android.material.card.MaterialCardView>

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

View File

@ -9,4 +9,11 @@
<color name="white">#FFFFFFFF</color>
<color name="primary">#5a7bbe</color>
<color name="primary_99">#995a7bbe</color>
<!-- 温暖主题色 -->
<color name="warm_bg">#FFF5F0EB</color>
<color name="warm_text_primary">#FF3E2723</color>
<color name="warm_text_secondary">#FF8D6E63</color>
<color name="warm_text_hint">#FFBCAAA4</color>
<color name="warm_accent">#FFE8926F</color>
</resources>

View File

@ -1,5 +1,8 @@
<resources>
<string name="app_name">DUIX Demo</string>
<string name="app_name">AI 情感陪护</string>
<string name="app_title">AI 情感陪护</string>
<string name="app_subtitle">选一位陪伴者,开始温暖对话</string>
<string name="choose_companion_hint">点击头像即可开始对话</string>
<string name="play">Play</string>
<string name="base_config_url">BaseConfig Url:</string>
<string name="model_url">Model Url:</string>

View File

@ -1,15 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.DUIX.Test" parent="Theme.AppCompat.Light.NoActionBar">
<style name="Theme.DUIX.Test" parent="Theme.MaterialComponents.Light.NoActionBar">
<item name="android:windowLightStatusBar">true</item>
<item name="android:windowBackground">@android:color/white</item>
<item name="android:windowBackground">@color/warm_bg</item>
<item name="android:windowNoTitle">true</item>
<item name="android:windowIsTranslucent">false</item>
<item name="android:windowIsFloating">false</item>
<item name="colorPrimary">#fff</item>
<item name="colorPrimaryDark">#4349a9</item>
<item name="colorAccent">#4349a9</item>
<item name="android:windowTranslucentStatus">true</item>
<item name="colorPrimary">@color/warm_bg</item>
<item name="colorPrimaryDark">@color/warm_accent</item>
<item name="colorAccent">@color/warm_accent</item>
<item name="android:statusBarColor">@android:color/transparent</item>
<item name="android:navigationBarColor">@android:color/transparent</item>
<item name="android:windowTranslucentStatus">false</item>
<item name="android:windowDrawsSystemBarBackgrounds">true</item>
</style>
<!-- CallActivity 全屏沉浸主题 -->
<style name="Theme.DUIX.Call" parent="Theme.DUIX.Test">
<item name="android:windowLightStatusBar">false</item>
<item name="android:windowTranslucentNavigation">true</item>
</style>
</resources>