适配Android 12+,使用MediaStore保存和播放录音文件。
1.AndroidManifest.xml
Android12以后,读写媒体文件的权限发生了变化,录音和播放需要单独申请访问权限。
XML
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- 声明修改音频设置的权限 -->
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<!-- 录音权限 -->
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<!-- Android 12 读外部存储(含 Download) -->
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="32" />
<!-- Android13+ 读取 Download 音频 -->
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO" />
<!-- 如果你的应用是专门用来录音的,建议加上这句话 -->
<uses-feature
android:name="android.hardware.microphone"
android:required="true" />
<application
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.AudioTest">
<activity
android:name=".MainActivity"
android:exported="true"
android:label="@string/app_name"
android:theme="@style/Theme.AudioTest">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
2.Layout文件
布局文件 activity_audio_record.xml,只有两个按钮和一个文本控件。
XML
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
xmlns:app="http://schemas.android.com/apk/res-auto"
tools:ignore="MissingClass">
<Button
android:id="@+id/audio_record"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/record"
android:layout_marginTop="100dp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"
/>
<Button
android:id="@+id/play_pcm"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/play"
android:layout_margin="10dp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@id/audio_record"
/>
<!-- 垂直靠上,占据垂直高度的10% -->
<TextView
android:id="@+id/tv_info"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="..."
android:layout_margin="10dp"
android:gravity="center"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@id/play_pcm"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintVertical_bias="0.1"
/>
</androidx.constraintlayout.widget.ConstraintLayout>
3.ViewModel 文件
AudioViewModel.java
定义了录音和播放状态,用于更新录音按钮、播放按钮的文本。实现在主线程中监听状态变化,进行UI更新。
java
package com.example.audiotest
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
class AudioViewModel : ViewModel() {
// 定义状态常量
companion object {
// Record state
const val RECORD_STATE_IDLE = 0
const val RECORD_STATE_RECORDING = 1
const val RECORD_STATE_STOPPED = 2
const val RECORD_STATE_ERROR = 3
// Play state
const val PLAY_STATE_IDLE = 10
const val PLAY_STATE_PLAYING = 11
const val PLAY_STATE_PAUSE = 12
const val PLAY_STATE_COMPLETED = 13
const val PLAY_STATE_FILE_NOT_EXIST = 14
const val PLAY_STATE_ERROR = 15
}
// 录音和播放状态,用于更新录音按钮、播放按钮的文本
private val _audioState = MutableLiveData(RECORD_STATE_IDLE)
val audioState: LiveData<Int> = _audioState
// 信息文本
private val _infoText = MutableLiveData<String>()
val infoText: LiveData<String> = _infoText
// current playing position for audio file
private val _currentPosition = MutableLiveData<Long>()
val currentPosition: LiveData<Long> = _currentPosition
// 更新状态
fun setState(state: Int) {
_audioState.postValue(state)
}
// 更新信息文本
fun setInfoText(text: String) {
_infoText.postValue(text)
}
fun setCurrentPosition(position: Long) {
_currentPosition.postValue(position)
}
}
4.MainActivity
主 Activity 类,创建了两个静态内部类线程用于录音和播放。
AudioThread 用于录音,AudioViewMode 作为参数传入。
AudioTrackThread用于播放录音,AudioViewMode 作为参数传入。
最初录音文件保存在应用私有目录下的,通过File实现读写。
保存到外部公共目录 Download 目录下,从 Android 12开始,需要使用 MediaStore 读写文件。
java
package com.example.audiotest
import android.Manifest
import android.annotation.SuppressLint
import android.content.ContentValues
import android.content.Context
import android.content.pm.PackageManager
import android.media.AudioAttributes
import android.media.AudioFormat
import android.media.AudioManager
import android.media.AudioRecord
import android.media.AudioTrack
import android.media.MediaRecorder
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.os.Environment
import android.provider.MediaStore
import android.util.Log
import android.view.MotionEvent
import android.widget.Button
import android.widget.TextView
import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.activity.enableEdgeToEdge
import androidx.annotation.RequiresApi
import androidx.annotation.RequiresPermission
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.ViewModelProvider
import com.example.audiotest.ui.theme.AudioTestTheme
import java.io.File
import java.io.FileInputStream
import java.io.FileOutputStream
import java.io.IOException
import java.io.OutputStream
import java.nio.channels.FileChannel
class MainActivity : ComponentActivity() {
// 1. 定义权限常量
private val REQUEST_RECORD_AUDIO = 100
private val REQUEST_PLAY_AUDIO = 101
private val RECORD_AUDIO_PERMISSION = Manifest.permission.RECORD_AUDIO
private val READ_AUDIO_PERMISSION = Manifest.permission.READ_MEDIA_AUDIO
companion object {
const val TAG: String = "AudioTest-MainActivity"
const val AUDIO_RATE = 44100
const val FILE_SIZE_MAX = 10 * 1024 * 1024 // 10MB
const val PCM_FILE_NAME = "audio_record.pcm" // 固定文件名
}
private lateinit var audioViewModel: AudioViewModel
private var mAudioThread: AudioThread? = null
private var mAudioTrackThread: AudioTrackThread? = null
//lateinit var PATH: String // Used for application private file path under "Android/data/"
lateinit var audio_record: Button
lateinit var play_pcm: Button
lateinit var tv_info: TextView
var currentPosition: Long = 0L
@RequiresApi(Build.VERSION_CODES.TIRAMISU)
@SuppressLint("MissingInflatedId", "ClickableViewAccessibility", "ResourceType")
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
/*setContent {
AudioTestTheme {
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
Greeting(
name = "Android",
modifier = Modifier.padding(innerPadding)
)
MyApp() // 调用一个Compose函数来构建UI
}
}
}*/
setContentView(R.layout.activity_audio_record)
audio_record = findViewById(R.id.audio_record)
audio_record.setOnTouchListener { view, motionEvent ->
if (motionEvent.action == MotionEvent.ACTION_DOWN) {
Log.d(TAG, "audio record button down")
if (mAudioThread?.state != Thread.State.RUNNABLE) {
checkAndStartRecord()
} else {
stopRecord()
}
}
return@setOnTouchListener false
}
play_pcm = findViewById(R.id.play_pcm)
// ====================== 【优化:播放按钮逻辑】 ======================
play_pcm.setOnClickListener {
// check read audio permission
if (hasReadAudioPermission()) {
playAudio()
} else {
// request audio permission
requestReadAudioPermission()
}
}
tv_info = findViewById(R.id.tv_info)
audioViewModel = ViewModelProvider(this)[AudioViewModel::class.java]
// 观察状态 → 更新按钮文字 + tv_info
audioViewModel.audioState.observe(this) { state ->
when (state) {
AudioViewModel.RECORD_STATE_IDLE -> {
audio_record.text = getString(R.string.record)
tv_info.text = "准备就绪"
}
AudioViewModel.RECORD_STATE_RECORDING -> {
audio_record.text = getString(R.string.stop)
tv_info.text = "开始录音"
}
AudioViewModel.RECORD_STATE_STOPPED -> {
audio_record.text = getString(R.string.record)
tv_info.append("\nRecord completed")
}
AudioViewModel.RECORD_STATE_ERROR -> {
audio_record.text = getString(R.string.record)
tv_info.text = "录制出错"
}
AudioViewModel.PLAY_STATE_IDLE -> {
play_pcm.text = getString(R.string.play)
tv_info.text = "准备就绪"
}
AudioViewModel.PLAY_STATE_PLAYING -> {
play_pcm.text = getString(R.string.pause)
//tv_info.text = "开始播放"
Toast.makeText(this, "开始播放", Toast.LENGTH_SHORT).show()
}
AudioViewModel.PLAY_STATE_PAUSE -> {
play_pcm.text = getString(R.string.play)
tv_info.append("\n暂停播放")
}
AudioViewModel.PLAY_STATE_COMPLETED -> {
play_pcm.text = getString(R.string.play)
tv_info.append("\nPlay completed")
currentPosition = 0L
}
AudioViewModel.PLAY_STATE_FILE_NOT_EXIST -> {
play_pcm.text = getString(R.string.play)
tv_info.text = "文件不存在"
Toast.makeText(this, "文件不存在", Toast.LENGTH_SHORT).show()
}
AudioViewModel.PLAY_STATE_ERROR -> {
play_pcm.text = getString(R.string.play)
tv_info.text = "播放出错"
}
}
}
// 观察信息文本
audioViewModel.infoText.observe(this) { text ->
tv_info.text = text
}
// hold current thread playing position, used for resume play
audioViewModel.currentPosition.observe(this) { position ->
currentPosition = position
}
// /storage/emulated/0/Android/data/com.example.audiotest/files/Download/AudioDemo
//PATH = getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS)?.absolutePath + "/AudioDemo"
lifecycle.addObserver(MainObserver {
runOnUiThread {
if(lifecycle.currentState.isAtLeast(Lifecycle.State.CREATED)){
Toast.makeText(this, "耗时任务完成,并成功更新ui", Toast.LENGTH_SHORT).show()
tv_info.text = "点击录音按钮,开始录音"
Log.d(TAG, "耗时任务完成,并成功更新ui")
}else{
Log.d(TAG, "生命周期状态不匹配,不能更新ui")
}
}
})
}
/*@Composable
fun MyApp() {
Column(modifier = Modifier.fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally) {
Text(text = "Hello, Compose!", fontSize = 24.sp)
Button(onClick = { *//* 处理点击事件 *//* }) {
Text("Click Me!")
}
}
}*/
private fun checkAndStartRecord() {
// 检查是否已有权限
if (ContextCompat.checkSelfPermission(this, RECORD_AUDIO_PERMISSION)
== PackageManager.PERMISSION_GRANTED) {
// 有权限 → 开始录音
startRecord()
} else {
// 没权限 → 申请权限
ActivityCompat.requestPermissions(
this,
arrayOf(RECORD_AUDIO_PERMISSION),
REQUEST_RECORD_AUDIO
)
}
}
// 3. 权限申请结果回调
@Deprecated("Deprecated in Java")
override fun onRequestPermissionsResult(
requestCode: Int,
permissions: Array<out String>,
grantResults: IntArray
) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
when (requestCode) {
REQUEST_RECORD_AUDIO -> {
if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
// 用户同意 → 开始录音
startRecord()
} else {
// 用户拒绝 → 提示必须开启权限
Toast.makeText(this, "请开启麦克风权限", Toast.LENGTH_SHORT).show()
}
}
REQUEST_PLAY_AUDIO -> {
if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
// 用户同意 → 开始播放
playAudio()
} else {
// 用户拒绝 → 提示必须开启权限
val permission = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
"音频文件"
} else {
"外部存储"
}
Toast.makeText(this, "请开启读取${permission}权限", Toast.LENGTH_SHORT).show()
}
}
}
}
// 是否有读取音频权限
@RequiresApi(Build.VERSION_CODES.TIRAMISU)
private fun hasReadAudioPermission(): Boolean {
val audioPermission = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
READ_AUDIO_PERMISSION
} else {
Manifest.permission.READ_EXTERNAL_STORAGE
}
return ContextCompat.checkSelfPermission(
this,
audioPermission
) == PackageManager.PERMISSION_GRANTED
}
// 请求权限
@RequiresApi(Build.VERSION_CODES.TIRAMISU)
private fun requestReadAudioPermission() {
val audioPermission = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
READ_AUDIO_PERMISSION
} else {
Manifest.permission.READ_EXTERNAL_STORAGE
}
ActivityCompat.requestPermissions(
this,
arrayOf(audioPermission),
REQUEST_PLAY_AUDIO
)
}
// 4. 你的实际录音逻辑
private fun startRecord() {
// 在这里写录音代码
// 开始录制
//mAudioThread = AudioThread(audioViewModel, PATH)
mAudioThread = AudioThread(this, audioViewModel)
mAudioThread?.start()
Log.d(TAG, "开始录制音频")
Toast.makeText(this, "开始录音", Toast.LENGTH_SHORT).show()
}
private fun stopRecord() {
mAudioThread?.let {
mAudioThread!!.done()
}
audio_record.text = getString(R.string.record)
tv_info.append("\nRecord completed")
Toast.makeText(this, "停止录音", Toast.LENGTH_SHORT).show()
}
// 音频录制线程
// 静态内部类 + 传入 ViewModel
// 无泄漏 + 能更新 UI
private class AudioThread(
private val context: MainActivity,
private val viewModel: AudioViewModel
//private val path: String
) : Thread() {
private lateinit var audioRecord: AudioRecord
private var minBufferSize: Int = 0
private var isDone: Boolean = false
private var outputStream: OutputStream? = null
private var fileUri: Uri? = null
init {
/**
* 获取最小 buffer 大小
* 采样率 44100,双声道,采样位数为 16bit
*/
minBufferSize = AudioRecord.getMinBufferSize(
AUDIO_RATE,
AudioFormat.CHANNEL_IN_STEREO,
AudioFormat.ENCODING_PCM_16BIT
)
Log.d(TAG, "minBufferSize = $minBufferSize")
}
@RequiresPermission(Manifest.permission.RECORD_AUDIO)
override fun run() {
super.run()
try {
// 通知开始录音
viewModel.setState(AudioViewModel.RECORD_STATE_RECORDING)
// Persists recording file in App private folder
// 先创建文件夹
// var dir = File(path)
// if (!dir.exists()) {
// val result = dir.mkdirs()
// Log.d(TAG, "create path result: ${result}")
// }
// 创建 pcm 文件
// val pcmFile = Utils.getFile(path, "test.pcm")
// Log.d(TAG, "pcmFile: ${pcmFile.isFile}")
// 1. MediaStore 创建 Download/AudioTest/ 目录下的文件
val values = ContentValues().apply {
put(MediaStore.Downloads.DISPLAY_NAME, PCM_FILE_NAME)
put(MediaStore.Downloads.MIME_TYPE, "audio/pcm")
put(MediaStore.Downloads.RELATIVE_PATH, "Download/AudioTest")
put(MediaStore.Downloads.IS_PENDING, 1)
}
// Download directory
// if the file has existed, will create new file with "file name.pcm (x)"
val resolver = context.contentResolver
fileUri = resolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, values)
if (fileUri == null) {
viewModel.setState(AudioViewModel.RECORD_STATE_ERROR)
Log.e(TAG, "recording file uri is null")
return
}
Log.d(TAG, "recording file uri: $fileUri")
outputStream = resolver.openOutputStream(fileUri!!)
/**
* 使用 AudioRecord 去录音
*/
if (!::audioRecord.isInitialized) {
audioRecord = AudioRecord(
MediaRecorder.AudioSource.MIC,
AUDIO_RATE,
AudioFormat.CHANNEL_IN_STEREO,
AudioFormat.ENCODING_PCM_16BIT,
minBufferSize
)
Log.d(TAG, "初始化 record ${audioRecord}")
}
if (audioRecord.state != AudioRecord.STATE_INITIALIZED) {
Log.e(TAG, "初始化 record failed!!!")
return
}
audioRecord.startRecording()
val buffer = ByteArray(size = minBufferSize)
var totalSize = 0L // the size of recording file
//FileOutputStream(pcmFile).use { outputStream ->
while (!isDone) {
val read = audioRecord.read(buffer, 0, buffer.size)
Log.d(TAG, "音频数据 ${read}")
if (read > 0) {
outputStream?.write(buffer, 0, read)
totalSize += read
}
//val duration = Utils.calculateAudioDuration(pcmFile.length(),
val duration = Utils.calculateAudioDuration(totalSize,
audioRecord.sampleRate,
16,
2)
// only update when duration is approximately equal to integer
if (duration - duration.toInt() < 0.1) {
viewModel.setInfoText("Recording ${duration.toInt()} s")
}
// file size bigger than max size limit, stop record
//Log.d(TAG, "recording, file size is ${pcmFile.length()}")
Log.d(TAG, "recording, file size is $totalSize")
//if (pcmFile.length() > FILE_SIZE_MAX) {
if (totalSize > FILE_SIZE_MAX) {
//Log.i(TAG, "stop record, file size is ${pcmFile.length()}")
Log.i(TAG, "stop record, file size is $totalSize")
done()
viewModel.setState(AudioViewModel.RECORD_STATE_STOPPED)
viewModel.setInfoText("Recording ${duration.toInt()} s" + "\nRecord completed. \nFile length has reached max size: $FILE_SIZE_MAX")
}
}
// unhide recording file
values.clear()
values.put(MediaStore.Downloads.IS_PENDING, 0)
var updateCount = resolver.update(fileUri!!, values, null, null)
Log.d(TAG, "update $updateCount recording file")
//}
} catch (e: IOException) {
e.printStackTrace()
viewModel.setState(AudioViewModel.RECORD_STATE_ERROR)
Log.d(TAG, "audio record exception: ${e.message}")
} finally {
// 录制结束,释放资源
audioRecord.stop()
audioRecord.release()
outputStream?.close()
fileUri = null
}
}
fun done() {
interrupt()
isDone = true
Log.d(TAG, "停止录制音频")
}
}
// ====================== 【完善:播放 / 暂停 / 恢复】 ======================
private fun playAudio() {
when {
// 1. 未播放 / 已停止 → 开始播放
mAudioTrackThread == null || mAudioTrackThread?.isPlaying == false -> {
playPcm()
}
// 2. 正在播放 → 暂停
mAudioTrackThread?.isPausedFlag == false -> {
pausePcm()
}
// 3. 已暂停 → 恢复
else -> {
resumePcm()
}
}
}
private fun playPcm() {
//mAudioTrackThread = AudioTrackThread(audioViewModel, PATH, currentPosition)
mAudioTrackThread = AudioTrackThread(this, audioViewModel, currentPosition)
mAudioTrackThread?.start()
}
private fun resumePcm() {
mAudioTrackThread?.resumePlay()
audioViewModel.setState(AudioViewModel.PLAY_STATE_PLAYING)
Toast.makeText(this, "恢复播放", Toast.LENGTH_SHORT).show()
}
private fun pausePcm() {
mAudioTrackThread?.pausePlay()
audioViewModel.setState(AudioViewModel.PLAY_STATE_PAUSE)
Toast.makeText(this, "暂停播放", Toast.LENGTH_SHORT).show()
}
// 静态内部类 + 传入 ViewModel
// 无泄漏 + 能更新 UI
private class AudioTrackThread(
private val context: MainActivity,
private val viewModel: AudioViewModel,
//private val path: String,
private val currentPosition: Long
) : Thread() {
var audioTrack: AudioTrack
private val bufferSize: Int
private var isDone = false // 完全停止
private var isPaused = false // 暂停标志
private lateinit var fileChannel: FileChannel
// 状态获取
val isPlaying: Boolean get() = !isDone && audioTrack.playState == AudioTrack.PLAYSTATE_PLAYING
val isPausedFlag: Boolean get() = isPaused
init {
Log.d(TAG, "Thread name ${Thread.currentThread().name} AudioTrackThread init BEGIN")
val channelConfig = AudioFormat.CHANNEL_IN_STEREO
/**
* 设置音频信息属性
* 1.设置支持多媒体属性,如 audio、video
* 2.设置音频格式,如 music
*/
val attributes = AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_MEDIA) // 标识为媒体流
.setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
.build()
/**
* 设置音频特色
* 1.设置采样率
* 2.设置采样位数
* 3.设置声道
*/
val format = AudioFormat.Builder()
.setSampleRate(AUDIO_RATE)
.setEncoding(AudioFormat.ENCODING_PCM_16BIT)
.setChannelMask(channelConfig)
.build()
bufferSize = AudioTrack.getMinBufferSize(AUDIO_RATE, channelConfig, AudioFormat.ENCODING_PCM_16BIT)
audioTrack = AudioTrack(
attributes,
format,
bufferSize,
AudioTrack.MODE_STREAM,
AudioManager.AUDIO_SESSION_ID_GENERATE
)
Log.d(TAG, "Thread name ${Thread.currentThread().name} AudioTrackThread init END")
}
// 暂停
fun pausePlay() {
isPaused = true
audioTrack.pause()
}
// 恢复
fun resumePlay() {
// Create a new thread to play
isPaused = false
audioTrack.play()
}
// 停止
fun stopPlay() {
isDone = true
isPaused = false
audioTrack.stop()
audioTrack.release()
}
/**
* 从系统Download目录查询指定文件Uri
*/
fun getDownloadFileUri(context: Context, fileName: String): Uri? {
val projection = arrayOf(MediaStore.Downloads._ID)
val selection = "${MediaStore.Downloads.DISPLAY_NAME} = ?"
val args = arrayOf(fileName)
context.contentResolver.query(
MediaStore.Downloads.EXTERNAL_CONTENT_URI,
projection, selection, args, null
)?.use { cursor ->
if (cursor.moveToFirst()) {
val id = cursor.getLong(0)
return Uri.withAppendedPath(
MediaStore.Downloads.EXTERNAL_CONTENT_URI,
id.toString()
)
}
}
return null
}
override fun run() {
super.run()
Log.d(TAG, "Thread name ${Thread.currentThread().name} AudioTrackThread run BEGIN isPaused $isPaused");
// PLAN A: play using file path for app private file
// val pcmFile = File(path, "test.pcm")
// Log.d(TAG, "pcm 文件是否存在:${pcmFile.exists()} isDone: ${isDone}")
// if (!pcmFile.exists()) {
// viewModel.setState(AudioViewModel.PLAY_STATE_FILE_NOT_EXIST)
// Log.w(TAG, "pcm 文件不存在")
// return
// }
// PLAN B: play using MediaStore for file on external public storage
val fileUri = getDownloadFileUri(context, PCM_FILE_NAME)
if (fileUri == null) {
viewModel.setState(AudioViewModel.PLAY_STATE_FILE_NOT_EXIST)
Log.w(TAG, "pcm 文件不存在")
return
}
// 开始播放
audioTrack.play()
viewModel.setState(AudioViewModel.PLAY_STATE_PLAYING)
// play using file path
// FileInputStream(pcmFile).use { inputStream ->
// val buffer = ByteArray(size = bufferSize)
// var bytesRead = 0
// //var playedBytes = 0L;
// fileChannel = inputStream.channel
//
// fileChannel.position(currentPosition)
// Log.d(TAG, "currentPosition = $currentPosition")
// while (!isDone && inputStream.read(buffer).also { bytesRead = it } != -1) {
// // ====================== 【真正的暂停】 ======================
// if (isPaused && !isDone) {
// Log.d(TAG, "paused ...");
// /*while (true) {
// // current thread runs over
// // 2026-04-05 23:02:21.055 26290-19837 AudioTest-MainActivity com.example.audiotest D paused ......
// // logcat: Unexpected EOF!
// Log.d(TAG, "paused ......");
// if (!isPaused) {
// Log.d(TAG, "resume ...");
// break;
// }
// }*/
// break // finish the current thread, start new one when resume to play
// }
//
// if (isDone) break
//
// val position = fileChannel.position()
// viewModel.setCurrentPosition(position)
//
// Log.d(TAG, "current play position = $position)")
// Log.d(TAG, "pcm 数据:${bytesRead}")
// // 写入音频
// audioTrack.write(buffer, 0, bytesRead)
//
// // 更新时长
// //playedBytes += bytesRead
// val duration = Utils.calculateAudioDuration(position,
// AUDIO_RATE,
// 16,
// 2)
// // only update when duration is approximately equal to integer
// if (duration - duration.toInt() < 0.1) {
// viewModel.setInfoText("Playing ${duration.toInt()} s")
// }
// }
// }
// play using MediaStore
val inputStream = context.contentResolver.openInputStream(fileUri) as FileInputStream
val buffer = ByteArray(size = bufferSize)
var bytesRead = 0
fileChannel = inputStream.channel
fileChannel.position(currentPosition)
Log.d(TAG, "currentPosition = $currentPosition")
while (!isDone && inputStream.read(buffer).also { bytesRead = it } != -1) {
// ====================== 【真正的暂停】 ======================
if (isPaused && !isDone) {
Log.d(TAG, "paused ...");
break // finish the current thread, start new one when resume to play
}
if (isDone) break
val position = fileChannel.position()
viewModel.setCurrentPosition(position)
Log.d(TAG, "current play position = $position)")
Log.d(TAG, "pcm 数据:${bytesRead}")
// 写入音频
audioTrack.write(buffer, 0, bytesRead)
// 更新时长
val duration = Utils.calculateAudioDuration(position,
AUDIO_RATE,
16,
2)
// only update when duration is approximately equal to integer
if (duration - duration.toInt() < 0.1) {
viewModel.setInfoText("Playing ${duration.toInt()} s")
}
}
Log.d(TAG, "Thread name ${Thread.currentThread().name} AudioTrackThread run END isPaused $isPaused");
if (!isPaused) {
// 播放结束
stopPlay()
viewModel.setState(AudioViewModel.PLAY_STATE_COMPLETED)
inputStream.close()
}
}
}
}
@Composable
fun Greeting(name: String, modifier: Modifier = Modifier) {
Text(
text = "Hello $name!",
modifier = modifier
)
}
@Preview(showBackground = true)
@Composable
fun GreetingPreview() {
AudioTestTheme {
Greeting("Android")
}
}