@@ -1,12 +1,18 @@
package cn.learningpad.oraltrainer.sample
import android.Manifest
import android.app.Activity
import android.content.Intent
import android.content.pm.ActivityInfo
import android.content.pm.PackageManager
import android.content.res.ColorStateList
import android.content.res.Configuration
import android.graphics.Color
import android.graphics.Typeface
import android.graphics.drawable.GradientDrawable
import android.media.MediaRecorder
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.view.Gravity
import android.view.View
@@ -14,17 +20,22 @@ import android.view.ViewGroup
import android.widget.Button
import android.widget.FrameLayout
import android.widget.HorizontalScrollView
import android.widget.ImageButton
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.Progress Bar
import android.widget.Seek Bar
import android.widget.Switch
import android.widget.TextView
import androidx.media3.common.MimeTypes
import cn.learningpad.oraltrainer.sdk.GestureEvent
import cn.learningpad.oraltrainer.sdk.GestureKind
import cn.learningpad.oraltrainer.sdk.ImitationAssessmentCallback
import cn.learningpad.oraltrainer.sdk.ImitationAssessmentResult
import cn.learningpad.oraltrainer.sdk.LoopMode
import cn.learningpad.oraltrainer.sdk.OralTrainerController
import cn.learningpad.oraltrainer.sdk.OralTrainerListener
import cn.learningpad.oraltrainer.sdk.OralTrainerPlayerView
import cn.learningpad.oraltrainer.sdk.OralTrainerSdk
import cn.learningpad.oraltrainer.sdk.OralTrainerSdkConfig
import cn.learningpad.oraltrainer.sdk.PlaybackSnapshot
import cn.learningpad.oraltrainer.sdk.PlayerConfig
import cn.learningpad.oraltrainer.sdk.SentenceBoundary
@@ -33,11 +44,13 @@ import cn.learningpad.oraltrainer.sdk.SentenceBoundaryApiResult
import cn.learningpad.oraltrainer.sdk.TrainingMediaItem
import cn.learningpad.oraltrainer.sdk.TrainingVideoSummary
import cn.learningpad.oraltrainer.sdk.VideoCatalogCallback
import java.io.File
import java.util.Locale
class MainActivity : Activity ( ) {
private lateinit var sdk : OralTrainerSdk
private lateinit var controller : OralTrainerController
private lateinit var controllerListener : OralTrainerListener
private lateinit var playerView : OralTrainerPlayerView
private lateinit var lessonTitleText : TextView
private lateinit var statusText : TextView
@@ -45,32 +58,78 @@ class MainActivity : Activity() {
private lateinit var sentenceMetaText : TextView
private lateinit var timeText : TextView
private lateinit var speedText : TextView
private lateinit var progressBar : Progress Bar
private lateinit var progressBar : Seek Bar
private var progressBarDragging = false
private lateinit var catalogList : LinearLayout
private lateinit var catalogStatusText : TextView
private lateinit var testStatusText : TextView
private lateinit var scoreSummaryText : TextView
private lateinit var scoreDetailText : TextView
private lateinit var recordButton : Button
private var mediaRecorder : MediaRecorder ? = null
private var recordingFile : File ? = null
private var activeItemId : String = SAMPLE _ID
private var activeItemId : String = " "
private var currentSentenceCount = 0
private var catalogVideos : List < TrainingVideoSummary > = emptyList ( )
private var catalogStatusTextValue = " 正在同步 "
private var activeModule = Module . TRAIN
private var continuousPlaybackEnabled = false
override fun onCreate ( savedInstanceState : Bundle ? ) {
super . onCreate ( savedInstanceState )
configureWindow ( )
sdk = OralTrainerSdk . init ( this )
sdk = OralTrainerSdk . init (
this ,
OralTrainerSdkConfig (
sentenceBoundaryApiBaseUrl = SERVER _BASE _URL ,
assessmentApiKey = ASSESSMENT _API _KEY ,
allowHttpFallback = true ,
)
)
controller = sdk . createController (
playerConfig = PlayerConfig (
sentenceMode = true ,
defaultSeekStepMs = 10 _000L ,
autoPlay = false ,
)
continuousPlayback = false ,
) ,
imitationAssessor = sdk . createRemoteImitationQualityAssessor ( ) ,
)
controller . setLoopMode ( LoopMode . ALL )
controllerListener = createControllerListener ( )
controller . addListener ( controllerListener )
setContentView ( createContentView ( ) )
bindPlayerEvents ( )
loadSampleLesson ( )
loadCatalog ( )
refreshCurrentUi ( )
}
override fun onConfigurationChanged ( newConfig : Configuration ) {
super . onConfigurationChanged ( newConfig )
setContentView ( createContentView ( ) )
renderCatalog ( )
refreshCurrentUi ( )
if ( activeModule == Module . TEST ) {
refreshTestUi ( )
}
}
override fun onRequestPermissionsResult (
requestCode : Int ,
permissions : Array < out String > ,
grantResults : IntArray ,
) {
super . onRequestPermissionsResult ( requestCode , permissions , grantResults )
if ( requestCode != RECORD _AUDIO _REQUEST ) {
return
}
if ( grantResults . firstOrNull ( ) == PackageManager . PERMISSION _GRANTED ) {
startRecording ( )
} else if ( :: testStatusText . isInitialized ) {
testStatusText . text = " 需要麦克风权限才能进行录音评测 "
}
}
override fun onActivityResult ( requestCode : Int , resultCode : Int , data : Intent ? ) {
@@ -122,6 +181,7 @@ class MainActivity : Activity() {
}
override fun onDestroy ( ) {
discardRecording ( )
controller . release ( )
super . onDestroy ( )
}
@@ -133,13 +193,117 @@ class MainActivity : Activity() {
}
private fun createContentView ( ) : View {
val isLandscape = resources . configuration . orientation == Configuration . ORIENTATION _LANDSCAPE
return if ( isLandscape ) {
createLandscapeContentView ( )
} else {
createPortraitContentView ( )
}
}
private fun createPortraitContentView ( ) : View {
return LinearLayout ( this ) . apply {
orientation = LinearLayout . VERTICAL
setBackgroundColor ( COLOR _BACKGROUND )
addView ( createModuleTabBar ( ) )
if ( activeModule == Module . TEST ) {
addView ( createTestContentView ( ) )
} else {
addView ( createHeader ( ) )
addView ( createPlayerSection ( ) )
addView ( createSentenceSection ( ) )
addView ( createCatalogSection ( ) )
}
}
}
private fun createTestContentView ( ) : View {
return LinearLayout ( this ) . apply {
orientation = LinearLayout . VERTICAL
setBackgroundColor ( COLOR _BACKGROUND )
addView ( createHeader ( ) )
addView ( createPlayerSection ( ) )
addView ( createSentenceSection ( ) )
addView ( createCatalog Section ( ) )
addView ( createTest Section ( ) )
}
}
private fun createModuleTabBar ( ) : View {
return LinearLayout ( this ) . apply {
orientation = LinearLayout . HORIZONTAL
setPadding ( 16. dp , 40. dp , 16. dp , 0 )
addView ( moduleTab ( " 训练 " , Module . TRAIN ) )
addView ( moduleTab ( " 测试 " , Module . TEST ) )
}
}
private fun moduleTab ( label : String , module : Module ) : TextView {
val selected = activeModule == module
return TextView ( this ) . apply {
text = label
textSize = 15f
typeface = Typeface . DEFAULT _BOLD
gravity = Gravity . CENTER
isClickable = true
setTextColor ( if ( selected ) Color . WHITE else COLOR _TEXT _MUTED )
background = if ( selected ) {
rounded ( COLOR _BUTTON , 8f )
} else {
rounded ( COLOR _SURFACE , 8f , COLOR _BORDER )
}
layoutParams = LinearLayout . LayoutParams ( 0 , 44. dp , 1f ) . withMargins ( 0 , 0 , 8. dp , 0 )
setOnClickListener {
if ( activeModule == module ) {
return @setOnClickListener
}
if ( activeModule == Module . TEST ) {
discardRecording ( )
}
activeModule = module
controller . setContinuousPlayback (
if ( activeModule == Module . TRAIN ) continuousPlaybackEnabled else true
)
setContentView ( createContentView ( ) )
renderCatalog ( )
refreshCurrentUi ( )
if ( activeModule == Module . TEST ) {
refreshTestUi ( )
}
}
}
}
private fun createLandscapeContentView ( ) : View {
return FrameLayout ( this ) . apply {
setBackgroundColor ( Color . BLACK )
addView ( createPlayerView ( ) )
addView ( createPlayerOverlay ( ) )
addView (
LinearLayout ( this @MainActivity ) . apply {
orientation = LinearLayout . VERTICAL
layoutParams = FrameLayout . LayoutParams (
ViewGroup . LayoutParams . MATCH _PARENT ,
ViewGroup . LayoutParams . WRAP _CONTENT ,
Gravity . BOTTOM ,
)
addView ( createLandscapeSentencePanel ( ) . apply {
layoutParams = LinearLayout . LayoutParams (
ViewGroup . LayoutParams . MATCH _PARENT ,
ViewGroup . LayoutParams . WRAP _CONTENT ,
)
} )
addView ( createRotationButtonRow ( ) )
}
)
}
}
private fun createLandscapeSentencePanel ( ) : View {
return createSentenceContent ( translucent = true ) . apply {
layoutParams = FrameLayout . LayoutParams (
ViewGroup . LayoutParams . MATCH _PARENT ,
ViewGroup . LayoutParams . WRAP _CONTENT ,
Gravity . BOTTOM ,
)
}
}
@@ -149,16 +313,16 @@ class MainActivity : Activity() {
gravity = Gravity . CENTER _VERTICAL
layoutParams = LinearLayout . LayoutParams ( 0 , ViewGroup . LayoutParams . WRAP _CONTENT , 1f )
addView ( TextView ( this @MainActivity ) . apply {
text = " 口语宝 "
text = " 跟读虫 "
setTextColor ( Color . WHITE )
textSize = 26 f
textSize = 3 2f
typeface = Typeface . DEFAULT _BOLD
includeFontPadding = false
} )
addView ( TextView ( this @MainActivity ) . apply {
text = " 中英 跟读训练"
text = " 外语 跟读训练神器 "
setTextColor ( COLOR _TEXT _MUTED )
textSize = 13 f
textSize = 20 f
setPadding ( 1. dp , 4. dp , 0 , 0 )
} )
}
@@ -174,14 +338,40 @@ class MainActivity : Activity() {
}
private fun createPlayerSection ( ) : View {
playerView = OralTrainerPlayerView ( this ) . apply {
return FrameLayout ( this ) . apply {
background = rounded ( COLOR _SURFACE , 8f , COLOR _BORDER )
clipToOutline = true
layoutParams = LinearLayout . LayoutParams (
ViewGroup . LayoutParams . MATCH _PARENT ,
0 ,
1f ,
) . withMargins ( 16. dp , 0 , 16. dp , 10. dp )
addView ( createPlayerView ( ) )
addView ( createPlayerOverlay ( ) )
addView ( createRotationButton ( ) . apply {
layoutParams = FrameLayout . LayoutParams (
44. dp ,
44. dp ,
Gravity . BOTTOM or Gravity . END ,
) . apply {
marginEnd = 12. dp
bottomMargin = 12. dp
}
} )
}
}
private fun createPlayerView ( ) : OralTrainerPlayerView {
return OralTrainerPlayerView ( this ) . apply {
bind ( controller )
layoutParams = FrameLayout . LayoutParams (
ViewGroup . LayoutParams . MATCH _PARENT ,
ViewGroup . LayoutParams . MATCH _PARENT ,
)
}
} . also { playerView = it }
}
private fun createPlayerOverlay ( ) : View {
val overlay = LinearLayout ( this ) . apply {
orientation = LinearLayout . HORIZONTAL
gravity = Gravity . CENTER _VERTICAL
@@ -192,71 +382,123 @@ class MainActivity : Activity() {
Gravity . TOP ,
)
}
timeText = overlayPill ( " --:-- / --:-- " ) . apply {
layoutParams = LinearLayout . LayoutParams ( 0 , ViewGroup . LayoutParams . WRAP _CONTENT , 1f )
}
speedText = overlayPill ( " 1x " )
overlay . addView ( timeText )
overlay . addView ( speedText )
return overlay
}
return FrameLayout ( this ) . apply {
background = rounded ( COLOR _SURFACE , 8f , COLOR _BORDER )
clipToOutline = true
layoutParams = LinearLayout . LayoutParams (
ViewGroup . LayoutParams . MATCH _PARENT ,
0 ,
1f ,
) . withMargins ( 16. dp , 0 , 16. dp , 10. dp )
addView ( playerView )
addView ( overlay )
private fun createRotationButtonRow ( ) : View {
return LinearLayout ( this ) . apply {
orientation = LinearLayout . HORIZONTAL
gravity = Gravity . END
setPadding ( 0 , 0 , 12. dp , 12. dp )
addView ( createRotationButton ( ) )
}
}
private fun createRotationButton ( ) : ImageButton {
val isLandscape = resources . configuration . orientation == Configuration . ORIENTATION _LANDSCAPE
return ImageButton ( this ) . apply {
setImageResource ( R . drawable . ic _screen _rotation )
imageTintList = ColorStateList . valueOf ( Color . WHITE )
background = rounded ( Color . argb ( 178 , 9 , 12 , 16 ) , 22. dp . toFloat ( ) )
scaleType = ImageView . ScaleType . CENTER _INSIDE
setPadding ( 10. dp , 10. dp , 10. dp , 10. dp )
contentDescription = if ( isLandscape ) " 竖屏 " else " 横屏 "
layoutParams = LinearLayout . LayoutParams ( 44. dp , 44. dp )
setOnClickListener { toggleOrientation ( ) }
}
}
private fun toggleOrientation ( ) {
val isLandscape = resources . configuration . orientation == Configuration . ORIENTATION _LANDSCAPE
requestedOrientation = if ( isLandscape ) {
ActivityInfo . SCREEN _ORIENTATION _PORTRAIT
} else {
ActivityInfo . SCREEN _ORIENTATION _SENSOR _LANDSCAPE
}
}
private fun createSentenceSection ( ) : View {
lessonTitleText = TextView ( this ) . apply {
setTextColor ( COLOR _TEXT _DARK )
textSize = 17f
typeface = Typeface . DEFAULT _BOLD
maxLines = 1
}
sentenceMetaText = TextView ( this ) . apply {
setTextColor ( COLOR _ACCENT _DEEP )
textSize = 13f
setPadding ( 0 , 8. dp , 0 , 0 )
}
sentenceText = TextView ( this ) . apply {
setTextColor ( COLOR _TEXT _DARK )
textSize = 22f
typeface = Typeface . DEFAULT _BOLD
setLineSpacing ( 2. dp . toFloat ( ) , 1.05f )
setPadding ( 0 , 10. dp , 0 , 10. dp )
}
progressBar = ProgressBar ( this , null , android . R . attr . progressBarStyleHorizontal ) . apply {
max = PROGRESS _MAX
progress = 0
progressTintList = ColorStateList . valueOf ( COLOR _ACCENT )
progressBackgroundTintList = ColorStateList . valueOf ( COLOR _PROGRESS _TRACK )
layoutParams = LinearLayout . LayoutParams (
ViewGroup . LayoutParams . MATCH _PARENT ,
6. dp ,
)
}
statusText = TextView ( this ) . apply {
setTextColor ( COLOR _TEXT _SUBTLE )
textSize = 13f
setPadding ( 0 , 10. dp , 0 , 0 )
}
return LinearLayout ( this ) . apply {
orientation = LinearLayout . VERTICAL
return createSentenceContent ( translucent = false ) . apply {
background = rounded ( Color . WHITE , 8f , COLOR _LIGHT _BORDER )
setPadding ( 18. dp , 16. dp , 18. dp , 16. dp )
layoutParams = LinearLayout . LayoutParams (
ViewGroup . LayoutParams . MATCH _PARENT ,
ViewGroup . LayoutParams . WRAP _CONTENT ,
) . withMargins ( 16. dp , 0 , 16. dp , 12. dp )
}
}
private fun createSentenceContent ( translucent : Boolean ) : LinearLayout {
lessonTitleText = TextView ( this ) . apply {
setTextColor ( if ( translucent ) Color . WHITE else COLOR _TEXT _DARK )
textSize = 17f
typeface = Typeface . DEFAULT _BOLD
maxLines = 1
}
sentenceMetaText = TextView ( this ) . apply {
setTextColor ( if ( translucent ) COLOR _ACCENT _LIGHT else COLOR _ACCENT _DEEP )
textSize = 13f
setPadding ( 0 , 8. dp , 0 , 0 )
}
sentenceText = TextView ( this ) . apply {
setTextColor ( if ( translucent ) Color . WHITE else COLOR _TEXT _DARK )
textSize = 22f
typeface = Typeface . DEFAULT _BOLD
setLineSpacing ( 2. dp . toFloat ( ) , 1.05f )
setPadding ( 0 , 10. dp , 0 , 10. dp )
}
progressBar = SeekBar ( this ) . apply {
max = PROGRESS _MAX
progress = 0
progressTintList = ColorStateList . valueOf ( COLOR _ACCENT )
progressBackgroundTintList = ColorStateList . valueOf (
if ( translucent ) COLOR _PROGRESS _TRACK _DARK else COLOR _PROGRESS _TRACK
)
thumbTintList = ColorStateList . valueOf ( COLOR _ACCENT )
layoutParams = LinearLayout . LayoutParams (
ViewGroup . LayoutParams . MATCH _PARENT ,
28. dp ,
)
setOnSeekBarChangeListener ( object : SeekBar . OnSeekBarChangeListener {
override fun onProgressChanged ( seekBar : SeekBar , progress : Int , fromUser : Boolean ) {
if ( fromUser ) {
val duration = controller . snapshot ( ) . durationMs
timeText . text = " ${formatTime(seekPositionForProgress(progress))} / ${formatTime(duration)} "
}
}
override fun onStartTrackingTouch ( seekBar : SeekBar ) {
progressBarDragging = true
}
override fun onStopTrackingTouch ( seekBar : SeekBar ) {
progressBarDragging = false
controller . seekTo ( seekPositionForProgress ( seekBar . progress ) )
}
} )
}
statusText = TextView ( this ) . apply {
setTextColor ( if ( translucent ) COLOR _TEXT _OVERLAY else COLOR _TEXT _SUBTLE )
textSize = 13f
setPadding ( 0 , 10. dp , 0 , 0 )
}
return LinearLayout ( this ) . apply {
orientation = LinearLayout . VERTICAL
if ( translucent ) {
background = rounded ( COLOR _OVERLAY , 0f , null )
setPadding ( 18. dp , 14. dp , 18. dp , 16. dp )
}
addView ( lessonTitleText )
if ( activeModule == Module . TRAIN ) {
addView ( createContinuousPlaybackRow ( translucent ) )
}
addView ( sentenceMetaText )
addView ( sentenceText )
addView ( progressBar )
@@ -264,6 +506,36 @@ class MainActivity : Activity() {
}
}
private fun createContinuousPlaybackRow ( translucent : Boolean ) : View {
return LinearLayout ( this ) . apply {
orientation = LinearLayout . HORIZONTAL
gravity = Gravity . CENTER _VERTICAL
setPadding ( 0 , 6. dp , 0 , 0 )
addView ( TextView ( this @MainActivity ) . apply {
text = " 连续播放 "
setTextColor ( if ( translucent ) COLOR _TEXT _OVERLAY else COLOR _TEXT _SUBTLE )
textSize = 13f
typeface = Typeface . DEFAULT _BOLD
layoutParams = LinearLayout . LayoutParams ( 0 , ViewGroup . LayoutParams . WRAP _CONTENT , 1f )
} )
addView ( Switch ( this @MainActivity ) . apply {
isChecked = continuousPlaybackEnabled
buttonTintList = ColorStateList . valueOf (
if ( translucent ) COLOR _ACCENT _LIGHT else COLOR _ACCENT
)
setOnCheckedChangeListener { _ , checked ->
continuousPlaybackEnabled = checked
controller . setContinuousPlayback ( checked )
statusText . text = if ( checked ) {
" 已开启连续播放,将按句子顺序连续播放 "
} else {
" 已关闭连续播放,将循环播放当前句子 "
}
}
} )
}
}
private fun createCatalogSection ( ) : View {
catalogStatusText = TextView ( this ) . apply {
setTextColor ( COLOR _TEXT _MUTED )
@@ -302,13 +574,243 @@ class MainActivity : Activity() {
}
}
private fun bindPlayerEvents ( ) {
controller . addListener ( object : OralTrainerListener {
private fun createTestSection ( ) : View {
testStatusText = TextView ( this ) . apply {
setTextColor ( COLOR _ACCENT _DEEP )
textSize = 13f
setPadding ( 0 , 0 , 0 , 8. dp )
}
scoreSummaryText = TextView ( this ) . apply {
setTextColor ( COLOR _TEXT _DARK )
textSize = 22f
typeface = Typeface . DEFAULT _BOLD
setPadding ( 0 , 8. dp , 0 , 0 )
text = " 尚未评测 "
}
scoreDetailText = TextView ( this ) . apply {
setTextColor ( COLOR _TEXT _SUBTLE )
textSize = 13f
setLineSpacing ( 4. dp . toFloat ( ) , 1f )
setPadding ( 0 , 8. dp , 0 , 0 )
}
val sentenceButton = { label : String , action : ( ) -> Unit ->
Button ( this ) . apply {
text = label
isAllCaps = false
setTextColor ( Color . WHITE )
textSize = 13f
typeface = Typeface . DEFAULT _BOLD
minWidth = 0
minHeight = 0
minimumWidth = 0
minimumHeight = 0
setPadding ( 0 , 0 , 0 , 0 )
background = rounded ( COLOR _BUTTON , 8f )
layoutParams = LinearLayout . LayoutParams ( 0 , 40. dp , 1f ) . withMargins ( 0 , 0 , 8. dp , 0 )
setOnClickListener { action ( ) }
}
}
recordButton = Button ( this ) . apply {
text = " 开始录音 "
isAllCaps = false
setTextColor ( Color . WHITE )
textSize = 14f
typeface = Typeface . DEFAULT _BOLD
minWidth = 0
minHeight = 0
minimumWidth = 0
minimumHeight = 0
background = rounded ( COLOR _ACCENT , 8f )
layoutParams = LinearLayout . LayoutParams (
ViewGroup . LayoutParams . MATCH _PARENT ,
44. dp ,
)
setOnClickListener {
if ( mediaRecorder == null ) {
startRecording ( )
} else {
stopRecordingAndAssess ( )
}
}
}
val controlRow = LinearLayout ( this ) . apply {
orientation = LinearLayout . HORIZONTAL
addView ( sentenceButton ( " 上一句 " ) { controller . seekToPreviousSentence ( ) } )
addView ( sentenceButton ( " 播放原句 " ) {
controller . playCurrentSentenceAndStop ( )
statusText . text = " 正在播放当前句子,播放完自动停止 "
} )
addView ( sentenceButton ( " 下一句 " ) { controller . seekToNextSentence ( ) } )
}
return LinearLayout ( this ) . apply {
orientation = LinearLayout . VERTICAL
background = rounded ( Color . WHITE , 8f , COLOR _LIGHT _BORDER )
setPadding ( 18. dp , 14. dp , 18. dp , 16. dp )
layoutParams = LinearLayout . LayoutParams (
ViewGroup . LayoutParams . MATCH _PARENT ,
ViewGroup . LayoutParams . WRAP _CONTENT ,
) . withMargins ( 16. dp , 0 , 16. dp , 12. dp )
addView ( TextView ( this @MainActivity ) . apply {
text = " 朗读评测(测试) "
setTextColor ( COLOR _TEXT _DARK )
textSize = 16f
typeface = Typeface . DEFAULT _BOLD
} )
addView ( testStatusText )
addView ( controlRow )
addView ( recordButton )
addView ( scoreSummaryText )
addView ( scoreDetailText )
}
}
private fun refreshTestUi ( ) {
if ( ! :: testStatusText . isInitialized ) {
return
}
recordButton . text = if ( mediaRecorder == null ) " 开始录音 " else " 停止并评测 "
val item = controller . currentTrainingItem ( )
val sentence = controller . currentSentence ( )
testStatusText . text = if ( item == null || sentence == null ) {
" 请先在训练模块选择一个云端课程 "
} else {
" 评测对象:第 ${sentence.index + 1} 句(共 ${item.sentences.size} 句) "
}
}
private fun startRecording ( ) {
controller . pause ( )
if ( ! :: testStatusText . isInitialized ) {
return
}
if ( checkSelfPermission ( Manifest . permission . RECORD _AUDIO ) != PackageManager . PERMISSION _GRANTED ) {
requestPermissions ( arrayOf ( Manifest . permission . RECORD _AUDIO ) , RECORD _AUDIO _REQUEST )
return
}
val file = File ( cacheDir , " attempt- ${System.currentTimeMillis()} .m4a " )
val recorder = if ( Build . VERSION . SDK _INT >= Build . VERSION_CODES . S ) {
MediaRecorder ( this )
} else {
@Suppress ( " DEPRECATION " )
MediaRecorder ( )
}
try {
recorder . setAudioSource ( MediaRecorder . AudioSource . MIC )
recorder . setOutputFormat ( MediaRecorder . OutputFormat . MPEG _4 )
recorder . setAudioEncoder ( MediaRecorder . AudioEncoder . AAC )
recorder . setAudioSamplingRate ( 16 _000 )
recorder . setAudioEncodingBitRate ( 96 _000 )
recorder . setOutputFile ( file . absolutePath )
recorder . prepare ( )
recorder . start ( )
mediaRecorder = recorder
recordingFile = file
recordButton . text = " 停止并评测 "
testStatusText . text = " 正在录音…读完当前句子后点击“停止并评测” "
} catch ( error : Throwable ) {
testStatusText . text = " 录音启动失败: ${error.message.orEmpty()} "
runCatching { recorder . release ( ) }
}
}
private fun stopRecordingAndAssess ( ) {
val recorder = mediaRecorder ?: return
val file = recordingFile ?: return
mediaRecorder = null
recordButton . text = " 开始录音 "
try {
recorder . stop ( )
} catch ( error : Throwable ) {
testStatusText . text = " 录音太短或无法保存: ${error.message.orEmpty()} "
file . delete ( )
recordingFile = null
return
} finally {
runCatching { recorder . release ( ) }
}
recordingFile = null
submitAssessment ( Uri . fromFile ( file ) )
}
private fun discardRecording ( ) {
val recorder = mediaRecorder ?: return
mediaRecorder = null
runCatching { recorder . stop ( ) }
runCatching { recorder . release ( ) }
recordingFile ?. delete ( )
recordingFile = null
}
private fun submitAssessment ( recordingUri : Uri ) {
val item = controller . currentTrainingItem ( )
val sentence = controller . currentSentence ( )
if ( item == null || sentence == null ) {
testStatusText . text = " 没有正在学习的句子,无法评测 "
return
}
if ( ! SHA256 _PATTERN . matches ( item . id ) ) {
testStatusText . text = " 仅云端课程支持朗读评测(本地视频请先上传到服务器) "
return
}
if ( ASSESSMENT_API_KEY . isBlank ( ) || ASSESSMENT_API_KEY . startsWith ( " replace- " ) ) {
testStatusText . text = " 未配置评分密钥:请在 MainActivity 填入服务器 .env 的 CLIENT_API_KEY "
return
}
testStatusText . text = " 正在评测第 ${sentence.index + 1} 句,请稍候… "
scoreSummaryText . text = " 评测中… "
scoreDetailText . text = " "
controller . assessCurrentSentence (
recordingUri = recordingUri ,
locale = sentence . language
?. takeIf { it . isNotBlank ( ) }
?: item . sentences . getOrNull ( sentence . index ) ?. language ,
metadata = mapOf ( " video_hash " to item . id ) ,
callback = object : ImitationAssessmentCallback {
override fun onResult ( result : ImitationAssessmentResult ) {
if ( :: testStatusText . isInitialized ) {
applyAssessmentResult ( result )
}
}
override fun onError ( error : Throwable ) {
if ( :: testStatusText . isInitialized ) {
testStatusText . text = " 评测失败: ${error.message.orEmpty()} "
scoreSummaryText . text = " 评测失败 "
}
}
} ,
)
}
private fun applyAssessmentResult ( result : ImitationAssessmentResult ) {
testStatusText . text = if ( result . passed == true ) " 已通过 " else " 未通过 "
scoreSummaryText . text = String . format ( Locale . US , " 总分 %.1f " , result . overallScore )
scoreDetailText . text = buildString {
result . contentScore ?. let { appendLine ( String . format ( Locale . US , " 内容分 %.1f " , it ) ) }
result . fluencyScore ?. let { appendLine ( String . format ( Locale . US , " 流畅度 %.1f " , it ) ) }
result . durationScore ?. let { appendLine ( String . format ( Locale . US , " 时长分 %.1f " , it ) ) }
result . pauseScore ?. let { appendLine ( String . format ( Locale . US , " 停顿分 %.1f " , it ) ) }
result . speechRateScore ?. let { appendLine ( String . format ( Locale . US , " 语速分 %.1f " , it ) ) }
result . referenceText ?. let { appendLine ( " 参考: $it " ) }
result . recognizedText ?. let { appendLine ( " 识别: $it " ) }
if ( result . missingTokens . isNotEmpty ( ) ) {
appendLine ( " 漏读: ${result.missingTokens.joinToString("、")} " )
}
if ( result . extraTokens . isNotEmpty ( ) ) {
appendLine ( " 多读: ${result.extraTokens.joinToString("、")} " )
}
result . feedback ?. let { appendLine ( it ) }
} . trimEnd ( )
}
private fun createControllerListener ( ) : OralTrainerListener {
return object : OralTrainerListener {
override fun onPlaybackSnapshot ( snapshot : PlaybackSnapshot ) {
timeText . text = " ${formatTime(snapshot.positionMs)} / ${formatTime(snapshot.durationMs)} "
speedText . text = formatSpeed ( snapshot . playbackSpeed )
statusText . text = playbackStatus ( snapshot )
progressBar . progress = playbackProgress ( snapshot )
applyPlaybackSnapshot ( snapshot )
}
override fun onMediaChanged ( item : TrainingMediaItem ? ) {
@@ -317,18 +819,7 @@ class MainActivity : Activity() {
}
override fun onSentenceChanged ( sentence : SentenceBoundary ? ) {
if ( sentence == null ) {
sentenceMetaText . text = " 暂无句子边界 "
sentenceText . text = lessonTitleText . text
return
}
val countText = if ( currentSentenceCount > 0 ) {
" / $currentSentenceCount "
} else {
" "
}
sentenceMetaText . text = " 第 ${sentence.index + 1} $countText 句 ${formatTime(sentence.startMs)} - ${formatTime(sentence.endMs)} "
sentenceText . text = sentence . text ?. takeIf { it . isNotBlank ( ) } ?: " 当前句子 "
applySentence ( sentence )
}
override fun onGesture ( event : GestureEvent ) {
@@ -343,33 +834,76 @@ class MainActivity : Activity() {
override fun onPlayerError ( error : Throwable ) {
statusText . text = " 播放失败: ${error.message.orEmpty()} "
}
} )
}
}
private fun loadSampleLesson ( ) {
val item = sampleOnlineLesson ( )
activeItemId = item . id
currentSentenceCount = item . sentences . size
controller . loadItem ( item )
lessonTitle Text. text = item . title
renderCatalog ( )
private fun applyPlaybackSnapshot ( snapshot : PlaybackSnapshot ) {
if ( ! progressBarDragging ) {
timeText . text = " ${formatTime(snapshot.positionMs)} / ${formatTime(snapshot.durationMs)} "
progressBar . progress = playbackProgress ( snapshot )
}
speed Text. text = formatSpeed ( snapshot . playbackSpeed )
statusText . text = playbackStatus ( snapshot )
}
private fun seekPositionForProgress ( progress : Int ) : Long {
val duration = controller . snapshot ( ) . durationMs
if ( duration <= 0L ) {
return 0L
}
return progress . toLong ( ) * duration / PROGRESS _MAX
}
private fun applySentence ( sentence : SentenceBoundary ? ) {
if ( sentence == null ) {
sentenceMetaText . text = " 暂无句子边界 "
sentenceText . text = lessonTitleText . text
return
}
val countText = if ( currentSentenceCount > 0 ) {
" / $currentSentenceCount "
} else {
" "
}
sentenceMetaText . text = " 第 ${sentence.index + 1} $countText 句 ${formatTime(sentence.startMs)} - ${formatTime(sentence.endMs)} "
sentenceText . text = sentence . text ?. takeIf { it . isNotBlank ( ) } ?: " 当前句子 "
}
private fun refreshCurrentUi ( ) {
val item = controller . currentTrainingItem ( )
lessonTitleText . text = item ?. title ?: " 未选择课程 "
currentSentenceCount = item ?. sentences ?. size ?: 0
applyPlaybackSnapshot ( controller . snapshot ( ) )
applySentence ( controller . currentSentence ( ) )
if ( :: catalogStatusText . isInitialized ) {
catalogStatusText . text = catalogStatusTextValue
}
}
private fun loadCatalog ( ) {
catalogStatusText. text = " 正在同步 "
catalogStatusTextValue = " 正在同步 "
if ( :: catalogStatusText . isInitialized ) {
catalogStatusText . text = catalogStatusTextValue
}
sdk . videoCatalogApi . fetch ( object : VideoCatalogCallback {
override fun onSuccess ( videos : List < TrainingVideoSummary > ) {
catalogVideos = videos
catalogStatusText. text = if ( videos . isEmpty ( ) ) {
catalogStatusTextValue = if ( videos . isEmpty ( ) ) {
" 暂无云端课程 "
} else {
" ${videos.size} 个云端课程 "
}
if ( :: catalogStatusText . isInitialized ) {
catalogStatusText . text = catalogStatusTextValue
}
renderCatalog ( )
}
override fun onError ( error : Throwable ) {
catalogStatusText. text = " 云端暂不可用 "
catalogStatusTextValue = " 云端暂不可用: ${error.message.orEmpty()} "
if ( :: catalogStatusText . isInitialized ) {
catalogStatusText . text = catalogStatusTextValue
}
renderCatalog ( )
}
} )
@@ -380,15 +914,6 @@ class MainActivity : Activity() {
return
}
catalogList . removeAllViews ( )
catalogList . addView (
videoCard (
id = SAMPLE _ID ,
title = " 示例课程 " ,
meta = " 4 句 · ${formatTime(16_000L)} " ,
) {
loadSampleLesson ( )
}
)
catalogVideos . forEach { video ->
catalogList . addView (
videoCard (
@@ -516,22 +1041,6 @@ class MainActivity : Activity() {
)
}
private fun sampleOnlineLesson ( ) : TrainingMediaItem {
return TrainingMediaItem (
id = SAMPLE _ID ,
title = " 口语宝示例课 " ,
uri = Uri . parse ( " https://storage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4 " ) ,
mimeType = MimeTypes . VIDEO _MP4 ,
customCacheKey = SAMPLE _ID ,
sentences = listOf (
SentenceBoundary ( 0 , 0L , 3 _000L , " Good morning, everyone. " ) ,
SentenceBoundary ( 1 , 3 _000L , 7 _000L , " Today we will practice listening carefully. " ) ,
SentenceBoundary ( 2 , 7 _000L , 11 _000L , " Please repeat each sentence clearly. " ) ,
SentenceBoundary ( 3 , 11 _000L , 16 _000L , " Small daily practice makes progress visible. " ) ,
) ,
)
}
private fun playbackStatus ( snapshot : PlaybackSnapshot ) : String {
val state = when {
snapshot . isPlaying -> " 播放中 "
@@ -599,22 +1108,38 @@ class MainActivity : Activity() {
get ( ) = ( this * resources . displayMetrics . density ) . toInt ( )
private companion object {
// 服务端地址:改成你实际部署的域名或 IP。
// 内网/开发环境可填 http://<服务器IP>:<端口>(例如 http://192.168.1.100:80) 。
const val SERVER _BASE _URL = " https://videoservice.d1kt.cn "
// 朗读评测密钥:填服务器 sentence_api/.env 里的 CLIENT_API_KEY。
const val ASSESSMENT _API _KEY = " fcf60fa10bc1c49e5ddb93d570bc54df5648e187b64096962498d57661c14220 "
const val PICK _VIDEO _REQUEST = 1001
const val SAMPLE _ID = " online_sample_01 "
const val RECORD _AUDIO _REQUEST = 1002
const val PROGRESS _MAX = 1000
private val SHA256 _PATTERN = Regex ( " ^[0-9a-fA-F]{64} $ " )
enum class Module {
TRAIN ,
TEST ,
}
val COLOR _BACKGROUND : Int = Color . rgb ( 12 , 15 , 18 )
val COLOR _SURFACE : Int = Color . rgb ( 28 , 34 , 40 )
val COLOR _BORDER : Int = Color . rgb ( 50 , 59 , 67 )
val COLOR _BUTTON : Int = Color . rgb ( 37 , 99 , 235 )
val COLOR _ACCENT : Int = Color . rgb ( 21 , 184 , 132 )
val COLOR _ACCENT _DEEP : Int = Color . rgb ( 7 , 118 , 86 )
val COLOR _ACCENT _LIGHT : Int = Color . rgb ( 94 , 234 , 182 )
val COLOR _SELECTED : Int = Color . rgb ( 229 , 248 , 240 )
val COLOR _PROGRESS _TRACK : Int = Color . rgb ( 224 , 231 , 235 )
val COLOR _PROGRESS _TRACK _DARK : Int = Color . rgb ( 55 , 62 , 70 )
val COLOR _LIGHT _BORDER : Int = Color . rgb ( 218 , 226 , 232 )
val COLOR _OVERLAY : Int = Color . argb ( 178 , 9 , 12 , 16 )
val COLOR _TEXT _DARK : Int = Color . rgb ( 18 , 24 , 31 )
val COLOR _TEXT _MUTED : Int = Color . rgb ( 151 , 162 , 174 )
val COLOR _TEXT _SUBTLE : Int = Color . rgb ( 88 , 98 , 108 )
val COLOR _TEXT _OVERLAY : Int = Color . rgb ( 163 , 172 , 182 )
}
}