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

@@ -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")
} }

View File

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

View File

@@ -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,10 +108,14 @@ 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) {
if (dubbingPlayback) {
stopDubPlayback("已停止配音播放")
} else {
controller.pause() controller.pause()
} }
} }
} }
}
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@@ -153,8 +180,12 @@ class MainActivity : Activity() {
override fun onPause() { override fun onPause() {
super.onPause() super.onPause()
if (dubbingPlayback) {
stopDubPlayback("已停止配音播放")
} else {
controller.pause() controller.pause()
} }
}
override fun onResume() { override fun onResume() {
super.onResume() super.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 ->
mediaRecorder = null
runCatching { recorder.stop() } runCatching { recorder.stop() }
runCatching { recorder.release() } runCatching { recorder.release() }
}
mediaRecorder = null
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()

View File

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