1 Commits
Author SHA1 Message Date
feie9454 e3274a77d4 添加埋点与性能监控 2026-04-15 11:52:55 +08:00
5 changed files with 821 additions and 0 deletions
@@ -0,0 +1,322 @@
package ai.guiji.duix.test.service
import ai.guiji.duix.test.App
import android.content.Context
import android.content.pm.PackageInfo
import android.os.Build
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONArray
import org.json.JSONObject
import java.util.Locale
import java.util.TimeZone
object AnalyticsReporter {
private const val ENDPOINT = "https://digital-human.xn--876a.net/api/track"
private const val PREFS_NAME = "digital_human_monitor_analytics"
private const val INSTALLATION_ID_KEY = "installation_id"
private const val SESSION_ID_KEY = "session_id"
private const val LAST_ACTIVE_AT_KEY = "last_active_at"
private const val SESSION_TIMEOUT_MS = 30 * 60 * 1000L
private const val MAX_DURATION_MS = 2_147_483_647L
@JvmStatic
@JvmOverloads
fun trackScreen(context: Context, screenName: String, metadata: Map<String, Any?> = emptyMap()) {
trackEvent(
context = context,
name = "screen_view",
type = "navigation",
screenName = screenName,
component = screenName,
metadata = metadata
)
}
@JvmStatic
@JvmOverloads
fun trackAction(
context: Context,
name: String,
screenName: String? = null,
component: String? = null,
value: String? = null,
metadata: Map<String, Any?> = emptyMap(),
) {
trackEvent(
context = context,
name = name,
type = "interaction",
screenName = screenName,
component = component,
value = value,
metadata = metadata
)
}
@JvmStatic
@JvmOverloads
fun trackPerformance(
context: Context,
name: String,
durationMs: Long,
screenName: String? = null,
component: String? = null,
value: String? = null,
sequence: Int? = null,
metadata: Map<String, Any?> = emptyMap(),
) {
trackEvent(
context = context,
name = name,
type = "performance",
screenName = screenName,
component = component,
value = value,
durationMs = durationMs,
sequence = sequence,
metadata = metadata
)
}
@JvmStatic
@JvmOverloads
fun trackConversationMessage(
context: Context,
role: String,
text: String,
screenName: String? = null,
turnIndex: Int? = null,
durationMs: Long? = null,
metadata: Map<String, Any?> = emptyMap(),
) {
val normalizedRole = role.trim().lowercase(Locale.US)
val normalizedText = text.trim().replace("\\s+".toRegex(), " ")
if (normalizedText.isBlank()) {
return
}
val eventName = if (normalizedRole == "assistant") {
"assistant_message_uploaded"
} else {
"user_message_uploaded"
}
val mergedMetadata = LinkedHashMap<String, Any?>().apply {
put("role", normalizedRole)
put("text", normalizedText)
put("textLength", normalizedText.length)
if (turnIndex != null) {
put("turnIndex", turnIndex)
}
putAll(metadata)
}
trackEvent(
context = context,
name = eventName,
type = "conversation",
screenName = screenName,
component = "chat_history",
elementText = shorten(normalizedText, 240),
durationMs = durationMs,
sequence = turnIndex,
metadata = mergedMetadata
)
}
@JvmStatic
@JvmOverloads
fun trackError(
context: Context,
name: String,
message: String,
screenName: String? = null,
metadata: Map<String, Any?> = emptyMap(),
) {
trackEvent(
context = context,
name = name,
type = "error",
screenName = screenName,
value = message,
metadata = metadata
)
}
@JvmStatic
@JvmOverloads
fun trackEvent(
context: Context,
name: String,
type: String,
screenName: String? = null,
component: String? = null,
value: String? = null,
elementText: String? = null,
durationMs: Long? = null,
sequence: Int? = null,
metadata: Map<String, Any?> = emptyMap(),
) {
val appContext = context.applicationContext
val payload = JSONObject()
val session = buildSession(appContext)
val event = JSONObject()
event.put("name", shorten(name, 120))
event.put("type", shorten(type, 80))
event.put("occurredAt", java.time.Instant.now().toString())
screenName?.let {
event.put("pathname", screenPath(it))
event.put("pageUrl", screenUrl(it))
event.put("title", it)
}
if (!component.isNullOrBlank()) {
event.put("component", shorten(component, 120))
}
if (!value.isNullOrBlank()) {
event.put("value", shorten(value, 240))
}
if (!elementText.isNullOrBlank()) {
event.put("elementText", shorten(elementText, 240))
}
durationMs?.let {
event.put("durationMs", clampDuration(it))
}
sequence?.let {
event.put("sequence", it.coerceAtLeast(0))
}
if (metadata.isNotEmpty()) {
event.put("metadata", toJsonObject(metadata))
}
val events = JSONArray()
events.put(event)
payload.put("session", session)
payload.put("events", events)
val request = Request.Builder()
.url(ENDPOINT)
.post(payload.toString().toRequestBody(JSON_MEDIA_TYPE))
.addHeader("Content-Type", "application/json")
.build()
App.getOkHttpClient().newCall(request).enqueue(object : okhttp3.Callback {
override fun onFailure(call: okhttp3.Call, e: java.io.IOException) {
}
override fun onResponse(call: okhttp3.Call, response: okhttp3.Response) {
response.close()
}
})
}
private fun buildSession(context: Context): JSONObject {
val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
val now = System.currentTimeMillis()
val lastActiveAt = prefs.getLong(LAST_ACTIVE_AT_KEY, 0L)
val sessionId = if (now - lastActiveAt > SESSION_TIMEOUT_MS) {
newId("android_session")
} else {
prefs.getString(SESSION_ID_KEY, null) ?: newId("android_session")
}
val installationId = prefs.getString(INSTALLATION_ID_KEY, null) ?: newId("android_install")
prefs.edit()
.putString(INSTALLATION_ID_KEY, installationId)
.putString(SESSION_ID_KEY, sessionId)
.putLong(LAST_ACTIVE_AT_KEY, now)
.apply()
val metrics = context.resources.displayMetrics
val packageInfo = packageInfo(context)
return JSONObject().apply {
put("sessionKey", sessionId)
put("platform", "ANDROID")
put("domain", "digital-human.xn--876a.net")
put("channel", "android-app")
put("visitorId", installationId)
put("locale", Locale.getDefault().toLanguageTag())
put("timezone", TimeZone.getDefault().id)
put("userAgent", "Android/${Build.VERSION.RELEASE} (${Build.BRAND} ${Build.MODEL}; API ${Build.VERSION.SDK_INT})")
put("appVersion", packageInfo?.versionName ?: "unknown")
put("deviceType", "mobile")
put("deviceBrand", Build.BRAND)
put("deviceModel", Build.MODEL)
put("osName", "Android")
put("osVersion", Build.VERSION.RELEASE)
put("screenWidth", metrics.widthPixels)
put("screenHeight", metrics.heightPixels)
}
}
private fun packageInfo(context: Context): PackageInfo? {
return try {
context.packageManager.getPackageInfo(context.packageName, 0)
} catch (_: Exception) {
null
}
}
private fun screenPath(screenName: String): String {
val normalized = screenName
.replace("Activity", "")
.replace(Regex("([a-z])([A-Z])"), "$1-$2")
.lowercase(Locale.US)
return "/android/$normalized"
}
private fun screenUrl(screenName: String): String {
return "android://digital-human${screenPath(screenName)}"
}
private fun newId(prefix: String): String {
return prefix + "_" + System.currentTimeMillis().toString(36) + "_" + (100000..999999).random()
}
private fun shorten(value: String, maxLength: Int): String {
val normalized = value.trim().replace("\\s+".toRegex(), " ")
return if (normalized.length > maxLength) normalized.take(maxLength) else normalized
}
private fun clampDuration(durationMs: Long): Long {
return durationMs.coerceIn(0L, MAX_DURATION_MS)
}
private fun toJsonObject(values: Map<String, Any?>): JSONObject {
return JSONObject().apply {
values.forEach { (key, value) ->
put(key, toJsonValue(value))
}
}
}
private fun toJsonValue(value: Any?): Any {
return when (value) {
null -> JSONObject.NULL
is JSONObject -> value
is JSONArray -> value
is Number, is Boolean, is String -> value
is Map<*, *> -> {
val jsonObject = JSONObject()
value.forEach { (entryKey, entryValue) ->
if (entryKey != null) {
jsonObject.put(entryKey.toString(), toJsonValue(entryValue))
}
}
jsonObject
}
is Iterable<*> -> {
val array = JSONArray()
value.forEach { array.put(toJsonValue(it)) }
array
}
is Array<*> -> {
val array = JSONArray()
value.forEach { array.put(toJsonValue(it)) }
array
}
else -> value.toString()
}
}
private val JSON_MEDIA_TYPE = "application/json; charset=utf-8".toMediaType()
}
@@ -1,5 +1,6 @@
package ai.guiji.duix.test.ui.activity; package ai.guiji.duix.test.ui.activity;
import ai.guiji.duix.test.service.AnalyticsReporter;
import android.content.pm.PackageManager; import android.content.pm.PackageManager;
import android.os.Build; import android.os.Build;
import android.os.Bundle; import android.os.Bundle;
@@ -59,6 +60,16 @@ public abstract class BaseActivity extends AppCompatActivity implements Handler.
getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
} }
@Override
protected void onResume() {
super.onResume();
AnalyticsReporter.trackScreen(this, analyticsScreenName());
}
protected String analyticsScreenName() {
return getClass().getSimpleName();
}
private String[] mRequestPermissions; private String[] mRequestPermissions;
private int mRequestPermissionCode; private int mRequestPermissionCode;
ActivityResultLauncher<String[]> permissionLauncher = registerForActivityResult(new ActivityResultContracts.RequestMultiplePermissions(), ActivityResultLauncher<String[]> permissionLauncher = registerForActivityResult(new ActivityResultContracts.RequestMultiplePermissions(),
@@ -12,6 +12,7 @@ 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.realtime.QwenTtsRealtimeClient
import ai.guiji.duix.test.service.AnalyticsReporter
import ai.guiji.duix.test.service.DashScopeConfig 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
@@ -23,6 +24,7 @@ import android.opengl.GLSurfaceView
import android.os.Bundle import android.os.Bundle
import android.os.Handler import android.os.Handler
import android.os.Looper import android.os.Looper
import android.os.SystemClock
import android.text.TextUtils import android.text.TextUtils
import android.util.Log import android.util.Log
import android.view.View import android.view.View
@@ -79,6 +81,21 @@ class CallActivity : BaseActivity() {
private var clonedVoice = "" private var clonedVoice = ""
private var cloneTtsModel = DashScopeConfig.VOICE_CLONE_TTS_MODEL private var cloneTtsModel = DashScopeConfig.VOICE_CLONE_TTS_MODEL
private var ttsRealtimeClient: QwenTtsRealtimeClient? = null private var ttsRealtimeClient: QwenTtsRealtimeClient? = null
private var duixInitStartedAtMs = 0L
private var aiConversationConnectStartedAtMs = 0L
private var aiConversationConnectedAtMs = 0L
private var conversationTurnIndex = 0
private var currentTurnRequestAtMs = 0L
private var currentTurnSpeechStartedAtMs = 0L
private var currentTurnSpeechStoppedAtMs = 0L
private var currentTurnFirstTextAtMs = 0L
private var currentTurnFirstAudioAtMs = 0L
private var currentTurnUserTranscript = ""
private var currentTurnAssistantTranscript = ""
private var cloneTtsStartedAtMs = 0L
private var cloneTtsFirstAudioAtMs = 0L
private var cameraEnableStartedAtMs = 0L
private var hasReportedCameraFirstFrame = false
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@@ -95,6 +112,14 @@ class CallActivity : BaseActivity() {
isCloneMode = intent.getBooleanExtra("cloneMode", false) isCloneMode = intent.getBooleanExtra("cloneMode", false)
clonedVoice = intent.getStringExtra("clonedVoice") ?: "" clonedVoice = intent.getStringExtra("clonedVoice") ?: ""
cloneTtsModel = intent.getStringExtra("cloneTtsModel") ?: DashScopeConfig.VOICE_CLONE_TTS_MODEL cloneTtsModel = intent.getStringExtra("cloneTtsModel") ?: DashScopeConfig.VOICE_CLONE_TTS_MODEL
AnalyticsReporter.trackAction(
mContext,
name = "call_activity_opened",
screenName = analyticsScreenName(),
component = "call_screen",
value = if (isCloneMode) "clone" else "standard",
metadata = mapOf("voice" to voice)
)
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)
@@ -214,12 +239,21 @@ class CallActivity : BaseActivity() {
// } // }
// }) // })
applyMessage("start init") applyMessage("start init")
duixInitStartedAtMs = SystemClock.elapsedRealtime()
duix?.init() duix?.init()
} }
private fun initOk() { private fun initOk() {
Log.i(TAG, "init ok") Log.i(TAG, "init ok")
applyMessage("init ok") applyMessage("init ok")
AnalyticsReporter.trackPerformance(
mContext,
name = "digital_human_init_ready",
durationMs = elapsedSince(duixInitStartedAtMs),
screenName = analyticsScreenName(),
component = "renderer",
metadata = mapOf("voice" to voice)
)
runOnUiThread { runOnUiThread {
binding.btnRecord.isEnabled = true binding.btnRecord.isEnabled = true
binding.btnPlayPCM.isEnabled = true binding.btnPlayPCM.isEnabled = true
@@ -345,13 +379,35 @@ class CallActivity : BaseActivity() {
private fun startAIConversation() { private fun startAIConversation() {
if (duix?.isReady() != true) { if (duix?.isReady() != true) {
AnalyticsReporter.trackError(
mContext,
name = "ai_conversation_unavailable",
message = "model_not_ready",
screenName = analyticsScreenName()
)
Toast.makeText(mContext, "数字人未就绪", Toast.LENGTH_SHORT).show() Toast.makeText(mContext, "数字人未就绪", Toast.LENGTH_SHORT).show()
return return
} }
if (isCloneMode && clonedVoice.isBlank()) { if (isCloneMode && clonedVoice.isBlank()) {
AnalyticsReporter.trackError(
mContext,
name = "clone_voice_unavailable",
message = "missing_cloned_voice",
screenName = analyticsScreenName()
)
Toast.makeText(mContext, R.string.voice_clone_unavailable, Toast.LENGTH_SHORT).show() Toast.makeText(mContext, R.string.voice_clone_unavailable, Toast.LENGTH_SHORT).show()
return return
} }
AnalyticsReporter.trackAction(
mContext,
name = "ai_conversation_connecting",
screenName = analyticsScreenName(),
component = "btnAIConversation",
value = if (isCloneMode) "clone" else "standard"
)
aiConversationConnectStartedAtMs = SystemClock.elapsedRealtime()
aiConversationConnectedAtMs = 0L
resetConversationTurnMetrics(resetCounter = true)
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)
@@ -365,6 +421,20 @@ class CallActivity : BaseActivity() {
omniClient = OmniRealtimeClient(buildRealtimeSessionOptions(), object : OmniRealtimeClient.Callback { omniClient = OmniRealtimeClient(buildRealtimeSessionOptions(), object : OmniRealtimeClient.Callback {
override fun onConnected() { override fun onConnected() {
mainHandler.post { mainHandler.post {
aiConversationConnectedAtMs = SystemClock.elapsedRealtime()
AnalyticsReporter.trackPerformance(
mContext,
name = "ai_conversation_connected",
durationMs = elapsedSince(aiConversationConnectStartedAtMs),
screenName = analyticsScreenName(),
component = "btnAIConversation",
value = if (isCloneMode) "clone" else "standard",
metadata = mapOf(
"searchEnabled" to enableWebSearch,
"cloneMode" to isCloneMode,
"model" to OmniRealtimeClient.DEFAULT_MODEL
)
)
binding.tvSubtitle.text = getString(R.string.ai_call_connected) binding.tvSubtitle.text = getString(R.string.ai_call_connected)
binding.btnCameraToggle.visibility = if (isCloneMode) View.GONE else 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)
@@ -387,10 +457,27 @@ class CallActivity : BaseActivity() {
} }
override fun onDisconnected() { override fun onDisconnected() {
AnalyticsReporter.trackAction(
mContext,
name = "ai_conversation_disconnected",
screenName = analyticsScreenName(),
component = "btnAIConversation"
)
mainHandler.post { stopAIConversation() } mainHandler.post { stopAIConversation() }
} }
override fun onError(message: String) { override fun onError(message: String) {
AnalyticsReporter.trackError(
mContext,
name = "ai_conversation_error",
message = message,
screenName = analyticsScreenName(),
metadata = mapOf(
"cameraEnabled" to isCameraEnabled,
"connectElapsedMs" to elapsedSince(aiConversationConnectStartedAtMs),
"turnIndex" to conversationTurnIndex.takeIf { it > 0 }
)
)
mainHandler.post { mainHandler.post {
if (isImageAppendError(message)) { if (isImageAppendError(message)) {
stopCamera() stopCamera()
@@ -407,24 +494,44 @@ class CallActivity : BaseActivity() {
stopCloneSpeech() stopCloneSpeech()
} }
cameraFrameCapture?.requestImmediateCapture() cameraFrameCapture?.requestImmediateCapture()
currentTurnSpeechStartedAtMs = SystemClock.elapsedRealtime()
mainHandler.post { mainHandler.post {
binding.tvSubtitle.text = getString(R.string.ai_call_listening) binding.tvSubtitle.text = getString(R.string.ai_call_listening)
} }
} }
override fun onUserSpeechStopped() { override fun onUserSpeechStopped() {
startConversationTurn()
mainHandler.post { mainHandler.post {
binding.tvSubtitle.text = getString(R.string.ai_call_thinking) binding.tvSubtitle.text = getString(R.string.ai_call_thinking)
} }
} }
override fun onUserTranscript(transcript: String) { override fun onUserTranscript(transcript: String) {
ensureConversationTurn()
currentTurnUserTranscript = transcript
AnalyticsReporter.trackConversationMessage(
mContext,
role = "user",
text = transcript,
screenName = analyticsScreenName(),
turnIndex = conversationTurnIndex,
durationMs = elapsedSince(currentTurnRequestAtMs),
metadata = mapOf(
"mode" to currentConversationMode(),
"speechDurationMs" to elapsedBetween(currentTurnSpeechStartedAtMs, currentTurnSpeechStoppedAtMs),
"transcriptionLatencyMs" to elapsedSince(currentTurnRequestAtMs)
)
)
mainHandler.post { mainHandler.post {
binding.tvSubtitle.text = getString(R.string.ai_call_user_said, transcript) binding.tvSubtitle.text = getString(R.string.ai_call_user_said, transcript)
} }
} }
override fun onAssistantTextDelta(delta: String) { override fun onAssistantTextDelta(delta: String) {
if (currentTurnFirstTextAtMs <= 0L) {
currentTurnFirstTextAtMs = SystemClock.elapsedRealtime()
}
mainHandler.post { mainHandler.post {
assistantTextBuffer.append(delta) assistantTextBuffer.append(delta)
binding.tvSubtitle.text = assistantTextBuffer.toString() binding.tvSubtitle.text = assistantTextBuffer.toString()
@@ -432,6 +539,24 @@ class CallActivity : BaseActivity() {
} }
override fun onAssistantTranscriptDone(transcript: String) { override fun onAssistantTranscriptDone(transcript: String) {
ensureConversationTurn()
if (currentTurnFirstTextAtMs <= 0L) {
currentTurnFirstTextAtMs = SystemClock.elapsedRealtime()
}
currentTurnAssistantTranscript = transcript
AnalyticsReporter.trackConversationMessage(
mContext,
role = "assistant",
text = transcript,
screenName = analyticsScreenName(),
turnIndex = conversationTurnIndex,
durationMs = elapsedSince(currentTurnRequestAtMs),
metadata = mapOf(
"mode" to currentConversationMode(),
"textReadyLatencyMs" to elapsedSince(currentTurnRequestAtMs),
"firstTextLatencyMs" to elapsedSince(currentTurnRequestAtMs, currentTurnFirstTextAtMs)
)
)
mainHandler.post { mainHandler.post {
assistantTextBuffer.clear() assistantTextBuffer.clear()
assistantTextBuffer.append(transcript) assistantTextBuffer.append(transcript)
@@ -443,6 +568,9 @@ class CallActivity : BaseActivity() {
if (isCloneMode) { if (isCloneMode) {
return return
} }
if (currentTurnFirstAudioAtMs <= 0L) {
currentTurnFirstAudioAtMs = SystemClock.elapsedRealtime()
}
if (!hasStartedPushForResponse) { if (!hasStartedPushForResponse) {
hasStartedPushForResponse = true hasStartedPushForResponse = true
duix?.startPush() duix?.startPush()
@@ -464,6 +592,27 @@ class CallActivity : BaseActivity() {
} }
override fun onResponseDone() { override fun onResponseDone() {
ensureConversationTurn()
if (currentTurnAssistantTranscript.isBlank()) {
currentTurnAssistantTranscript = assistantTextBuffer.toString().trim()
}
AnalyticsReporter.trackPerformance(
mContext,
name = "assistant_response_done",
durationMs = elapsedSince(currentTurnRequestAtMs),
screenName = analyticsScreenName(),
component = if (isCloneMode) "clone-tts" else "audio-stream",
value = currentTurnAssistantTranscript.length.toString(),
sequence = conversationTurnIndex,
metadata = mapOf(
"mode" to currentConversationMode(),
"firstTextLatencyMs" to elapsedSince(currentTurnRequestAtMs, currentTurnFirstTextAtMs),
"firstAudioLatencyMs" to elapsedSince(currentTurnRequestAtMs, currentTurnFirstAudioAtMs),
"userTextLength" to currentTurnUserTranscript.length,
"assistantTextLength" to currentTurnAssistantTranscript.length,
"speechDurationMs" to elapsedBetween(currentTurnSpeechStartedAtMs, currentTurnSpeechStoppedAtMs)
)
)
mainHandler.post { mainHandler.post {
if (isCloneMode) { if (isCloneMode) {
val replyText = assistantTextBuffer.toString().trim() val replyText = assistantTextBuffer.toString().trim()
@@ -475,6 +624,7 @@ class CallActivity : BaseActivity() {
hasStartedPushForResponse = false hasStartedPushForResponse = false
} }
assistantTextBuffer.clear() assistantTextBuffer.clear()
resetConversationTurnMetrics(resetCounter = false)
} }
} }
}) })
@@ -484,6 +634,7 @@ class CallActivity : BaseActivity() {
} }
private fun stopAIConversation() { private fun stopAIConversation() {
val wasActive = isAIConversationActive
isAIConversationActive = false isAIConversationActive = false
stopCamera() stopCamera()
stopCloneSpeech() stopCloneSpeech()
@@ -494,6 +645,21 @@ class CallActivity : BaseActivity() {
duix?.stopAudio() duix?.stopAudio()
assistantTextBuffer.clear() assistantTextBuffer.clear()
hasStartedPushForResponse = false hasStartedPushForResponse = false
if (wasActive) {
AnalyticsReporter.trackAction(
mContext,
name = "ai_conversation_stopped",
screenName = analyticsScreenName(),
component = "btnAIConversation",
metadata = mapOf(
"sessionDurationMs" to elapsedSince(aiConversationConnectedAtMs),
"turnCount" to conversationTurnIndex
)
)
}
resetConversationTurnMetrics(resetCounter = true)
aiConversationConnectStartedAtMs = 0L
aiConversationConnectedAtMs = 0L
mainHandler.post { mainHandler.post {
if (!isFinishing) { if (!isFinishing) {
@@ -514,6 +680,16 @@ class CallActivity : BaseActivity() {
return return
} }
stopCloneSpeech() stopCloneSpeech()
cloneTtsStartedAtMs = SystemClock.elapsedRealtime()
cloneTtsFirstAudioAtMs = 0L
AnalyticsReporter.trackAction(
mContext,
name = "clone_tts_started",
screenName = analyticsScreenName(),
component = "clone-tts",
value = text.length.toString(),
metadata = mapOf("voice" to clonedVoice)
)
ttsRealtimeClient = QwenTtsRealtimeClient( ttsRealtimeClient = QwenTtsRealtimeClient(
QwenTtsRealtimeClient.SessionOptions( QwenTtsRealtimeClient.SessionOptions(
voice = clonedVoice, voice = clonedVoice,
@@ -530,6 +706,9 @@ class CallActivity : BaseActivity() {
} }
override fun onAudioDelta(pcmData: ByteArray) { override fun onAudioDelta(pcmData: ByteArray) {
if (cloneTtsFirstAudioAtMs <= 0L) {
cloneTtsFirstAudioAtMs = SystemClock.elapsedRealtime()
}
if (!hasStartedPushForResponse) { if (!hasStartedPushForResponse) {
hasStartedPushForResponse = true hasStartedPushForResponse = true
duix?.startPush() duix?.startPush()
@@ -551,14 +730,40 @@ class CallActivity : BaseActivity() {
} }
override fun onSessionFinished() { override fun onSessionFinished() {
AnalyticsReporter.trackPerformance(
mContext,
name = "clone_tts_finished",
durationMs = elapsedSince(cloneTtsStartedAtMs),
screenName = analyticsScreenName(),
component = "clone-tts",
metadata = mapOf(
"voice" to clonedVoice,
"firstAudioLatencyMs" to elapsedSince(cloneTtsStartedAtMs, cloneTtsFirstAudioAtMs),
"mode" to currentConversationMode(),
"textLength" to text.length
)
)
if (hasStartedPushForResponse) { if (hasStartedPushForResponse) {
duix?.stopPush() duix?.stopPush()
hasStartedPushForResponse = false hasStartedPushForResponse = false
} }
ttsRealtimeClient = null ttsRealtimeClient = null
cloneTtsStartedAtMs = 0L
cloneTtsFirstAudioAtMs = 0L
} }
override fun onError(message: String) { override fun onError(message: String) {
AnalyticsReporter.trackError(
mContext,
name = "clone_tts_error",
message = message,
screenName = analyticsScreenName(),
metadata = mapOf(
"voice" to clonedVoice,
"elapsedMs" to elapsedSince(cloneTtsStartedAtMs),
"textLength" to text.length
)
)
mainHandler.post { mainHandler.post {
if (hasStartedPushForResponse) { if (hasStartedPushForResponse) {
duix?.stopPush() duix?.stopPush()
@@ -581,18 +786,44 @@ class CallActivity : BaseActivity() {
hasStartedPushForResponse = false hasStartedPushForResponse = false
} }
duix?.stopAudio() duix?.stopAudio()
cloneTtsStartedAtMs = 0L
cloneTtsFirstAudioAtMs = 0L
} }
private fun toggleCamera() { private fun toggleCamera() {
if (!isAIConversationActive || omniClient == null) return if (!isAIConversationActive || omniClient == null) return
isCameraEnabled = !isCameraEnabled isCameraEnabled = !isCameraEnabled
if (isCameraEnabled) { if (isCameraEnabled) {
cameraEnableStartedAtMs = SystemClock.elapsedRealtime()
hasReportedCameraFirstFrame = false
AnalyticsReporter.trackAction(
mContext,
name = "camera_enabled",
screenName = analyticsScreenName(),
component = "btnCameraToggle"
)
binding.cameraPreview.visibility = View.VISIBLE binding.cameraPreview.visibility = View.VISIBLE
cameraFrameCapture = CameraFrameCapture(mContext, object : CameraFrameCapture.FrameCallback { cameraFrameCapture = CameraFrameCapture(mContext, object : CameraFrameCapture.FrameCallback {
override fun onFrame(jpegData: ByteArray) { override fun onFrame(jpegData: ByteArray) {
if (!hasReportedCameraFirstFrame) {
hasReportedCameraFirstFrame = true
AnalyticsReporter.trackPerformance(
mContext,
name = "camera_first_frame_ready",
durationMs = elapsedSince(cameraEnableStartedAtMs),
screenName = analyticsScreenName(),
component = "btnCameraToggle"
)
}
omniClient?.appendImage(jpegData) omniClient?.appendImage(jpegData)
} }
override fun onError(message: String) { override fun onError(message: String) {
AnalyticsReporter.trackError(
mContext,
name = "camera_capture_error",
message = message,
screenName = analyticsScreenName()
)
mainHandler.post { mainHandler.post {
Toast.makeText(mContext, message, Toast.LENGTH_SHORT).show() Toast.makeText(mContext, message, Toast.LENGTH_SHORT).show()
stopCamera() stopCamera()
@@ -603,6 +834,12 @@ class CallActivity : BaseActivity() {
binding.btnCameraToggle.setBackgroundResource(R.drawable.shape_fab_camera_on) binding.btnCameraToggle.setBackgroundResource(R.drawable.shape_fab_camera_on)
binding.btnCameraToggle.contentDescription = getString(R.string.camera_on) binding.btnCameraToggle.contentDescription = getString(R.string.camera_on)
} else { } else {
AnalyticsReporter.trackAction(
mContext,
name = "camera_disabled",
screenName = analyticsScreenName(),
component = "btnCameraToggle"
)
stopCamera() stopCamera()
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)
@@ -613,9 +850,65 @@ class CallActivity : BaseActivity() {
cameraFrameCapture?.stop() cameraFrameCapture?.stop()
cameraFrameCapture = null cameraFrameCapture = null
isCameraEnabled = false isCameraEnabled = false
cameraEnableStartedAtMs = 0L
hasReportedCameraFirstFrame = false
binding.cameraPreview.visibility = View.GONE binding.cameraPreview.visibility = View.GONE
} }
private fun startConversationTurn() {
conversationTurnIndex += 1
currentTurnRequestAtMs = SystemClock.elapsedRealtime()
currentTurnSpeechStoppedAtMs = currentTurnRequestAtMs
currentTurnFirstTextAtMs = 0L
currentTurnFirstAudioAtMs = 0L
currentTurnUserTranscript = ""
currentTurnAssistantTranscript = ""
}
private fun ensureConversationTurn() {
if (currentTurnRequestAtMs <= 0L) {
startConversationTurn()
}
}
private fun resetConversationTurnMetrics(resetCounter: Boolean) {
currentTurnRequestAtMs = 0L
currentTurnSpeechStartedAtMs = 0L
currentTurnSpeechStoppedAtMs = 0L
currentTurnFirstTextAtMs = 0L
currentTurnFirstAudioAtMs = 0L
currentTurnUserTranscript = ""
currentTurnAssistantTranscript = ""
if (resetCounter) {
conversationTurnIndex = 0
}
}
private fun currentConversationMode(): String {
return if (isCloneMode) "clone" else "standard"
}
private fun elapsedSince(startAtMs: Long): Long {
if (startAtMs <= 0L) {
return 0L
}
return (SystemClock.elapsedRealtime() - startAtMs).coerceAtLeast(0L)
}
private fun elapsedSince(startAtMs: Long, endAtMs: Long): Long {
if (startAtMs <= 0L || endAtMs <= 0L) {
return 0L
}
return (endAtMs - startAtMs).coerceAtLeast(0L)
}
private fun elapsedBetween(startAtMs: Long, endAtMs: Long): Long {
if (startAtMs <= 0L || endAtMs <= 0L) {
return 0L
}
return (endAtMs - startAtMs).coerceAtLeast(0L)
}
private fun showRecordDialog(){ private fun showRecordDialog(){
val audioRecordDialog = AudioRecordDialog(mContext, object : AudioRecordDialog.Listener{ val audioRecordDialog = AudioRecordDialog(mContext, object : AudioRecordDialog.Listener{
override fun onFinish(path: String) { override fun onFinish(path: String) {
@@ -5,10 +5,12 @@ 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.AvatarCatalog
import ai.guiji.duix.test.model.AvatarModel import ai.guiji.duix.test.model.AvatarModel
import ai.guiji.duix.test.service.AnalyticsReporter
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
import android.content.Intent import android.content.Intent
import android.os.Bundle import android.os.Bundle
import android.os.SystemClock
import android.widget.Toast import android.widget.Toast
import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.GridLayoutManager
import java.io.File import java.io.File
@@ -19,6 +21,8 @@ class MainActivity : BaseActivity() {
private lateinit var binding: ActivityMainBinding private lateinit var binding: ActivityMainBinding
private var mLoadingDialog: LoadingDialog? = null private var mLoadingDialog: LoadingDialog? = null
private var mLastProgress = 0 private var mLastProgress = 0
private var baseConfigDownloadStartedAtMs = 0L
private var modelDownloadStartedAtMs = 0L
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"
@@ -30,12 +34,26 @@ class MainActivity : BaseActivity() {
setContentView(binding.root) setContentView(binding.root)
binding.btnVoiceClone.setOnClickListener { binding.btnVoiceClone.setOnClickListener {
AnalyticsReporter.trackAction(
mContext,
name = "voice_clone_entry_tap",
screenName = analyticsScreenName(),
component = "btnVoiceClone"
)
startActivity(Intent(mContext, VoiceCloneSetupActivity::class.java)) startActivity(Intent(mContext, VoiceCloneSetupActivity::class.java))
} }
binding.rvAvatars.layoutManager = GridLayoutManager(this, 2) binding.rvAvatars.layoutManager = GridLayoutManager(this, 2)
binding.rvAvatars.adapter = AvatarAdapter(AvatarCatalog.models, object : AvatarAdapter.Callback { binding.rvAvatars.adapter = AvatarAdapter(AvatarCatalog.models, object : AvatarAdapter.Callback {
override fun onSelect(model: AvatarModel) { override fun onSelect(model: AvatarModel) {
AnalyticsReporter.trackAction(
mContext,
name = "avatar_selected",
screenName = analyticsScreenName(),
component = "avatar_grid",
value = model.name,
metadata = mapOf("voice" to model.voice)
)
mSelectedModel = model mSelectedModel = model
play(model) play(model)
} }
@@ -66,6 +84,14 @@ class MainActivity : BaseActivity() {
private fun jumpPlayPage() { private fun jumpPlayPage() {
val model = mSelectedModel ?: return val model = mSelectedModel ?: return
AnalyticsReporter.trackAction(
mContext,
name = "call_page_opened",
screenName = analyticsScreenName(),
component = "avatar_grid",
value = model.name,
metadata = mapOf("mode" to "standard", "voice" to model.voice)
)
val intent = Intent(mContext, CallActivity::class.java) val intent = Intent(mContext, CallActivity::class.java)
intent.putExtra("modelUrl", model.modelUrl) intent.putExtra("modelUrl", model.modelUrl)
intent.putExtra("voice", model.voice) intent.putExtra("voice", model.voice)
@@ -77,6 +103,13 @@ class MainActivity : BaseActivity() {
mLoadingDialog?.dismiss() mLoadingDialog?.dismiss()
mLoadingDialog = LoadingDialog(mContext, "正在准备资源...") mLoadingDialog = LoadingDialog(mContext, "正在准备资源...")
mLoadingDialog?.show() mLoadingDialog?.show()
baseConfigDownloadStartedAtMs = SystemClock.elapsedRealtime()
AnalyticsReporter.trackAction(
mContext,
name = "base_config_download_started",
screenName = analyticsScreenName(),
component = "resource_loader"
)
VirtualModelUtil.baseConfigDownload(mContext, baseConfigUrl, object : VirtualModelUtil.baseConfigDownload(mContext, baseConfigUrl, object :
VirtualModelUtil.ModelDownloadCallback { VirtualModelUtil.ModelDownloadCallback {
override fun onDownloadProgress(url: String?, current: Long, total: Long) { override fun onDownloadProgress(url: String?, current: Long, total: Long) {
@@ -105,6 +138,14 @@ class MainActivity : BaseActivity() {
override fun onDownloadComplete(url: String?, dir: File?) { override fun onDownloadComplete(url: String?, dir: File?) {
runOnUiThread { runOnUiThread {
AnalyticsReporter.trackPerformance(
mContext,
name = "base_config_download_completed",
durationMs = elapsedSince(baseConfigDownloadStartedAtMs),
screenName = analyticsScreenName(),
component = "resource_loader",
metadata = mapOf("url" to url)
)
mLoadingDialog?.dismiss() mLoadingDialog?.dismiss()
checkModel() checkModel()
} }
@@ -112,6 +153,16 @@ class MainActivity : BaseActivity() {
override fun onDownloadFail(url: String?, code: Int, msg: String?) { override fun onDownloadFail(url: String?, code: Int, msg: String?) {
runOnUiThread { runOnUiThread {
AnalyticsReporter.trackError(
mContext,
name = "base_config_download_failed",
message = msg ?: "unknown",
screenName = analyticsScreenName(),
metadata = mapOf(
"code" to code,
"elapsedMs" to elapsedSince(baseConfigDownloadStartedAtMs)
)
)
mLoadingDialog?.dismiss() mLoadingDialog?.dismiss()
Toast.makeText(mContext, "资源下载失败: $msg", Toast.LENGTH_SHORT).show() Toast.makeText(mContext, "资源下载失败: $msg", Toast.LENGTH_SHORT).show()
} }
@@ -124,6 +175,15 @@ class MainActivity : BaseActivity() {
mLoadingDialog?.dismiss() mLoadingDialog?.dismiss()
mLoadingDialog = LoadingDialog(mContext, "正在下载 ${model.name} ...") mLoadingDialog = LoadingDialog(mContext, "正在下载 ${model.name} ...")
mLoadingDialog?.show() mLoadingDialog?.show()
modelDownloadStartedAtMs = SystemClock.elapsedRealtime()
AnalyticsReporter.trackAction(
mContext,
name = "avatar_download_started",
screenName = analyticsScreenName(),
component = "resource_loader",
value = model.name,
metadata = mapOf("voice" to model.voice)
)
VirtualModelUtil.modelDownload(mContext, model.modelUrl, object : VirtualModelUtil.ModelDownloadCallback { VirtualModelUtil.modelDownload(mContext, model.modelUrl, object : VirtualModelUtil.ModelDownloadCallback {
override fun onDownloadProgress(url: String?, current: Long, total: Long) { override fun onDownloadProgress(url: String?, current: Long, total: Long) {
val progress = (current * 100 / total).toInt() val progress = (current * 100 / total).toInt()
@@ -151,6 +211,15 @@ class MainActivity : BaseActivity() {
override fun onDownloadComplete(url: String?, dir: File?) { override fun onDownloadComplete(url: String?, dir: File?) {
runOnUiThread { runOnUiThread {
AnalyticsReporter.trackPerformance(
mContext,
name = "avatar_download_completed",
durationMs = elapsedSince(modelDownloadStartedAtMs),
screenName = analyticsScreenName(),
component = "resource_loader",
value = model.name,
metadata = mapOf("voice" to model.voice)
)
mLoadingDialog?.dismiss() mLoadingDialog?.dismiss()
jumpPlayPage() jumpPlayPage()
} }
@@ -158,10 +227,28 @@ class MainActivity : BaseActivity() {
override fun onDownloadFail(url: String?, code: Int, msg: String?) { override fun onDownloadFail(url: String?, code: Int, msg: String?) {
runOnUiThread { runOnUiThread {
AnalyticsReporter.trackError(
mContext,
name = "avatar_download_failed",
message = msg ?: "unknown",
screenName = analyticsScreenName(),
metadata = mapOf(
"code" to code,
"avatar" to model.name,
"elapsedMs" to elapsedSince(modelDownloadStartedAtMs)
)
)
mLoadingDialog?.dismiss() mLoadingDialog?.dismiss()
Toast.makeText(mContext, "下载失败: $msg", Toast.LENGTH_SHORT).show() Toast.makeText(mContext, "下载失败: $msg", Toast.LENGTH_SHORT).show()
} }
} }
}) })
} }
private fun elapsedSince(startAtMs: Long): Long {
if (startAtMs <= 0L) {
return 0L
}
return (SystemClock.elapsedRealtime() - startAtMs).coerceAtLeast(0L)
}
} }
@@ -7,6 +7,7 @@ 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.model.VoiceCloneProfile import ai.guiji.duix.test.model.VoiceCloneProfile
import ai.guiji.duix.test.service.DashScopeConfig import ai.guiji.duix.test.service.DashScopeConfig
import ai.guiji.duix.test.service.AnalyticsReporter
import ai.guiji.duix.test.service.VoiceCloneProfileStore import ai.guiji.duix.test.service.VoiceCloneProfileStore
import ai.guiji.duix.test.service.VoiceCloneService import ai.guiji.duix.test.service.VoiceCloneService
import ai.guiji.duix.test.ui.adapter.AvatarAdapter import ai.guiji.duix.test.ui.adapter.AvatarAdapter
@@ -37,6 +38,7 @@ class VoiceCloneSetupActivity : BaseActivity() {
private var isRecording = false private var isRecording = false
private var isCreatingVoice = false private var isCreatingVoice = false
private var recordStartedAtMs = 0L private var recordStartedAtMs = 0L
private var voiceCloneCreateStartedAtMs = 0L
private val recordTicker = object : Runnable { private val recordTicker = object : Runnable {
override fun run() { override fun run() {
@@ -66,6 +68,14 @@ class VoiceCloneSetupActivity : BaseActivity() {
AvatarCatalog.models, AvatarCatalog.models,
object : AvatarAdapter.Callback { object : AvatarAdapter.Callback {
override fun onSelect(model: AvatarModel) { override fun onSelect(model: AvatarModel) {
AnalyticsReporter.trackAction(
mContext,
name = "clone_avatar_selected",
screenName = analyticsScreenName(),
component = "avatar_grid",
value = model.name,
metadata = mapOf("voice" to model.voice)
)
selectedAvatar = model selectedAvatar = model
avatarAdapter.setSelectedModel(model) avatarAdapter.setSelectedModel(model)
updateUi() updateUi()
@@ -93,10 +103,24 @@ class VoiceCloneSetupActivity : BaseActivity() {
return@setOnClickListener return@setOnClickListener
} }
if (currentRecording != null) { if (currentRecording != null) {
AnalyticsReporter.trackAction(
mContext,
name = "voice_sample_discarded",
screenName = analyticsScreenName(),
component = "btnSecondaryAction",
value = selectedAvatar.name
)
currentRecording?.file?.delete() currentRecording?.file?.delete()
currentRecording = null currentRecording = null
updateUi() updateUi()
} else { } else {
AnalyticsReporter.trackAction(
mContext,
name = "voice_rerecord_requested",
screenName = analyticsScreenName(),
component = "btnSecondaryAction",
value = selectedAvatar.name
)
requestPermission(arrayOf(Manifest.permission.RECORD_AUDIO), REQUEST_RECORD_AUDIO) requestPermission(arrayOf(Manifest.permission.RECORD_AUDIO), REQUEST_RECORD_AUDIO)
} }
} }
@@ -127,6 +151,13 @@ class VoiceCloneSetupActivity : BaseActivity() {
val outputFile = File(filesDir, "voice_clone/sample_${System.currentTimeMillis()}.m4a") val outputFile = File(filesDir, "voice_clone/sample_${System.currentTimeMillis()}.m4a")
try { try {
recorder.start(outputFile) recorder.start(outputFile)
AnalyticsReporter.trackAction(
mContext,
name = "voice_record_started",
screenName = analyticsScreenName(),
component = "btnPrimaryAction",
value = selectedAvatar.name
)
currentRecording = null currentRecording = null
isRecording = true isRecording = true
recordStartedAtMs = SystemClock.elapsedRealtime() recordStartedAtMs = SystemClock.elapsedRealtime()
@@ -135,6 +166,12 @@ class VoiceCloneSetupActivity : BaseActivity() {
mainHandler.post(recordTicker) mainHandler.post(recordTicker)
updateUi() updateUi()
} catch (e: Exception) { } catch (e: Exception) {
AnalyticsReporter.trackError(
mContext,
name = "voice_record_start_failed",
message = e.message ?: getString(R.string.voice_clone_record_failed),
screenName = analyticsScreenName()
)
Toast.makeText(mContext, e.message ?: getString(R.string.voice_clone_record_failed), Toast.LENGTH_SHORT).show() Toast.makeText(mContext, e.message ?: getString(R.string.voice_clone_record_failed), Toast.LENGTH_SHORT).show()
recorder.release() recorder.release()
isRecording = false isRecording = false
@@ -151,10 +188,28 @@ class VoiceCloneSetupActivity : BaseActivity() {
try { try {
val recording = recorder.stop() val recording = recorder.stop()
if (recording.durationMs < MIN_RECORDING_MS) { if (recording.durationMs < MIN_RECORDING_MS) {
AnalyticsReporter.trackError(
mContext,
name = "voice_record_too_short",
message = recording.durationMs.toString(),
screenName = analyticsScreenName(),
metadata = mapOf("autoStopped" to autoStopped)
)
recording.file.delete() recording.file.delete()
currentRecording = null currentRecording = null
Toast.makeText(mContext, R.string.voice_clone_record_too_short, Toast.LENGTH_SHORT).show() Toast.makeText(mContext, R.string.voice_clone_record_too_short, Toast.LENGTH_SHORT).show()
} else { } else {
AnalyticsReporter.trackAction(
mContext,
name = "voice_record_finished",
screenName = analyticsScreenName(),
component = "btnPrimaryAction",
value = selectedAvatar.name,
metadata = mapOf(
"durationMs" to recording.durationMs,
"autoStopped" to autoStopped,
)
)
currentRecording = recording currentRecording = recording
if (autoStopped) { if (autoStopped) {
Toast.makeText(mContext, R.string.voice_clone_record_auto_stopped, Toast.LENGTH_SHORT).show() Toast.makeText(mContext, R.string.voice_clone_record_auto_stopped, Toast.LENGTH_SHORT).show()
@@ -163,6 +218,12 @@ class VoiceCloneSetupActivity : BaseActivity() {
} }
} }
} catch (e: Exception) { } catch (e: Exception) {
AnalyticsReporter.trackError(
mContext,
name = "voice_record_stop_failed",
message = e.message ?: getString(R.string.voice_clone_record_failed),
screenName = analyticsScreenName()
)
currentRecording = null currentRecording = null
Toast.makeText(mContext, e.message ?: getString(R.string.voice_clone_record_failed), Toast.LENGTH_SHORT).show() Toast.makeText(mContext, e.message ?: getString(R.string.voice_clone_record_failed), Toast.LENGTH_SHORT).show()
} }
@@ -173,7 +234,16 @@ class VoiceCloneSetupActivity : BaseActivity() {
val recording = currentRecording ?: return val recording = currentRecording ?: return
val oldVoice = activeProfile?.voice val oldVoice = activeProfile?.voice
isCreatingVoice = true isCreatingVoice = true
voiceCloneCreateStartedAtMs = SystemClock.elapsedRealtime()
updateUi() updateUi()
AnalyticsReporter.trackAction(
mContext,
name = "voice_clone_create_started",
screenName = analyticsScreenName(),
component = "btnPrimaryAction",
value = selectedAvatar.name,
metadata = mapOf("durationMs" to recording.durationMs)
)
voiceCloneService.createVoice( voiceCloneService.createVoice(
sampleFile = recording.file, sampleFile = recording.file,
preferredName = "family_${selectedAvatar.voice}", preferredName = "family_${selectedAvatar.voice}",
@@ -196,12 +266,35 @@ class VoiceCloneSetupActivity : BaseActivity() {
profileStore.save(profile) profileStore.save(profile)
activeProfile = profile activeProfile = profile
currentRecording = null currentRecording = null
AnalyticsReporter.trackPerformance(
mContext,
name = "voice_clone_create_succeeded",
durationMs = elapsedSince(voiceCloneCreateStartedAtMs),
screenName = analyticsScreenName(),
component = "btnPrimaryAction",
value = selectedAvatar.name,
metadata = mapOf(
"voice" to cloneResult.voice,
"sampleDurationMs" to recording.durationMs
)
)
if (!oldVoice.isNullOrBlank() && oldVoice != profile.voice) { if (!oldVoice.isNullOrBlank() && oldVoice != profile.voice) {
voiceCloneService.deleteVoice(oldVoice) voiceCloneService.deleteVoice(oldVoice)
} }
Toast.makeText(mContext, R.string.voice_clone_saved, Toast.LENGTH_SHORT).show() Toast.makeText(mContext, R.string.voice_clone_saved, Toast.LENGTH_SHORT).show()
updateUi() updateUi()
}.onFailure { throwable -> }.onFailure { throwable ->
AnalyticsReporter.trackError(
mContext,
name = "voice_clone_create_failed",
message = throwable.message ?: getString(R.string.voice_clone_create_failed),
screenName = analyticsScreenName(),
metadata = mapOf(
"avatar" to selectedAvatar.name,
"elapsedMs" to elapsedSince(voiceCloneCreateStartedAtMs),
"sampleDurationMs" to recording.durationMs
)
)
Toast.makeText(mContext, throwable.message ?: getString(R.string.voice_clone_create_failed), Toast.LENGTH_SHORT).show() Toast.makeText(mContext, throwable.message ?: getString(R.string.voice_clone_create_failed), Toast.LENGTH_SHORT).show()
updateUi() updateUi()
} }
@@ -218,6 +311,14 @@ class VoiceCloneSetupActivity : BaseActivity() {
) )
profileStore.save(profile) profileStore.save(profile)
activeProfile = profile activeProfile = profile
AnalyticsReporter.trackAction(
mContext,
name = "voice_clone_chat_started",
screenName = analyticsScreenName(),
component = "btnPrimaryAction",
value = selectedAvatar.name,
metadata = mapOf("clonedVoice" to profile.voice)
)
val intent = Intent(mContext, CallActivity::class.java) val intent = Intent(mContext, CallActivity::class.java)
intent.putExtra("modelUrl", selectedAvatar.modelUrl) intent.putExtra("modelUrl", selectedAvatar.modelUrl)
@@ -267,6 +368,13 @@ class VoiceCloneSetupActivity : BaseActivity() {
return String.format(Locale.getDefault(), "%02d:%02d", minutes, seconds) return String.format(Locale.getDefault(), "%02d:%02d", minutes, seconds)
} }
private fun elapsedSince(startAtMs: Long): Long {
if (startAtMs <= 0L) {
return 0L
}
return (SystemClock.elapsedRealtime() - startAtMs).coerceAtLeast(0L)
}
companion object { companion object {
private const val REQUEST_RECORD_AUDIO = 11 private const val REQUEST_RECORD_AUDIO = 11
private const val MIN_RECORDING_MS = 3_000L private const val MIN_RECORDING_MS = 3_000L