From e4e6b8481118bcc29e684ac247cf1b5087f8aa3d Mon Sep 17 00:00:00 2001 From: feie9456 Date: Wed, 15 Apr 2026 10:03:32 +0800 Subject: [PATCH] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E9=9F=B3=E8=89=B2=E5=85=8B?= =?UTF-8?q?=E9=9A=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- test/src/main/AndroidManifest.xml | 6 + .../duix/test/audio/VoiceCloneRecorder.kt | 63 ++++ .../ai/guiji/duix/test/model/AvatarCatalog.kt | 20 ++ .../duix/test/model/VoiceCloneProfile.kt | 22 ++ .../test/realtime/OmniRealtimeClient.java | 153 +++++++++- .../test/realtime/QwenTtsRealtimeClient.kt | 154 ++++++++++ .../duix/test/service/DashScopeConfig.java | 14 + .../test/service/VoiceCloneProfileStore.kt | 53 ++++ .../duix/test/service/VoiceCloneService.kt | 151 ++++++++++ .../duix/test/ui/activity/CallActivity.kt | 115 +++++++- .../duix/test/ui/activity/MainActivity.kt | 17 +- .../ui/activity/VoiceCloneSetupActivity.kt | 276 ++++++++++++++++++ .../duix/test/ui/adapter/AvatarAdapter.kt | 25 +- .../res/color/text_warm_primary_button.xml | 5 + .../res/color/text_warm_secondary_button.xml | 5 + .../drawable/selector_warm_primary_button.xml | 6 + .../selector_warm_secondary_button.xml | 6 + .../res/drawable/shape_warm_feature_card.xml | 7 + .../drawable/shape_warm_primary_button.xml | 4 + .../shape_warm_primary_button_disabled.xml | 4 + .../shape_warm_primary_button_pressed.xml | 4 + .../drawable/shape_warm_secondary_button.xml | 7 + .../shape_warm_secondary_button_disabled.xml | 7 + .../shape_warm_secondary_button_pressed.xml | 7 + test/src/main/res/layout/activity_main.xml | 156 +++++++--- .../res/layout/activity_voice_clone_setup.xml | 127 ++++++++ test/src/main/res/layout/item_avatar_card.xml | 13 +- test/src/main/res/values-zh-rCN/strings.xml | 30 ++ test/src/main/res/values/colors.xml | 9 + test/src/main/res/values/strings.xml | 30 ++ 30 files changed, 1410 insertions(+), 86 deletions(-) create mode 100644 test/src/main/java/ai/guiji/duix/test/audio/VoiceCloneRecorder.kt create mode 100644 test/src/main/java/ai/guiji/duix/test/model/AvatarCatalog.kt create mode 100644 test/src/main/java/ai/guiji/duix/test/model/VoiceCloneProfile.kt create mode 100644 test/src/main/java/ai/guiji/duix/test/realtime/QwenTtsRealtimeClient.kt create mode 100644 test/src/main/java/ai/guiji/duix/test/service/DashScopeConfig.java create mode 100644 test/src/main/java/ai/guiji/duix/test/service/VoiceCloneProfileStore.kt create mode 100644 test/src/main/java/ai/guiji/duix/test/service/VoiceCloneService.kt create mode 100644 test/src/main/java/ai/guiji/duix/test/ui/activity/VoiceCloneSetupActivity.kt create mode 100644 test/src/main/res/color/text_warm_primary_button.xml create mode 100644 test/src/main/res/color/text_warm_secondary_button.xml create mode 100644 test/src/main/res/drawable/selector_warm_primary_button.xml create mode 100644 test/src/main/res/drawable/selector_warm_secondary_button.xml create mode 100644 test/src/main/res/drawable/shape_warm_feature_card.xml create mode 100644 test/src/main/res/drawable/shape_warm_primary_button.xml create mode 100644 test/src/main/res/drawable/shape_warm_primary_button_disabled.xml create mode 100644 test/src/main/res/drawable/shape_warm_primary_button_pressed.xml create mode 100644 test/src/main/res/drawable/shape_warm_secondary_button.xml create mode 100644 test/src/main/res/drawable/shape_warm_secondary_button_disabled.xml create mode 100644 test/src/main/res/drawable/shape_warm_secondary_button_pressed.xml create mode 100644 test/src/main/res/layout/activity_voice_clone_setup.xml 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