added saving recorded sound
This commit is contained in:
@@ -36,5 +36,7 @@ kotlin {
|
|||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation(project(":oral-trainer-sdk"))
|
implementation(project(":oral-trainer-sdk"))
|
||||||
|
implementation("androidx.core:core-ktx:1.13.1")
|
||||||
implementation("androidx.media3:media3-common:1.11.0")
|
implementation("androidx.media3:media3-common:1.11.0")
|
||||||
|
implementation("androidx.media3:media3-exoplayer:1.11.0")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,5 +18,15 @@
|
|||||||
<category android:name="android.intent.category.LAUNCHER" />
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
|
<provider
|
||||||
|
android:name="androidx.core.content.FileProvider"
|
||||||
|
android:authorities="${applicationId}.fileprovider"
|
||||||
|
android:exported="false"
|
||||||
|
android:grantUriPermissions="true">
|
||||||
|
<meta-data
|
||||||
|
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||||
|
android:resource="@xml/file_paths" />
|
||||||
|
</provider>
|
||||||
</application>
|
</application>
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|||||||
@@ -13,8 +13,15 @@ import android.content.res.Configuration
|
|||||||
import android.graphics.Color
|
import android.graphics.Color
|
||||||
import android.graphics.Typeface
|
import android.graphics.Typeface
|
||||||
import android.graphics.drawable.GradientDrawable
|
import android.graphics.drawable.GradientDrawable
|
||||||
|
import android.media.MediaCodec
|
||||||
|
import android.media.MediaFormat
|
||||||
|
import android.media.MediaExtractor
|
||||||
|
import android.media.MediaMuxer
|
||||||
import android.media.MediaRecorder
|
import android.media.MediaRecorder
|
||||||
|
import android.media.MediaMetadataRetriever
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.view.Gravity
|
import android.view.Gravity
|
||||||
@@ -50,7 +57,13 @@ import cn.learningpad.oraltrainer.sdk.TrainingMediaItem
|
|||||||
import cn.learningpad.oraltrainer.sdk.TrainingVideoSummary
|
import cn.learningpad.oraltrainer.sdk.TrainingVideoSummary
|
||||||
import cn.learningpad.oraltrainer.sdk.VideoCatalogCallback
|
import cn.learningpad.oraltrainer.sdk.VideoCatalogCallback
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
import java.nio.ByteBuffer
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
import androidx.core.content.FileProvider
|
||||||
|
import kotlin.math.max
|
||||||
|
import androidx.media3.common.MediaItem
|
||||||
|
import androidx.media3.common.Player
|
||||||
|
import androidx.media3.exoplayer.ExoPlayer
|
||||||
|
|
||||||
class MainActivity : Activity() {
|
class MainActivity : Activity() {
|
||||||
private lateinit var sdk: OralTrainerSdk
|
private lateinit var sdk: OralTrainerSdk
|
||||||
@@ -71,8 +84,18 @@ class MainActivity : Activity() {
|
|||||||
private lateinit var scoreSummaryText: TextView
|
private lateinit var scoreSummaryText: TextView
|
||||||
private lateinit var scoreDetailText: TextView
|
private lateinit var scoreDetailText: TextView
|
||||||
private lateinit var recordButton: Button
|
private lateinit var recordButton: Button
|
||||||
|
private lateinit var playDubButton: Button
|
||||||
|
private lateinit var mergeDubButton: Button
|
||||||
|
private lateinit var shareDubButton: Button
|
||||||
|
private var dubPlayer: ExoPlayer? = null
|
||||||
|
private var dubPlayerListener: Player.Listener? = null
|
||||||
|
private var dubPlaybackGeneration = 0
|
||||||
|
private val mainHandler = Handler(Looper.getMainLooper())
|
||||||
private var mediaRecorder: MediaRecorder? = null
|
private var mediaRecorder: MediaRecorder? = null
|
||||||
private var recordingFile: File? = null
|
private var recordingFile: File? = null
|
||||||
|
private val dubSegments = mutableListOf<File>()
|
||||||
|
private var mergedDubFile: File? = null
|
||||||
|
private var dubbingPlayback = false
|
||||||
|
|
||||||
private var activeItemId: String = ""
|
private var activeItemId: String = ""
|
||||||
private var currentSentenceCount = 0
|
private var currentSentenceCount = 0
|
||||||
@@ -85,7 +108,11 @@ class MainActivity : Activity() {
|
|||||||
private val screenActionReceiver = object : BroadcastReceiver() {
|
private val screenActionReceiver = object : BroadcastReceiver() {
|
||||||
override fun onReceive(context: Context?, intent: Intent?) {
|
override fun onReceive(context: Context?, intent: Intent?) {
|
||||||
if (intent?.action == Intent.ACTION_SCREEN_OFF) {
|
if (intent?.action == Intent.ACTION_SCREEN_OFF) {
|
||||||
controller.pause()
|
if (dubbingPlayback) {
|
||||||
|
stopDubPlayback("已停止配音播放")
|
||||||
|
} else {
|
||||||
|
controller.pause()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -153,7 +180,11 @@ class MainActivity : Activity() {
|
|||||||
|
|
||||||
override fun onPause() {
|
override fun onPause() {
|
||||||
super.onPause()
|
super.onPause()
|
||||||
controller.pause()
|
if (dubbingPlayback) {
|
||||||
|
stopDubPlayback("已停止配音播放")
|
||||||
|
} else {
|
||||||
|
controller.pause()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onResume() {
|
override fun onResume() {
|
||||||
@@ -227,7 +258,7 @@ class MainActivity : Activity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onDestroy() {
|
override fun onDestroy() {
|
||||||
discardRecording()
|
releaseDubbingResources()
|
||||||
controller.release()
|
controller.release()
|
||||||
super.onDestroy()
|
super.onDestroy()
|
||||||
}
|
}
|
||||||
@@ -301,9 +332,6 @@ class MainActivity : Activity() {
|
|||||||
if (activeModule == module) {
|
if (activeModule == module) {
|
||||||
return@setOnClickListener
|
return@setOnClickListener
|
||||||
}
|
}
|
||||||
if (activeModule == Module.TEST) {
|
|
||||||
discardRecording()
|
|
||||||
}
|
|
||||||
activeModule = module
|
activeModule = module
|
||||||
controller.setContinuousPlayback(
|
controller.setContinuousPlayback(
|
||||||
if (activeModule == Module.TRAIN) continuousPlaybackEnabled else true
|
if (activeModule == Module.TRAIN) continuousPlaybackEnabled else true
|
||||||
@@ -697,6 +725,23 @@ class MainActivity : Activity() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val dubButton: (String, () -> Unit) -> Button = { label, action ->
|
||||||
|
Button(this).apply {
|
||||||
|
text = label
|
||||||
|
isAllCaps = false
|
||||||
|
setTextColor(Color.WHITE)
|
||||||
|
textSize = 13f
|
||||||
|
typeface = Typeface.DEFAULT_BOLD
|
||||||
|
minWidth = 0
|
||||||
|
minHeight = 0
|
||||||
|
minimumWidth = 0
|
||||||
|
minimumHeight = 0
|
||||||
|
background = rounded(COLOR_BUTTON, 8f)
|
||||||
|
layoutParams = LinearLayout.LayoutParams(0, 40.dp, 1f).withMargins(0, 8.dp, 8.dp, 0)
|
||||||
|
setOnClickListener { action() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
recordButton = Button(this).apply {
|
recordButton = Button(this).apply {
|
||||||
text = "开始录音"
|
text = "开始录音"
|
||||||
isAllCaps = false
|
isAllCaps = false
|
||||||
@@ -711,6 +756,7 @@ class MainActivity : Activity() {
|
|||||||
layoutParams = LinearLayout.LayoutParams(
|
layoutParams = LinearLayout.LayoutParams(
|
||||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||||
44.dp,
|
44.dp,
|
||||||
|
1f,
|
||||||
)
|
)
|
||||||
setOnClickListener {
|
setOnClickListener {
|
||||||
if (mediaRecorder == null) {
|
if (mediaRecorder == null) {
|
||||||
@@ -721,6 +767,13 @@ class MainActivity : Activity() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
playDubButton = dubButton("播放配音", {}).apply { isEnabled = false }
|
||||||
|
mergeDubButton = dubButton("生成配音", {}).apply { isEnabled = false }
|
||||||
|
shareDubButton = dubButton("分享配音", {}).apply { isEnabled = false }
|
||||||
|
playDubButton.setOnClickListener { toggleDubPlayback() }
|
||||||
|
mergeDubButton.setOnClickListener { mergeDubSegments() }
|
||||||
|
shareDubButton.setOnClickListener { shareMergedDubbing() }
|
||||||
|
|
||||||
val controlRow = LinearLayout(this).apply {
|
val controlRow = LinearLayout(this).apply {
|
||||||
orientation = LinearLayout.HORIZONTAL
|
orientation = LinearLayout.HORIZONTAL
|
||||||
addView(sentenceButton("上一句") { controller.seekToPreviousSentence() })
|
addView(sentenceButton("上一句") { controller.seekToPreviousSentence() })
|
||||||
@@ -748,6 +801,12 @@ class MainActivity : Activity() {
|
|||||||
addView(testStatusText)
|
addView(testStatusText)
|
||||||
addView(controlRow)
|
addView(controlRow)
|
||||||
addView(recordButton)
|
addView(recordButton)
|
||||||
|
addView(LinearLayout(this@MainActivity).apply {
|
||||||
|
orientation = LinearLayout.HORIZONTAL
|
||||||
|
addView(playDubButton.apply { layoutParams = (layoutParams as LinearLayout.LayoutParams).withMargins(0, 8.dp, 8.dp, 0) })
|
||||||
|
addView(mergeDubButton.apply { layoutParams = (layoutParams as LinearLayout.LayoutParams).withMargins(0, 8.dp, 8.dp, 0) })
|
||||||
|
addView(shareDubButton.apply { layoutParams = (layoutParams as LinearLayout.LayoutParams).withMargins(0, 8.dp, 0, 0) })
|
||||||
|
})
|
||||||
addView(scoreSummaryText)
|
addView(scoreSummaryText)
|
||||||
addView(scoreDetailText)
|
addView(scoreDetailText)
|
||||||
}
|
}
|
||||||
@@ -758,13 +817,15 @@ class MainActivity : Activity() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
recordButton.text = if (mediaRecorder == null) "开始录音" else "停止并评测"
|
recordButton.text = if (mediaRecorder == null) "开始录音" else "停止并评测"
|
||||||
|
val segmentLabel = if (dubSegments.isEmpty()) "" else " · 已保存 ${dubSegments.size} 段"
|
||||||
val item = controller.currentTrainingItem()
|
val item = controller.currentTrainingItem()
|
||||||
val sentence = controller.currentSentence()
|
val sentence = controller.currentSentence()
|
||||||
testStatusText.text = if (item == null || sentence == null) {
|
testStatusText.text = if (item == null || sentence == null) {
|
||||||
"请先在训练模块选择一个云端课程"
|
"请先在训练模块选择课程$segmentLabel"
|
||||||
} else {
|
} else {
|
||||||
"评测对象:第 ${sentence.index + 1} 句(共 ${item.sentences.size} 句)"
|
"评测对象:第 ${sentence.index + 1} 句(共 ${item.sentences.size} 句)$segmentLabel"
|
||||||
}
|
}
|
||||||
|
refreshDubButtons()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun startRecording() {
|
private fun startRecording() {
|
||||||
@@ -794,8 +855,8 @@ class MainActivity : Activity() {
|
|||||||
recorder.start()
|
recorder.start()
|
||||||
mediaRecorder = recorder
|
mediaRecorder = recorder
|
||||||
recordingFile = file
|
recordingFile = file
|
||||||
recordButton.text = "停止并评测"
|
recordButton.text = "停止并保存"
|
||||||
testStatusText.text = "正在录音…读完当前句子后点击“停止并评测”"
|
testStatusText.text = "正在录音…读完当前句子后点击“停止并保存”"
|
||||||
} catch (error: Throwable) {
|
} catch (error: Throwable) {
|
||||||
testStatusText.text = "录音启动失败:${error.message.orEmpty()}"
|
testStatusText.text = "录音启动失败:${error.message.orEmpty()}"
|
||||||
runCatching { recorder.release() }
|
runCatching { recorder.release() }
|
||||||
@@ -806,7 +867,7 @@ class MainActivity : Activity() {
|
|||||||
val recorder = mediaRecorder ?: return
|
val recorder = mediaRecorder ?: return
|
||||||
val file = recordingFile ?: return
|
val file = recordingFile ?: return
|
||||||
mediaRecorder = null
|
mediaRecorder = null
|
||||||
recordButton.text = "开始录音"
|
recordButton.text = "重新录音"
|
||||||
try {
|
try {
|
||||||
recorder.stop()
|
recorder.stop()
|
||||||
} catch (error: Throwable) {
|
} catch (error: Throwable) {
|
||||||
@@ -817,19 +878,187 @@ class MainActivity : Activity() {
|
|||||||
} finally {
|
} finally {
|
||||||
runCatching { recorder.release() }
|
runCatching { recorder.release() }
|
||||||
}
|
}
|
||||||
|
dubSegments.add(file)
|
||||||
|
mergedDubFile = null
|
||||||
recordingFile = null
|
recordingFile = null
|
||||||
submitAssessment(Uri.fromFile(file))
|
submitAssessment(Uri.fromFile(file))
|
||||||
|
refreshTestUi()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun discardRecording() {
|
private fun discardRecording() {
|
||||||
val recorder = mediaRecorder ?: return
|
mediaRecorder?.let { recorder ->
|
||||||
|
runCatching { recorder.stop() }
|
||||||
|
runCatching { recorder.release() }
|
||||||
|
}
|
||||||
mediaRecorder = null
|
mediaRecorder = null
|
||||||
runCatching { recorder.stop() }
|
|
||||||
runCatching { recorder.release() }
|
|
||||||
recordingFile?.delete()
|
recordingFile?.delete()
|
||||||
recordingFile = null
|
recordingFile = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun releaseDubbingResources() {
|
||||||
|
discardRecording()
|
||||||
|
dubPlayerListener?.let { listener -> dubPlayer?.removeListener(listener) }
|
||||||
|
dubPlayer?.release()
|
||||||
|
dubPlayerListener = null
|
||||||
|
dubPlayer = null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun refreshDubButtons() {
|
||||||
|
playDubButton.isEnabled = dubSegments.isNotEmpty() && !dubbingPlayback
|
||||||
|
mergeDubButton.isEnabled = dubSegments.size >= 2 && !dubbingPlayback
|
||||||
|
shareDubButton.isEnabled = mergedDubFile?.exists() == true && !dubbingPlayback
|
||||||
|
playDubButton.text = when {
|
||||||
|
dubbingPlayback -> "停止配音"
|
||||||
|
mergedDubFile?.exists() == true -> "播放配音"
|
||||||
|
else -> "播放录音(${dubSegments.size})"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun toggleDubPlayback() {
|
||||||
|
if (dubbingPlayback) {
|
||||||
|
stopDubPlayback("已停止配音播放")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
startDubPlayback(mergedDubFile ?: dubSegments.lastOrNull() ?: return)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startDubPlayback(audioFile: File) {
|
||||||
|
val sentence = controller.currentSentence()
|
||||||
|
val player = dubPlayer ?: ExoPlayer.Builder(this).build().also { dubPlayer = it }
|
||||||
|
dubPlayerListener?.let(player::removeListener)
|
||||||
|
val playbackGeneration = ++dubPlaybackGeneration
|
||||||
|
player.setMediaItem(MediaItem.fromUri(Uri.fromFile(audioFile)))
|
||||||
|
player.prepare()
|
||||||
|
player.volume = 1f
|
||||||
|
if (mergedDubFile == null && sentence != null) {
|
||||||
|
controller.seekTo(sentence.startMs)
|
||||||
|
}
|
||||||
|
controller.setVolume(0f)
|
||||||
|
controller.play()
|
||||||
|
player.playWhenReady = true
|
||||||
|
dubbingPlayback = true
|
||||||
|
dubPlayerListener = object : Player.Listener {
|
||||||
|
override fun onPlaybackStateChanged(playbackState: Int) {
|
||||||
|
if (playbackState == Player.STATE_ENDED && playbackGeneration == dubPlaybackGeneration) {
|
||||||
|
mainHandler.postDelayed({
|
||||||
|
if (dubbingPlayback && playbackGeneration == dubPlaybackGeneration) {
|
||||||
|
stopDubPlayback("配音播放完成")
|
||||||
|
}
|
||||||
|
}, 250L)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
player.addListener(dubPlayerListener!!)
|
||||||
|
refreshTestUi()
|
||||||
|
statusText.text = if (mergedDubFile == null) {
|
||||||
|
"正在播放当前句配音,原视频画面同步但原声静音"
|
||||||
|
} else {
|
||||||
|
"正在播放整段配音,原视频画面同步但原声静音"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun stopDubPlayback(message: String) {
|
||||||
|
dubPlayer?.pause()
|
||||||
|
dubPlayer?.seekTo(0L)
|
||||||
|
controller.pause()
|
||||||
|
controller.setVolume(1f)
|
||||||
|
dubbingPlayback = false
|
||||||
|
dubPlaybackGeneration++
|
||||||
|
refreshTestUi()
|
||||||
|
statusText.text = message
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun mergeDubSegments() {
|
||||||
|
if (dubSegments.size < 2) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val output = File(cacheDir, "dubbing-${System.currentTimeMillis()}.m4a")
|
||||||
|
try {
|
||||||
|
val muxer = MediaMuxer(output.absolutePath, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4)
|
||||||
|
var muxerAudioTrack = -1
|
||||||
|
var presentationTimeUs = 0L
|
||||||
|
|
||||||
|
dubSegments.forEach { segment ->
|
||||||
|
val extractor = MediaExtractor().apply { setDataSource(segment.absolutePath) }
|
||||||
|
try {
|
||||||
|
val trackIndex = (0 until extractor.trackCount).firstOrNull { index ->
|
||||||
|
extractor.getTrackFormat(index).getString(MediaFormat.KEY_MIME)
|
||||||
|
?.startsWith("audio/") == true
|
||||||
|
} ?: throw IllegalStateException("录音没有音频轨道")
|
||||||
|
extractor.selectTrack(trackIndex)
|
||||||
|
val format = extractor.getTrackFormat(trackIndex)
|
||||||
|
if (muxerAudioTrack == -1) {
|
||||||
|
muxerAudioTrack = muxer.addTrack(format)
|
||||||
|
}
|
||||||
|
val buffer = ByteBuffer.allocateDirect(
|
||||||
|
if (format.containsKey(MediaFormat.KEY_MAX_INPUT_SIZE)) {
|
||||||
|
format.getInteger(MediaFormat.KEY_MAX_INPUT_SIZE)
|
||||||
|
} else {
|
||||||
|
64 * 1024
|
||||||
|
}
|
||||||
|
)
|
||||||
|
val info = MediaCodec.BufferInfo()
|
||||||
|
while (true) {
|
||||||
|
val sampleSize = extractor.readSampleData(buffer, 0)
|
||||||
|
if (sampleSize < 0) break
|
||||||
|
info.set(0, sampleSize, presentationTimeUs, extractor.sampleFlags)
|
||||||
|
muxer.writeSampleData(muxerAudioTrack, buffer, info)
|
||||||
|
presentationTimeUs += 23_000L
|
||||||
|
extractor.advance()
|
||||||
|
}
|
||||||
|
val durationUs = segmentDurationUs(segment)
|
||||||
|
require(durationUs > 0L) { "无法读取录音时长" }
|
||||||
|
presentationTimeUs = max(presentationTimeUs, durationUs)
|
||||||
|
} finally {
|
||||||
|
extractor.release()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
muxer.stop()
|
||||||
|
muxer.release()
|
||||||
|
mergedDubFile = output
|
||||||
|
refreshTestUi()
|
||||||
|
statusText.text = "已合成 ${dubSegments.size} 段配音:${output.name}"
|
||||||
|
} catch (error: Throwable) {
|
||||||
|
output.delete()
|
||||||
|
statusText.text = "合成失败:${error.message.orEmpty()}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun segmentDurationUs(segment: File): Long {
|
||||||
|
val retriever = MediaMetadataRetriever()
|
||||||
|
return try {
|
||||||
|
retriever.setDataSource(segment.absolutePath)
|
||||||
|
val durationMs = retriever.extractMetadata(
|
||||||
|
MediaMetadataRetriever.METADATA_KEY_DURATION
|
||||||
|
)?.toLongOrNull() ?: return 0L
|
||||||
|
durationMs * 1000L
|
||||||
|
} catch (error: Throwable) {
|
||||||
|
0L
|
||||||
|
} finally {
|
||||||
|
runCatching { retriever.release() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun shareMergedDubbing() {
|
||||||
|
val file = mergedDubFile?.takeIf { it.exists() } ?: return
|
||||||
|
try {
|
||||||
|
val uri = FileProvider.getUriForFile(
|
||||||
|
this,
|
||||||
|
"${packageName}.fileprovider",
|
||||||
|
file,
|
||||||
|
)
|
||||||
|
val intent = Intent(Intent.ACTION_SEND).apply {
|
||||||
|
type = "audio/mp4"
|
||||||
|
putExtra(Intent.EXTRA_STREAM, uri)
|
||||||
|
putExtra(Intent.EXTRA_TITLE, "我的口语配音")
|
||||||
|
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
|
}
|
||||||
|
startActivity(Intent.createChooser(intent, "分享配音"))
|
||||||
|
} catch (error: Throwable) {
|
||||||
|
statusText.text = "分享失败:${error.message.orEmpty()}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun submitAssessment(recordingUri: Uri) {
|
private fun submitAssessment(recordingUri: Uri) {
|
||||||
val item = controller.currentTrainingItem()
|
val item = controller.currentTrainingItem()
|
||||||
val sentence = controller.currentSentence()
|
val sentence = controller.currentSentence()
|
||||||
|
|||||||
6
android/sample-app/src/main/res/xml/file_paths.xml
Normal file
6
android/sample-app/src/main/res/xml/file_paths.xml
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<paths>
|
||||||
|
<cache-path
|
||||||
|
name="dubbing"
|
||||||
|
path="." />
|
||||||
|
</paths>
|
||||||
Reference in New Issue
Block a user