添加音色克隆
This commit is contained in:
@@ -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");
|
||||||
|
if (options.isAudioOutputEnabled()) {
|
||||||
|
modalities.put("audio");
|
||||||
|
}
|
||||||
|
session.put("modalities", modalities);
|
||||||
|
if (options.isAudioOutputEnabled()) {
|
||||||
session.put("voice", options.getVoice());
|
session.put("voice", options.getVoice());
|
||||||
session.put("input_audio_format", "pcm");
|
|
||||||
session.put("output_audio_format", "pcm");
|
session.put("output_audio_format", "pcm");
|
||||||
|
}
|
||||||
|
session.put("input_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>
|
||||||
@@ -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
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:paddingTop="40dp"
|
||||||
|
android:paddingBottom="20dp">
|
||||||
|
|
||||||
<!-- 顶部标题区域 -->
|
|
||||||
<TextView
|
<TextView
|
||||||
android:id="@+id/tv_title"
|
android:id="@+id/tv_title"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:gravity="center"
|
android:gravity="center"
|
||||||
android:paddingTop="48dp"
|
android:paddingStart="24dp"
|
||||||
android:paddingBottom="4dp"
|
android:paddingEnd="24dp"
|
||||||
android:text="@string/app_title"
|
android:text="@string/app_title"
|
||||||
android:textColor="@color/warm_text_primary"
|
android:textColor="@color/warm_text_primary"
|
||||||
android:textSize="22sp"
|
android:textSize="24sp"
|
||||||
android:textStyle="bold"
|
android:textStyle="bold" />
|
||||||
app:layout_constraintTop_toTopOf="parent" />
|
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
android:id="@+id/tv_subtitle"
|
android:id="@+id/tv_subtitle"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="8dp"
|
||||||
android:gravity="center"
|
android:gravity="center"
|
||||||
android:paddingBottom="16dp"
|
android:paddingStart="28dp"
|
||||||
|
android:paddingEnd="28dp"
|
||||||
android:text="@string/app_subtitle"
|
android:text="@string/app_subtitle"
|
||||||
android:textColor="@color/warm_text_secondary"
|
android:textColor="@color/warm_text_secondary"
|
||||||
android:textSize="14sp"
|
android:textSize="15sp" />
|
||||||
app:layout_constraintTop_toBottomOf="@+id/tv_title" />
|
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/cardVoiceCloneEntry"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginStart="16dp"
|
||||||
|
android:layout_marginTop="18dp"
|
||||||
|
android:layout_marginEnd="16dp"
|
||||||
|
android:background="@drawable/shape_warm_feature_card"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:padding="18dp">
|
||||||
|
|
||||||
|
<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
|
<androidx.recyclerview.widget.RecyclerView
|
||||||
android:id="@+id/rvAvatars"
|
android:id="@+id/rvAvatars"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="0dp"
|
android:layout_height="wrap_content"
|
||||||
android:clipToPadding="false"
|
android:clipToPadding="false"
|
||||||
|
android:nestedScrollingEnabled="false"
|
||||||
android:paddingStart="10dp"
|
android:paddingStart="10dp"
|
||||||
|
android:paddingTop="8dp"
|
||||||
android:paddingEnd="10dp"
|
android:paddingEnd="10dp"
|
||||||
android:paddingBottom="16dp"
|
android:paddingBottom="12dp" />
|
||||||
app:layout_constraintBottom_toTopOf="@+id/tvBottomHint"
|
|
||||||
app:layout_constraintTop_toBottomOf="@+id/tv_subtitle" />
|
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
android:id="@+id/tvBottomHint"
|
android:id="@+id/tvBottomHint"
|
||||||
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:paddingTop="8dp"
|
android:paddingStart="24dp"
|
||||||
android:paddingBottom="16dp"
|
android:paddingTop="6dp"
|
||||||
|
android:paddingEnd="24dp"
|
||||||
|
android:paddingBottom="10dp"
|
||||||
android:text="@string/choose_companion_hint"
|
android:text="@string/choose_companion_hint"
|
||||||
android:textColor="@color/warm_text_hint"
|
android:textColor="@color/warm_text_hint"
|
||||||
android:textSize="12sp"
|
android:textSize="12sp" />
|
||||||
app:layout_constraintBottom_toBottomOf="parent" />
|
|
||||||
|
|
||||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
</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>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
Reference in New Issue
Block a user