添加音色克隆

This commit is contained in:
2026-04-15 10:03:32 +08:00
parent 959ca062de
commit e4e6b84811
30 changed files with 1410 additions and 86 deletions
+6
View File
@@ -26,6 +26,12 @@
</intent-filter> </intent-filter>
</activity> </activity>
<activity
android:name=".ui.activity.VoiceCloneSetupActivity"
android:exported="false"
android:theme="@style/Theme.DUIX.Test">
</activity>
<activity <activity
android:name=".ui.activity.CallActivity" android:name=".ui.activity.CallActivity"
android:exported="false" android:exported="false"
@@ -0,0 +1,63 @@
package ai.guiji.duix.test.audio
import ai.guiji.duix.test.service.DashScopeConfig
import android.media.MediaRecorder
import android.os.SystemClock
import java.io.File
class VoiceCloneRecorder {
private var mediaRecorder: MediaRecorder? = null
private var recordingFile: File? = null
private var startAtMs: Long = 0L
fun start(outputFile: File) {
release()
outputFile.parentFile?.mkdirs()
mediaRecorder = MediaRecorder().apply {
setAudioSource(MediaRecorder.AudioSource.MIC)
setOutputFormat(MediaRecorder.OutputFormat.MPEG_4)
setAudioEncoder(MediaRecorder.AudioEncoder.AAC)
setAudioChannels(1)
setAudioSamplingRate(DashScopeConfig.AUDIO_SAMPLE_RATE)
setAudioEncodingBitRate(96000)
setOutputFile(outputFile.absolutePath)
prepare()
start()
}
recordingFile = outputFile
startAtMs = SystemClock.elapsedRealtime()
}
fun stop(): VoiceRecording {
val file = recordingFile ?: throw IllegalStateException("还没有开始录音")
val recorder = mediaRecorder ?: throw IllegalStateException("录音器不可用")
val duration = (SystemClock.elapsedRealtime() - startAtMs).coerceAtLeast(0L)
try {
recorder.stop()
} catch (e: RuntimeException) {
file.delete()
throw IllegalStateException("录音太短,请再试一次", e)
} finally {
release()
}
return VoiceRecording(file, duration)
}
fun release() {
mediaRecorder?.runCatching {
reset()
}
mediaRecorder?.runCatching {
release()
}
mediaRecorder = null
recordingFile = null
startAtMs = 0L
}
data class VoiceRecording(
val file: File,
val durationMs: Long
)
}
@@ -0,0 +1,20 @@
package ai.guiji.duix.test.model
import ai.guiji.duix.test.R
object AvatarCatalog {
val models = 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),
)
fun findByModelUrl(modelUrl: String?): AvatarModel? {
return models.firstOrNull { it.modelUrl == modelUrl }
}
}
@@ -0,0 +1,22 @@
package ai.guiji.duix.test.model
data class VoiceCloneProfile(
val voice: String,
val targetModel: String,
val preferredName: String,
val samplePath: String,
val avatarName: String,
val avatarVoice: String,
val avatarModelUrl: String,
val avatarRes: Int,
val createdAt: Long
) {
fun toAvatarModel(): AvatarModel {
return AvatarModel(
name = avatarName,
voice = avatarVoice,
modelUrl = avatarModelUrl,
avatarRes = avatarRes
)
}
}
@@ -1,5 +1,7 @@
package ai.guiji.duix.test.realtime; package ai.guiji.duix.test.realtime;
import ai.guiji.duix.test.service.DashScopeConfig;
import android.util.Base64; import android.util.Base64;
import android.util.Log; import android.util.Log;
@@ -24,9 +26,9 @@ import okhttp3.WebSocketListener;
public class OmniRealtimeClient { public class OmniRealtimeClient {
private static final String TAG = "OmniRealtimeClient"; private static final String TAG = "OmniRealtimeClient";
private static final String DEFAULT_API_KEY = "sk-7c32083ace99472e97209e611b9ecbb1"; private static final String DEFAULT_API_KEY = DashScopeConfig.DEFAULT_API_KEY;
public static final String DEFAULT_MODEL = "qwen3.5-omni-plus-realtime"; public static final String DEFAULT_MODEL = "qwen3.5-omni-plus-realtime";
private static final String DEFAULT_BASE_URL = "wss://dashscope.aliyuncs.com/api-ws/v1/realtime"; private static final String DEFAULT_BASE_URL = DashScopeConfig.REALTIME_BASE_URL;
private static final String INPUT_AUDIO_TRANSCRIPTION_MODEL = "gummy-realtime-v1"; private static final String INPUT_AUDIO_TRANSCRIPTION_MODEL = "gummy-realtime-v1";
private static final int MAX_IMAGE_BYTES = 500 * 1024; private static final int MAX_IMAGE_BYTES = 500 * 1024;
private static final long MIN_IMAGE_APPEND_INTERVAL_MS = 800L; private static final long MIN_IMAGE_APPEND_INTERVAL_MS = 800L;
@@ -66,6 +68,8 @@ public class OmniRealtimeClient {
private final AtomicBoolean imageWindowOpen = new AtomicBoolean(false); private final AtomicBoolean imageWindowOpen = new AtomicBoolean(false);
private final AtomicBoolean hasSentAnyAudio = new AtomicBoolean(false); private final AtomicBoolean hasSentAnyAudio = new AtomicBoolean(false);
private final AtomicLong lastImageAppendAtMs = new AtomicLong(0L); private final AtomicLong lastImageAppendAtMs = new AtomicLong(0L);
private final AtomicBoolean assistantTextFinalized = new AtomicBoolean(false);
private final StringBuilder assistantTextBuffer = new StringBuilder();
public OmniRealtimeClient(SessionOptions sessionOptions, Callback callback) { public OmniRealtimeClient(SessionOptions sessionOptions, Callback callback) {
this.sessionOptions = sessionOptions; this.sessionOptions = sessionOptions;
@@ -155,10 +159,16 @@ public class OmniRealtimeClient {
} }
try { try {
JSONObject session = new JSONObject(); JSONObject session = new JSONObject();
session.put("modalities", new JSONArray().put("text").put("audio")); JSONArray modalities = new JSONArray().put("text");
session.put("voice", options.getVoice()); if (options.isAudioOutputEnabled()) {
modalities.put("audio");
}
session.put("modalities", modalities);
if (options.isAudioOutputEnabled()) {
session.put("voice", options.getVoice());
session.put("output_audio_format", "pcm");
}
session.put("input_audio_format", "pcm"); session.put("input_audio_format", "pcm");
session.put("output_audio_format", "pcm");
session.put("instructions", options.getInstructions()); session.put("instructions", options.getInstructions());
session.put("enable_search", options.isEnableSearch()); session.put("enable_search", options.isEnableSearch());
@@ -226,6 +236,7 @@ public class OmniRealtimeClient {
case "response.created": case "response.created":
assistantResponding.set(true); assistantResponding.set(true);
imageWindowOpen.set(false); imageWindowOpen.set(false);
resetAssistantTextState();
break; break;
case "conversation.item.input_audio_transcription.completed": case "conversation.item.input_audio_transcription.completed":
@@ -237,18 +248,23 @@ public class OmniRealtimeClient {
break; break;
case "response.audio_transcript.delta": case "response.audio_transcript.delta":
String textDelta = event.optString("delta", ""); handleAssistantTextDelta(event.optString("delta", ""));
if (callback != null && !textDelta.isEmpty()) {
callback.onAssistantTextDelta(textDelta);
}
break; break;
case "response.audio_transcript.done": case "response.audio_transcript.done":
String fullTranscript = event.optString("transcript", ""); dispatchAssistantFinalText(event.optString("transcript", event.optString("text", "")));
Log.d(TAG, "Assistant said: " + fullTranscript); break;
if (callback != null) {
callback.onAssistantTranscriptDone(fullTranscript); case "response.text.delta":
} handleAssistantTextDelta(event.optString("delta", ""));
break;
case "response.text.done":
dispatchAssistantFinalText(event.optString("text", event.optString("transcript", "")));
break;
case "response.output_item.done":
dispatchAssistantFinalTextIfNeeded(extractTextFromItem(event.optJSONObject("item")));
break; break;
case "response.audio.delta": case "response.audio.delta":
@@ -270,6 +286,7 @@ public class OmniRealtimeClient {
Log.d(TAG, "Response done"); Log.d(TAG, "Response done");
assistantResponding.set(false); assistantResponding.set(false);
imageWindowOpen.set(false); imageWindowOpen.set(false);
dispatchAssistantFinalTextIfNeeded(extractTextFromResponse(event.optJSONObject("response")));
if (callback != null) { if (callback != null) {
callback.onResponseDone(); callback.onResponseDone();
} }
@@ -401,6 +418,95 @@ public class OmniRealtimeClient {
imageWindowOpen.set(false); imageWindowOpen.set(false);
hasSentAnyAudio.set(false); hasSentAnyAudio.set(false);
lastImageAppendAtMs.set(0L); lastImageAppendAtMs.set(0L);
resetAssistantTextState();
}
private void handleAssistantTextDelta(String delta) {
if (delta == null || delta.isEmpty()) {
return;
}
synchronized (assistantTextBuffer) {
assistantTextBuffer.append(delta);
}
if (callback != null) {
callback.onAssistantTextDelta(delta);
}
}
private void dispatchAssistantFinalTextIfNeeded(String text) {
if (assistantTextFinalized.get()) {
return;
}
dispatchAssistantFinalText(text);
}
private void dispatchAssistantFinalText(String text) {
String finalText = text;
if (finalText == null || finalText.isEmpty()) {
synchronized (assistantTextBuffer) {
finalText = assistantTextBuffer.toString();
}
}
if (finalText == null || finalText.isEmpty()) {
return;
}
synchronized (assistantTextBuffer) {
assistantTextBuffer.setLength(0);
assistantTextBuffer.append(finalText);
}
assistantTextFinalized.set(true);
Log.d(TAG, "Assistant said: " + finalText);
if (callback != null) {
callback.onAssistantTranscriptDone(finalText);
}
}
private void resetAssistantTextState() {
synchronized (assistantTextBuffer) {
assistantTextBuffer.setLength(0);
}
assistantTextFinalized.set(false);
}
private String extractTextFromResponse(JSONObject response) {
if (response == null) {
return "";
}
StringBuilder builder = new StringBuilder();
JSONArray output = response.optJSONArray("output");
if (output == null) {
return "";
}
for (int i = 0; i < output.length(); i++) {
JSONObject item = output.optJSONObject(i);
String itemText = extractTextFromItem(item);
if (!itemText.isEmpty()) {
builder.append(itemText);
}
}
return builder.toString();
}
private String extractTextFromItem(JSONObject item) {
if (item == null) {
return "";
}
StringBuilder builder = new StringBuilder();
JSONArray content = item.optJSONArray("content");
if (content == null) {
return "";
}
for (int i = 0; i < content.length(); i++) {
JSONObject part = content.optJSONObject(i);
if (part == null) {
continue;
}
String text = part.optString("text", "");
if (!text.isEmpty()) {
builder.append(text);
}
}
return builder.toString();
} }
private String nextEventId() { private String nextEventId() {
@@ -408,6 +514,11 @@ public class OmniRealtimeClient {
} }
public static class SessionOptions { public static class SessionOptions {
public enum OutputMode {
AUDIO_AND_TEXT,
TEXT_ONLY
}
private final String apiKey; private final String apiKey;
private final String baseUrl; private final String baseUrl;
private final String model; private final String model;
@@ -416,15 +527,20 @@ public class OmniRealtimeClient {
private final boolean enableSearch; private final boolean enableSearch;
private final boolean enableTurnDetection; private final boolean enableTurnDetection;
private final boolean enableInputAudioTranscription; private final boolean enableInputAudioTranscription;
private final OutputMode outputMode;
public SessionOptions(String voice, boolean enableSearch) { public SessionOptions(String voice, boolean enableSearch) {
this(voice, enableSearch, OutputMode.AUDIO_AND_TEXT);
}
public SessionOptions(String voice, boolean enableSearch, OutputMode outputMode) {
this(DEFAULT_API_KEY, DEFAULT_BASE_URL, DEFAULT_MODEL, voice, DEFAULT_INSTRUCTIONS, this(DEFAULT_API_KEY, DEFAULT_BASE_URL, DEFAULT_MODEL, voice, DEFAULT_INSTRUCTIONS,
enableSearch, true, true); enableSearch, true, true, outputMode);
} }
public SessionOptions(String apiKey, String baseUrl, String model, String voice, public SessionOptions(String apiKey, String baseUrl, String model, String voice,
String instructions, boolean enableSearch, boolean enableTurnDetection, String instructions, boolean enableSearch, boolean enableTurnDetection,
boolean enableInputAudioTranscription) { boolean enableInputAudioTranscription, OutputMode outputMode) {
this.apiKey = apiKey == null ? "" : apiKey; this.apiKey = apiKey == null ? "" : apiKey;
this.baseUrl = baseUrl == null || baseUrl.trim().isEmpty() ? DEFAULT_BASE_URL : baseUrl; this.baseUrl = baseUrl == null || baseUrl.trim().isEmpty() ? DEFAULT_BASE_URL : baseUrl;
this.model = model == null || model.trim().isEmpty() ? DEFAULT_MODEL : model; this.model = model == null || model.trim().isEmpty() ? DEFAULT_MODEL : model;
@@ -434,6 +550,7 @@ public class OmniRealtimeClient {
this.enableSearch = enableSearch; this.enableSearch = enableSearch;
this.enableTurnDetection = enableTurnDetection; this.enableTurnDetection = enableTurnDetection;
this.enableInputAudioTranscription = enableInputAudioTranscription; this.enableInputAudioTranscription = enableInputAudioTranscription;
this.outputMode = outputMode == null ? OutputMode.AUDIO_AND_TEXT : outputMode;
} }
public String getApiKey() { public String getApiKey() {
@@ -463,6 +580,10 @@ public class OmniRealtimeClient {
public boolean isEnableInputAudioTranscription() { public boolean isEnableInputAudioTranscription() {
return enableInputAudioTranscription; return enableInputAudioTranscription;
} }
public boolean isAudioOutputEnabled() {
return outputMode == OutputMode.AUDIO_AND_TEXT;
}
} }
public interface Callback { public interface Callback {
@@ -0,0 +1,154 @@
package ai.guiji.duix.test.realtime
import ai.guiji.duix.test.service.DashScopeConfig
import android.util.Base64
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import okhttp3.WebSocket
import okhttp3.WebSocketListener
import org.json.JSONObject
import java.util.UUID
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean
class QwenTtsRealtimeClient(
private val sessionOptions: SessionOptions,
private val callback: Callback
) {
private val client = OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(120, TimeUnit.MINUTES)
.writeTimeout(30, TimeUnit.SECONDS)
.pingInterval(30, TimeUnit.SECONDS)
.build()
private val connected = AtomicBoolean(false)
private var webSocket: WebSocket? = null
fun connect() {
if (connected.get()) {
return
}
val request = Request.Builder()
.url(sessionOptions.baseUrl + "?model=" + sessionOptions.model)
.addHeader("Authorization", "Bearer " + sessionOptions.apiKey)
.build()
webSocket = client.newWebSocket(request, object : WebSocketListener() {
override fun onOpen(webSocket: WebSocket, response: Response) {
connected.set(true)
sendSessionUpdate()
callback.onConnected()
}
override fun onMessage(webSocket: WebSocket, text: String) {
handleMessage(text)
}
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
connected.set(false)
callback.onError(t.message ?: "连接失败")
callback.onDisconnected()
}
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
connected.set(false)
callback.onDisconnected()
}
})
}
fun appendText(text: String) {
if (text.isBlank()) {
return
}
val event = JSONObject()
event.put("event_id", nextEventId())
event.put("type", "input_text_buffer.append")
event.put("text", text)
sendEvent(event)
}
fun finish() {
val event = JSONObject()
event.put("event_id", nextEventId())
event.put("type", "session.finish")
sendEvent(event)
}
fun disconnect() {
webSocket?.close(1000, "bye")
webSocket = null
connected.set(false)
}
private fun sendSessionUpdate() {
val session = JSONObject()
session.put("voice", sessionOptions.voice)
session.put("mode", "server_commit")
session.put("response_format", "pcm")
session.put("sample_rate", DashScopeConfig.AUDIO_SAMPLE_RATE)
val event = JSONObject()
event.put("event_id", nextEventId())
event.put("type", "session.update")
event.put("session", session)
sendEvent(event)
}
private fun handleMessage(text: String) {
try {
val event = JSONObject(text)
when (event.optString("type")) {
"response.audio.delta" -> {
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)
}
}
@@ -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() {
}
}
@@ -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"
}
}
@@ -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<VoiceCloneResult>) -> 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()
}
}
@@ -11,6 +11,8 @@ import ai.guiji.duix.test.audio.StreamingAudioRecorder
import ai.guiji.duix.test.camera.CameraFrameCapture import ai.guiji.duix.test.camera.CameraFrameCapture
import ai.guiji.duix.test.databinding.ActivityCallBinding 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.realtime.QwenTtsRealtimeClient
import ai.guiji.duix.test.service.DashScopeConfig
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.ui.widget.AudioSpectrumView
@@ -73,6 +75,10 @@ class CallActivity : BaseActivity() {
private var assistantTextBuffer = StringBuilder() private var assistantTextBuffer = StringBuilder()
private var hasStartedPushForResponse = false private var hasStartedPushForResponse = false
private val enableWebSearch = true 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?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@@ -86,6 +92,9 @@ class CallActivity : BaseActivity() {
modelUrl = intent.getStringExtra("modelUrl") ?: "" modelUrl = intent.getStringExtra("modelUrl") ?: ""
voice = intent.getStringExtra("voice") ?: "Ethan" voice = intent.getStringExtra("voice") ?: "Ethan"
debug = intent.getBooleanExtra("debug", false) 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) 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 { 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 { private fun isImageAppendError(message: String): Boolean {
@@ -334,6 +348,10 @@ class CallActivity : BaseActivity() {
Toast.makeText(mContext, "数字人未就绪", Toast.LENGTH_SHORT).show() Toast.makeText(mContext, "数字人未就绪", Toast.LENGTH_SHORT).show()
return return
} }
if (isCloneMode && clonedVoice.isBlank()) {
Toast.makeText(mContext, R.string.voice_clone_unavailable, Toast.LENGTH_SHORT).show()
return
}
applyMessage("AI对话: 连接中...") applyMessage("AI对话: 连接中...")
binding.tvSubtitle.visibility = View.VISIBLE binding.tvSubtitle.visibility = View.VISIBLE
binding.tvSubtitle.text = getString(R.string.ai_call_connecting) binding.tvSubtitle.text = getString(R.string.ai_call_connecting)
@@ -348,11 +366,11 @@ class CallActivity : BaseActivity() {
override fun onConnected() { override fun onConnected() {
mainHandler.post { mainHandler.post {
binding.tvSubtitle.text = getString(R.string.ai_call_connected) 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.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对话: 已连接, model=${OmniRealtimeClient.DEFAULT_MODEL}, search=on") applyMessage("AI对话: 已连接, model=${OmniRealtimeClient.DEFAULT_MODEL}, search=on, cloneMode=$isCloneMode")
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)
@@ -385,6 +403,9 @@ class CallActivity : BaseActivity() {
} }
override fun onUserSpeechStarted() { override fun onUserSpeechStarted() {
if (isCloneMode) {
stopCloneSpeech()
}
cameraFrameCapture?.requestImmediateCapture() cameraFrameCapture?.requestImmediateCapture()
mainHandler.post { mainHandler.post {
binding.tvSubtitle.text = getString(R.string.ai_call_listening) binding.tvSubtitle.text = getString(R.string.ai_call_listening)
@@ -419,6 +440,9 @@ class CallActivity : BaseActivity() {
} }
override fun onAssistantAudioDelta(pcmData: ByteArray) { override fun onAssistantAudioDelta(pcmData: ByteArray) {
if (isCloneMode) {
return
}
if (!hasStartedPushForResponse) { if (!hasStartedPushForResponse) {
hasStartedPushForResponse = true hasStartedPushForResponse = true
duix?.startPush() duix?.startPush()
@@ -430,6 +454,9 @@ class CallActivity : BaseActivity() {
} }
override fun onAssistantAudioDone() { override fun onAssistantAudioDone() {
if (isCloneMode) {
return
}
if (hasStartedPushForResponse) { if (hasStartedPushForResponse) {
duix?.stopPush() duix?.stopPush()
hasStartedPushForResponse = false hasStartedPushForResponse = false
@@ -438,7 +465,12 @@ class CallActivity : BaseActivity() {
override fun onResponseDone() { override fun onResponseDone() {
mainHandler.post { mainHandler.post {
if (hasStartedPushForResponse) { if (isCloneMode) {
val replyText = assistantTextBuffer.toString().trim()
if (replyText.isNotEmpty()) {
speakWithClonedVoice(replyText)
}
} else if (hasStartedPushForResponse) {
duix?.stopPush() duix?.stopPush()
hasStartedPushForResponse = false hasStartedPushForResponse = false
} }
@@ -454,6 +486,7 @@ class CallActivity : BaseActivity() {
private fun stopAIConversation() { private fun stopAIConversation() {
isAIConversationActive = false isAIConversationActive = false
stopCamera() stopCamera()
stopCloneSpeech()
streamingRecorder?.release() streamingRecorder?.release()
streamingRecorder = null streamingRecorder = null
omniClient?.disconnect() 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() { private fun toggleCamera() {
if (!isAIConversationActive || omniClient == null) return if (!isAIConversationActive || omniClient == null) return
isCameraEnabled = !isCameraEnabled isCameraEnabled = !isCameraEnabled
@@ -3,6 +3,7 @@ package ai.guiji.duix.test.ui.activity
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.AvatarCatalog
import ai.guiji.duix.test.model.AvatarModel import ai.guiji.duix.test.model.AvatarModel
import ai.guiji.duix.test.ui.adapter.AvatarAdapter import ai.guiji.duix.test.ui.adapter.AvatarAdapter
import ai.guiji.duix.test.ui.dialog.LoadingDialog 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 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 private var mSelectedModel: AvatarModel? = null
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
@@ -38,8 +29,12 @@ class MainActivity : BaseActivity() {
binding = ActivityMainBinding.inflate(layoutInflater) binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root) setContentView(binding.root)
binding.btnVoiceClone.setOnClickListener {
startActivity(Intent(mContext, VoiceCloneSetupActivity::class.java))
}
binding.rvAvatars.layoutManager = GridLayoutManager(this, 2) 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) { override fun onSelect(model: AvatarModel) {
mSelectedModel = model mSelectedModel = model
play(model) play(model)
@@ -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
}
}
@@ -4,13 +4,17 @@ import ai.guiji.duix.test.databinding.ItemAvatarCardBinding
import ai.guiji.duix.test.model.AvatarModel import ai.guiji.duix.test.model.AvatarModel
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.ViewGroup import android.view.ViewGroup
import androidx.core.content.ContextCompat
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
class AvatarAdapter( class AvatarAdapter(
private val list: List<AvatarModel>, private val list: List<AvatarModel>,
private val callback: Callback private val callback: Callback,
private val enableSelection: Boolean = false
) : RecyclerView.Adapter<AvatarAdapter.ViewHolder>() { ) : RecyclerView.Adapter<AvatarAdapter.ViewHolder>() {
private var selectedModelUrl: String? = null
class ViewHolder(val binding: ItemAvatarCardBinding) : class ViewHolder(val binding: ItemAvatarCardBinding) :
RecyclerView.ViewHolder(binding.root) RecyclerView.ViewHolder(binding.root)
@@ -25,13 +29,32 @@ class AvatarAdapter(
val model = list[position] val model = list[position]
holder.binding.ivAvatar.setImageResource(model.avatarRes) holder.binding.ivAvatar.setImageResource(model.avatarRes)
holder.binding.tvName.text = model.name 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 { holder.binding.cardAvatar.setOnClickListener {
if (enableSelection) {
setSelectedModel(model)
}
callback.onSelect(model) callback.onSelect(model)
} }
} }
override fun getItemCount(): Int = list.size override fun getItemCount(): Int = list.size
fun setSelectedModel(model: AvatarModel?) {
selectedModelUrl = model?.modelUrl
notifyDataSetChanged()
}
interface Callback { interface Callback {
fun onSelect(model: AvatarModel) fun onSelect(model: AvatarModel)
} }
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:color="#FFF7EDE6" android:state_enabled="false" />
<item android:color="@android:color/white" />
</selector>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:color="@color/warm_text_hint" android:state_enabled="false" />
<item android:color="@color/warm_text_primary" />
</selector>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@drawable/shape_warm_primary_button_disabled" android:state_enabled="false" />
<item android:drawable="@drawable/shape_warm_primary_button_pressed" android:state_pressed="true" />
<item android:drawable="@drawable/shape_warm_primary_button" />
</selector>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@drawable/shape_warm_secondary_button_disabled" android:state_enabled="false" />
<item android:drawable="@drawable/shape_warm_secondary_button_pressed" android:state_pressed="true" />
<item android:drawable="@drawable/shape_warm_secondary_button" />
</selector>
@@ -0,0 +1,7 @@
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<corners android:radius="22dp" />
<solid android:color="@color/warm_surface_alt" />
<stroke
android:width="1dp"
android:color="#FFDAB89E" />
</shape>
@@ -0,0 +1,4 @@
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<corners android:radius="28dp" />
<solid android:color="@color/warm_button_primary" />
</shape>
@@ -0,0 +1,4 @@
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<corners android:radius="28dp" />
<solid android:color="@color/warm_button_primary_disabled" />
</shape>
@@ -0,0 +1,4 @@
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<corners android:radius="28dp" />
<solid android:color="@color/warm_button_primary_pressed" />
</shape>
@@ -0,0 +1,7 @@
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<corners android:radius="28dp" />
<solid android:color="@color/warm_button_secondary_bg" />
<stroke
android:width="1dp"
android:color="@color/warm_button_secondary_stroke" />
</shape>
@@ -0,0 +1,7 @@
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<corners android:radius="28dp" />
<solid android:color="@color/warm_button_secondary_bg_disabled" />
<stroke
android:width="1dp"
android:color="#FFE6D3C7" />
</shape>
@@ -0,0 +1,7 @@
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<corners android:radius="28dp" />
<solid android:color="@color/warm_button_secondary_bg_pressed" />
<stroke
android:width="1dp"
android:color="@color/warm_button_secondary_stroke" />
</shape>
+108 -48
View File
@@ -1,58 +1,118 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" <androidx.core.widget.NestedScrollView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:fitsSystemWindows="true" android:background="@color/warm_bg"
android:background="@color/warm_bg"> android:fillViewport="true">
<!-- 顶部标题区域 --> <LinearLayout
<TextView
android:id="@+id/tv_title"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:gravity="center" android:orientation="vertical"
android:paddingTop="48dp" android:paddingTop="40dp"
android:paddingBottom="4dp" android:paddingBottom="20dp">
android:text="@string/app_title"
android:textColor="@color/warm_text_primary"
android:textSize="22sp"
android:textStyle="bold"
app:layout_constraintTop_toTopOf="parent" />
<TextView <TextView
android:id="@+id/tv_subtitle" android:id="@+id/tv_title"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:gravity="center" android:gravity="center"
android:paddingBottom="16dp" android:paddingStart="24dp"
android:text="@string/app_subtitle" android:paddingEnd="24dp"
android:textColor="@color/warm_text_secondary" android:text="@string/app_title"
android:textSize="14sp" android:textColor="@color/warm_text_primary"
app:layout_constraintTop_toBottomOf="@+id/tv_title" /> android:textSize="24sp"
android:textStyle="bold" />
<!-- 角色列表 --> <TextView
<androidx.recyclerview.widget.RecyclerView android:id="@+id/tv_subtitle"
android:id="@+id/rvAvatars" android:layout_width="match_parent"
android:layout_width="match_parent" android:layout_height="wrap_content"
android:layout_height="0dp" android:layout_marginTop="8dp"
android:clipToPadding="false" android:gravity="center"
android:paddingStart="10dp" android:paddingStart="28dp"
android:paddingEnd="10dp" android:paddingEnd="28dp"
android:paddingBottom="16dp" android:text="@string/app_subtitle"
app:layout_constraintBottom_toTopOf="@+id/tvBottomHint" android:textColor="@color/warm_text_secondary"
app:layout_constraintTop_toBottomOf="@+id/tv_subtitle" /> android:textSize="15sp" />
<TextView <LinearLayout
android:id="@+id/tvBottomHint" android:id="@+id/cardVoiceCloneEntry"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:gravity="center" android:layout_marginStart="16dp"
android:paddingTop="8dp" android:layout_marginTop="18dp"
android:paddingBottom="16dp" android:layout_marginEnd="16dp"
android:text="@string/choose_companion_hint" android:background="@drawable/shape_warm_feature_card"
android:textColor="@color/warm_text_hint" android:orientation="vertical"
android:textSize="12sp" android:padding="18dp">
app:layout_constraintBottom_toBottomOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout> <TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/voice_clone_entry_title"
android:textColor="@color/warm_text_primary"
android:textSize="19sp"
android:textStyle="bold" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:lineSpacingExtra="4dp"
android:text="@string/voice_clone_entry_desc"
android:textColor="@color/warm_text_secondary"
android:textSize="14sp" />
<androidx.appcompat.widget.AppCompatButton
android:id="@+id/btnVoiceClone"
android:layout_width="match_parent"
android:layout_height="50dp"
android:layout_marginTop="16dp"
android:background="@drawable/selector_warm_primary_button"
android:elevation="0dp"
android:stateListAnimator="@null"
android:text="@string/voice_clone_entry_action"
android:textAllCaps="false"
android:textColor="@color/text_warm_primary_button"
android:textSize="17sp"
android:textStyle="bold" />
</LinearLayout>
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="20dp"
android:paddingStart="18dp"
android:paddingEnd="18dp"
android:text="@string/choose_companion_hint"
android:textColor="@color/warm_text_secondary"
android:textSize="15sp" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rvAvatars"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:clipToPadding="false"
android:nestedScrollingEnabled="false"
android:paddingStart="10dp"
android:paddingTop="8dp"
android:paddingEnd="10dp"
android:paddingBottom="12dp" />
<TextView
android:id="@+id/tvBottomHint"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center"
android:paddingStart="24dp"
android:paddingTop="6dp"
android:paddingEnd="24dp"
android:paddingBottom="10dp"
android:text="@string/choose_companion_hint"
android:textColor="@color/warm_text_hint"
android:textSize="12sp" />
</LinearLayout>
</androidx.core.widget.NestedScrollView>
@@ -0,0 +1,127 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.core.widget.NestedScrollView xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/warm_bg"
android:clipToPadding="false"
android:fillViewport="false">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:paddingStart="18dp"
android:paddingTop="32dp"
android:paddingEnd="18dp"
android:paddingBottom="28dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/voice_clone_title"
android:textColor="@color/warm_text_primary"
android:textSize="25sp"
android:textStyle="bold" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/voice_clone_subtitle"
android:textColor="@color/warm_text_secondary"
android:textSize="15sp" />
<TextView
android:id="@+id/tvStatus"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="18dp"
android:background="@drawable/shape_warm_feature_card"
android:lineSpacingExtra="4dp"
android:padding="18dp"
android:textColor="@color/warm_text_primary"
android:textSize="18sp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="20dp"
android:text="@string/voice_clone_choose_avatar"
android:textColor="@color/warm_text_primary"
android:textSize="18sp"
android:textStyle="bold" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rvAvatars"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:clipToPadding="false"
android:nestedScrollingEnabled="false"
android:paddingBottom="12dp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/voice_clone_record_title"
android:textColor="@color/warm_text_primary"
android:textSize="18sp"
android:textStyle="bold" />
<TextView
android:id="@+id/tvRecordHint"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:textColor="@color/warm_text_secondary"
android:textSize="15sp" />
<TextView
android:id="@+id/tvRecordTime"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_horizontal"
android:layout_marginTop="12dp"
android:textColor="@color/warm_button_primary"
android:textSize="24sp"
android:textStyle="bold" />
<TextView
android:id="@+id/tvRecordScript"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="14dp"
android:background="@drawable/shape_warm_feature_card"
android:lineSpacingExtra="5dp"
android:padding="18dp"
android:textColor="@color/warm_text_primary"
android:textSize="16sp" />
<androidx.appcompat.widget.AppCompatButton
android:id="@+id/btnPrimaryAction"
android:layout_width="match_parent"
android:layout_height="56dp"
android:layout_marginTop="20dp"
android:background="@drawable/selector_warm_primary_button"
android:elevation="0dp"
android:stateListAnimator="@null"
android:textAllCaps="false"
android:textColor="@color/text_warm_primary_button"
android:textSize="18sp"
android:textStyle="bold" />
<androidx.appcompat.widget.AppCompatButton
android:id="@+id/btnSecondaryAction"
android:layout_width="match_parent"
android:layout_height="54dp"
android:layout_marginTop="12dp"
android:background="@drawable/selector_warm_secondary_button"
android:elevation="0dp"
android:stateListAnimator="@null"
android:textAllCaps="false"
android:textColor="@color/text_warm_secondary_button"
android:textSize="17sp" />
</LinearLayout>
</androidx.core.widget.NestedScrollView>
@@ -4,18 +4,19 @@
android:id="@+id/cardAvatar" android:id="@+id/cardAvatar"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_margin="6dp" android:layout_margin="8dp"
android:clickable="true" android:clickable="true"
android:focusable="true" android:focusable="true"
android:foreground="?android:attr/selectableItemBackground" android:foreground="?android:attr/selectableItemBackground"
app:cardCornerRadius="16dp" app:cardCornerRadius="18dp"
app:cardElevation="2dp" app:cardElevation="3dp"
app:strokeColor="@android:color/transparent"
app:strokeWidth="0dp"> app:strokeWidth="0dp">
<androidx.constraintlayout.widget.ConstraintLayout <androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:background="@color/white"> android:background="@color/warm_surface">
<ImageView <ImageView
android:id="@+id/ivAvatar" android:id="@+id/ivAvatar"
@@ -32,8 +33,8 @@
android:gravity="center" android:gravity="center"
android:paddingTop="10dp" android:paddingTop="10dp"
android:paddingBottom="12dp" android:paddingBottom="12dp"
android:textColor="#333333" android:textColor="@color/warm_text_primary"
android:textSize="15sp" android:textSize="16sp"
android:textStyle="bold" android:textStyle="bold"
app:layout_constraintTop_toBottomOf="@+id/ivAvatar" /> app:layout_constraintTop_toBottomOf="@+id/ivAvatar" />
@@ -30,6 +30,36 @@
<string name="ai_call_user_said">您刚才说:%1$s</string> <string name="ai_call_user_said">您刚才说:%1$s</string>
<string name="ai_call_camera_paused">镜头先休息一下,您继续说话就可以</string> <string name="ai_call_camera_paused">镜头先休息一下,您继续说话就可以</string>
<string name="ai_call_error_retry">刚刚有点忙,我们稍后再试一次</string> <string name="ai_call_error_retry">刚刚有点忙,我们稍后再试一次</string>
<string name="voice_clone_entry_title">熟悉声音陪伴</string>
<string name="voice_clone_entry_desc">先录一段熟悉的声音,保存好以后,陪伴者会尽量用这份声音和您说话。</string>
<string name="voice_clone_entry_action">去录熟悉声音</string>
<string name="voice_clone_title">熟悉声音陪伴</string>
<string name="voice_clone_subtitle">录一小段声音,保存后就能在陪伴模式里使用。</string>
<string name="voice_clone_choose_avatar">先选一个形象</string>
<string name="voice_clone_record_title">再录一段声音</string>
<string name="voice_clone_status_idle">已为您选好 %1$s。接下来录 10 到 20 秒清楚的人声,就可以开始保存了。</string>
<string name="voice_clone_status_recording">正在为 %1$s 记住这段声音,您慢慢说就好。</string>
<string name="voice_clone_status_sample_ready">这段声音已经记下来了。确认后,%1$s 就会学着用这份声音陪您说话。</string>
<string name="voice_clone_status_sample_replace">新的声音样本已经准备好。确认后,会把 %1$s 的熟悉声音更新成这段新的录音。</string>
<string name="voice_clone_status_creating">正在保存熟悉声音,请稍候。</string>
<string name="voice_clone_status_ready">熟悉声音已经准备好了。现在就可以让 %1$s 开始陪您说话。</string>
<string name="voice_clone_record_hint">建议在安静一点的地方录制,保持 10 厘米左右距离,自然说 10 到 20 秒,最长不超过 60 秒。</string>
<string name="voice_clone_record_script">可以这样慢慢念:\n您好,今天天气不错,我想和家里人说说话。最近吃得还可以,睡得也还行,希望大家都平平安安,心里踏实。</string>
<string name="voice_clone_record_time">录音时长:%1$s</string>
<string name="voice_clone_primary_start_record">开始录音</string>
<string name="voice_clone_primary_stop_record">结束录音</string>
<string name="voice_clone_primary_create">保存熟悉声音</string>
<string name="voice_clone_primary_creating">正在保存…</string>
<string name="voice_clone_primary_start_chat">开始陪伴</string>
<string name="voice_clone_secondary_rerecord">重新录一段</string>
<string name="voice_clone_secondary_cancel_sample">放弃这段新录音</string>
<string name="voice_clone_record_failed">录音没有成功,请再试一次</string>
<string name="voice_clone_record_too_short">这段声音太短了,请再录长一点</string>
<string name="voice_clone_record_auto_stopped">已经帮您停下来了,这段录音可以直接使用</string>
<string name="voice_clone_record_recommendation">已经录好了。如果能说到 10 秒以上,效果通常会更自然</string>
<string name="voice_clone_saved">熟悉声音已经保存好了</string>
<string name="voice_clone_create_failed">熟悉声音保存失败,请稍后再试</string>
<string name="voice_clone_unavailable">请先在熟悉声音陪伴里保存一段声音</string>
<string name="main_download_tips">Github地址可能下载失败,您可以考虑使用代理或者将文件缓存到自己的存储服务</string> <string name="main_download_tips">Github地址可能下载失败,您可以考虑使用代理或者将文件缓存到自己的存储服务</string>
<string name="debug_message">调试信息</string> <string name="debug_message">调试信息</string>
+9
View File
@@ -16,4 +16,13 @@
<color name="warm_text_secondary">#FF8D6E63</color> <color name="warm_text_secondary">#FF8D6E63</color>
<color name="warm_text_hint">#FFBCAAA4</color> <color name="warm_text_hint">#FFBCAAA4</color>
<color name="warm_accent">#FFE8926F</color> <color name="warm_accent">#FFE8926F</color>
<color name="warm_surface">#FFFFFBF7</color>
<color name="warm_surface_alt">#FFF0E2D4</color>
<color name="warm_button_primary">#FFC16845</color>
<color name="warm_button_primary_pressed">#FFAF583A</color>
<color name="warm_button_primary_disabled">#FFD9B9A8</color>
<color name="warm_button_secondary_bg">#FFF8EEE5</color>
<color name="warm_button_secondary_bg_pressed">#FFF0E0D2</color>
<color name="warm_button_secondary_bg_disabled">#FFF8F1EB</color>
<color name="warm_button_secondary_stroke">#FFD7B39B</color>
</resources> </resources>
+30
View File
@@ -33,6 +33,36 @@
<string name="ai_call_user_said">You just said: %1$s</string> <string name="ai_call_user_said">You just said: %1$s</string>
<string name="ai_call_camera_paused">The camera is paused for now, you can keep talking</string> <string name="ai_call_camera_paused">The camera is paused for now, you can keep talking</string>
<string name="ai_call_error_retry">Something was a little busy just now, please try again shortly</string> <string name="ai_call_error_retry">Something was a little busy just now, please try again shortly</string>
<string name="voice_clone_entry_title">Familiar Voice Companion</string>
<string name="voice_clone_entry_desc">Record a familiar voice first. After it is saved, the companion will try to speak with that voice.</string>
<string name="voice_clone_entry_action">Record Familiar Voice</string>
<string name="voice_clone_title">Familiar Voice Companion</string>
<string name="voice_clone_subtitle">Record a short voice sample, save it, and use it in companion mode.</string>
<string name="voice_clone_choose_avatar">Choose an avatar first</string>
<string name="voice_clone_record_title">Then record a voice sample</string>
<string name="voice_clone_status_idle">%1$s is ready. Record 10 to 20 seconds of clear speech, then save it.</string>
<string name="voice_clone_status_recording">Recording a familiar voice for %1$s. You can speak slowly.</string>
<string name="voice_clone_status_sample_ready">This voice sample is ready. After saving, %1$s will try to speak with this voice.</string>
<string name="voice_clone_status_sample_replace">A new sample is ready. After saving, %1$s will use this new familiar voice.</string>
<string name="voice_clone_status_creating">Saving the familiar voice. Please wait.</string>
<string name="voice_clone_status_ready">The familiar voice is ready. %1$s can start the conversation now.</string>
<string name="voice_clone_record_hint">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.</string>
<string name="voice_clone_record_script">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.</string>
<string name="voice_clone_record_time">Recording time: %1$s</string>
<string name="voice_clone_primary_start_record">Start Recording</string>
<string name="voice_clone_primary_stop_record">Stop Recording</string>
<string name="voice_clone_primary_create">Save Familiar Voice</string>
<string name="voice_clone_primary_creating">Saving…</string>
<string name="voice_clone_primary_start_chat">Start Companion Mode</string>
<string name="voice_clone_secondary_rerecord">Record Again</string>
<string name="voice_clone_secondary_cancel_sample">Discard New Recording</string>
<string name="voice_clone_record_failed">Recording failed, please try again</string>
<string name="voice_clone_record_too_short">The recording is too short, please record a little longer</string>
<string name="voice_clone_record_auto_stopped">The recording has been stopped automatically and can be used now</string>
<string name="voice_clone_record_recommendation">The sample is saved. If it is longer than 10 seconds, the result is usually more natural</string>
<string name="voice_clone_saved">The familiar voice has been saved</string>
<string name="voice_clone_create_failed">Failed to save the familiar voice, please try again later</string>
<string name="voice_clone_unavailable">Please save a familiar voice first</string>
<string name="main_download_tips">The GitHub url may fail to download, you can consider using a proxy or caching the file to your own storage service</string> <string name="main_download_tips">The GitHub url may fail to download, you can consider using a proxy or caching the file to your own storage service</string>
<string name="debug_message">Debug message</string> <string name="debug_message">Debug message</string>
</resources> </resources>