diff --git a/test/src/main/AndroidManifest.xml b/test/src/main/AndroidManifest.xml
index ec2408a..2f84f62 100644
--- a/test/src/main/AndroidManifest.xml
+++ b/test/src/main/AndroidManifest.xml
@@ -26,6 +26,12 @@
+
+
+
{
+ val audioBase64 = event.optString("delta")
+ if (audioBase64.isNotBlank()) {
+ callback.onAudioDelta(Base64.decode(audioBase64, Base64.NO_WRAP))
+ }
+ }
+ "response.audio.done" -> callback.onAudioDone()
+ "response.done" -> callback.onResponseDone()
+ "session.finished" -> callback.onSessionFinished()
+ "error" -> {
+ val error = event.optJSONObject("error")
+ callback.onError(error?.optString("message") ?: "语音合成失败")
+ }
+ }
+ } catch (e: Exception) {
+ callback.onError(e.message ?: "语音合成失败")
+ }
+ }
+
+ private fun sendEvent(event: JSONObject): Boolean {
+ return connected.get() && webSocket?.send(event.toString()) == true
+ }
+
+ private fun nextEventId(): String {
+ return "event_" + UUID.randomUUID().toString().replace("-", "")
+ }
+
+ data class SessionOptions(
+ val voice: String,
+ val model: String = DashScopeConfig.VOICE_CLONE_TTS_MODEL,
+ val apiKey: String = DashScopeConfig.DEFAULT_API_KEY,
+ val baseUrl: String = DashScopeConfig.REALTIME_BASE_URL
+ )
+
+ interface Callback {
+ fun onConnected()
+
+ fun onDisconnected()
+
+ fun onAudioDelta(pcmData: ByteArray)
+
+ fun onAudioDone()
+
+ fun onResponseDone()
+
+ fun onSessionFinished()
+
+ fun onError(message: String)
+ }
+}
\ No newline at end of file
diff --git a/test/src/main/java/ai/guiji/duix/test/service/DashScopeConfig.java b/test/src/main/java/ai/guiji/duix/test/service/DashScopeConfig.java
new file mode 100644
index 0000000..152d178
--- /dev/null
+++ b/test/src/main/java/ai/guiji/duix/test/service/DashScopeConfig.java
@@ -0,0 +1,14 @@
+package ai.guiji.duix.test.service;
+
+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 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 int AUDIO_SAMPLE_RATE = 24000;
+
+ private DashScopeConfig() {
+ }
+}
\ No newline at end of file
diff --git a/test/src/main/java/ai/guiji/duix/test/service/VoiceCloneProfileStore.kt b/test/src/main/java/ai/guiji/duix/test/service/VoiceCloneProfileStore.kt
new file mode 100644
index 0000000..531a5a5
--- /dev/null
+++ b/test/src/main/java/ai/guiji/duix/test/service/VoiceCloneProfileStore.kt
@@ -0,0 +1,53 @@
+package ai.guiji.duix.test.service
+
+import ai.guiji.duix.test.model.VoiceCloneProfile
+import android.content.Context
+import org.json.JSONObject
+
+class VoiceCloneProfileStore(context: Context) {
+
+ private val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
+
+ fun load(): VoiceCloneProfile? {
+ val raw = prefs.getString(KEY_PROFILE, null) ?: return null
+ return try {
+ val json = JSONObject(raw)
+ VoiceCloneProfile(
+ voice = json.optString("voice"),
+ targetModel = json.optString("targetModel"),
+ preferredName = json.optString("preferredName"),
+ samplePath = json.optString("samplePath"),
+ avatarName = json.optString("avatarName"),
+ avatarVoice = json.optString("avatarVoice"),
+ avatarModelUrl = json.optString("avatarModelUrl"),
+ avatarRes = json.optInt("avatarRes"),
+ createdAt = json.optLong("createdAt")
+ )
+ } catch (_: Exception) {
+ null
+ }
+ }
+
+ fun save(profile: VoiceCloneProfile) {
+ val json = JSONObject()
+ json.put("voice", profile.voice)
+ json.put("targetModel", profile.targetModel)
+ json.put("preferredName", profile.preferredName)
+ json.put("samplePath", profile.samplePath)
+ json.put("avatarName", profile.avatarName)
+ json.put("avatarVoice", profile.avatarVoice)
+ json.put("avatarModelUrl", profile.avatarModelUrl)
+ json.put("avatarRes", profile.avatarRes)
+ json.put("createdAt", profile.createdAt)
+ prefs.edit().putString(KEY_PROFILE, json.toString()).apply()
+ }
+
+ fun clear() {
+ prefs.edit().remove(KEY_PROFILE).apply()
+ }
+
+ companion object {
+ private const val PREFS_NAME = "voice_clone_profile"
+ private const val KEY_PROFILE = "active_profile"
+ }
+}
\ No newline at end of file
diff --git a/test/src/main/java/ai/guiji/duix/test/service/VoiceCloneService.kt b/test/src/main/java/ai/guiji/duix/test/service/VoiceCloneService.kt
new file mode 100644
index 0000000..550ead9
--- /dev/null
+++ b/test/src/main/java/ai/guiji/duix/test/service/VoiceCloneService.kt
@@ -0,0 +1,151 @@
+package ai.guiji.duix.test.service
+
+import android.util.Base64
+import okhttp3.Call
+import okhttp3.Callback
+import okhttp3.MediaType.Companion.toMediaType
+import okhttp3.OkHttpClient
+import okhttp3.Request
+import okhttp3.RequestBody.Companion.toRequestBody
+import okhttp3.Response
+import org.json.JSONObject
+import java.io.File
+import java.io.IOException
+import java.util.Locale
+import java.util.concurrent.TimeUnit
+
+class VoiceCloneService {
+
+ private val client = OkHttpClient.Builder()
+ .connectTimeout(30, TimeUnit.SECONDS)
+ .readTimeout(60, TimeUnit.SECONDS)
+ .writeTimeout(60, TimeUnit.SECONDS)
+ .build()
+
+ fun createVoice(
+ sampleFile: File,
+ preferredName: String,
+ targetModel: String = DashScopeConfig.VOICE_CLONE_TTS_MODEL,
+ callback: (Result) -> Unit
+ ) {
+ if (!sampleFile.exists()) {
+ callback(Result.failure(IllegalStateException("录音文件不存在")))
+ return
+ }
+ val payload = JSONObject()
+ val input = JSONObject()
+ val audio = JSONObject()
+ audio.put("data", buildDataUri(sampleFile))
+ input.put("action", "create")
+ input.put("target_model", targetModel)
+ input.put("preferred_name", normalizePreferredName(preferredName))
+ input.put("audio", audio)
+ payload.put("model", DashScopeConfig.VOICE_CLONE_MODEL)
+ payload.put("input", input)
+
+ val request = Request.Builder()
+ .url(DashScopeConfig.VOICE_CLONE_URL)
+ .addHeader("Authorization", "Bearer " + DashScopeConfig.DEFAULT_API_KEY)
+ .addHeader("Content-Type", "application/json")
+ .post(payload.toString().toRequestBody(JSON_MEDIA_TYPE))
+ .build()
+
+ client.newCall(request).enqueue(object : Callback {
+ override fun onFailure(call: Call, e: IOException) {
+ callback(Result.failure(e))
+ }
+
+ override fun onResponse(call: Call, response: Response) {
+ response.use {
+ val body = response.body?.string().orEmpty()
+ if (!response.isSuccessful) {
+ callback(Result.failure(IllegalStateException(parseErrorMessage(body).ifEmpty { "保存熟悉声音失败" })))
+ return
+ }
+ try {
+ val json = JSONObject(body)
+ val output = json.getJSONObject("output")
+ val voice = output.optString("voice")
+ if (voice.isBlank()) {
+ callback(Result.failure(IllegalStateException("没有拿到可用音色")))
+ return
+ }
+ callback(
+ Result.success(
+ VoiceCloneResult(
+ voice = voice,
+ targetModel = output.optString("target_model", targetModel)
+ )
+ )
+ )
+ } catch (e: Exception) {
+ callback(Result.failure(e))
+ }
+ }
+ }
+ })
+ }
+
+ fun deleteVoice(voice: String) {
+ if (voice.isBlank()) {
+ return
+ }
+ val payload = JSONObject()
+ val input = JSONObject()
+ input.put("action", "delete")
+ input.put("voice", voice)
+ payload.put("model", DashScopeConfig.VOICE_CLONE_MODEL)
+ payload.put("input", input)
+
+ val request = Request.Builder()
+ .url(DashScopeConfig.VOICE_CLONE_URL)
+ .addHeader("Authorization", "Bearer " + DashScopeConfig.DEFAULT_API_KEY)
+ .addHeader("Content-Type", "application/json")
+ .post(payload.toString().toRequestBody(JSON_MEDIA_TYPE))
+ .build()
+
+ client.newCall(request).enqueue(object : Callback {
+ override fun onFailure(call: Call, e: IOException) {
+ }
+
+ override fun onResponse(call: Call, response: Response) {
+ response.close()
+ }
+ })
+ }
+
+ private fun buildDataUri(file: File): String {
+ val mimeType = when (file.extension.lowercase(Locale.US)) {
+ "wav" -> "audio/wav"
+ "mp3" -> "audio/mpeg"
+ else -> "audio/mp4"
+ }
+ val encoded = Base64.encodeToString(file.readBytes(), Base64.NO_WRAP)
+ return "data:$mimeType;base64,$encoded"
+ }
+
+ private fun normalizePreferredName(source: String): String {
+ val normalized = source.lowercase(Locale.US)
+ .replace("[^a-z0-9_]".toRegex(), "_")
+ .trim('_')
+ .ifBlank { "family_voice" }
+ return normalized.take(16)
+ }
+
+ private fun parseErrorMessage(body: String): String {
+ return try {
+ JSONObject(body).optJSONObject("error")?.optString("message").orEmpty()
+ } catch (_: Exception) {
+ ""
+ }
+ }
+
+ data class VoiceCloneResult(
+ val voice: String,
+ val targetModel: String
+ )
+
+ companion object {
+ private val JSON_MEDIA_TYPE = "application/json; charset=utf-8".toMediaType()
+ }
+}
\ No newline at end of file
diff --git a/test/src/main/java/ai/guiji/duix/test/ui/activity/CallActivity.kt b/test/src/main/java/ai/guiji/duix/test/ui/activity/CallActivity.kt
index f2a5862..a315850 100644
--- a/test/src/main/java/ai/guiji/duix/test/ui/activity/CallActivity.kt
+++ b/test/src/main/java/ai/guiji/duix/test/ui/activity/CallActivity.kt
@@ -11,6 +11,8 @@ import ai.guiji.duix.test.audio.StreamingAudioRecorder
import ai.guiji.duix.test.camera.CameraFrameCapture
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.ui.adapter.MotionAdapter
import ai.guiji.duix.test.ui.dialog.AudioRecordDialog
import ai.guiji.duix.test.ui.widget.AudioSpectrumView
@@ -73,6 +75,10 @@ class CallActivity : BaseActivity() {
private var assistantTextBuffer = StringBuilder()
private var hasStartedPushForResponse = false
private val enableWebSearch = true
+ private var isCloneMode = false
+ private var clonedVoice = ""
+ private var cloneTtsModel = DashScopeConfig.VOICE_CLONE_TTS_MODEL
+ private var ttsRealtimeClient: QwenTtsRealtimeClient? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@@ -86,6 +92,9 @@ class CallActivity : BaseActivity() {
modelUrl = intent.getStringExtra("modelUrl") ?: ""
voice = intent.getStringExtra("voice") ?: "Ethan"
debug = intent.getBooleanExtra("debug", false)
+ isCloneMode = intent.getBooleanExtra("cloneMode", false)
+ clonedVoice = intent.getStringExtra("clonedVoice") ?: ""
+ cloneTtsModel = intent.getStringExtra("cloneTtsModel") ?: DashScopeConfig.VOICE_CLONE_TTS_MODEL
Glide.with(mContext).load("file:///android_asset/bg/bg1.png").into(binding.ivBg)
@@ -249,7 +258,12 @@ class CallActivity : BaseActivity() {
}
private fun buildRealtimeSessionOptions(): OmniRealtimeClient.SessionOptions {
- return OmniRealtimeClient.SessionOptions(voice, enableWebSearch)
+ val outputMode = if (isCloneMode) {
+ OmniRealtimeClient.SessionOptions.OutputMode.TEXT_ONLY
+ } else {
+ OmniRealtimeClient.SessionOptions.OutputMode.AUDIO_AND_TEXT
+ }
+ return OmniRealtimeClient.SessionOptions(voice, enableWebSearch, outputMode)
}
private fun isImageAppendError(message: String): Boolean {
@@ -334,6 +348,10 @@ class CallActivity : BaseActivity() {
Toast.makeText(mContext, "数字人未就绪", Toast.LENGTH_SHORT).show()
return
}
+ if (isCloneMode && clonedVoice.isBlank()) {
+ Toast.makeText(mContext, R.string.voice_clone_unavailable, Toast.LENGTH_SHORT).show()
+ return
+ }
applyMessage("AI对话: 连接中...")
binding.tvSubtitle.visibility = View.VISIBLE
binding.tvSubtitle.text = getString(R.string.ai_call_connecting)
@@ -348,11 +366,11 @@ class CallActivity : BaseActivity() {
override fun onConnected() {
mainHandler.post {
binding.tvSubtitle.text = getString(R.string.ai_call_connected)
- binding.btnCameraToggle.visibility = View.VISIBLE
+ 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")
+ applyMessage("AI对话: 已连接, model=${OmniRealtimeClient.DEFAULT_MODEL}, search=on, cloneMode=$isCloneMode")
streamingRecorder = StreamingAudioRecorder(mContext, object : StreamingAudioRecorder.StreamingCallback {
override fun onAudioData(pcmData: ByteArray) {
omniClient?.appendAudio(pcmData)
@@ -385,6 +403,9 @@ class CallActivity : BaseActivity() {
}
override fun onUserSpeechStarted() {
+ if (isCloneMode) {
+ stopCloneSpeech()
+ }
cameraFrameCapture?.requestImmediateCapture()
mainHandler.post {
binding.tvSubtitle.text = getString(R.string.ai_call_listening)
@@ -419,6 +440,9 @@ class CallActivity : BaseActivity() {
}
override fun onAssistantAudioDelta(pcmData: ByteArray) {
+ if (isCloneMode) {
+ return
+ }
if (!hasStartedPushForResponse) {
hasStartedPushForResponse = true
duix?.startPush()
@@ -430,6 +454,9 @@ class CallActivity : BaseActivity() {
}
override fun onAssistantAudioDone() {
+ if (isCloneMode) {
+ return
+ }
if (hasStartedPushForResponse) {
duix?.stopPush()
hasStartedPushForResponse = false
@@ -438,7 +465,12 @@ class CallActivity : BaseActivity() {
override fun onResponseDone() {
mainHandler.post {
- if (hasStartedPushForResponse) {
+ if (isCloneMode) {
+ val replyText = assistantTextBuffer.toString().trim()
+ if (replyText.isNotEmpty()) {
+ speakWithClonedVoice(replyText)
+ }
+ } else if (hasStartedPushForResponse) {
duix?.stopPush()
hasStartedPushForResponse = false
}
@@ -454,6 +486,7 @@ class CallActivity : BaseActivity() {
private fun stopAIConversation() {
isAIConversationActive = false
stopCamera()
+ stopCloneSpeech()
streamingRecorder?.release()
streamingRecorder = null
omniClient?.disconnect()
@@ -476,6 +509,80 @@ class CallActivity : BaseActivity() {
}
}
+ private fun speakWithClonedVoice(text: String) {
+ if (!isAIConversationActive || clonedVoice.isBlank()) {
+ return
+ }
+ stopCloneSpeech()
+ ttsRealtimeClient = QwenTtsRealtimeClient(
+ QwenTtsRealtimeClient.SessionOptions(
+ voice = clonedVoice,
+ model = cloneTtsModel
+ ),
+ object : QwenTtsRealtimeClient.Callback {
+ override fun onConnected() {
+ ttsRealtimeClient?.appendText(text)
+ ttsRealtimeClient?.finish()
+ }
+
+ override fun onDisconnected() {
+ ttsRealtimeClient = null
+ }
+
+ override fun onAudioDelta(pcmData: ByteArray) {
+ if (!hasStartedPushForResponse) {
+ hasStartedPushForResponse = true
+ duix?.startPush()
+ }
+ val resampled = AudioResampler.resample24kTo16k(pcmData)
+ if (resampled.isNotEmpty()) {
+ duix?.pushPcm(resampled)
+ }
+ }
+
+ override fun onAudioDone() {
+ if (hasStartedPushForResponse) {
+ duix?.stopPush()
+ hasStartedPushForResponse = false
+ }
+ }
+
+ override fun onResponseDone() {
+ }
+
+ override fun onSessionFinished() {
+ if (hasStartedPushForResponse) {
+ duix?.stopPush()
+ hasStartedPushForResponse = false
+ }
+ ttsRealtimeClient = null
+ }
+
+ override fun onError(message: String) {
+ mainHandler.post {
+ if (hasStartedPushForResponse) {
+ duix?.stopPush()
+ hasStartedPushForResponse = false
+ }
+ Toast.makeText(mContext, toFriendlyErrorMessage(message), Toast.LENGTH_SHORT).show()
+ applyMessage("复刻声音错误: $message")
+ }
+ }
+ }
+ )
+ ttsRealtimeClient?.connect()
+ }
+
+ private fun stopCloneSpeech() {
+ ttsRealtimeClient?.disconnect()
+ ttsRealtimeClient = null
+ if (hasStartedPushForResponse) {
+ duix?.stopPush()
+ hasStartedPushForResponse = false
+ }
+ duix?.stopAudio()
+ }
+
private fun toggleCamera() {
if (!isAIConversationActive || omniClient == null) return
isCameraEnabled = !isCameraEnabled
diff --git a/test/src/main/java/ai/guiji/duix/test/ui/activity/MainActivity.kt b/test/src/main/java/ai/guiji/duix/test/ui/activity/MainActivity.kt
index d61b1f1..1cc56f2 100644
--- a/test/src/main/java/ai/guiji/duix/test/ui/activity/MainActivity.kt
+++ b/test/src/main/java/ai/guiji/duix/test/ui/activity/MainActivity.kt
@@ -3,6 +3,7 @@ package ai.guiji.duix.test.ui.activity
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.AvatarCatalog
import ai.guiji.duix.test.model.AvatarModel
import ai.guiji.duix.test.ui.adapter.AvatarAdapter
import ai.guiji.duix.test.ui.dialog.LoadingDialog
@@ -21,16 +22,6 @@ class MainActivity : BaseActivity() {
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("詹妮弗", "Maia", "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("四月", "Serena", "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 mSelectedModel: AvatarModel? = null
override fun onCreate(savedInstanceState: Bundle?) {
@@ -38,8 +29,12 @@ class MainActivity : BaseActivity() {
binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)
+ binding.btnVoiceClone.setOnClickListener {
+ startActivity(Intent(mContext, VoiceCloneSetupActivity::class.java))
+ }
+
binding.rvAvatars.layoutManager = GridLayoutManager(this, 2)
- binding.rvAvatars.adapter = AvatarAdapter(avatarModels, object : AvatarAdapter.Callback {
+ binding.rvAvatars.adapter = AvatarAdapter(AvatarCatalog.models, object : AvatarAdapter.Callback {
override fun onSelect(model: AvatarModel) {
mSelectedModel = model
play(model)
diff --git a/test/src/main/java/ai/guiji/duix/test/ui/activity/VoiceCloneSetupActivity.kt b/test/src/main/java/ai/guiji/duix/test/ui/activity/VoiceCloneSetupActivity.kt
new file mode 100644
index 0000000..c8e0141
--- /dev/null
+++ b/test/src/main/java/ai/guiji/duix/test/ui/activity/VoiceCloneSetupActivity.kt
@@ -0,0 +1,276 @@
+package ai.guiji.duix.test.ui.activity
+
+import ai.guiji.duix.test.R
+import ai.guiji.duix.test.audio.VoiceCloneRecorder
+import ai.guiji.duix.test.databinding.ActivityVoiceCloneSetupBinding
+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.VoiceCloneProfileStore
+import ai.guiji.duix.test.service.VoiceCloneService
+import ai.guiji.duix.test.ui.adapter.AvatarAdapter
+import android.Manifest
+import android.content.Intent
+import android.os.Bundle
+import android.os.Handler
+import android.os.Looper
+import android.os.SystemClock
+import android.view.View
+import android.widget.Toast
+import androidx.recyclerview.widget.GridLayoutManager
+import java.io.File
+import java.util.Locale
+
+class VoiceCloneSetupActivity : BaseActivity() {
+
+ private lateinit var binding: ActivityVoiceCloneSetupBinding
+ private lateinit var avatarAdapter: AvatarAdapter
+ private val mainHandler = Handler(Looper.getMainLooper())
+ private val profileStore by lazy { VoiceCloneProfileStore(this) }
+ private val voiceCloneService = VoiceCloneService()
+ private val recorder = VoiceCloneRecorder()
+
+ private var selectedAvatar: AvatarModel = AvatarCatalog.models.first()
+ private var activeProfile: VoiceCloneProfile? = null
+ private var currentRecording: VoiceCloneRecorder.VoiceRecording? = null
+ private var isRecording = false
+ private var isCreatingVoice = false
+ private var recordStartedAtMs = 0L
+
+ private val recordTicker = object : Runnable {
+ override fun run() {
+ if (!isRecording) {
+ return
+ }
+ val elapsed = (SystemClock.elapsedRealtime() - recordStartedAtMs).coerceAtLeast(0L)
+ binding.tvRecordTime.text = getString(R.string.voice_clone_record_time, formatDuration(elapsed))
+ if (elapsed >= MAX_RECORDING_MS) {
+ stopRecording(true)
+ return
+ }
+ mainHandler.postDelayed(this, 500L)
+ }
+ }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ keepScreenOn()
+ binding = ActivityVoiceCloneSetupBinding.inflate(layoutInflater)
+ setContentView(binding.root)
+
+ activeProfile = profileStore.load()
+ selectedAvatar = activeProfile?.toAvatarModel() ?: AvatarCatalog.models.first()
+
+ avatarAdapter = AvatarAdapter(
+ AvatarCatalog.models,
+ object : AvatarAdapter.Callback {
+ override fun onSelect(model: AvatarModel) {
+ selectedAvatar = model
+ avatarAdapter.setSelectedModel(model)
+ updateUi()
+ }
+ },
+ enableSelection = true
+ )
+
+ binding.rvAvatars.layoutManager = GridLayoutManager(this, 2)
+ binding.rvAvatars.adapter = avatarAdapter
+ avatarAdapter.setSelectedModel(selectedAvatar)
+
+ binding.btnPrimaryAction.setOnClickListener {
+ when {
+ isCreatingVoice -> Unit
+ isRecording -> stopRecording(false)
+ currentRecording != null -> createVoiceFromSample()
+ activeProfile != null -> startVoiceCloneConversation()
+ else -> requestPermission(arrayOf(Manifest.permission.RECORD_AUDIO), REQUEST_RECORD_AUDIO)
+ }
+ }
+
+ binding.btnSecondaryAction.setOnClickListener {
+ if (isCreatingVoice || isRecording) {
+ return@setOnClickListener
+ }
+ if (currentRecording != null) {
+ currentRecording?.file?.delete()
+ currentRecording = null
+ updateUi()
+ } else {
+ requestPermission(arrayOf(Manifest.permission.RECORD_AUDIO), REQUEST_RECORD_AUDIO)
+ }
+ }
+
+ binding.tvRecordScript.text = getString(R.string.voice_clone_record_script)
+ binding.tvRecordTime.text = getString(R.string.voice_clone_record_time, formatDuration(0L))
+ updateUi()
+ }
+
+ override fun onDestroy() {
+ super.onDestroy()
+ mainHandler.removeCallbacks(recordTicker)
+ recorder.release()
+ }
+
+ override fun permissionsGet(get: Boolean, code: Int) {
+ super.permissionsGet(get, code)
+ if (code == REQUEST_RECORD_AUDIO) {
+ if (get) {
+ startRecording()
+ } else {
+ Toast.makeText(mContext, R.string.need_permission_continue, Toast.LENGTH_SHORT).show()
+ }
+ }
+ }
+
+ private fun startRecording() {
+ val outputFile = File(filesDir, "voice_clone/sample_${System.currentTimeMillis()}.m4a")
+ try {
+ recorder.start(outputFile)
+ currentRecording = null
+ isRecording = true
+ recordStartedAtMs = SystemClock.elapsedRealtime()
+ binding.tvRecordTime.text = getString(R.string.voice_clone_record_time, formatDuration(0L))
+ mainHandler.removeCallbacks(recordTicker)
+ mainHandler.post(recordTicker)
+ updateUi()
+ } catch (e: Exception) {
+ Toast.makeText(mContext, e.message ?: getString(R.string.voice_clone_record_failed), Toast.LENGTH_SHORT).show()
+ recorder.release()
+ isRecording = false
+ updateUi()
+ }
+ }
+
+ private fun stopRecording(autoStopped: Boolean) {
+ if (!isRecording) {
+ return
+ }
+ isRecording = false
+ mainHandler.removeCallbacks(recordTicker)
+ try {
+ val recording = recorder.stop()
+ if (recording.durationMs < MIN_RECORDING_MS) {
+ recording.file.delete()
+ currentRecording = null
+ Toast.makeText(mContext, R.string.voice_clone_record_too_short, Toast.LENGTH_SHORT).show()
+ } else {
+ currentRecording = recording
+ if (autoStopped) {
+ Toast.makeText(mContext, R.string.voice_clone_record_auto_stopped, Toast.LENGTH_SHORT).show()
+ } else if (recording.durationMs < RECOMMENDED_RECORDING_MS) {
+ Toast.makeText(mContext, R.string.voice_clone_record_recommendation, Toast.LENGTH_SHORT).show()
+ }
+ }
+ } catch (e: Exception) {
+ currentRecording = null
+ Toast.makeText(mContext, e.message ?: getString(R.string.voice_clone_record_failed), Toast.LENGTH_SHORT).show()
+ }
+ updateUi()
+ }
+
+ private fun createVoiceFromSample() {
+ val recording = currentRecording ?: return
+ val oldVoice = activeProfile?.voice
+ isCreatingVoice = true
+ updateUi()
+ voiceCloneService.createVoice(
+ sampleFile = recording.file,
+ preferredName = "family_${selectedAvatar.voice}",
+ targetModel = DashScopeConfig.VOICE_CLONE_TTS_MODEL
+ ) { result ->
+ runOnUiThread {
+ isCreatingVoice = false
+ result.onSuccess { cloneResult ->
+ val profile = VoiceCloneProfile(
+ voice = cloneResult.voice,
+ targetModel = cloneResult.targetModel,
+ preferredName = "family_${selectedAvatar.voice}",
+ samplePath = recording.file.absolutePath,
+ avatarName = selectedAvatar.name,
+ avatarVoice = selectedAvatar.voice,
+ avatarModelUrl = selectedAvatar.modelUrl,
+ avatarRes = selectedAvatar.avatarRes,
+ createdAt = System.currentTimeMillis()
+ )
+ profileStore.save(profile)
+ activeProfile = profile
+ currentRecording = null
+ if (!oldVoice.isNullOrBlank() && oldVoice != profile.voice) {
+ voiceCloneService.deleteVoice(oldVoice)
+ }
+ Toast.makeText(mContext, R.string.voice_clone_saved, Toast.LENGTH_SHORT).show()
+ updateUi()
+ }.onFailure { throwable ->
+ Toast.makeText(mContext, throwable.message ?: getString(R.string.voice_clone_create_failed), Toast.LENGTH_SHORT).show()
+ updateUi()
+ }
+ }
+ }
+ }
+
+ private fun startVoiceCloneConversation() {
+ val profile = (activeProfile ?: return).copy(
+ avatarName = selectedAvatar.name,
+ avatarVoice = selectedAvatar.voice,
+ avatarModelUrl = selectedAvatar.modelUrl,
+ avatarRes = selectedAvatar.avatarRes
+ )
+ profileStore.save(profile)
+ activeProfile = profile
+
+ val intent = Intent(mContext, CallActivity::class.java)
+ intent.putExtra("modelUrl", selectedAvatar.modelUrl)
+ intent.putExtra("voice", selectedAvatar.voice)
+ intent.putExtra("debug", false)
+ intent.putExtra("cloneMode", true)
+ intent.putExtra("clonedVoice", profile.voice)
+ intent.putExtra("cloneTtsModel", profile.targetModel)
+ startActivity(intent)
+ }
+
+ private fun updateUi() {
+ val hasReadyProfile = activeProfile != null
+ val hasSample = currentRecording != null
+
+ binding.tvStatus.text = when {
+ isCreatingVoice -> getString(R.string.voice_clone_status_creating)
+ isRecording -> getString(R.string.voice_clone_status_recording, selectedAvatar.name)
+ hasSample && hasReadyProfile -> getString(R.string.voice_clone_status_sample_replace, selectedAvatar.name)
+ hasSample -> getString(R.string.voice_clone_status_sample_ready, selectedAvatar.name)
+ hasReadyProfile -> getString(R.string.voice_clone_status_ready, selectedAvatar.name)
+ else -> getString(R.string.voice_clone_status_idle, selectedAvatar.name)
+ }
+
+ binding.btnPrimaryAction.text = when {
+ isCreatingVoice -> getString(R.string.voice_clone_primary_creating)
+ isRecording -> getString(R.string.voice_clone_primary_stop_record)
+ hasSample -> getString(R.string.voice_clone_primary_create)
+ hasReadyProfile -> getString(R.string.voice_clone_primary_start_chat)
+ else -> getString(R.string.voice_clone_primary_start_record)
+ }
+
+ binding.btnPrimaryAction.isEnabled = !isCreatingVoice
+ binding.btnSecondaryAction.visibility = if (isCreatingVoice || isRecording) View.INVISIBLE else View.VISIBLE
+ binding.btnSecondaryAction.text = if (hasSample) {
+ getString(R.string.voice_clone_secondary_cancel_sample)
+ } else {
+ getString(R.string.voice_clone_secondary_rerecord)
+ }
+ binding.tvRecordHint.text = getString(R.string.voice_clone_record_hint)
+ }
+
+ private fun formatDuration(durationMs: Long): String {
+ val totalSeconds = (durationMs / 1000L).toInt()
+ val minutes = totalSeconds / 60
+ val seconds = totalSeconds % 60
+ return String.format(Locale.getDefault(), "%02d:%02d", minutes, seconds)
+ }
+
+ companion object {
+ private const val REQUEST_RECORD_AUDIO = 11
+ private const val MIN_RECORDING_MS = 3_000L
+ private const val RECOMMENDED_RECORDING_MS = 10_000L
+ private const val MAX_RECORDING_MS = 60_000L
+ }
+}
\ No newline at end of file
diff --git a/test/src/main/java/ai/guiji/duix/test/ui/adapter/AvatarAdapter.kt b/test/src/main/java/ai/guiji/duix/test/ui/adapter/AvatarAdapter.kt
index 011aecc..4f4029c 100644
--- a/test/src/main/java/ai/guiji/duix/test/ui/adapter/AvatarAdapter.kt
+++ b/test/src/main/java/ai/guiji/duix/test/ui/adapter/AvatarAdapter.kt
@@ -4,13 +4,17 @@ import ai.guiji.duix.test.databinding.ItemAvatarCardBinding
import ai.guiji.duix.test.model.AvatarModel
import android.view.LayoutInflater
import android.view.ViewGroup
+import androidx.core.content.ContextCompat
import androidx.recyclerview.widget.RecyclerView
class AvatarAdapter(
private val list: List,
- private val callback: Callback
+ private val callback: Callback,
+ private val enableSelection: Boolean = false
) : RecyclerView.Adapter() {
+ private var selectedModelUrl: String? = null
+
class ViewHolder(val binding: ItemAvatarCardBinding) :
RecyclerView.ViewHolder(binding.root)
@@ -25,13 +29,32 @@ class AvatarAdapter(
val model = list[position]
holder.binding.ivAvatar.setImageResource(model.avatarRes)
holder.binding.tvName.text = model.name
+ val selected = enableSelection && model.modelUrl == selectedModelUrl
+ holder.binding.cardAvatar.strokeWidth = if (selected) 3 else 0
+ holder.binding.cardAvatar.cardElevation = if (selected) 8f else 3f
+ holder.binding.cardAvatar.alpha = if (selected) 1f else 0.96f
+ holder.binding.cardAvatar.setStrokeColor(
+ if (selected) {
+ ContextCompat.getColor(holder.binding.root.context, ai.guiji.duix.test.R.color.warm_accent)
+ } else {
+ ContextCompat.getColor(holder.binding.root.context, android.R.color.transparent)
+ }
+ )
holder.binding.cardAvatar.setOnClickListener {
+ if (enableSelection) {
+ setSelectedModel(model)
+ }
callback.onSelect(model)
}
}
override fun getItemCount(): Int = list.size
+ fun setSelectedModel(model: AvatarModel?) {
+ selectedModelUrl = model?.modelUrl
+ notifyDataSetChanged()
+ }
+
interface Callback {
fun onSelect(model: AvatarModel)
}
diff --git a/test/src/main/res/color/text_warm_primary_button.xml b/test/src/main/res/color/text_warm_primary_button.xml
new file mode 100644
index 0000000..af047eb
--- /dev/null
+++ b/test/src/main/res/color/text_warm_primary_button.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/test/src/main/res/color/text_warm_secondary_button.xml b/test/src/main/res/color/text_warm_secondary_button.xml
new file mode 100644
index 0000000..0241953
--- /dev/null
+++ b/test/src/main/res/color/text_warm_secondary_button.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/test/src/main/res/drawable/selector_warm_primary_button.xml b/test/src/main/res/drawable/selector_warm_primary_button.xml
new file mode 100644
index 0000000..3ecfd10
--- /dev/null
+++ b/test/src/main/res/drawable/selector_warm_primary_button.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/test/src/main/res/drawable/selector_warm_secondary_button.xml b/test/src/main/res/drawable/selector_warm_secondary_button.xml
new file mode 100644
index 0000000..355be5f
--- /dev/null
+++ b/test/src/main/res/drawable/selector_warm_secondary_button.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/test/src/main/res/drawable/shape_warm_feature_card.xml b/test/src/main/res/drawable/shape_warm_feature_card.xml
new file mode 100644
index 0000000..c12c870
--- /dev/null
+++ b/test/src/main/res/drawable/shape_warm_feature_card.xml
@@ -0,0 +1,7 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/test/src/main/res/drawable/shape_warm_primary_button.xml b/test/src/main/res/drawable/shape_warm_primary_button.xml
new file mode 100644
index 0000000..c3912b9
--- /dev/null
+++ b/test/src/main/res/drawable/shape_warm_primary_button.xml
@@ -0,0 +1,4 @@
+
+
+
+
\ No newline at end of file
diff --git a/test/src/main/res/drawable/shape_warm_primary_button_disabled.xml b/test/src/main/res/drawable/shape_warm_primary_button_disabled.xml
new file mode 100644
index 0000000..ea2268c
--- /dev/null
+++ b/test/src/main/res/drawable/shape_warm_primary_button_disabled.xml
@@ -0,0 +1,4 @@
+
+
+
+
\ No newline at end of file
diff --git a/test/src/main/res/drawable/shape_warm_primary_button_pressed.xml b/test/src/main/res/drawable/shape_warm_primary_button_pressed.xml
new file mode 100644
index 0000000..ab7ad6f
--- /dev/null
+++ b/test/src/main/res/drawable/shape_warm_primary_button_pressed.xml
@@ -0,0 +1,4 @@
+
+
+
+
\ No newline at end of file
diff --git a/test/src/main/res/drawable/shape_warm_secondary_button.xml b/test/src/main/res/drawable/shape_warm_secondary_button.xml
new file mode 100644
index 0000000..15a8500
--- /dev/null
+++ b/test/src/main/res/drawable/shape_warm_secondary_button.xml
@@ -0,0 +1,7 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/test/src/main/res/drawable/shape_warm_secondary_button_disabled.xml b/test/src/main/res/drawable/shape_warm_secondary_button_disabled.xml
new file mode 100644
index 0000000..5d228e0
--- /dev/null
+++ b/test/src/main/res/drawable/shape_warm_secondary_button_disabled.xml
@@ -0,0 +1,7 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/test/src/main/res/drawable/shape_warm_secondary_button_pressed.xml b/test/src/main/res/drawable/shape_warm_secondary_button_pressed.xml
new file mode 100644
index 0000000..30c1ad3
--- /dev/null
+++ b/test/src/main/res/drawable/shape_warm_secondary_button_pressed.xml
@@ -0,0 +1,7 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/test/src/main/res/layout/activity_main.xml b/test/src/main/res/layout/activity_main.xml
index 5715811..ef3c29f 100644
--- a/test/src/main/res/layout/activity_main.xml
+++ b/test/src/main/res/layout/activity_main.xml
@@ -1,58 +1,118 @@
-
+ android:background="@color/warm_bg"
+ android:fillViewport="true">
-
-
+ android:orientation="vertical"
+ android:paddingTop="40dp"
+ android:paddingBottom="20dp">
-
+
-
-
+
-
+
-
\ No newline at end of file
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/test/src/main/res/layout/activity_voice_clone_setup.xml b/test/src/main/res/layout/activity_voice_clone_setup.xml
new file mode 100644
index 0000000..a62f05f
--- /dev/null
+++ b/test/src/main/res/layout/activity_voice_clone_setup.xml
@@ -0,0 +1,127 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/test/src/main/res/layout/item_avatar_card.xml b/test/src/main/res/layout/item_avatar_card.xml
index a2ee6a3..60242b2 100644
--- a/test/src/main/res/layout/item_avatar_card.xml
+++ b/test/src/main/res/layout/item_avatar_card.xml
@@ -4,18 +4,19 @@
android:id="@+id/cardAvatar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
- android:layout_margin="6dp"
+ android:layout_margin="8dp"
android:clickable="true"
android:focusable="true"
android:foreground="?android:attr/selectableItemBackground"
- app:cardCornerRadius="16dp"
- app:cardElevation="2dp"
+ app:cardCornerRadius="18dp"
+ app:cardElevation="3dp"
+ app:strokeColor="@android:color/transparent"
app:strokeWidth="0dp">
+ android:background="@color/warm_surface">
diff --git a/test/src/main/res/values-zh-rCN/strings.xml b/test/src/main/res/values-zh-rCN/strings.xml
index 690bcb6..df06302 100644
--- a/test/src/main/res/values-zh-rCN/strings.xml
+++ b/test/src/main/res/values-zh-rCN/strings.xml
@@ -30,6 +30,36 @@
您刚才说:%1$s
镜头先休息一下,您继续说话就可以
刚刚有点忙,我们稍后再试一次
+ 熟悉声音陪伴
+ 先录一段熟悉的声音,保存好以后,陪伴者会尽量用这份声音和您说话。
+ 去录熟悉声音
+ 熟悉声音陪伴
+ 录一小段声音,保存后就能在陪伴模式里使用。
+ 先选一个形象
+ 再录一段声音
+ 已为您选好 %1$s。接下来录 10 到 20 秒清楚的人声,就可以开始保存了。
+ 正在为 %1$s 记住这段声音,您慢慢说就好。
+ 这段声音已经记下来了。确认后,%1$s 就会学着用这份声音陪您说话。
+ 新的声音样本已经准备好。确认后,会把 %1$s 的熟悉声音更新成这段新的录音。
+ 正在保存熟悉声音,请稍候。
+ 熟悉声音已经准备好了。现在就可以让 %1$s 开始陪您说话。
+ 建议在安静一点的地方录制,保持 10 厘米左右距离,自然说 10 到 20 秒,最长不超过 60 秒。
+ 可以这样慢慢念:\n您好,今天天气不错,我想和家里人说说话。最近吃得还可以,睡得也还行,希望大家都平平安安,心里踏实。
+ 录音时长:%1$s
+ 开始录音
+ 结束录音
+ 保存熟悉声音
+ 正在保存…
+ 开始陪伴
+ 重新录一段
+ 放弃这段新录音
+ 录音没有成功,请再试一次
+ 这段声音太短了,请再录长一点
+ 已经帮您停下来了,这段录音可以直接使用
+ 已经录好了。如果能说到 10 秒以上,效果通常会更自然
+ 熟悉声音已经保存好了
+ 熟悉声音保存失败,请稍后再试
+ 请先在熟悉声音陪伴里保存一段声音
Github地址可能下载失败,您可以考虑使用代理或者将文件缓存到自己的存储服务
调试信息
diff --git a/test/src/main/res/values/colors.xml b/test/src/main/res/values/colors.xml
index 0f72057..47b74d5 100644
--- a/test/src/main/res/values/colors.xml
+++ b/test/src/main/res/values/colors.xml
@@ -16,4 +16,13 @@
#FF8D6E63
#FFBCAAA4
#FFE8926F
+ #FFFFFBF7
+ #FFF0E2D4
+ #FFC16845
+ #FFAF583A
+ #FFD9B9A8
+ #FFF8EEE5
+ #FFF0E0D2
+ #FFF8F1EB
+ #FFD7B39B
\ No newline at end of file
diff --git a/test/src/main/res/values/strings.xml b/test/src/main/res/values/strings.xml
index fa5f950..1f94686 100644
--- a/test/src/main/res/values/strings.xml
+++ b/test/src/main/res/values/strings.xml
@@ -33,6 +33,36 @@
You just said: %1$s
The camera is paused for now, you can keep talking
Something was a little busy just now, please try again shortly
+ Familiar Voice Companion
+ Record a familiar voice first. After it is saved, the companion will try to speak with that voice.
+ Record Familiar Voice
+ Familiar Voice Companion
+ Record a short voice sample, save it, and use it in companion mode.
+ Choose an avatar first
+ Then record a voice sample
+ %1$s is ready. Record 10 to 20 seconds of clear speech, then save it.
+ Recording a familiar voice for %1$s. You can speak slowly.
+ This voice sample is ready. After saving, %1$s will try to speak with this voice.
+ A new sample is ready. After saving, %1$s will use this new familiar voice.
+ Saving the familiar voice. Please wait.
+ The familiar voice is ready. %1$s can start the conversation now.
+ Try to record in a quiet place, keep about 10 cm away, speak naturally for 10 to 20 seconds, and do not exceed 60 seconds.
+ You may read this slowly:\nHello, today feels calm and comfortable. I would like to talk with my family for a while, and I hope everyone is safe and at ease.
+ Recording time: %1$s
+ Start Recording
+ Stop Recording
+ Save Familiar Voice
+ Saving…
+ Start Companion Mode
+ Record Again
+ Discard New Recording
+ Recording failed, please try again
+ The recording is too short, please record a little longer
+ The recording has been stopped automatically and can be used now
+ The sample is saved. If it is longer than 10 seconds, the result is usually more natural
+ The familiar voice has been saved
+ Failed to save the familiar voice, please try again later
+ Please save a familiar voice first
The GitHub url may fail to download, you can consider using a proxy or caching the file to your own storage service
Debug message
\ No newline at end of file