添加埋点与性能监控
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;
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user