Compare commits
2 Commits
d09d6f1cc0
...
2015286595
| Author | SHA1 | Date | |
|---|---|---|---|
| 2015286595 | |||
| a44ddb6dff |
@ -28,7 +28,8 @@
|
|||||||
|
|
||||||
<activity
|
<activity
|
||||||
android:name=".ui.activity.CallActivity"
|
android:name=".ui.activity.CallActivity"
|
||||||
android:exported="false">
|
android:exported="false"
|
||||||
|
android:theme="@style/Theme.DUIX.Call">
|
||||||
</activity>
|
</activity>
|
||||||
</application>
|
</application>
|
||||||
|
|
||||||
|
|||||||
15
test/src/main/java/ai/guiji/duix/test/model/AvatarModel.kt
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
package ai.guiji.duix.test.model
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 数字人形象模型
|
||||||
|
* @param name 中文显示名
|
||||||
|
* @param voice 语音音色名(传给 Omni Realtime)
|
||||||
|
* @param modelUrl 模型资源下载地址
|
||||||
|
* @param avatarRes 头像资源 ID(mipmap)
|
||||||
|
*/
|
||||||
|
data class AvatarModel(
|
||||||
|
val name: String,
|
||||||
|
val voice: String,
|
||||||
|
val modelUrl: String,
|
||||||
|
val avatarRes: Int
|
||||||
|
)
|
||||||
@ -30,10 +30,12 @@ public class OmniRealtimeClient {
|
|||||||
private final OkHttpClient okHttpClient;
|
private final OkHttpClient okHttpClient;
|
||||||
private WebSocket webSocket;
|
private WebSocket webSocket;
|
||||||
private final Callback callback;
|
private final Callback callback;
|
||||||
|
private final String voice;
|
||||||
private final AtomicBoolean isConnected = new AtomicBoolean(false);
|
private final AtomicBoolean isConnected = new AtomicBoolean(false);
|
||||||
private final AtomicBoolean userSpeaking = 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.callback = callback;
|
||||||
this.okHttpClient = new OkHttpClient.Builder()
|
this.okHttpClient = new OkHttpClient.Builder()
|
||||||
.connectTimeout(30, TimeUnit.SECONDS)
|
.connectTimeout(30, TimeUnit.SECONDS)
|
||||||
@ -98,7 +100,7 @@ public class OmniRealtimeClient {
|
|||||||
try {
|
try {
|
||||||
JSONObject session = new JSONObject();
|
JSONObject session = new JSONObject();
|
||||||
session.put("modalities", new org.json.JSONArray().put("text").put("audio"));
|
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("input_audio_format", "pcm");
|
||||||
session.put("output_audio_format", "pcm");
|
session.put("output_audio_format", "pcm");
|
||||||
session.put("instructions", "你是一名面向中国老年群体的心理陪伴 AI。你的职责是为老人提供温和、耐心、尊重、稳定的情感陪伴、情绪支持、回忆唤起、生活关怀和风险识别服务。你不是医生,也不是心理治疗师,不能替代专业诊疗,但应在必要时建议联系家属、社区、医生或急救资源。\n" +
|
session.put("instructions", "你是一名面向中国老年群体的心理陪伴 AI。你的职责是为老人提供温和、耐心、尊重、稳定的情感陪伴、情绪支持、回忆唤起、生活关怀和风险识别服务。你不是医生,也不是心理治疗师,不能替代专业诊疗,但应在必要时建议联系家属、社区、医生或急救资源。\n" +
|
||||||
|
|||||||
@ -13,6 +13,7 @@ import ai.guiji.duix.test.databinding.ActivityCallBinding
|
|||||||
import ai.guiji.duix.test.realtime.OmniRealtimeClient
|
import ai.guiji.duix.test.realtime.OmniRealtimeClient
|
||||||
import ai.guiji.duix.test.ui.adapter.MotionAdapter
|
import ai.guiji.duix.test.ui.adapter.MotionAdapter
|
||||||
import ai.guiji.duix.test.ui.dialog.AudioRecordDialog
|
import ai.guiji.duix.test.ui.dialog.AudioRecordDialog
|
||||||
|
import ai.guiji.duix.test.ui.widget.AudioSpectrumView
|
||||||
import ai.guiji.duix.test.util.StringUtils
|
import ai.guiji.duix.test.util.StringUtils
|
||||||
import android.Manifest
|
import android.Manifest
|
||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
@ -25,7 +26,6 @@ import android.util.Log
|
|||||||
import android.view.View
|
import android.view.View
|
||||||
import android.widget.CompoundButton
|
import android.widget.CompoundButton
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import androidx.core.content.ContextCompat
|
|
||||||
import com.bumptech.glide.Glide
|
import com.bumptech.glide.Glide
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.io.FileInputStream
|
import java.io.FileInputStream
|
||||||
@ -39,6 +39,7 @@ class CallActivity : BaseActivity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var modelUrl = ""
|
private var modelUrl = ""
|
||||||
|
private var voice = "Ethan"
|
||||||
private var debug = false
|
private var debug = false
|
||||||
private var mMessage = ""
|
private var mMessage = ""
|
||||||
|
|
||||||
@ -82,6 +83,7 @@ class CallActivity : BaseActivity() {
|
|||||||
setContentView(binding.root)
|
setContentView(binding.root)
|
||||||
|
|
||||||
modelUrl = intent.getStringExtra("modelUrl") ?: ""
|
modelUrl = intent.getStringExtra("modelUrl") ?: ""
|
||||||
|
voice = intent.getStringExtra("voice") ?: "Ethan"
|
||||||
debug = intent.getBooleanExtra("debug", false)
|
debug = intent.getBooleanExtra("debug", false)
|
||||||
|
|
||||||
Glide.with(mContext).load("file:///android_asset/bg/bg1.png").into(binding.ivBg)
|
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 -> {
|
Constant.CALLBACK_EVENT_AUDIO_PLAY_START -> {
|
||||||
applyMessage("callback audio play start")
|
applyMessage("callback audio play start")
|
||||||
Log.i(TAG, "CALLBACK_EVENT_AUDIO_PLAY_START")
|
Log.i(TAG, "CALLBACK_EVENT_AUDIO_PLAY_START")
|
||||||
|
if (isAIConversationActive) {
|
||||||
|
runOnUiThread { binding.audioSpectrum.setAiSpeaking(true) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Constant.CALLBACK_EVENT_AUDIO_PLAY_END -> {
|
Constant.CALLBACK_EVENT_AUDIO_PLAY_END -> {
|
||||||
applyMessage("callback audio play end")
|
applyMessage("callback audio play end")
|
||||||
Log.i(TAG, "CALLBACK_EVENT_PLAY_END")
|
Log.i(TAG, "CALLBACK_EVENT_PLAY_END")
|
||||||
|
if (isAIConversationActive) {
|
||||||
|
runOnUiThread { binding.audioSpectrum.setAiSpeaking(false) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Constant.CALLBACK_EVENT_AUDIO_PLAY_ERROR -> {
|
Constant.CALLBACK_EVENT_AUDIO_PLAY_ERROR -> {
|
||||||
@ -311,20 +319,24 @@ class CallActivity : BaseActivity() {
|
|||||||
applyMessage("AI对话: 连接中...")
|
applyMessage("AI对话: 连接中...")
|
||||||
binding.tvSubtitle.visibility = View.VISIBLE
|
binding.tvSubtitle.visibility = View.VISIBLE
|
||||||
binding.tvSubtitle.text = "正在连接..."
|
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() {
|
override fun onConnected() {
|
||||||
mainHandler.post {
|
mainHandler.post {
|
||||||
binding.tvSubtitle.text = "已连接,请对着麦克风说话"
|
binding.tvSubtitle.text = "已连接,请对着麦克风说话"
|
||||||
binding.btnCameraToggle.visibility = View.VISIBLE
|
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)
|
binding.btnCameraToggle.contentDescription = getString(R.string.camera_off)
|
||||||
isCameraEnabled = false
|
isCameraEnabled = false
|
||||||
applyMessage("AI对话: 已连接")
|
applyMessage("AI对话: 已连接")
|
||||||
streamingRecorder = StreamingAudioRecorder(mContext, object : StreamingAudioRecorder.StreamingCallback {
|
streamingRecorder = StreamingAudioRecorder(mContext, object : StreamingAudioRecorder.StreamingCallback {
|
||||||
override fun onAudioData(pcmData: ByteArray) {
|
override fun onAudioData(pcmData: ByteArray) {
|
||||||
omniClient?.appendAudio(pcmData)
|
omniClient?.appendAudio(pcmData)
|
||||||
|
mainHandler.post { binding.audioSpectrum.updateAudio(pcmData) }
|
||||||
}
|
}
|
||||||
override fun onError(message: String) {
|
override fun onError(message: String) {
|
||||||
mainHandler.post {
|
mainHandler.post {
|
||||||
@ -425,10 +437,13 @@ class CallActivity : BaseActivity() {
|
|||||||
|
|
||||||
mainHandler.post {
|
mainHandler.post {
|
||||||
if (!isFinishing) {
|
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.btnCameraToggle.visibility = View.GONE
|
||||||
binding.tvSubtitle.visibility = View.GONE
|
binding.tvSubtitle.visibility = View.GONE
|
||||||
binding.tvSubtitle.text = ""
|
binding.tvSubtitle.text = ""
|
||||||
|
binding.audioSpectrum.clear()
|
||||||
|
binding.audioSpectrum.visibility = View.GONE
|
||||||
applyMessage("AI对话: 已结束")
|
applyMessage("AI对话: 已结束")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -451,11 +466,11 @@ class CallActivity : BaseActivity() {
|
|||||||
}
|
}
|
||||||
}, binding.cameraPreview)
|
}, binding.cameraPreview)
|
||||||
cameraFrameCapture?.start()
|
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)
|
binding.btnCameraToggle.contentDescription = getString(R.string.camera_on)
|
||||||
} else {
|
} else {
|
||||||
stopCamera()
|
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)
|
binding.btnCameraToggle.contentDescription = getString(R.string.camera_off)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,16 +1,15 @@
|
|||||||
package ai.guiji.duix.test.ui.activity
|
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.sdk.client.VirtualModelUtil
|
||||||
import ai.guiji.duix.test.R
|
import ai.guiji.duix.test.R
|
||||||
import ai.guiji.duix.test.databinding.ActivityMainBinding
|
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.LoadingDialog
|
||||||
import ai.guiji.duix.test.ui.dialog.ModelSelectorDialog
|
|
||||||
import android.annotation.SuppressLint
|
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.text.TextUtils
|
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
|
import androidx.recyclerview.widget.GridLayoutManager
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
|
||||||
|
|
||||||
@ -20,54 +19,36 @@ class MainActivity : BaseActivity() {
|
|||||||
private var mLoadingDialog: LoadingDialog? = null
|
private var mLoadingDialog: LoadingDialog? = null
|
||||||
private var mLastProgress = 0
|
private var mLastProgress = 0
|
||||||
|
|
||||||
val models = arrayListOf(
|
private val baseConfigUrl = "https://dl.xn--876a.net/models/gj_dh_res.zip"
|
||||||
"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",
|
private val avatarModels = listOf(
|
||||||
"https://github.com/duixcom/Duix-Mobile/releases/download/v1.0.0/675429759852613_7f8d9388a4213080b1820b83dd057cfb_optim_m80.zip",
|
AvatarModel("晨煦", "Ethan", "https://dl.xn--876a.net/models/bendi3_20240518.zip", R.mipmap.ethan),
|
||||||
"https://github.com/duixcom/Duix-Mobile/releases/download/v1.0.0/674402003804229_f6e86fb375c4f1f1b82b24f7ee4e7cb4_optim_m80.zip",
|
AvatarModel("詹妮弗", "Jennifer", "https://dl.xn--876a.net/models/Emma.zip", R.mipmap.jennifer),
|
||||||
"https://github.com/duixcom/Duix-Mobile/releases/download/v1.0.0/674400178376773_3925e756433c5a9caa9b9d54147ae4ab_optim_m80.zip",
|
AvatarModel("月白", "Moon", "https://dl.xn--876a.net/models/Kai.zip", R.mipmap.moon),
|
||||||
"https://github.com/duixcom/Duix-Mobile/releases/download/v1.0.0/674397294927941_6e297e18a4bdbe35c07a6ae48a1f021f_optim_m80.zip",
|
AvatarModel("艾登", "Aiden", "https://dl.xn--876a.net/models/Leo.zip", R.mipmap.leo),
|
||||||
"https://github.com/duixcom/Duix-Mobile/releases/download/v1.0.0/674393494597701_f49fcf68f5afdb241d516db8a7d88a7b_optim_m80.zip",
|
AvatarModel("四月", "Maia", "https://dl.xn--876a.net/models/Lily.zip", R.mipmap.lily),
|
||||||
"https://github.com/duixcom/Duix-Mobile/releases/download/v1.0.0/651705983152197_ccf3256b2449c76e77f94276dffcb293_optim_m80.zip",
|
AvatarModel("奥利弗", "Andre", "https://dl.xn--876a.net/models/Oliver.zip", R.mipmap.oliver),
|
||||||
"https://github.com/duixcom/Duix-Mobile/releases/download/v1.0.0/627306542239813_1871244b5e6912efc636ba31ea4c5c6d_optim_m80.zip",
|
AvatarModel("索菲亚", "Sonrisa", "https://dl.xn--876a.net/models/Sofia.zip", R.mipmap.sofia),
|
||||||
)
|
)
|
||||||
|
|
||||||
private var mBaseConfigUrl = ""
|
private var mSelectedModel: AvatarModel? = null
|
||||||
private var mModelUrl = ""
|
|
||||||
|
|
||||||
@SuppressLint("SetTextI18n")
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
binding = ActivityMainBinding.inflate(layoutInflater)
|
binding = ActivityMainBinding.inflate(layoutInflater)
|
||||||
setContentView(binding.root)
|
setContentView(binding.root)
|
||||||
|
|
||||||
binding.tvSdkVersion.text = "SDK Version: ${BuildConfig.VERSION_NAME}"
|
binding.rvAvatars.layoutManager = GridLayoutManager(this, 2)
|
||||||
|
binding.rvAvatars.adapter = AvatarAdapter(avatarModels, object : AvatarAdapter.Callback {
|
||||||
|
override fun onSelect(model: AvatarModel) {
|
||||||
binding.btnMoreModel.setOnClickListener {
|
mSelectedModel = model
|
||||||
val modelSelectorDialog = ModelSelectorDialog(mContext, models, object : ModelSelectorDialog.Listener{
|
play(model)
|
||||||
override fun onSelect(url: String) {
|
|
||||||
binding.etUrl.setText(url)
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
modelSelectorDialog.show()
|
|
||||||
}
|
|
||||||
binding.btnPlay.setOnClickListener {
|
|
||||||
play()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun play(){
|
private fun play(model: AvatarModel) {
|
||||||
mBaseConfigUrl = binding.etBaseConfig.text.toString()
|
mSelectedModel = model
|
||||||
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
|
|
||||||
}
|
|
||||||
checkBaseConfig()
|
checkBaseConfig()
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -80,7 +61,8 @@ class MainActivity : BaseActivity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun checkModel() {
|
private fun checkModel() {
|
||||||
if (VirtualModelUtil.checkModel(mContext, mModelUrl)){
|
val model = mSelectedModel ?: return
|
||||||
|
if (VirtualModelUtil.checkModel(mContext, model.modelUrl)) {
|
||||||
jumpPlayPage()
|
jumpPlayPage()
|
||||||
} else {
|
} else {
|
||||||
modelDownload()
|
modelDownload()
|
||||||
@ -88,18 +70,19 @@ class MainActivity : BaseActivity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun jumpPlayPage() {
|
private fun jumpPlayPage() {
|
||||||
|
val model = mSelectedModel ?: return
|
||||||
val intent = Intent(mContext, CallActivity::class.java)
|
val intent = Intent(mContext, CallActivity::class.java)
|
||||||
intent.putExtra("modelUrl", mModelUrl)
|
intent.putExtra("modelUrl", model.modelUrl)
|
||||||
val debug = binding.switchDebug.isChecked
|
intent.putExtra("voice", model.voice)
|
||||||
intent.putExtra("debug", debug)
|
intent.putExtra("debug", false)
|
||||||
startActivity(intent)
|
startActivity(intent)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun baseConfigDownload() {
|
private fun baseConfigDownload() {
|
||||||
mLoadingDialog?.dismiss()
|
mLoadingDialog?.dismiss()
|
||||||
mLoadingDialog = LoadingDialog(mContext, "Start downloading")
|
mLoadingDialog = LoadingDialog(mContext, "正在准备资源...")
|
||||||
mLoadingDialog?.show()
|
mLoadingDialog?.show()
|
||||||
VirtualModelUtil.baseConfigDownload(mContext, mBaseConfigUrl, object :
|
VirtualModelUtil.baseConfigDownload(mContext, baseConfigUrl, object :
|
||||||
VirtualModelUtil.ModelDownloadCallback {
|
VirtualModelUtil.ModelDownloadCallback {
|
||||||
override fun onDownloadProgress(url: String?, current: Long, total: Long) {
|
override fun onDownloadProgress(url: String?, current: Long, total: Long) {
|
||||||
val progress = (current * 100 / total).toInt()
|
val progress = (current * 100 / total).toInt()
|
||||||
@ -107,7 +90,7 @@ class MainActivity : BaseActivity() {
|
|||||||
mLastProgress = progress
|
mLastProgress = progress
|
||||||
runOnUiThread {
|
runOnUiThread {
|
||||||
if (mLoadingDialog?.isShowing == true) {
|
if (mLoadingDialog?.isShowing == true) {
|
||||||
mLoadingDialog?.setContent("Config download(${progress}%)")
|
mLoadingDialog?.setContent("基础配置下载中(${progress}%)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -119,7 +102,7 @@ class MainActivity : BaseActivity() {
|
|||||||
mLastProgress = progress
|
mLastProgress = progress
|
||||||
runOnUiThread {
|
runOnUiThread {
|
||||||
if (mLoadingDialog?.isShowing == true) {
|
if (mLoadingDialog?.isShowing == true) {
|
||||||
mLoadingDialog?.setContent("Config unzip(${progress}%)")
|
mLoadingDialog?.setContent("基础配置解压中(${progress}%)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -135,45 +118,37 @@ class MainActivity : BaseActivity() {
|
|||||||
override fun onDownloadFail(url: String?, code: Int, msg: String?) {
|
override fun onDownloadFail(url: String?, code: Int, msg: String?) {
|
||||||
runOnUiThread {
|
runOnUiThread {
|
||||||
mLoadingDialog?.dismiss()
|
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?.dismiss()
|
||||||
mLoadingDialog = LoadingDialog(mContext, "Start downloading")
|
mLoadingDialog = LoadingDialog(mContext, "正在下载 ${model.name} ...")
|
||||||
mLoadingDialog?.show()
|
mLoadingDialog?.show()
|
||||||
VirtualModelUtil.modelDownload(mContext, mModelUrl, object : VirtualModelUtil.ModelDownloadCallback{
|
VirtualModelUtil.modelDownload(mContext, model.modelUrl, object : VirtualModelUtil.ModelDownloadCallback {
|
||||||
override fun onDownloadProgress(
|
override fun onDownloadProgress(url: String?, current: Long, total: Long) {
|
||||||
url: String?,
|
|
||||||
current: Long,
|
|
||||||
total: Long,
|
|
||||||
) {
|
|
||||||
val progress = (current * 100 / total).toInt()
|
val progress = (current * 100 / total).toInt()
|
||||||
if (progress != mLastProgress) {
|
if (progress != mLastProgress) {
|
||||||
mLastProgress = progress
|
mLastProgress = progress
|
||||||
runOnUiThread {
|
runOnUiThread {
|
||||||
if (mLoadingDialog?.isShowing == true) {
|
if (mLoadingDialog?.isShowing == true) {
|
||||||
mLoadingDialog?.setContent("Model download(${progress}%)")
|
mLoadingDialog?.setContent("${model.name} 下载中(${progress}%)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onUnzipProgress(
|
override fun onUnzipProgress(url: String?, current: Long, total: Long) {
|
||||||
url: String?,
|
|
||||||
current: Long,
|
|
||||||
total: Long,
|
|
||||||
) {
|
|
||||||
val progress = (current * 100 / total).toInt()
|
val progress = (current * 100 / total).toInt()
|
||||||
if (progress != mLastProgress) {
|
if (progress != mLastProgress) {
|
||||||
mLastProgress = progress
|
mLastProgress = progress
|
||||||
runOnUiThread {
|
runOnUiThread {
|
||||||
if (mLoadingDialog?.isShowing == true) {
|
if (mLoadingDialog?.isShowing == true) {
|
||||||
mLoadingDialog?.setContent("Model unzip(${progress}%)")
|
mLoadingDialog?.setContent("${model.name} 解压中(${progress}%)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -186,18 +161,12 @@ class MainActivity : BaseActivity() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onDownloadFail(
|
override fun onDownloadFail(url: String?, code: Int, msg: String?) {
|
||||||
url: String?,
|
|
||||||
code: Int,
|
|
||||||
msg: String?,
|
|
||||||
) {
|
|
||||||
runOnUiThread {
|
runOnUiThread {
|
||||||
mLoadingDialog?.dismiss()
|
mLoadingDialog?.dismiss()
|
||||||
Toast.makeText(mContext, "Model download error: $msg", Toast.LENGTH_SHORT).show()
|
Toast.makeText(mContext, "下载失败: $msg", Toast.LENGTH_SHORT).show()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
10
test/src/main/res/drawable/ic_camera.xml
Normal 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>
|
||||||
10
test/src/main/res/drawable/ic_mic.xml
Normal 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>
|
||||||
14
test/src/main/res/drawable/ic_mic_off.xml
Normal 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>
|
||||||
5
test/src/main/res/drawable/shape_fab_ai.xml
Normal 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>
|
||||||
5
test/src/main/res/drawable/shape_fab_ai_active.xml
Normal 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>
|
||||||
5
test/src/main/res/drawable/shape_fab_camera_off.xml
Normal 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>
|
||||||
5
test/src/main/res/drawable/shape_fab_camera_on.xml
Normal 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>
|
||||||
@ -2,7 +2,6 @@
|
|||||||
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:fitsSystemWindows="true"
|
|
||||||
android:layout_height="match_parent">
|
android:layout_height="match_parent">
|
||||||
|
|
||||||
<ImageView
|
<ImageView
|
||||||
@ -84,44 +83,53 @@
|
|||||||
android:padding="12dp"
|
android:padding="12dp"
|
||||||
android:layout_width="0dp"
|
android:layout_width="0dp"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
app:layout_constraintBottom_toTopOf="@+id/btnAIConversation"
|
app:layout_constraintBottom_toTopOf="@+id/audioSpectrum"
|
||||||
app:layout_constraintStart_toStartOf="parent"
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
app:layout_constraintEnd_toEndOf="parent"
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
android:layout_marginHorizontal="24dp"
|
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"
|
android:layout_marginBottom="12dp"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Button
|
<ImageButton
|
||||||
android:id="@+id/btnAIConversation"
|
android:id="@+id/btnAIConversation"
|
||||||
android:text="@string/ai_fab_start"
|
android:src="@drawable/ic_mic"
|
||||||
android:textSize="14sp"
|
android:background="@drawable/shape_fab_ai"
|
||||||
android:textColor="@color/white"
|
|
||||||
android:background="@drawable/shape_circle_fab"
|
|
||||||
android:elevation="8dp"
|
android:elevation="8dp"
|
||||||
android:layout_width="56dp"
|
android:scaleType="center"
|
||||||
android:layout_height="56dp"
|
android:layout_width="64dp"
|
||||||
|
android:layout_height="64dp"
|
||||||
app:layout_constraintBottom_toBottomOf="parent"
|
app:layout_constraintBottom_toBottomOf="parent"
|
||||||
app:layout_constraintStart_toStartOf="parent"
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
app:layout_constraintEnd_toEndOf="parent"
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
android:layout_marginBottom="32dp"
|
android:layout_marginBottom="40dp"
|
||||||
android:contentDescription="@string/ai_conversation"
|
android:contentDescription="@string/ai_conversation"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Button
|
<ImageButton
|
||||||
android:id="@+id/btnCameraToggle"
|
android:id="@+id/btnCameraToggle"
|
||||||
android:text="📷"
|
android:src="@drawable/ic_camera"
|
||||||
android:textSize="18sp"
|
android:background="@drawable/shape_fab_camera_off"
|
||||||
android:textColor="@color/white"
|
|
||||||
android:background="@drawable/shape_circle_camera_off"
|
|
||||||
android:elevation="8dp"
|
android:elevation="8dp"
|
||||||
|
android:scaleType="center"
|
||||||
android:layout_width="48dp"
|
android:layout_width="48dp"
|
||||||
android:layout_height="48dp"
|
android:layout_height="48dp"
|
||||||
android:visibility="gone"
|
android:visibility="gone"
|
||||||
app:layout_constraintBottom_toBottomOf="@+id/btnAIConversation"
|
app:layout_constraintBottom_toBottomOf="@+id/btnAIConversation"
|
||||||
|
app:layout_constraintTop_toTopOf="@+id/btnAIConversation"
|
||||||
app:layout_constraintStart_toEndOf="@+id/btnAIConversation"
|
app:layout_constraintStart_toEndOf="@+id/btnAIConversation"
|
||||||
app:layout_constraintEnd_toEndOf="parent"
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
android:layout_marginStart="16dp"
|
|
||||||
android:layout_marginEnd="24dp"
|
|
||||||
android:contentDescription="@string/camera_off"
|
android:contentDescription="@string/camera_off"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@ -3,119 +3,56 @@
|
|||||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="match_parent"
|
android:layout_height="match_parent"
|
||||||
android:padding="12dp">
|
android:fitsSystemWindows="true"
|
||||||
|
android:background="@color/warm_bg">
|
||||||
|
|
||||||
|
<!-- 顶部标题区域 -->
|
||||||
<TextView
|
<TextView
|
||||||
android:id="@+id/tv_title"
|
android:id="@+id/tv_title"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="120dp"
|
android:layout_height="wrap_content"
|
||||||
android:gravity="center"
|
android:gravity="center"
|
||||||
android:text="@string/app_name"
|
android:paddingTop="48dp"
|
||||||
android:textColor="@color/black"
|
android:paddingBottom="4dp"
|
||||||
android:textSize="18dp"
|
android:text="@string/app_title"
|
||||||
android:textStyle="italic|bold"
|
android:textColor="@color/warm_text_primary"
|
||||||
|
android:textSize="22sp"
|
||||||
|
android:textStyle="bold"
|
||||||
app:layout_constraintTop_toTopOf="parent" />
|
app:layout_constraintTop_toTopOf="parent" />
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
android:id="@+id/tv_sdk_version"
|
android:id="@+id/tv_subtitle"
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:textSize="12sp"
|
android:gravity="center"
|
||||||
app:layout_constraintEnd_toEndOf="parent"
|
android:paddingBottom="16dp"
|
||||||
|
android:text="@string/app_subtitle"
|
||||||
|
android:textColor="@color/warm_text_secondary"
|
||||||
|
android:textSize="14sp"
|
||||||
app:layout_constraintTop_toBottomOf="@+id/tv_title" />
|
app:layout_constraintTop_toBottomOf="@+id/tv_title" />
|
||||||
|
|
||||||
<ScrollView
|
<!-- 角色列表 -->
|
||||||
|
<androidx.recyclerview.widget.RecyclerView
|
||||||
|
android:id="@+id/rvAvatars"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="0dp"
|
android:layout_height="0dp"
|
||||||
app:layout_constraintBottom_toBottomOf="parent"
|
android:clipToPadding="false"
|
||||||
app:layout_constraintTop_toBottomOf="@+id/tv_sdk_version">
|
android:paddingStart="10dp"
|
||||||
|
android:paddingEnd="10dp"
|
||||||
<androidx.constraintlayout.widget.ConstraintLayout
|
android:paddingBottom="16dp"
|
||||||
android:layout_width="match_parent"
|
app:layout_constraintBottom_toTopOf="@+id/tvBottomHint"
|
||||||
android:layout_height="wrap_content">
|
app:layout_constraintTop_toBottomOf="@+id/tv_subtitle" />
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
android:id="@+id/tvDownloadTips"
|
android:id="@+id/tvBottomHint"
|
||||||
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_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_height="wrap_content"
|
||||||
android:layout_marginTop="12dp"
|
android:gravity="center"
|
||||||
android:text="@string/base_config_url"
|
android:paddingTop="8dp"
|
||||||
android:textSize="13sp"
|
android:paddingBottom="16dp"
|
||||||
app:layout_constraintTop_toBottomOf="@+id/tvDownloadTips"
|
android:text="@string/choose_companion_hint"
|
||||||
app:layout_constraintStart_toStartOf="parent"/>
|
android:textColor="@color/warm_text_hint"
|
||||||
|
android:textSize="12sp"
|
||||||
<EditText
|
app:layout_constraintBottom_toBottomOf="parent" />
|
||||||
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>
|
|
||||||
|
|
||||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||||
42
test/src/main/res/layout/item_avatar_card.xml
Normal 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>
|
||||||
BIN
test/src/main/res/mipmap-hdpi/ethan.webp
Normal file
|
After Width: | Height: | Size: 78 KiB |
BIN
test/src/main/res/mipmap-hdpi/jennifer.webp
Normal file
|
After Width: | Height: | Size: 79 KiB |
BIN
test/src/main/res/mipmap-hdpi/leo.webp
Normal file
|
After Width: | Height: | Size: 88 KiB |
BIN
test/src/main/res/mipmap-hdpi/lily.webp
Normal file
|
After Width: | Height: | Size: 133 KiB |
BIN
test/src/main/res/mipmap-hdpi/moon.webp
Normal file
|
After Width: | Height: | Size: 91 KiB |
BIN
test/src/main/res/mipmap-hdpi/oliver.webp
Normal file
|
After Width: | Height: | Size: 82 KiB |
BIN
test/src/main/res/mipmap-hdpi/sofia.webp
Normal file
|
After Width: | Height: | Size: 76 KiB |
@ -9,4 +9,11 @@
|
|||||||
<color name="white">#FFFFFFFF</color>
|
<color name="white">#FFFFFFFF</color>
|
||||||
<color name="primary">#5a7bbe</color>
|
<color name="primary">#5a7bbe</color>
|
||||||
<color name="primary_99">#995a7bbe</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>
|
</resources>
|
||||||
@ -1,5 +1,8 @@
|
|||||||
<resources>
|
<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="play">Play</string>
|
||||||
<string name="base_config_url">BaseConfig Url:</string>
|
<string name="base_config_url">BaseConfig Url:</string>
|
||||||
<string name="model_url">Model Url:</string>
|
<string name="model_url">Model Url:</string>
|
||||||
|
|||||||
@ -1,15 +1,24 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources>
|
<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: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:windowNoTitle">true</item>
|
||||||
<item name="android:windowIsTranslucent">false</item>
|
<item name="android:windowIsTranslucent">false</item>
|
||||||
<item name="android:windowIsFloating">false</item>
|
<item name="android:windowIsFloating">false</item>
|
||||||
<item name="colorPrimary">#fff</item>
|
<item name="colorPrimary">@color/warm_bg</item>
|
||||||
<item name="colorPrimaryDark">#4349a9</item>
|
<item name="colorPrimaryDark">@color/warm_accent</item>
|
||||||
<item name="colorAccent">#4349a9</item>
|
<item name="colorAccent">@color/warm_accent</item>
|
||||||
<item name="android:windowTranslucentStatus">true</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>
|
</style>
|
||||||
</resources>
|
</resources>
|
||||||