添加批量测试数字人功能

This commit is contained in:
2026-04-18 22:40:16 +08:00
parent f31df36bf4
commit 0b6ba4be7e
21 changed files with 3284 additions and 0 deletions
+1
View File
@@ -83,6 +83,7 @@ dependencies {
implementation 'androidx.constraintlayout:constraintlayout:2.1.1' implementation 'androidx.constraintlayout:constraintlayout:2.1.1'
implementation "androidx.activity:activity:1.3.0" implementation "androidx.activity:activity:1.3.0"
implementation "androidx.fragment:fragment:1.3.0" implementation "androidx.fragment:fragment:1.3.0"
implementation 'androidx.documentfile:documentfile:1.0.1'
implementation 'com.github.bumptech.glide:glide:4.12.0' implementation 'com.github.bumptech.glide:glide:4.12.0'
+15
View File
@@ -4,6 +4,9 @@
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.RECORD_AUDIO"/> <uses-permission android:name="android.permission.RECORD_AUDIO"/>
<uses-permission android:name="android.permission.CAMERA"/> <uses-permission android:name="android.permission.CAMERA"/>
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="29" />
<uses-feature android:name="android.hardware.camera" android:required="false"/> <uses-feature android:name="android.hardware.camera" android:required="false"/>
<uses-feature android:name="android.hardware.camera.front" android:required="false"/> <uses-feature android:name="android.hardware.camera.front" android:required="false"/>
@@ -38,6 +41,18 @@
android:theme="@style/Theme.DUIX.Call"> android:theme="@style/Theme.DUIX.Call">
</activity> </activity>
<activity
android:name=".ui.activity.FolderPickerActivity"
android:exported="false"
android:theme="@style/Theme.DUIX.Test">
</activity>
<activity
android:name=".ui.activity.DigitalHumanTestActivity"
android:exported="false"
android:theme="@style/Theme.DUIX.Test">
</activity>
<activity <activity
android:name=".ui.activity.SettingsActivity" android:name=".ui.activity.SettingsActivity"
android:exported="false" android:exported="false"
@@ -0,0 +1,220 @@
package ai.guiji.duix.test.audio
import android.content.Context
import android.media.AudioFormat
import android.media.MediaCodec
import android.media.MediaExtractor
import android.media.MediaFormat
import android.net.Uri
import java.io.ByteArrayOutputStream
import java.util.Locale
import kotlin.math.ceil
import kotlin.math.floor
import kotlin.math.roundToInt
object AudioFileDecoder {
private const val TARGET_SAMPLE_RATE = 16_000
data class DecodedAudio(
val pcm16Mono16k: ByteArray,
val durationMs: Long,
)
fun decodeToPcm16Mono16k(context: Context, uri: Uri): DecodedAudio {
val extractor = MediaExtractor()
var decoder: MediaCodec? = null
val rawPcmOutput = ByteArrayOutputStream()
var selectedTrack = -1
var sampleRate = TARGET_SAMPLE_RATE
var channelCount = 1
try {
val scheme = uri.scheme?.lowercase(Locale.US)
if (scheme.isNullOrEmpty() || scheme == "file") {
val path = uri.path ?: throw IllegalArgumentException("无法读取所选音频路径")
extractor.setDataSource(path)
} else {
context.contentResolver.openAssetFileDescriptor(uri, "r")?.use { descriptor ->
extractor.setDataSource(descriptor.fileDescriptor, descriptor.startOffset, descriptor.length)
} ?: throw IllegalArgumentException("无法打开所选音频")
}
selectedTrack = findFirstAudioTrack(extractor)
if (selectedTrack < 0) {
throw IllegalArgumentException("没有找到可读取的音频轨道")
}
extractor.selectTrack(selectedTrack)
val inputFormat = extractor.getTrackFormat(selectedTrack)
val mime = inputFormat.getString(MediaFormat.KEY_MIME)
?: throw IllegalArgumentException("无法识别音频格式")
sampleRate = inputFormat.optInt(MediaFormat.KEY_SAMPLE_RATE, TARGET_SAMPLE_RATE)
channelCount = inputFormat.optInt(MediaFormat.KEY_CHANNEL_COUNT, 1)
decoder = MediaCodec.createDecoderByType(mime)
decoder.configure(inputFormat, null, null, 0)
decoder.start()
val bufferInfo = MediaCodec.BufferInfo()
var inputDone = false
var outputDone = false
while (!outputDone) {
if (!inputDone) {
val inputIndex = decoder.dequeueInputBuffer(TIMEOUT_US)
if (inputIndex >= 0) {
val inputBuffer = decoder.getInputBuffer(inputIndex)
?: throw IllegalStateException("解码器输入缓冲区不可用")
val sampleSize = extractor.readSampleData(inputBuffer, 0)
if (sampleSize < 0) {
decoder.queueInputBuffer(
inputIndex,
0,
0,
0L,
MediaCodec.BUFFER_FLAG_END_OF_STREAM,
)
inputDone = true
} else {
decoder.queueInputBuffer(
inputIndex,
0,
sampleSize,
extractor.sampleTime.coerceAtLeast(0L),
0,
)
extractor.advance()
}
}
}
when (val outputIndex = decoder.dequeueOutputBuffer(bufferInfo, TIMEOUT_US)) {
MediaCodec.INFO_TRY_AGAIN_LATER -> Unit
MediaCodec.INFO_OUTPUT_FORMAT_CHANGED -> {
val outputFormat = decoder.outputFormat
sampleRate = outputFormat.optInt(MediaFormat.KEY_SAMPLE_RATE, sampleRate)
channelCount = outputFormat.optInt(MediaFormat.KEY_CHANNEL_COUNT, channelCount)
val pcmEncoding = outputFormat.optInt(
MediaFormat.KEY_PCM_ENCODING,
AudioFormat.ENCODING_PCM_16BIT,
)
if (pcmEncoding != AudioFormat.ENCODING_PCM_16BIT) {
throw IllegalArgumentException("暂不支持该音频 PCM 编码")
}
}
else -> {
if (outputIndex >= 0) {
val outputBuffer = decoder.getOutputBuffer(outputIndex)
?: throw IllegalStateException("解码器输出缓冲区不可用")
if (bufferInfo.size > 0) {
val chunk = ByteArray(bufferInfo.size)
outputBuffer.position(bufferInfo.offset)
outputBuffer.limit(bufferInfo.offset + bufferInfo.size)
outputBuffer.get(chunk)
rawPcmOutput.write(chunk)
}
val endOfStream = (bufferInfo.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0
decoder.releaseOutputBuffer(outputIndex, false)
outputDone = endOfStream
}
}
}
}
val converted = convertPcm16ToMono16k(rawPcmOutput.toByteArray(), sampleRate, channelCount)
if (converted.isEmpty()) {
throw IllegalArgumentException("音频解码后没有可用数据")
}
val durationMs = ((converted.size / 2L) * 1000L / TARGET_SAMPLE_RATE).coerceAtLeast(0L)
return DecodedAudio(
pcm16Mono16k = converted,
durationMs = durationMs,
)
} finally {
runCatching { decoder?.stop() }
runCatching { decoder?.release() }
runCatching { if (selectedTrack >= 0) extractor.unselectTrack(selectedTrack) }
runCatching { extractor.release() }
runCatching { rawPcmOutput.close() }
}
}
private fun findFirstAudioTrack(extractor: MediaExtractor): Int {
for (index in 0 until extractor.trackCount) {
val mime = extractor.getTrackFormat(index).getString(MediaFormat.KEY_MIME) ?: continue
if (mime.startsWith("audio/")) {
return index
}
}
return -1
}
private fun convertPcm16ToMono16k(input: ByteArray, inputSampleRate: Int, inputChannels: Int): ByteArray {
if (input.isEmpty()) {
return ByteArray(0)
}
if (inputChannels <= 0) {
throw IllegalArgumentException("无效的声道数")
}
val frameCount = input.size / (2 * inputChannels)
if (frameCount <= 0) {
return ByteArray(0)
}
val monoSamples = FloatArray(frameCount)
var byteOffset = 0
for (frameIndex in 0 until frameCount) {
var sum = 0
for (channelIndex in 0 until inputChannels) {
sum += readShortLE(input, byteOffset).toInt()
byteOffset += 2
}
monoSamples[frameIndex] = sum.toFloat() / inputChannels.toFloat()
}
if (inputSampleRate == TARGET_SAMPLE_RATE) {
val output = ByteArray(frameCount * 2)
for (index in monoSamples.indices) {
writeShortLE(output, index * 2, monoSamples[index].roundToInt().toShort())
}
return output
}
val outputFrameCount = ceil(frameCount.toDouble() * TARGET_SAMPLE_RATE / inputSampleRate).toInt()
val output = ByteArray(outputFrameCount * 2)
val positionScale = inputSampleRate.toDouble() / TARGET_SAMPLE_RATE.toDouble()
for (outputIndex in 0 until outputFrameCount) {
val srcPosition = outputIndex * positionScale
val srcIndex0 = floor(srcPosition).toInt().coerceIn(0, monoSamples.lastIndex)
val srcIndex1 = (srcIndex0 + 1).coerceAtMost(monoSamples.lastIndex)
val fraction = srcPosition - srcIndex0
val interpolated = monoSamples[srcIndex0] +
((monoSamples[srcIndex1] - monoSamples[srcIndex0]) * fraction.toFloat())
val clamped = interpolated.roundToInt().coerceIn(Short.MIN_VALUE.toInt(), Short.MAX_VALUE.toInt())
writeShortLE(output, outputIndex * 2, clamped.toShort())
}
return output
}
private fun readShortLE(data: ByteArray, offset: Int): Short {
val low = data[offset].toInt() and 0xFF
val high = data[offset + 1].toInt() shl 8
return (low or high).toShort()
}
private fun writeShortLE(data: ByteArray, offset: Int, value: Short) {
data[offset] = (value.toInt() and 0xFF).toByte()
data[offset + 1] = ((value.toInt() ushr 8) and 0xFF).toByte()
}
private fun MediaFormat.optInt(key: String, fallback: Int): Int {
return runCatching {
if (containsKey(key)) getInteger(key) else fallback
}.getOrDefault(fallback)
}
private const val TIMEOUT_US = 10_000L
}
@@ -0,0 +1,23 @@
package ai.guiji.duix.test.model
import android.net.Uri
data class BatchAudioJob(
val sourceUri: Uri,
val sourceRelativePath: String,
val displayName: String,
val outputRelativePath: String,
val attemptCount: Int = 0,
val status: Status = Status.PENDING,
val statusText: String = "",
val detailText: String = "",
val outputUri: Uri? = null,
) {
enum class Status {
PENDING,
RUNNING,
COMPLETED,
FAILED,
SKIPPED,
}
}
@@ -446,6 +446,22 @@ public class OmniRealtimeClient {
} }
} }
public boolean commitInputAudio() {
try {
JSONObject event = new JSONObject();
event.put("event_id", nextEventId());
event.put("type", "input_audio_buffer.commit");
boolean sent = sendEvent(event);
if (sent) {
imageWindowOpen.set(false);
}
return sent;
} catch (JSONException e) {
Log.e(TAG, "Failed to send input_audio_buffer.commit: " + e.getMessage());
return false;
}
}
private void handleServerError(JSONObject event) { private void handleServerError(JSONObject event) {
String errMsg = event.optJSONObject("error") != null String errMsg = event.optJSONObject("error") != null
? event.optJSONObject("error").optString("message", "Unknown error") ? event.optJSONObject("error").optString("message", "Unknown error")
@@ -0,0 +1,138 @@
package ai.guiji.duix.test.service
import android.content.Context
import android.net.Uri
import androidx.documentfile.provider.DocumentFile
import java.io.File
import java.util.Locale
object DocumentTreeAudioStore {
data class AudioEntry(
val uri: Uri,
val displayName: String,
val relativePath: String,
)
data class ScanResult(
val rootName: String,
val files: List<AudioEntry>,
)
fun scanAudioFiles(context: Context, treeUri: Uri): ScanResult {
val root = DocumentFile.fromTreeUri(context, treeUri)
?: throw IllegalArgumentException("无法读取所选文件夹")
if (!root.isDirectory) {
throw IllegalArgumentException("所选内容不是文件夹")
}
val files = mutableListOf<AudioEntry>()
traverse(root, "", files)
return ScanResult(
rootName = root.name ?: "selected-folder",
files = files.sortedBy { it.relativePath.lowercase(Locale.US) },
)
}
fun writeVideoResult(
context: Context,
treeUri: Uri,
outputRootName: String,
sourceRelativePath: String,
localFile: File,
): Uri {
val root = DocumentFile.fromTreeUri(context, treeUri)
?: throw IllegalArgumentException("无法写入所选文件夹")
if (!root.isDirectory) {
throw IllegalArgumentException("所选内容不是文件夹")
}
var currentDir = findOrCreateDirectory(root, outputRootName)
val normalizedPath = sourceRelativePath.replace('\\', '/')
val pathSegments = normalizedPath.split('/').filter { it.isNotBlank() }
if (pathSegments.isEmpty()) {
throw IllegalArgumentException("无效的源文件路径")
}
for (segment in pathSegments.dropLast(1)) {
currentDir = findOrCreateDirectory(currentDir, segment)
}
val outputFileName = replaceExtension(pathSegments.last(), "mp4")
currentDir.findFile(outputFileName)?.delete()
val outputDocument = currentDir.createFile(VIDEO_MIME_TYPE, outputFileName)
?: throw IllegalStateException("无法创建输出文件")
localFile.inputStream().use { input ->
context.contentResolver.openOutputStream(outputDocument.uri, "w")?.use { output ->
input.copyTo(output)
} ?: throw IllegalStateException("无法打开输出文件")
}
return outputDocument.uri
}
fun buildOutputRelativePath(outputRootName: String, sourceRelativePath: String): String {
val normalizedPath = sourceRelativePath.replace('\\', '/')
return outputRootName + "/" + replaceExtension(normalizedPath, "mp4")
}
private fun traverse(directory: DocumentFile, parentPath: String, output: MutableList<AudioEntry>) {
val children = directory.listFiles().sortedBy { it.name?.lowercase(Locale.US) ?: "" }
for (child in children) {
val childName = child.name ?: continue
val childRelativePath = if (parentPath.isEmpty()) childName else "$parentPath/$childName"
when {
child.isDirectory -> traverse(child, childRelativePath, output)
child.isFile && isAudioFile(child) -> {
output += AudioEntry(
uri = child.uri,
displayName = childName,
relativePath = childRelativePath,
)
}
}
}
}
private fun isAudioFile(document: DocumentFile): Boolean {
val mimeType = document.type?.lowercase(Locale.US).orEmpty()
if (mimeType.startsWith("audio/")) {
return true
}
val name = document.name?.lowercase(Locale.US).orEmpty()
return AUDIO_EXTENSIONS.any { extension -> name.endsWith(".$extension") }
}
private fun findOrCreateDirectory(parent: DocumentFile, name: String): DocumentFile {
parent.findFile(name)?.let { existing ->
if (existing.isDirectory) {
return existing
}
existing.delete()
}
return parent.createDirectory(name)
?: throw IllegalStateException("无法创建目录: $name")
}
private fun replaceExtension(name: String, extension: String): String {
val dotIndex = name.lastIndexOf('.')
val baseName = if (dotIndex > 0) name.substring(0, dotIndex) else name
return "$baseName.$extension"
}
private val AUDIO_EXTENSIONS = setOf(
"aac",
"amr",
"flac",
"m4a",
"mp3",
"ogg",
"opus",
"wav",
"webm",
"wma",
"3gp",
)
private const val VIDEO_MIME_TYPE = "video/mp4"
}
@@ -0,0 +1,130 @@
package ai.guiji.duix.test.service
import android.net.Uri
import java.io.File
import java.util.Locale
object LocalFileAudioStore {
data class AudioEntry(
val uri: Uri,
val displayName: String,
val relativePath: String,
)
data class ScanResult(
val rootDir: File,
val rootName: String,
val files: List<AudioEntry>,
)
data class WriteResult(
val file: File,
val skipped: Boolean,
)
fun scanAudioFiles(rootDir: File): ScanResult {
val normalizedRoot = rootDir.canonicalFile
if (!normalizedRoot.exists() || !normalizedRoot.isDirectory) {
throw IllegalArgumentException("无法读取所选文件夹")
}
val files = mutableListOf<AudioEntry>()
traverse(normalizedRoot, "", files)
return ScanResult(
rootDir = normalizedRoot,
rootName = normalizedRoot.name.ifBlank { normalizedRoot.absolutePath },
files = files.sortedBy { it.relativePath.lowercase(Locale.US) },
)
}
fun writeVideoResult(
rootDir: File,
outputRootName: String,
sourceRelativePath: String,
localFile: File,
): WriteResult {
val normalizedRoot = rootDir.canonicalFile
if (!normalizedRoot.exists() || !normalizedRoot.isDirectory) {
throw IllegalArgumentException("无法写入所选文件夹")
}
val outputFile = resolveOutputFile(normalizedRoot, outputRootName, sourceRelativePath)
if (outputFile.exists()) {
return WriteResult(outputFile, skipped = true)
}
outputFile.parentFile?.let { parent ->
if (!parent.exists() && !parent.mkdirs()) {
throw IllegalStateException("无法创建输出目录")
}
}
return runCatching {
localFile.copyTo(outputFile, overwrite = false)
WriteResult(outputFile, skipped = false)
}.getOrElse { error ->
if (outputFile.exists()) {
WriteResult(outputFile, skipped = true)
} else {
throw error
}
}
}
fun resolveOutputFile(
rootDir: File,
outputRootName: String,
sourceRelativePath: String,
): File {
val normalizedRoot = rootDir.canonicalFile
return File(normalizedRoot, buildOutputRelativePath(outputRootName, sourceRelativePath))
}
fun buildOutputRelativePath(outputRootName: String, sourceRelativePath: String): String {
val normalizedPath = sourceRelativePath.replace('\\', '/')
return outputRootName + "/" + replaceExtension(normalizedPath, "mp4")
}
private fun traverse(directory: File, parentPath: String, output: MutableList<AudioEntry>) {
val children = directory.listFiles()
?.sortedBy { it.name.lowercase(Locale.US) }
.orEmpty()
for (child in children) {
val childRelativePath = if (parentPath.isEmpty()) child.name else "$parentPath/${child.name}"
when {
child.isDirectory -> traverse(child, childRelativePath, output)
child.isFile && isAudioFile(child) -> {
output += AudioEntry(
uri = Uri.fromFile(child),
displayName = child.name,
relativePath = childRelativePath,
)
}
}
}
}
private fun isAudioFile(file: File): Boolean {
val extension = file.extension.lowercase(Locale.US)
return extension in AUDIO_EXTENSIONS
}
private fun replaceExtension(name: String, extension: String): String {
val dotIndex = name.lastIndexOf('.')
val baseName = if (dotIndex > 0) name.substring(0, dotIndex) else name
return "$baseName.$extension"
}
private val AUDIO_EXTENSIONS = setOf(
"aac",
"amr",
"flac",
"m4a",
"mp3",
"ogg",
"opus",
"wav",
"webm",
"wma",
"3gp",
)
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,133 @@
package ai.guiji.duix.test.ui.activity
import ai.guiji.duix.test.R
import ai.guiji.duix.test.databinding.ActivityFolderPickerBinding
import ai.guiji.duix.test.ui.adapter.FolderBrowserAdapter
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.os.Environment
import android.view.View
import androidx.activity.OnBackPressedCallback
import androidx.recyclerview.widget.LinearLayoutManager
import java.io.File
class FolderPickerActivity : BaseActivity() {
private lateinit var binding: ActivityFolderPickerBinding
private lateinit var folderAdapter: FolderBrowserAdapter
private lateinit var browseRoot: File
private lateinit var currentDirectory: File
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityFolderPickerBinding.inflate(layoutInflater)
setContentView(binding.root)
browseRoot = resolveBrowseRoot()
currentDirectory = resolveInitialDirectory(intent.getStringExtra(EXTRA_INITIAL_PATH))
onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
if (canNavigateUp()) {
navigateUp()
} else {
isEnabled = false
onBackPressedDispatcher.onBackPressed()
}
}
})
setupList()
setupActions()
renderCurrentDirectory()
}
private fun setupList() {
folderAdapter = FolderBrowserAdapter { folder ->
currentDirectory = folder
renderCurrentDirectory()
}
binding.rvFolders.layoutManager = LinearLayoutManager(this)
binding.rvFolders.adapter = folderAdapter
}
private fun setupActions() {
binding.btnBack.setOnClickListener { finish() }
binding.btnNavigateUp.setOnClickListener { navigateUp() }
binding.btnSelectCurrent.setOnClickListener {
setResult(
Activity.RESULT_OK,
Intent().putExtra(EXTRA_SELECTED_PATH, currentDirectory.absolutePath),
)
finish()
}
}
private fun renderCurrentDirectory() {
val subFolders = currentDirectory.listFiles()
?.filter { it.isDirectory && it.canRead() }
?.sortedBy { it.name.lowercase() }
.orEmpty()
binding.tvCurrentFolderName.text = currentDirectory.name.ifBlank { currentDirectory.absolutePath }
binding.tvCurrentPath.text = currentDirectory.absolutePath
binding.btnNavigateUp.isEnabled = canNavigateUp()
binding.tvEmpty.visibility = if (subFolders.isEmpty()) View.VISIBLE else View.GONE
binding.rvFolders.visibility = if (subFolders.isEmpty()) View.GONE else View.VISIBLE
folderAdapter.submitList(subFolders)
}
private fun navigateUp() {
if (!canNavigateUp()) {
return
}
currentDirectory.parentFile?.let { parent ->
currentDirectory = parent
renderCurrentDirectory()
}
}
private fun canNavigateUp(): Boolean {
val rootPath = browseRoot.canonicalPath
val currentPath = currentDirectory.canonicalPath
return currentPath != rootPath && currentDirectory.parentFile != null && currentPath.startsWith(rootPath)
}
private fun resolveBrowseRoot(): File {
return Environment.getExternalStorageDirectory().canonicalFile
}
private fun resolveInitialDirectory(initialPath: String?): File {
val candidates = listOfNotNull(
initialPath?.takeIf { it.isNotBlank() }?.let(::File),
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOCUMENTS),
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_MUSIC),
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_MOVIES),
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS),
browseRoot,
)
for (candidate in candidates) {
val file = runCatching { candidate.canonicalFile }.getOrNull() ?: continue
if (!file.exists() || !file.isDirectory || !file.canRead()) {
continue
}
if (file.canonicalPath.startsWith(browseRoot.canonicalPath)) {
return file
}
}
return browseRoot
}
companion object {
const val EXTRA_INITIAL_PATH = "extra_initial_path"
const val EXTRA_SELECTED_PATH = "extra_selected_path"
fun createIntent(context: Context, initialPath: String?): Intent {
return Intent(context, FolderPickerActivity::class.java).apply {
putExtra(EXTRA_INITIAL_PATH, initialPath)
}
}
}
}
@@ -28,6 +28,9 @@ class SettingsActivity : BaseActivity() {
binding.rowDebugMode.setOnClickListener { binding.rowDebugMode.setOnClickListener {
binding.switchDebugMode.performClick() binding.switchDebugMode.performClick()
} }
binding.rowDigitalHumanTest.setOnClickListener {
startActivity(Intent(mContext, DigitalHumanTestActivity::class.java))
}
binding.tvVersionValue.text = "${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})" binding.tvVersionValue.text = "${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})"
binding.tvServerValue.text = Uri.parse(DashScopeConfig.MONITOR_BASE_URL).host binding.tvServerValue.text = Uri.parse(DashScopeConfig.MONITOR_BASE_URL).host
@@ -0,0 +1,62 @@
package ai.guiji.duix.test.ui.adapter
import ai.guiji.duix.test.R
import ai.guiji.duix.test.databinding.ItemDigitalHumanBatchJobBinding
import ai.guiji.duix.test.model.BatchAudioJob
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.core.content.ContextCompat
import androidx.recyclerview.widget.RecyclerView
import java.util.Locale
class DigitalHumanBatchJobAdapter : RecyclerView.Adapter<DigitalHumanBatchJobAdapter.ViewHolder>() {
private val items = mutableListOf<BatchAudioJob>()
class ViewHolder(val binding: ItemDigitalHumanBatchJobBinding) :
RecyclerView.ViewHolder(binding.root)
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val binding = ItemDigitalHumanBatchJobBinding.inflate(
LayoutInflater.from(parent.context),
parent,
false,
)
return ViewHolder(binding)
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val item = items[position]
val context = holder.binding.root.context
holder.binding.tvIndex.text = String.format(Locale.getDefault(), "%02d", position + 1)
holder.binding.tvPath.text = item.sourceRelativePath
holder.binding.tvStatus.text = item.statusText
holder.binding.tvDetail.text = item.detailText
holder.binding.tvDetail.alpha = if (item.detailText.isBlank()) 0.55f else 1f
val statusColor = when (item.status) {
BatchAudioJob.Status.PENDING -> R.color.warm_status_pending
BatchAudioJob.Status.RUNNING -> R.color.warm_status_running
BatchAudioJob.Status.COMPLETED -> R.color.warm_status_success
BatchAudioJob.Status.FAILED -> R.color.warm_status_error
BatchAudioJob.Status.SKIPPED -> R.color.warm_status_skipped
}
holder.binding.tvStatus.setTextColor(ContextCompat.getColor(context, statusColor))
}
override fun getItemCount(): Int = items.size
fun submitList(newItems: List<BatchAudioJob>) {
items.clear()
items.addAll(newItems)
notifyDataSetChanged()
}
fun updateItem(index: Int, item: BatchAudioJob) {
if (index !in items.indices) {
return
}
items[index] = item
notifyItemChanged(index)
}
}
@@ -0,0 +1,42 @@
package ai.guiji.duix.test.ui.adapter
import ai.guiji.duix.test.databinding.ItemFolderEntryBinding
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import java.io.File
class FolderBrowserAdapter(
private val onFolderSelected: (File) -> Unit,
) : RecyclerView.Adapter<FolderBrowserAdapter.ViewHolder>() {
private val items = mutableListOf<File>()
class ViewHolder(val binding: ItemFolderEntryBinding) : RecyclerView.ViewHolder(binding.root)
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val binding = ItemFolderEntryBinding.inflate(
LayoutInflater.from(parent.context),
parent,
false,
)
return ViewHolder(binding)
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val item = items[position]
holder.binding.tvFolderName.text = item.name.ifBlank { item.absolutePath }
holder.binding.tvFolderPath.text = item.absolutePath
holder.binding.root.setOnClickListener {
onFolderSelected(item)
}
}
override fun getItemCount(): Int = items.size
fun submitList(newItems: List<File>) {
items.clear()
items.addAll(newItems)
notifyDataSetChanged()
}
}
@@ -0,0 +1,560 @@
package ai.guiji.duix.test.video
import ai.guiji.duix.sdk.client.bean.ImageFrame
import android.media.MediaCodec
import android.media.MediaCodecInfo
import android.media.MediaFormat
import android.media.MediaMuxer
import java.io.File
import java.nio.ByteBuffer
import java.util.concurrent.CountDownLatch
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
import kotlin.math.max
import kotlin.math.min
class DigitalHumanMp4Exporter(
private val outputFile: File,
) {
data class ExportResult(
val file: File,
val durationMs: Long,
)
private data class QueuedAudio(val data: ByteArray)
private data class FramePacket(
val raw: ByteArray,
val mask: ByteArray,
val width: Int,
val height: Int,
val timestampNs: Long,
)
private data class EncodedSample(
val data: ByteArray,
val info: MediaCodec.BufferInfo,
)
private val executor = Executors.newSingleThreadExecutor()
private var startNs = -1L
private var initialized = false
private var closed = false
private var videoWidth = 0
private var videoHeight = 0
private var videoColorFormat = MediaCodecInfo.CodecCapabilities.COLOR_FormatYUV420Flexible
private var muxer: MediaMuxer? = null
private var videoEncoder: MediaCodec? = null
private var audioEncoder: MediaCodec? = null
private var videoTrackIndex = -1
private var audioTrackIndex = -1
private var muxerStarted = false
private val pendingAudioChunks = ArrayList<QueuedAudio>()
private val pendingVideoSamples = ArrayList<EncodedSample>()
private val pendingAudioSamples = ArrayList<EncodedSample>()
private var lastFramePacket: FramePacket? = null
private var lastVideoPtsUs = -1L
private var audioBytesQueued = 0L
fun recordAudioChunk(pcm24k: ByteArray, timestampNs: Long = System.nanoTime()) {
if (pcm24k.isEmpty() || closed) {
return
}
val copy = pcm24k.clone()
executor.execute {
if (closed) {
return@execute
}
ensureStarted(timestampNs)
if (!initialized) {
pendingAudioChunks.add(QueuedAudio(copy))
} else {
queueAudioInternal(copy)
}
}
}
fun recordFrame(imageFrame: ImageFrame, timestampNs: Long = System.nanoTime()) {
if (closed) {
return
}
val rawSize = imageFrame.width * imageFrame.height * RGB_CHANNELS
val rawCopy = copyBufferBytes(imageFrame.rawBuffer, rawSize)
val maskCopy = copyBufferBytes(imageFrame.maskBuffer, rawSize)
val packet = FramePacket(rawCopy, maskCopy, imageFrame.width, imageFrame.height, timestampNs)
executor.execute {
if (closed) {
return@execute
}
ensureStarted(timestampNs)
if (!initialized) {
initializeEncoders(packet.width, packet.height)
}
queueVideoInternal(packet)
lastFramePacket = packet
if (pendingAudioChunks.isNotEmpty()) {
flushPendingAudioChunks()
}
}
}
fun finish(): ExportResult {
val latch = CountDownLatch(1)
var result: ExportResult? = null
var failure: Throwable? = null
executor.execute {
try {
closed = true
if (!initialized) {
throw IllegalStateException("未收到数字人渲染帧,无法导出视频")
}
if (pendingAudioChunks.isNotEmpty()) {
flushPendingAudioChunks()
}
if (audioBytesQueued <= 0L) {
throw IllegalStateException("未收到模型音频响应")
}
val audioDurationUs = calculateAudioDurationUs()
val lastFrame = lastFramePacket
if (lastFrame != null && audioDurationUs > lastVideoPtsUs + FRAME_PADDING_US) {
queueVideoInternal(lastFrame.copy(timestampNs = startNs + (audioDurationUs * 1000L)))
}
signalAudioEndOfStream(audioDurationUs)
drainAudioEncoder(endOfStream = true)
val finalVideoPtsUs = max(lastVideoPtsUs, audioDurationUs)
signalVideoEndOfStream(finalVideoPtsUs)
drainVideoEncoder(endOfStream = true)
releaseInternal(deleteOutput = false)
result = ExportResult(
file = outputFile,
durationMs = (audioDurationUs / 1000L).coerceAtLeast(0L),
)
} catch (throwable: Throwable) {
failure = throwable
releaseInternal(deleteOutput = true)
} finally {
latch.countDown()
}
}
if (!latch.await(FINISH_TIMEOUT_SECONDS, TimeUnit.SECONDS)) {
executor.shutdownNow()
throw IllegalStateException("视频导出超时")
}
executor.shutdown()
failure?.let { throw it }
return result ?: throw IllegalStateException("视频导出失败")
}
fun cancel() {
val latch = CountDownLatch(1)
executor.execute {
try {
closed = true
releaseInternal(deleteOutput = true)
} finally {
latch.countDown()
}
}
latch.await(CANCEL_TIMEOUT_SECONDS, TimeUnit.SECONDS)
executor.shutdownNow()
}
private fun ensureStarted(timestampNs: Long) {
if (startNs < 0L) {
startNs = timestampNs
}
}
private fun initializeEncoders(width: Int, height: Int) {
if (initialized) {
return
}
if (width <= 0 || height <= 0 || width % 2 != 0 || height % 2 != 0) {
throw IllegalArgumentException("渲染帧尺寸无效: ${width}x${height}")
}
outputFile.parentFile?.mkdirs()
if (outputFile.exists()) {
outputFile.delete()
}
videoWidth = width
videoHeight = height
muxer = MediaMuxer(outputFile.absolutePath, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4)
videoEncoder = MediaCodec.createEncoderByType(VIDEO_MIME_TYPE).apply {
videoColorFormat = selectVideoColorFormat(codecInfo)
val format = MediaFormat.createVideoFormat(VIDEO_MIME_TYPE, width, height).apply {
setInteger(MediaFormat.KEY_COLOR_FORMAT, videoColorFormat)
setInteger(MediaFormat.KEY_BIT_RATE, max(width * height * VIDEO_BITRATE_FACTOR, MIN_VIDEO_BITRATE))
setInteger(MediaFormat.KEY_FRAME_RATE, VIDEO_FRAME_RATE)
setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, VIDEO_I_FRAME_INTERVAL)
}
configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE)
start()
}
audioEncoder = MediaCodec.createEncoderByType(AUDIO_MIME_TYPE).apply {
val format = MediaFormat.createAudioFormat(AUDIO_MIME_TYPE, AUDIO_SAMPLE_RATE, AUDIO_CHANNEL_COUNT).apply {
setInteger(MediaFormat.KEY_AAC_PROFILE, MediaCodecInfo.CodecProfileLevel.AACObjectLC)
setInteger(MediaFormat.KEY_BIT_RATE, AUDIO_BITRATE)
setInteger(MediaFormat.KEY_MAX_INPUT_SIZE, AUDIO_MAX_INPUT_SIZE)
}
configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE)
start()
}
initialized = true
}
private fun flushPendingAudioChunks() {
if (pendingAudioChunks.isEmpty()) {
return
}
val iterator = pendingAudioChunks.iterator()
while (iterator.hasNext()) {
queueAudioInternal(iterator.next().data)
iterator.remove()
}
}
private fun queueAudioInternal(pcm24k: ByteArray) {
var offset = 0
val encoder = audioEncoder ?: throw IllegalStateException("音频编码器尚未初始化")
while (offset < pcm24k.size) {
val inputIndex = dequeueInputBuffer(encoder)
val inputBuffer = encoder.getInputBuffer(inputIndex)
?: throw IllegalStateException("音频输入缓冲区不可用")
inputBuffer.clear()
val chunkSize = min(inputBuffer.remaining(), pcm24k.size - offset)
inputBuffer.put(pcm24k, offset, chunkSize)
val ptsUs = audioBytesQueued * MICROS_PER_SECOND / AUDIO_BYTES_PER_SECOND
encoder.queueInputBuffer(inputIndex, 0, chunkSize, ptsUs, 0)
audioBytesQueued += chunkSize.toLong()
offset += chunkSize
drainAudioEncoder(endOfStream = false)
}
}
private fun queueVideoInternal(packet: FramePacket) {
if (packet.width != videoWidth || packet.height != videoHeight) {
throw IllegalStateException("渲染帧尺寸发生变化,无法继续编码")
}
val encoder = videoEncoder ?: throw IllegalStateException("视频编码器尚未初始化")
val inputIndex = dequeueInputBuffer(encoder)
val inputBuffer = encoder.getInputBuffer(inputIndex)
?: throw IllegalStateException("视频输入缓冲区不可用")
inputBuffer.clear()
val yuv = convertFrameToYuv(packet.raw, packet.mask, packet.width, packet.height, videoColorFormat)
if (inputBuffer.remaining() < yuv.size) {
throw IllegalStateException("视频输入缓冲区容量不足")
}
inputBuffer.put(yuv)
val ptsUs = computeVideoPresentationTimeUs(packet.timestampNs)
encoder.queueInputBuffer(inputIndex, 0, yuv.size, ptsUs, 0)
lastVideoPtsUs = ptsUs
drainVideoEncoder(endOfStream = false)
}
private fun signalAudioEndOfStream(finalPtsUs: Long) {
val encoder = audioEncoder ?: return
val inputIndex = dequeueInputBuffer(encoder)
encoder.queueInputBuffer(inputIndex, 0, 0, finalPtsUs, MediaCodec.BUFFER_FLAG_END_OF_STREAM)
}
private fun signalVideoEndOfStream(finalPtsUs: Long) {
val encoder = videoEncoder ?: return
val inputIndex = dequeueInputBuffer(encoder)
encoder.queueInputBuffer(inputIndex, 0, 0, finalPtsUs, MediaCodec.BUFFER_FLAG_END_OF_STREAM)
}
private fun drainAudioEncoder(endOfStream: Boolean) {
drainEncoder(
encoder = audioEncoder,
pendingSamples = pendingAudioSamples,
getTrackIndex = { audioTrackIndex },
setTrackIndex = { audioTrackIndex = it },
endOfStream = endOfStream,
)
}
private fun drainVideoEncoder(endOfStream: Boolean) {
drainEncoder(
encoder = videoEncoder,
pendingSamples = pendingVideoSamples,
getTrackIndex = { videoTrackIndex },
setTrackIndex = { videoTrackIndex = it },
endOfStream = endOfStream,
)
}
private fun drainEncoder(
encoder: MediaCodec?,
pendingSamples: MutableList<EncodedSample>,
getTrackIndex: () -> Int,
setTrackIndex: (Int) -> Unit,
endOfStream: Boolean,
) {
if (encoder == null) {
return
}
val bufferInfo = MediaCodec.BufferInfo()
while (true) {
val outputIndex = encoder.dequeueOutputBuffer(bufferInfo, TIMEOUT_US)
when {
outputIndex == MediaCodec.INFO_TRY_AGAIN_LATER -> {
if (!endOfStream) {
return
}
}
outputIndex == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED -> {
val muxerInstance = muxer ?: throw IllegalStateException("复用器未初始化")
val trackIndex = muxerInstance.addTrack(encoder.outputFormat)
setTrackIndex(trackIndex)
startMuxerIfReady()
}
outputIndex >= 0 -> {
val outputBuffer = encoder.getOutputBuffer(outputIndex)
?: throw IllegalStateException("编码器输出缓冲区不可用")
if ((bufferInfo.flags and MediaCodec.BUFFER_FLAG_CODEC_CONFIG) != 0) {
bufferInfo.size = 0
}
if (bufferInfo.size > 0) {
val trackIndex = getTrackIndex()
if (trackIndex < 0) {
throw IllegalStateException("编码轨道尚未创建")
}
val sample = copyEncodedSample(outputBuffer, bufferInfo)
if (muxerStarted) {
writeEncodedSample(trackIndex, sample)
} else {
pendingSamples.add(sample)
}
}
val endReached = (bufferInfo.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0
encoder.releaseOutputBuffer(outputIndex, false)
if (endReached) {
return
}
}
}
}
}
private fun startMuxerIfReady() {
if (muxerStarted || videoTrackIndex < 0 || audioTrackIndex < 0) {
return
}
val muxerInstance = muxer ?: throw IllegalStateException("复用器未初始化")
muxerInstance.start()
muxerStarted = true
flushPendingSamples(videoTrackIndex, pendingVideoSamples)
flushPendingSamples(audioTrackIndex, pendingAudioSamples)
}
private fun flushPendingSamples(trackIndex: Int, pendingSamples: MutableList<EncodedSample>) {
if (pendingSamples.isEmpty()) {
return
}
pendingSamples.sortBy { it.info.presentationTimeUs }
val iterator = pendingSamples.iterator()
while (iterator.hasNext()) {
writeEncodedSample(trackIndex, iterator.next())
iterator.remove()
}
}
private fun writeEncodedSample(trackIndex: Int, sample: EncodedSample) {
val muxerInstance = muxer ?: throw IllegalStateException("复用器未初始化")
muxerInstance.writeSampleData(trackIndex, ByteBuffer.wrap(sample.data), sample.info)
}
private fun copyEncodedSample(buffer: ByteBuffer, info: MediaCodec.BufferInfo): EncodedSample {
val data = ByteArray(info.size)
buffer.position(info.offset)
buffer.limit(info.offset + info.size)
buffer.get(data)
val copiedInfo = MediaCodec.BufferInfo().apply {
offset = 0
size = data.size
presentationTimeUs = info.presentationTimeUs
flags = info.flags
}
return EncodedSample(data, copiedInfo)
}
private fun computeVideoPresentationTimeUs(timestampNs: Long): Long {
val relativeUs = ((timestampNs - startNs) / 1_000L).coerceAtLeast(0L)
return if (lastVideoPtsUs < 0L) {
relativeUs
} else {
max(relativeUs, lastVideoPtsUs + 1L)
}
}
private fun calculateAudioDurationUs(): Long {
return audioBytesQueued * MICROS_PER_SECOND / AUDIO_BYTES_PER_SECOND
}
private fun dequeueInputBuffer(encoder: MediaCodec): Int {
while (true) {
val inputIndex = encoder.dequeueInputBuffer(TIMEOUT_US)
if (inputIndex >= 0) {
return inputIndex
}
if (inputIndex != MediaCodec.INFO_TRY_AGAIN_LATER) {
throw IllegalStateException("获取编码器输入缓冲区失败")
}
if (closed) {
throw IllegalStateException("编码任务已结束")
}
}
}
private fun releaseInternal(deleteOutput: Boolean) {
runCatching {
if (muxerStarted) {
muxer?.stop()
}
}
runCatching { muxer?.release() }
runCatching { videoEncoder?.stop() }
runCatching { videoEncoder?.release() }
runCatching { audioEncoder?.stop() }
runCatching { audioEncoder?.release() }
muxer = null
videoEncoder = null
audioEncoder = null
muxerStarted = false
initialized = false
pendingAudioChunks.clear()
pendingAudioSamples.clear()
pendingVideoSamples.clear()
if (deleteOutput && outputFile.exists()) {
outputFile.delete()
}
}
private fun selectVideoColorFormat(codecInfo: MediaCodecInfo): Int {
val capabilities = codecInfo.getCapabilitiesForType(VIDEO_MIME_TYPE)
val candidates = intArrayOf(
MediaCodecInfo.CodecCapabilities.COLOR_FormatYUV420SemiPlanar,
MediaCodecInfo.CodecCapabilities.COLOR_FormatYUV420Planar,
MediaCodecInfo.CodecCapabilities.COLOR_FormatYUV420Flexible,
)
for (candidate in candidates) {
if (capabilities.colorFormats.contains(candidate)) {
return candidate
}
}
throw IllegalStateException("当前设备不支持可用的 YUV420 视频编码格式")
}
private fun convertFrameToYuv(
raw: ByteArray,
mask: ByteArray,
width: Int,
height: Int,
colorFormat: Int,
): ByteArray {
val frameSize = width * height
val output = ByteArray(frameSize + frameSize / 2)
val isSemiPlanar = colorFormat == MediaCodecInfo.CodecCapabilities.COLOR_FormatYUV420SemiPlanar
var yIndex = 0
var uIndex = frameSize
var vIndex = if (isSemiPlanar) frameSize + 1 else frameSize + frameSize / 4
for (row in 0 until height) {
for (col in 0 until width) {
val pixelIndex = row * width + col
val rgbOffset = pixelIndex * RGB_CHANNELS
val alpha = ((mask[rgbOffset].toInt() and 0xFF) +
(mask[rgbOffset + 1].toInt() and 0xFF) +
(mask[rgbOffset + 2].toInt() and 0xFF)) / RGB_CHANNELS
val blue = ((raw[rgbOffset].toInt() and 0xFF) * alpha) / 255
val green = ((raw[rgbOffset + 1].toInt() and 0xFF) * alpha) / 255
val red = ((raw[rgbOffset + 2].toInt() and 0xFF) * alpha) / 255
val y = clampToByte(((66 * red + 129 * green + 25 * blue + 128) shr 8) + 16)
val u = clampToByte(((-38 * red - 74 * green + 112 * blue + 128) shr 8) + 128)
val v = clampToByte(((112 * red - 94 * green - 18 * blue + 128) shr 8) + 128)
output[yIndex++] = y.toByte()
if (row % 2 == 0 && col % 2 == 0) {
if (isSemiPlanar) {
output[uIndex] = u.toByte()
output[vIndex] = v.toByte()
uIndex += 2
vIndex += 2
} else {
output[uIndex++] = u.toByte()
output[vIndex++] = v.toByte()
}
}
}
}
return output
}
private fun clampToByte(value: Int): Int {
return value.coerceIn(0, 255)
}
private fun copyBufferBytes(buffer: ByteBuffer, size: Int): ByteArray {
if (buffer.hasArray()) {
return buffer.array().copyOf(size)
}
val duplicate = buffer.duplicate()
duplicate.position(0)
val output = ByteArray(size)
duplicate.get(output, 0, min(size, duplicate.remaining()))
return output
}
companion object {
private const val VIDEO_MIME_TYPE = "video/avc"
private const val AUDIO_MIME_TYPE = "audio/mp4a-latm"
private const val AUDIO_SAMPLE_RATE = 24_000
private const val AUDIO_CHANNEL_COUNT = 1
private const val AUDIO_BYTES_PER_SECOND = AUDIO_SAMPLE_RATE * AUDIO_CHANNEL_COUNT * 2L
private const val AUDIO_BITRATE = 64_000
private const val AUDIO_MAX_INPUT_SIZE = 48_000
private const val VIDEO_FRAME_RATE = 24
private const val VIDEO_I_FRAME_INTERVAL = 1
private const val VIDEO_BITRATE_FACTOR = 6
private const val MIN_VIDEO_BITRATE = 1_200_000
private const val RGB_CHANNELS = 3
private const val FRAME_PADDING_US = 80_000L
private const val MICROS_PER_SECOND = 1_000_000L
private const val TIMEOUT_US = 10_000L
private const val FINISH_TIMEOUT_SECONDS = 60L
private const val CANCEL_TIMEOUT_SECONDS = 5L
}
}
@@ -0,0 +1,258 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.core.widget.NestedScrollView xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/warm_bg"
android:fillViewport="true">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:paddingStart="18dp"
android:paddingTop="28dp"
android:paddingEnd="18dp"
android:paddingBottom="28dp">
<androidx.appcompat.widget.AppCompatImageButton
android:id="@+id/btnBack"
android:layout_width="44dp"
android:layout_height="44dp"
android:background="@drawable/shape_warm_icon_button"
android:contentDescription="@string/device_settings_back"
android:padding="10dp"
android:scaleType="centerInside"
android:src="@drawable/ic_arrow_back" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="18dp"
android:text="@string/digital_human_test_title"
android:textColor="@color/warm_text_primary"
android:textSize="25sp"
android:textStyle="bold" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:lineSpacingExtra="4dp"
android:text="@string/digital_human_test_subtitle"
android:textColor="@color/warm_text_secondary"
android:textSize="15sp" />
<FrameLayout
android:layout_width="match_parent"
android:layout_height="340dp"
android:layout_marginTop="18dp"
android:background="@drawable/shape_warm_feature_card"
android:clipChildren="true"
android:clipToPadding="true">
<ImageView
android:id="@+id/ivBg"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:scaleType="centerCrop" />
<ai.guiji.duix.sdk.client.render.DUIXTextureView
android:id="@+id/glTextureView"
android:layout_width="match_parent"
android:layout_height="match_parent" />
<TextView
android:id="@+id/tvPreviewHint"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:background="#8A1A1E1F"
android:gravity="center"
android:paddingStart="16dp"
android:paddingTop="10dp"
android:paddingEnd="16dp"
android:paddingBottom="10dp"
android:textColor="@color/white"
android:textSize="16sp" />
<TextView
android:id="@+id/tvSubtitle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="bottom"
android:background="#99000000"
android:lineSpacingExtra="4dp"
android:padding="14dp"
android:textColor="@color/white"
android:textSize="16sp"
android:visibility="gone" />
</FrameLayout>
<TextView
android:id="@+id/tvStatus"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:background="@drawable/shape_warm_feature_card"
android:lineSpacingExtra="4dp"
android:padding="18dp"
android:textColor="@color/warm_text_primary"
android:textSize="17sp" />
<TextView
android:id="@+id/tvInputTranscriptLabel"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="18dp"
android:text="@string/digital_human_test_input_transcript_title"
android:textColor="@color/warm_text_primary"
android:textSize="18sp"
android:textStyle="bold"
android:visibility="gone" />
<TextView
android:id="@+id/tvInputTranscript"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:background="@drawable/shape_warm_feature_card"
android:lineSpacingExtra="4dp"
android:padding="18dp"
android:textColor="@color/warm_text_primary"
android:textSize="16sp"
android:visibility="gone" />
<LinearLayout
android:id="@+id/layoutAvatarSection"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="20dp"
android:text="@string/voice_clone_choose_avatar"
android:textColor="@color/warm_text_primary"
android:textSize="18sp"
android:textStyle="bold" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rvAvatars"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:clipToPadding="false"
android:nestedScrollingEnabled="false"
android:paddingBottom="12dp" />
</LinearLayout>
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/digital_human_test_folder_title"
android:textColor="@color/warm_text_primary"
android:textSize="18sp"
android:textStyle="bold" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:background="@drawable/shape_warm_feature_card"
android:orientation="vertical"
android:padding="18dp">
<TextView
android:id="@+id/tvFolderName"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textColor="@color/warm_text_primary"
android:textSize="17sp"
android:textStyle="bold" />
<TextView
android:id="@+id/tvFolderMeta"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:lineSpacingExtra="4dp"
android:textColor="@color/warm_text_secondary"
android:textSize="14sp" />
<androidx.appcompat.widget.AppCompatButton
android:id="@+id/btnSelectFolder"
android:layout_width="match_parent"
android:layout_height="54dp"
android:layout_marginTop="14dp"
android:background="@drawable/selector_warm_secondary_button"
android:elevation="0dp"
android:stateListAnimator="@null"
android:text="@string/digital_human_test_select_folder"
android:textAllCaps="false"
android:textColor="@color/text_warm_secondary_button"
android:textSize="17sp" />
</LinearLayout>
<LinearLayout
android:id="@+id/layoutProgressSection"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:visibility="gone">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="20dp"
android:text="@string/digital_human_test_progress_title"
android:textColor="@color/warm_text_primary"
android:textSize="18sp"
android:textStyle="bold" />
<TextView
android:id="@+id/tvProgressSummary"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:textColor="@color/warm_text_secondary"
android:textSize="14sp" />
<TextView
android:id="@+id/tvProgressEmpty"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:background="@drawable/shape_warm_feature_card"
android:lineSpacingExtra="4dp"
android:padding="18dp"
android:text="@string/digital_human_test_progress_empty"
android:textColor="@color/warm_text_secondary"
android:textSize="15sp" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rvBatchJobs"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:clipToPadding="false"
android:nestedScrollingEnabled="false"
android:paddingBottom="6dp" />
</LinearLayout>
<androidx.appcompat.widget.AppCompatButton
android:id="@+id/btnPrimaryAction"
android:layout_width="match_parent"
android:layout_height="56dp"
android:layout_marginTop="20dp"
android:background="@drawable/selector_warm_primary_button"
android:elevation="0dp"
android:stateListAnimator="@null"
android:text="@string/digital_human_test_generate"
android:textAllCaps="false"
android:textColor="@color/text_warm_primary_button"
android:textSize="18sp"
android:textStyle="bold" />
</LinearLayout>
</androidx.core.widget.NestedScrollView>
@@ -0,0 +1,132 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/warm_bg"
android:orientation="vertical"
android:paddingStart="18dp"
android:paddingTop="28dp"
android:paddingEnd="18dp"
android:paddingBottom="20dp">
<androidx.appcompat.widget.AppCompatImageButton
android:id="@+id/btnBack"
android:layout_width="44dp"
android:layout_height="44dp"
android:background="@drawable/shape_warm_icon_button"
android:contentDescription="@string/device_settings_back"
android:padding="10dp"
android:scaleType="centerInside"
android:src="@drawable/ic_arrow_back" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="18dp"
android:text="@string/folder_picker_title"
android:textColor="@color/warm_text_primary"
android:textSize="25sp"
android:textStyle="bold" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:lineSpacingExtra="4dp"
android:text="@string/folder_picker_subtitle"
android:textColor="@color/warm_text_secondary"
android:textSize="15sp" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="18dp"
android:background="@drawable/shape_warm_feature_card"
android:orientation="vertical"
android:padding="18dp">
<TextView
android:id="@+id/tvCurrentFolderName"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textColor="@color/warm_text_primary"
android:textSize="17sp"
android:textStyle="bold" />
<TextView
android:id="@+id/tvCurrentPath"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:lineSpacingExtra="4dp"
android:textColor="@color/warm_text_secondary"
android:textSize="14sp" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="14dp"
android:orientation="horizontal">
<androidx.appcompat.widget.AppCompatButton
android:id="@+id/btnNavigateUp"
android:layout_width="0dp"
android:layout_height="52dp"
android:layout_weight="1"
android:background="@drawable/selector_warm_secondary_button"
android:elevation="0dp"
android:stateListAnimator="@null"
android:text="@string/folder_picker_up"
android:textAllCaps="false"
android:textColor="@color/text_warm_secondary_button"
android:textSize="16sp" />
<androidx.appcompat.widget.AppCompatButton
android:id="@+id/btnSelectCurrent"
android:layout_width="0dp"
android:layout_height="52dp"
android:layout_marginStart="12dp"
android:layout_weight="1"
android:background="@drawable/selector_warm_primary_button"
android:elevation="0dp"
android:stateListAnimator="@null"
android:text="@string/folder_picker_select_current"
android:textAllCaps="false"
android:textColor="@color/text_warm_primary_button"
android:textSize="16sp"
android:textStyle="bold" />
</LinearLayout>
</LinearLayout>
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="20dp"
android:text="@string/folder_picker_list_title"
android:textColor="@color/warm_text_primary"
android:textSize="18sp"
android:textStyle="bold" />
<TextView
android:id="@+id/tvEmpty"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:background="@drawable/shape_warm_feature_card"
android:lineSpacingExtra="4dp"
android:padding="18dp"
android:text="@string/folder_picker_empty"
android:textColor="@color/warm_text_secondary"
android:textSize="15sp"
android:visibility="gone" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rvFolders"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_marginTop="10dp"
android:layout_weight="1"
android:clipToPadding="false"
android:nestedScrollingEnabled="false"
android:paddingBottom="8dp" />
</LinearLayout>
@@ -134,6 +134,51 @@
android:text="" /> android:text="" />
</LinearLayout> </LinearLayout>
<LinearLayout
android:id="@+id/rowDigitalHumanTest"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:background="@drawable/shape_warm_feature_card"
android:clickable="true"
android:focusable="true"
android:foreground="?attr/selectableItemBackground"
android:gravity="center_vertical"
android:orientation="horizontal"
android:padding="18dp">
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/settings_digital_human_test_title"
android:textColor="@color/warm_text_primary"
android:textSize="18sp"
android:textStyle="bold" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:lineSpacingExtra="4dp"
android:text="@string/settings_digital_human_test_desc"
android:textColor="@color/warm_text_secondary"
android:textSize="14sp" />
</LinearLayout>
<ImageView
android:layout_width="24dp"
android:layout_height="24dp"
android:layout_marginStart="12dp"
android:contentDescription="@null"
android:src="@drawable/ic_chevron_right" />
</LinearLayout>
<LinearLayout <LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
@@ -0,0 +1,64 @@
<?xml version="1.0" encoding="utf-8"?>
<com.google.android.material.card.MaterialCardView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="10dp"
app:cardBackgroundColor="@color/warm_surface"
app:cardCornerRadius="18dp"
app:cardElevation="2dp"
app:strokeColor="@color/warm_surface_alt"
app:strokeWidth="1dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:orientation="horizontal"
android:padding="16dp">
<TextView
android:id="@+id/tvIndex"
android:layout_width="44dp"
android:layout_height="44dp"
android:background="@drawable/shape_warm_icon_button"
android:gravity="center"
android:textColor="@color/warm_text_primary"
android:textSize="15sp"
android:textStyle="bold" />
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="14dp"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:id="@+id/tvPath"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textColor="@color/warm_text_primary"
android:textSize="16sp"
android:textStyle="bold" />
<TextView
android:id="@+id/tvStatus"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:textSize="14sp"
android:textStyle="bold" />
<TextView
android:id="@+id/tvDetail"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:lineSpacingExtra="3dp"
android:textColor="@color/warm_text_secondary"
android:textSize="13sp" />
</LinearLayout>
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
@@ -0,0 +1,41 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="10dp"
android:background="@drawable/shape_warm_feature_card"
android:gravity="center_vertical"
android:orientation="horizontal"
android:padding="16dp">
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:id="@+id/tvFolderName"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textColor="@color/warm_text_primary"
android:textSize="16sp"
android:textStyle="bold" />
<TextView
android:id="@+id/tvFolderPath"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:lineSpacingExtra="3dp"
android:textColor="@color/warm_text_secondary"
android:textSize="13sp" />
</LinearLayout>
<ImageView
android:layout_width="20dp"
android:layout_height="20dp"
android:layout_marginStart="12dp"
android:contentDescription="@null"
android:src="@drawable/ic_chevron_right" />
</LinearLayout>
@@ -82,10 +82,60 @@
<string name="settings_device_code_desc">打开设备二维码页面后,家里人扫一扫就能把这台设备绑定到自己的账号。</string> <string name="settings_device_code_desc">打开设备二维码页面后,家里人扫一扫就能把这台设备绑定到自己的账号。</string>
<string name="settings_debug_title">显示调试详情</string> <string name="settings_debug_title">显示调试详情</string>
<string name="settings_debug_desc">开启后,聊天页面会显示完整的交互日志,包括工具调用的输入和输出。</string> <string name="settings_debug_desc">开启后,聊天页面会显示完整的交互日志,包括工具调用的输入和输出。</string>
<string name="settings_digital_human_test_title">数字人测试</string>
<string name="settings_digital_human_test_desc">选择一个文件夹,批量把里面的音频送给 Omni,关闭 search 和 tooluse,并按原目录结构导出数字人 mp4。</string>
<string name="settings_about_title">关于这台设备</string> <string name="settings_about_title">关于这台设备</string>
<string name="settings_version_label">应用版本</string> <string name="settings_version_label">应用版本</string>
<string name="settings_server_label">家属面板域名</string> <string name="settings_server_label">家属面板域名</string>
<string name="settings_device_label">当前设备 UUID</string> <string name="settings_device_label">当前设备 UUID</string>
<string name="digital_human_test_title">数字人测试</string>
<string name="digital_human_test_subtitle">选择一个文件夹后,应用会递归处理其中的音频,关闭 search 和 tools,并按原目录结构批量导出数字人回应 mp4。</string>
<string name="digital_human_test_status_idle">为 %1$s 选好一个文件夹后,我会按顺序处理里面的音频,并批量导出完整 mp4。</string>
<string name="digital_human_test_status_scanning_folder">正在扫描所选文件夹里的音频…</string>
<string name="digital_human_test_status_preparing_resources">正在为 %1$s 准备测试资源…</string>
<string name="digital_human_test_status_loading_avatar">正在让 %1$s 准备好批量处理…</string>
<string name="digital_human_test_status_decoding_audio">正在解析您选好的音频…</string>
<string name="digital_human_test_status_connecting">正在连接 Omni…</string>
<string name="digital_human_test_status_uploading_audio">正在把音频送给 Omni…</string>
<string name="digital_human_test_status_generating">Omni 正在回应,数字人也在跟着这段声音做同步反应。</string>
<string name="digital_human_test_status_muxing">回应已经完成,正在整理并封装最终 mp4…</string>
<string name="digital_human_test_status_done">当前这条完整 mp4 已经保存好了。</string>
<string name="digital_human_test_status_batch_ready">已准备好 %1$d 条待处理任务,点击开始后会按顺序处理。</string>
<string name="digital_human_test_status_batch_current">正在处理第 %1$d/%2$d 条:%3$s</string>
<string name="digital_human_test_status_batch_done">批处理完成,成功 %1$d 条,跳过 %2$d 条,失败 %3$d 条。</string>
<string name="digital_human_test_status_copying">正在按原目录结构保存结果…</string>
<string name="digital_human_test_status_retrying">当前任务失败,正在重试 %3$s(%1$d/%2$d)…</string>
<string name="digital_human_test_preview_idle">%1$s 会在这里依次回应当前队列里的音频。</string>
<string name="digital_human_test_preview_loading">%1$s 正在准备当前这条任务…</string>
<string name="digital_human_test_folder_title">选择输入文件夹</string>
<string name="digital_human_test_folder_none">还没有选文件夹</string>
<string name="digital_human_test_folder_hint">直接选择一个文件夹,应用会递归扫描其中的常见音频文件,并按相同目录层级写回 mp4。</string>
<string name="digital_human_test_folder_summary">已扫描 %1$d 段音频,结果会保存到 %2$s/%3$s 下,并保留原目录结构。</string>
<string name="digital_human_test_select_folder">选择文件夹</string>
<string name="digital_human_test_generate">开始批量生成</string>
<string name="digital_human_test_running">批处理中…</string>
<string name="digital_human_test_need_folder">请先选择一个文件夹</string>
<string name="digital_human_test_all_outputs_exist">目标 mp4 都已存在,当前没有需要处理的任务。</string>
<string name="digital_human_test_input_transcript_title">当前输入识别</string>
<string name="digital_human_test_input_transcript_empty">暂时还没有识别内容</string>
<string name="digital_human_test_progress_title">处理进度</string>
<string name="digital_human_test_progress_summary">总计 %1$d 条,已完成 %2$d 条,已跳过 %3$d 条,失败 %4$d 条。</string>
<string name="digital_human_test_progress_empty">点击开始后,这里会按顺序显示每条音频的处理进度。</string>
<string name="digital_human_test_job_pending">等待处理</string>
<string name="digital_human_test_job_decoding">解析音频</string>
<string name="digital_human_test_job_connecting">连接 Omni</string>
<string name="digital_human_test_job_uploading">发送音频</string>
<string name="digital_human_test_job_generating">生成响应</string>
<string name="digital_human_test_job_muxing">封装 mp4</string>
<string name="digital_human_test_job_copying">保存结果</string>
<string name="digital_human_test_job_retrying">重试中(%1$d/%2$d)</string>
<string name="digital_human_test_job_done">已完成</string>
<string name="digital_human_test_job_failed">失败</string>
<string name="digital_human_test_job_skipped">已跳过</string>
<string name="digital_human_test_job_output">输出:%1$s</string>
<string name="digital_human_test_no_audio_found">所选文件夹里没有找到可处理的音频文件</string>
<string name="digital_human_test_no_audio_response">这次 Omni 没有返回可用的响应音频</string>
<string name="digital_human_test_connection_lost">测试连接中途断开了</string>
<string name="main_download_tips">Github地址可能下载失败,您可以考虑使用代理或者将文件缓存到自己的存储服务</string> <string name="main_download_tips">Github地址可能下载失败,您可以考虑使用代理或者将文件缓存到自己的存储服务</string>
<string name="debug_message">调试信息</string> <string name="debug_message">调试信息</string>
+5
View File
@@ -25,4 +25,9 @@
<color name="warm_button_secondary_bg_pressed">#FFF0E0D2</color> <color name="warm_button_secondary_bg_pressed">#FFF0E0D2</color>
<color name="warm_button_secondary_bg_disabled">#FFF8F1EB</color> <color name="warm_button_secondary_bg_disabled">#FFF8F1EB</color>
<color name="warm_button_secondary_stroke">#FFD7B39B</color> <color name="warm_button_secondary_stroke">#FFD7B39B</color>
<color name="warm_status_pending">#FF9C7C6E</color>
<color name="warm_status_running">#FFC16845</color>
<color name="warm_status_success">#FF5C8C5A</color>
<color name="warm_status_error">#FFB85C4A</color>
<color name="warm_status_skipped">#FF7B8A94</color>
</resources> </resources>
+59
View File
@@ -84,10 +84,69 @@
<string name="settings_device_code_desc">Open the QR code page for your family to scan and bind this elder device.</string> <string name="settings_device_code_desc">Open the QR code page for your family to scan and bind this elder device.</string>
<string name="settings_debug_title">Show debug details</string> <string name="settings_debug_title">Show debug details</string>
<string name="settings_debug_desc">When enabled, the chat page will show full interaction logs, including tool input and output.</string> <string name="settings_debug_desc">When enabled, the chat page will show full interaction logs, including tool input and output.</string>
<string name="settings_digital_human_test_title">Digital Human Test</string>
<string name="settings_digital_human_test_desc">Choose a local folder, batch-send the audio files inside to Omni, and export avatar MP4s while keeping the original directory structure.</string>
<string name="settings_about_title">About this device</string> <string name="settings_about_title">About this device</string>
<string name="settings_version_label">App version</string> <string name="settings_version_label">App version</string>
<string name="settings_server_label">Family panel domain</string> <string name="settings_server_label">Family panel domain</string>
<string name="settings_device_label">Current device UUID</string> <string name="settings_device_label">Current device UUID</string>
<string name="digital_human_test_title">Digital Human Test</string>
<string name="digital_human_test_subtitle">Choose a local folder, recursively process its audio files without search or tools, and export avatar MP4s while preserving the original directory structure.</string>
<string name="digital_human_test_status_idle">Choose a local folder for %1$s, then I will process the audio files one by one and export complete MP4 responses in batch.</string>
<string name="digital_human_test_status_scanning_folder">Scanning the selected folder for audio files…</string>
<string name="digital_human_test_status_preparing_resources">Preparing %1$s for the test…</string>
<string name="digital_human_test_status_loading_avatar">Loading %1$s for the batch…</string>
<string name="digital_human_test_status_decoding_audio">Decoding the selected audio…</string>
<string name="digital_human_test_status_connecting">Connecting to Omni…</string>
<string name="digital_human_test_status_uploading_audio">Sending the selected audio to Omni…</string>
<string name="digital_human_test_status_generating">Omni is answering. The avatar is following the response audio now.</string>
<string name="digital_human_test_status_muxing">The answer is ready. Packing the final MP4…</string>
<string name="digital_human_test_status_done">The current MP4 has been saved.</string>
<string name="digital_human_test_status_batch_ready">%1$d jobs are ready to run. Tap start to process them one by one.</string>
<string name="digital_human_test_status_batch_current">Processing %1$d/%2$d: %3$s</string>
<string name="digital_human_test_status_batch_done">Batch completed. %1$d succeeded, %2$d were skipped, and %3$d failed.</string>
<string name="digital_human_test_status_copying">Saving the result with the original folder structure…</string>
<string name="digital_human_test_status_retrying">Retrying %3$s (%1$d/%2$d)…</string>
<string name="digital_human_test_preview_idle">%1$s will respond to the queued audio files here.</string>
<string name="digital_human_test_preview_loading">Getting %1$s ready for the current job…</string>
<string name="digital_human_test_folder_title">Choose an input folder</string>
<string name="digital_human_test_folder_none">No folder selected yet</string>
<string name="digital_human_test_folder_hint">Choose a local folder directly. On Android 11 and above, the app will first ask for file management access so it can avoid the system folder picker restrictions.</string>
<string name="digital_human_test_folder_summary">Found %1$d audio files. Results will be saved under %2$s/%3$s while keeping the original structure.</string>
<string name="digital_human_test_select_folder">Choose Folder</string>
<string name="digital_human_test_generate">Start Batch Export</string>
<string name="digital_human_test_running">Processing…</string>
<string name="digital_human_test_need_folder">Choose a folder first</string>
<string name="digital_human_test_all_outputs_exist">All target MP4 files already exist. There is nothing to process right now.</string>
<string name="digital_human_test_need_storage_access">Allow file management access first, then choose the input folder.</string>
<string name="digital_human_test_manage_storage_denied">File management access is still disabled, so the app cannot browse local folders yet.</string>
<string name="digital_human_test_legacy_storage_denied">Storage permission is required to browse local folders on this Android version.</string>
<string name="digital_human_test_input_transcript_title">Current recognized input</string>
<string name="digital_human_test_input_transcript_empty">No recognized transcript yet</string>
<string name="digital_human_test_progress_title">Progress</string>
<string name="digital_human_test_progress_summary">%1$d total, %2$d completed, %3$d skipped, %4$d failed.</string>
<string name="digital_human_test_progress_empty">After the batch starts, each audio job will appear here in order.</string>
<string name="digital_human_test_job_pending">Queued</string>
<string name="digital_human_test_job_decoding">Decoding audio</string>
<string name="digital_human_test_job_connecting">Connecting to Omni</string>
<string name="digital_human_test_job_uploading">Uploading audio</string>
<string name="digital_human_test_job_generating">Generating response</string>
<string name="digital_human_test_job_muxing">Packing MP4</string>
<string name="digital_human_test_job_copying">Saving result</string>
<string name="digital_human_test_job_retrying">Retrying (%1$d/%2$d)</string>
<string name="digital_human_test_job_done">Completed</string>
<string name="digital_human_test_job_failed">Failed</string>
<string name="digital_human_test_job_skipped">Skipped</string>
<string name="digital_human_test_job_output">Output: %1$s</string>
<string name="digital_human_test_no_audio_found">No supported audio files were found in the selected folder</string>
<string name="digital_human_test_no_audio_response">Omni did not return response audio this time</string>
<string name="digital_human_test_connection_lost">The test connection was interrupted</string>
<string name="folder_picker_title">Choose Folder</string>
<string name="folder_picker_subtitle">Browse the device storage directly, enter the target folder, and then use the current folder as the batch input root.</string>
<string name="folder_picker_select_current">Use Current Folder</string>
<string name="folder_picker_up">Up One Level</string>
<string name="folder_picker_list_title">Subfolders</string>
<string name="folder_picker_empty">There are no readable subfolders here. You can still use the current folder.</string>
<string name="main_download_tips">The GitHub url may fail to download, you can consider using a proxy or caching the file to your own storage service</string> <string name="main_download_tips">The GitHub url may fail to download, you can consider using a proxy or caching the file to your own storage service</string>
<string name="debug_message">Debug message</string> <string name="debug_message">Debug message</string>
</resources> </resources>