added saving recorded sound

This commit is contained in:
2026-08-26 08:33:43 +08:00
parent 28674def97
commit 9feb876b7a
4 changed files with 261 additions and 14 deletions

View File

@@ -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>

View File

@@ -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()

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<paths>
<cache-path
name="dubbing"
path="." />
</paths>