添加埋点与性能监控
This commit is contained in:
@@ -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;
|
||||
|
||||
import ai.guiji.duix.test.service.AnalyticsReporter;
|
||||
import android.content.pm.PackageManager;
|
||||
import android.os.Build;
|
||||
import android.os.Bundle;
|
||||
@@ -59,6 +60,16 @@ public abstract class BaseActivity extends AppCompatActivity implements Handler.
|
||||
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 int mRequestPermissionCode;
|
||||
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.realtime.OmniRealtimeClient
|
||||
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.ui.adapter.MotionAdapter
|
||||
import ai.guiji.duix.test.ui.dialog.AudioRecordDialog
|
||||
@@ -23,6 +24,7 @@ import android.opengl.GLSurfaceView
|
||||
import android.os.Bundle
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.os.SystemClock
|
||||
import android.text.TextUtils
|
||||
import android.util.Log
|
||||
import android.view.View
|
||||
@@ -79,6 +81,21 @@ class CallActivity : BaseActivity() {
|
||||
private var clonedVoice = ""
|
||||
private var cloneTtsModel = DashScopeConfig.VOICE_CLONE_TTS_MODEL
|
||||
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?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
@@ -95,6 +112,14 @@ class CallActivity : BaseActivity() {
|
||||
isCloneMode = intent.getBooleanExtra("cloneMode", false)
|
||||
clonedVoice = intent.getStringExtra("clonedVoice") ?: ""
|
||||
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)
|
||||
|
||||
@@ -214,12 +239,21 @@ class CallActivity : BaseActivity() {
|
||||
// }
|
||||
// })
|
||||
applyMessage("start init")
|
||||
duixInitStartedAtMs = SystemClock.elapsedRealtime()
|
||||
duix?.init()
|
||||
}
|
||||
|
||||
private fun initOk() {
|
||||
Log.i(TAG, "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 {
|
||||
binding.btnRecord.isEnabled = true
|
||||
binding.btnPlayPCM.isEnabled = true
|
||||
@@ -345,13 +379,35 @@ class CallActivity : BaseActivity() {
|
||||
|
||||
private fun startAIConversation() {
|
||||
if (duix?.isReady() != true) {
|
||||
AnalyticsReporter.trackError(
|
||||
mContext,
|
||||
name = "ai_conversation_unavailable",
|
||||
message = "model_not_ready",
|
||||
screenName = analyticsScreenName()
|
||||
)
|
||||
Toast.makeText(mContext, "数字人未就绪", Toast.LENGTH_SHORT).show()
|
||||
return
|
||||
}
|
||||
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()
|
||||
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对话: 连接中...")
|
||||
binding.tvSubtitle.visibility = View.VISIBLE
|
||||
binding.tvSubtitle.text = getString(R.string.ai_call_connecting)
|
||||
@@ -365,6 +421,20 @@ class CallActivity : BaseActivity() {
|
||||
omniClient = OmniRealtimeClient(buildRealtimeSessionOptions(), object : OmniRealtimeClient.Callback {
|
||||
override fun onConnected() {
|
||||
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.btnCameraToggle.visibility = if (isCloneMode) View.GONE else View.VISIBLE
|
||||
binding.btnCameraToggle.setBackgroundResource(R.drawable.shape_fab_camera_off)
|
||||
@@ -387,10 +457,27 @@ class CallActivity : BaseActivity() {
|
||||
}
|
||||
|
||||
override fun onDisconnected() {
|
||||
AnalyticsReporter.trackAction(
|
||||
mContext,
|
||||
name = "ai_conversation_disconnected",
|
||||
screenName = analyticsScreenName(),
|
||||
component = "btnAIConversation"
|
||||
)
|
||||
mainHandler.post { stopAIConversation() }
|
||||
}
|
||||
|
||||
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 {
|
||||
if (isImageAppendError(message)) {
|
||||
stopCamera()
|
||||
@@ -407,24 +494,44 @@ class CallActivity : BaseActivity() {
|
||||
stopCloneSpeech()
|
||||
}
|
||||
cameraFrameCapture?.requestImmediateCapture()
|
||||
currentTurnSpeechStartedAtMs = SystemClock.elapsedRealtime()
|
||||
mainHandler.post {
|
||||
binding.tvSubtitle.text = getString(R.string.ai_call_listening)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onUserSpeechStopped() {
|
||||
startConversationTurn()
|
||||
mainHandler.post {
|
||||
binding.tvSubtitle.text = getString(R.string.ai_call_thinking)
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
binding.tvSubtitle.text = getString(R.string.ai_call_user_said, transcript)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onAssistantTextDelta(delta: String) {
|
||||
if (currentTurnFirstTextAtMs <= 0L) {
|
||||
currentTurnFirstTextAtMs = SystemClock.elapsedRealtime()
|
||||
}
|
||||
mainHandler.post {
|
||||
assistantTextBuffer.append(delta)
|
||||
binding.tvSubtitle.text = assistantTextBuffer.toString()
|
||||
@@ -432,6 +539,24 @@ class CallActivity : BaseActivity() {
|
||||
}
|
||||
|
||||
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 {
|
||||
assistantTextBuffer.clear()
|
||||
assistantTextBuffer.append(transcript)
|
||||
@@ -443,6 +568,9 @@ class CallActivity : BaseActivity() {
|
||||
if (isCloneMode) {
|
||||
return
|
||||
}
|
||||
if (currentTurnFirstAudioAtMs <= 0L) {
|
||||
currentTurnFirstAudioAtMs = SystemClock.elapsedRealtime()
|
||||
}
|
||||
if (!hasStartedPushForResponse) {
|
||||
hasStartedPushForResponse = true
|
||||
duix?.startPush()
|
||||
@@ -464,6 +592,27 @@ class CallActivity : BaseActivity() {
|
||||
}
|
||||
|
||||
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 {
|
||||
if (isCloneMode) {
|
||||
val replyText = assistantTextBuffer.toString().trim()
|
||||
@@ -475,6 +624,7 @@ class CallActivity : BaseActivity() {
|
||||
hasStartedPushForResponse = false
|
||||
}
|
||||
assistantTextBuffer.clear()
|
||||
resetConversationTurnMetrics(resetCounter = false)
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -484,6 +634,7 @@ class CallActivity : BaseActivity() {
|
||||
}
|
||||
|
||||
private fun stopAIConversation() {
|
||||
val wasActive = isAIConversationActive
|
||||
isAIConversationActive = false
|
||||
stopCamera()
|
||||
stopCloneSpeech()
|
||||
@@ -494,6 +645,21 @@ class CallActivity : BaseActivity() {
|
||||
duix?.stopAudio()
|
||||
assistantTextBuffer.clear()
|
||||
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 {
|
||||
if (!isFinishing) {
|
||||
@@ -514,6 +680,16 @@ class CallActivity : BaseActivity() {
|
||||
return
|
||||
}
|
||||
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(
|
||||
QwenTtsRealtimeClient.SessionOptions(
|
||||
voice = clonedVoice,
|
||||
@@ -530,6 +706,9 @@ class CallActivity : BaseActivity() {
|
||||
}
|
||||
|
||||
override fun onAudioDelta(pcmData: ByteArray) {
|
||||
if (cloneTtsFirstAudioAtMs <= 0L) {
|
||||
cloneTtsFirstAudioAtMs = SystemClock.elapsedRealtime()
|
||||
}
|
||||
if (!hasStartedPushForResponse) {
|
||||
hasStartedPushForResponse = true
|
||||
duix?.startPush()
|
||||
@@ -551,14 +730,40 @@ class CallActivity : BaseActivity() {
|
||||
}
|
||||
|
||||
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) {
|
||||
duix?.stopPush()
|
||||
hasStartedPushForResponse = false
|
||||
}
|
||||
ttsRealtimeClient = null
|
||||
cloneTtsStartedAtMs = 0L
|
||||
cloneTtsFirstAudioAtMs = 0L
|
||||
}
|
||||
|
||||
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 {
|
||||
if (hasStartedPushForResponse) {
|
||||
duix?.stopPush()
|
||||
@@ -581,18 +786,44 @@ class CallActivity : BaseActivity() {
|
||||
hasStartedPushForResponse = false
|
||||
}
|
||||
duix?.stopAudio()
|
||||
cloneTtsStartedAtMs = 0L
|
||||
cloneTtsFirstAudioAtMs = 0L
|
||||
}
|
||||
|
||||
private fun toggleCamera() {
|
||||
if (!isAIConversationActive || omniClient == null) return
|
||||
isCameraEnabled = !isCameraEnabled
|
||||
if (isCameraEnabled) {
|
||||
cameraEnableStartedAtMs = SystemClock.elapsedRealtime()
|
||||
hasReportedCameraFirstFrame = false
|
||||
AnalyticsReporter.trackAction(
|
||||
mContext,
|
||||
name = "camera_enabled",
|
||||
screenName = analyticsScreenName(),
|
||||
component = "btnCameraToggle"
|
||||
)
|
||||
binding.cameraPreview.visibility = View.VISIBLE
|
||||
cameraFrameCapture = CameraFrameCapture(mContext, object : CameraFrameCapture.FrameCallback {
|
||||
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)
|
||||
}
|
||||
override fun onError(message: String) {
|
||||
AnalyticsReporter.trackError(
|
||||
mContext,
|
||||
name = "camera_capture_error",
|
||||
message = message,
|
||||
screenName = analyticsScreenName()
|
||||
)
|
||||
mainHandler.post {
|
||||
Toast.makeText(mContext, message, Toast.LENGTH_SHORT).show()
|
||||
stopCamera()
|
||||
@@ -603,6 +834,12 @@ class CallActivity : BaseActivity() {
|
||||
binding.btnCameraToggle.setBackgroundResource(R.drawable.shape_fab_camera_on)
|
||||
binding.btnCameraToggle.contentDescription = getString(R.string.camera_on)
|
||||
} else {
|
||||
AnalyticsReporter.trackAction(
|
||||
mContext,
|
||||
name = "camera_disabled",
|
||||
screenName = analyticsScreenName(),
|
||||
component = "btnCameraToggle"
|
||||
)
|
||||
stopCamera()
|
||||
binding.btnCameraToggle.setBackgroundResource(R.drawable.shape_fab_camera_off)
|
||||
binding.btnCameraToggle.contentDescription = getString(R.string.camera_off)
|
||||
@@ -613,9 +850,65 @@ class CallActivity : BaseActivity() {
|
||||
cameraFrameCapture?.stop()
|
||||
cameraFrameCapture = null
|
||||
isCameraEnabled = false
|
||||
cameraEnableStartedAtMs = 0L
|
||||
hasReportedCameraFirstFrame = false
|
||||
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(){
|
||||
val audioRecordDialog = AudioRecordDialog(mContext, object : AudioRecordDialog.Listener{
|
||||
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.model.AvatarCatalog
|
||||
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.dialog.LoadingDialog
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.os.SystemClock
|
||||
import android.widget.Toast
|
||||
import androidx.recyclerview.widget.GridLayoutManager
|
||||
import java.io.File
|
||||
@@ -19,6 +21,8 @@ class MainActivity : BaseActivity() {
|
||||
private lateinit var binding: ActivityMainBinding
|
||||
private var mLoadingDialog: LoadingDialog? = null
|
||||
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"
|
||||
|
||||
@@ -30,12 +34,26 @@ class MainActivity : BaseActivity() {
|
||||
setContentView(binding.root)
|
||||
|
||||
binding.btnVoiceClone.setOnClickListener {
|
||||
AnalyticsReporter.trackAction(
|
||||
mContext,
|
||||
name = "voice_clone_entry_tap",
|
||||
screenName = analyticsScreenName(),
|
||||
component = "btnVoiceClone"
|
||||
)
|
||||
startActivity(Intent(mContext, VoiceCloneSetupActivity::class.java))
|
||||
}
|
||||
|
||||
binding.rvAvatars.layoutManager = GridLayoutManager(this, 2)
|
||||
binding.rvAvatars.adapter = AvatarAdapter(AvatarCatalog.models, object : AvatarAdapter.Callback {
|
||||
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
|
||||
play(model)
|
||||
}
|
||||
@@ -66,6 +84,14 @@ class MainActivity : BaseActivity() {
|
||||
|
||||
private fun jumpPlayPage() {
|
||||
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)
|
||||
intent.putExtra("modelUrl", model.modelUrl)
|
||||
intent.putExtra("voice", model.voice)
|
||||
@@ -77,6 +103,13 @@ class MainActivity : BaseActivity() {
|
||||
mLoadingDialog?.dismiss()
|
||||
mLoadingDialog = LoadingDialog(mContext, "正在准备资源...")
|
||||
mLoadingDialog?.show()
|
||||
baseConfigDownloadStartedAtMs = SystemClock.elapsedRealtime()
|
||||
AnalyticsReporter.trackAction(
|
||||
mContext,
|
||||
name = "base_config_download_started",
|
||||
screenName = analyticsScreenName(),
|
||||
component = "resource_loader"
|
||||
)
|
||||
VirtualModelUtil.baseConfigDownload(mContext, baseConfigUrl, object :
|
||||
VirtualModelUtil.ModelDownloadCallback {
|
||||
override fun onDownloadProgress(url: String?, current: Long, total: Long) {
|
||||
@@ -105,6 +138,14 @@ class MainActivity : BaseActivity() {
|
||||
|
||||
override fun onDownloadComplete(url: String?, dir: File?) {
|
||||
runOnUiThread {
|
||||
AnalyticsReporter.trackPerformance(
|
||||
mContext,
|
||||
name = "base_config_download_completed",
|
||||
durationMs = elapsedSince(baseConfigDownloadStartedAtMs),
|
||||
screenName = analyticsScreenName(),
|
||||
component = "resource_loader",
|
||||
metadata = mapOf("url" to url)
|
||||
)
|
||||
mLoadingDialog?.dismiss()
|
||||
checkModel()
|
||||
}
|
||||
@@ -112,6 +153,16 @@ class MainActivity : BaseActivity() {
|
||||
|
||||
override fun onDownloadFail(url: String?, code: Int, msg: String?) {
|
||||
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()
|
||||
Toast.makeText(mContext, "资源下载失败: $msg", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
@@ -124,6 +175,15 @@ class MainActivity : BaseActivity() {
|
||||
mLoadingDialog?.dismiss()
|
||||
mLoadingDialog = LoadingDialog(mContext, "正在下载 ${model.name} ...")
|
||||
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 {
|
||||
override fun onDownloadProgress(url: String?, current: Long, total: Long) {
|
||||
val progress = (current * 100 / total).toInt()
|
||||
@@ -151,6 +211,15 @@ class MainActivity : BaseActivity() {
|
||||
|
||||
override fun onDownloadComplete(url: String?, dir: File?) {
|
||||
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()
|
||||
jumpPlayPage()
|
||||
}
|
||||
@@ -158,10 +227,28 @@ class MainActivity : BaseActivity() {
|
||||
|
||||
override fun onDownloadFail(url: String?, code: Int, msg: String?) {
|
||||
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()
|
||||
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.VoiceCloneProfile
|
||||
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.VoiceCloneService
|
||||
import ai.guiji.duix.test.ui.adapter.AvatarAdapter
|
||||
@@ -37,6 +38,7 @@ class VoiceCloneSetupActivity : BaseActivity() {
|
||||
private var isRecording = false
|
||||
private var isCreatingVoice = false
|
||||
private var recordStartedAtMs = 0L
|
||||
private var voiceCloneCreateStartedAtMs = 0L
|
||||
|
||||
private val recordTicker = object : Runnable {
|
||||
override fun run() {
|
||||
@@ -66,6 +68,14 @@ class VoiceCloneSetupActivity : BaseActivity() {
|
||||
AvatarCatalog.models,
|
||||
object : AvatarAdapter.Callback {
|
||||
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
|
||||
avatarAdapter.setSelectedModel(model)
|
||||
updateUi()
|
||||
@@ -93,10 +103,24 @@ class VoiceCloneSetupActivity : BaseActivity() {
|
||||
return@setOnClickListener
|
||||
}
|
||||
if (currentRecording != null) {
|
||||
AnalyticsReporter.trackAction(
|
||||
mContext,
|
||||
name = "voice_sample_discarded",
|
||||
screenName = analyticsScreenName(),
|
||||
component = "btnSecondaryAction",
|
||||
value = selectedAvatar.name
|
||||
)
|
||||
currentRecording?.file?.delete()
|
||||
currentRecording = null
|
||||
updateUi()
|
||||
} else {
|
||||
AnalyticsReporter.trackAction(
|
||||
mContext,
|
||||
name = "voice_rerecord_requested",
|
||||
screenName = analyticsScreenName(),
|
||||
component = "btnSecondaryAction",
|
||||
value = selectedAvatar.name
|
||||
)
|
||||
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")
|
||||
try {
|
||||
recorder.start(outputFile)
|
||||
AnalyticsReporter.trackAction(
|
||||
mContext,
|
||||
name = "voice_record_started",
|
||||
screenName = analyticsScreenName(),
|
||||
component = "btnPrimaryAction",
|
||||
value = selectedAvatar.name
|
||||
)
|
||||
currentRecording = null
|
||||
isRecording = true
|
||||
recordStartedAtMs = SystemClock.elapsedRealtime()
|
||||
@@ -135,6 +166,12 @@ class VoiceCloneSetupActivity : BaseActivity() {
|
||||
mainHandler.post(recordTicker)
|
||||
updateUi()
|
||||
} 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()
|
||||
recorder.release()
|
||||
isRecording = false
|
||||
@@ -151,10 +188,28 @@ class VoiceCloneSetupActivity : BaseActivity() {
|
||||
try {
|
||||
val recording = recorder.stop()
|
||||
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()
|
||||
currentRecording = null
|
||||
Toast.makeText(mContext, R.string.voice_clone_record_too_short, Toast.LENGTH_SHORT).show()
|
||||
} 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
|
||||
if (autoStopped) {
|
||||
Toast.makeText(mContext, R.string.voice_clone_record_auto_stopped, Toast.LENGTH_SHORT).show()
|
||||
@@ -163,6 +218,12 @@ class VoiceCloneSetupActivity : BaseActivity() {
|
||||
}
|
||||
}
|
||||
} 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
|
||||
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 oldVoice = activeProfile?.voice
|
||||
isCreatingVoice = true
|
||||
voiceCloneCreateStartedAtMs = SystemClock.elapsedRealtime()
|
||||
updateUi()
|
||||
AnalyticsReporter.trackAction(
|
||||
mContext,
|
||||
name = "voice_clone_create_started",
|
||||
screenName = analyticsScreenName(),
|
||||
component = "btnPrimaryAction",
|
||||
value = selectedAvatar.name,
|
||||
metadata = mapOf("durationMs" to recording.durationMs)
|
||||
)
|
||||
voiceCloneService.createVoice(
|
||||
sampleFile = recording.file,
|
||||
preferredName = "family_${selectedAvatar.voice}",
|
||||
@@ -196,12 +266,35 @@ class VoiceCloneSetupActivity : BaseActivity() {
|
||||
profileStore.save(profile)
|
||||
activeProfile = profile
|
||||
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) {
|
||||
voiceCloneService.deleteVoice(oldVoice)
|
||||
}
|
||||
Toast.makeText(mContext, R.string.voice_clone_saved, Toast.LENGTH_SHORT).show()
|
||||
updateUi()
|
||||
}.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()
|
||||
updateUi()
|
||||
}
|
||||
@@ -218,6 +311,14 @@ class VoiceCloneSetupActivity : BaseActivity() {
|
||||
)
|
||||
profileStore.save(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)
|
||||
intent.putExtra("modelUrl", selectedAvatar.modelUrl)
|
||||
@@ -267,6 +368,13 @@ class VoiceCloneSetupActivity : BaseActivity() {
|
||||
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 {
|
||||
private const val REQUEST_RECORD_AUDIO = 11
|
||||
private const val MIN_RECORDING_MS = 3_000L
|
||||
|
||||
Reference in New Issue
Block a user