added saving recorded sound
This commit is contained in:
@@ -18,5 +18,15 @@
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</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>
|
||||
</manifest>
|
||||
|
||||
@@ -13,8 +13,15 @@ import android.content.res.Configuration
|
||||
import android.graphics.Color
|
||||
import android.graphics.Typeface
|
||||
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.MediaMetadataRetriever
|
||||
import android.net.Uri
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
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.VideoCatalogCallback
|
||||
import java.io.File
|
||||
import java.nio.ByteBuffer
|
||||
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() {
|
||||
private lateinit var sdk: OralTrainerSdk
|
||||
@@ -71,8 +84,18 @@ class MainActivity : Activity() {
|
||||
private lateinit var scoreSummaryText: TextView
|
||||
private lateinit var scoreDetailText: TextView
|
||||
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 recordingFile: File? = null
|
||||
private val dubSegments = mutableListOf<File>()
|
||||
private var mergedDubFile: File? = null
|
||||
private var dubbingPlayback = false
|
||||
|
||||
private var activeItemId: String = ""
|
||||
private var currentSentenceCount = 0
|
||||
@@ -85,7 +108,11 @@ class MainActivity : Activity() {
|
||||
private val screenActionReceiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context?, intent: Intent?) {
|
||||
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() {
|
||||
super.onPause()
|
||||
controller.pause()
|
||||
if (dubbingPlayback) {
|
||||
stopDubPlayback("已停止配音播放")
|
||||
} else {
|
||||
controller.pause()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
@@ -227,7 +258,7 @@ class MainActivity : Activity() {
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
discardRecording()
|
||||
releaseDubbingResources()
|
||||
controller.release()
|
||||
super.onDestroy()
|
||||
}
|
||||
@@ -301,9 +332,6 @@ class MainActivity : Activity() {
|
||||
if (activeModule == module) {
|
||||
return@setOnClickListener
|
||||
}
|
||||
if (activeModule == Module.TEST) {
|
||||
discardRecording()
|
||||
}
|
||||
activeModule = module
|
||||
controller.setContinuousPlayback(
|
||||
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 {
|
||||
text = "开始录音"
|
||||
isAllCaps = false
|
||||
@@ -711,6 +756,7 @@ class MainActivity : Activity() {
|
||||
layoutParams = LinearLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
44.dp,
|
||||
1f,
|
||||
)
|
||||
setOnClickListener {
|
||||
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 {
|
||||
orientation = LinearLayout.HORIZONTAL
|
||||
addView(sentenceButton("上一句") { controller.seekToPreviousSentence() })
|
||||
@@ -748,6 +801,12 @@ class MainActivity : Activity() {
|
||||
addView(testStatusText)
|
||||
addView(controlRow)
|
||||
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(scoreDetailText)
|
||||
}
|
||||
@@ -758,13 +817,15 @@ class MainActivity : Activity() {
|
||||
return
|
||||
}
|
||||
recordButton.text = if (mediaRecorder == null) "开始录音" else "停止并评测"
|
||||
val segmentLabel = if (dubSegments.isEmpty()) "" else " · 已保存 ${dubSegments.size} 段"
|
||||
val item = controller.currentTrainingItem()
|
||||
val sentence = controller.currentSentence()
|
||||
testStatusText.text = if (item == null || sentence == null) {
|
||||
"请先在训练模块选择一个云端课程"
|
||||
"请先在训练模块选择课程$segmentLabel"
|
||||
} else {
|
||||
"评测对象:第 ${sentence.index + 1} 句(共 ${item.sentences.size} 句)"
|
||||
"评测对象:第 ${sentence.index + 1} 句(共 ${item.sentences.size} 句)$segmentLabel"
|
||||
}
|
||||
refreshDubButtons()
|
||||
}
|
||||
|
||||
private fun startRecording() {
|
||||
@@ -794,8 +855,8 @@ class MainActivity : Activity() {
|
||||
recorder.start()
|
||||
mediaRecorder = recorder
|
||||
recordingFile = file
|
||||
recordButton.text = "停止并评测"
|
||||
testStatusText.text = "正在录音…读完当前句子后点击“停止并评测”"
|
||||
recordButton.text = "停止并保存"
|
||||
testStatusText.text = "正在录音…读完当前句子后点击“停止并保存”"
|
||||
} catch (error: Throwable) {
|
||||
testStatusText.text = "录音启动失败:${error.message.orEmpty()}"
|
||||
runCatching { recorder.release() }
|
||||
@@ -806,7 +867,7 @@ class MainActivity : Activity() {
|
||||
val recorder = mediaRecorder ?: return
|
||||
val file = recordingFile ?: return
|
||||
mediaRecorder = null
|
||||
recordButton.text = "开始录音"
|
||||
recordButton.text = "重新录音"
|
||||
try {
|
||||
recorder.stop()
|
||||
} catch (error: Throwable) {
|
||||
@@ -817,19 +878,187 @@ class MainActivity : Activity() {
|
||||
} finally {
|
||||
runCatching { recorder.release() }
|
||||
}
|
||||
dubSegments.add(file)
|
||||
mergedDubFile = null
|
||||
recordingFile = null
|
||||
submitAssessment(Uri.fromFile(file))
|
||||
refreshTestUi()
|
||||
}
|
||||
|
||||
private fun discardRecording() {
|
||||
val recorder = mediaRecorder ?: return
|
||||
mediaRecorder?.let { recorder ->
|
||||
runCatching { recorder.stop() }
|
||||
runCatching { recorder.release() }
|
||||
}
|
||||
mediaRecorder = null
|
||||
runCatching { recorder.stop() }
|
||||
runCatching { recorder.release() }
|
||||
recordingFile?.delete()
|
||||
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) {
|
||||
val item = controller.currentTrainingItem()
|
||||
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