Android m4a音频录制

前言

Android没有直接录制MP3的方式,添加依赖来实现转换也相对麻烦。

比较推荐使用m4a来替代MP3,同样的质量它有更好的压缩率,并且兼容性也比较好。

添加录音权限

1
<uses-permission android:name="android.permission.RECORD_AUDIO" />

申请权限

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
import android.Manifest
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
import android.provider.Settings
import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.content.ContextCompat

// 检查是否有被永久拒绝的权限
fun hasPermanentlyDeniedPermissions(
context: Context,
permissions: Array<String>
): Boolean {
return if (context is ComponentActivity) {
permissions.any { permission ->
!context.shouldShowRequestPermissionRationale(permission)
}
} else {
false
}
}

// 打开应用设置页面
fun openAppSettings(context: Context) {
val intent = Intent(
Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
Uri.fromParts("package", context.packageName, null)
)
context.startActivity(intent)
}

@Composable
fun ZNativePermissionComp() {
val permissionList = mutableListOf(
Manifest.permission.CAMERA,
Manifest.permission.ACCESS_FINE_LOCATION,
Manifest.permission.ACCESS_COARSE_LOCATION,
Manifest.permission.READ_EXTERNAL_STORAGE
)
// 判断是否是 Android 13 及以上(API 33+)
val isAndroid13OrHigher = Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU
if (!isAndroid13OrHigher) {
permissionList.add(Manifest.permission.READ_EXTERNAL_STORAGE)
}
ZNativePermissionCompBase(permissionList)
}

@Composable
fun ZAudioPermissionComp() {
val permissionList = mutableListOf(
Manifest.permission.RECORD_AUDIO
)
ZNativePermissionCompBase(permissionList)
}

@Composable
fun ZNativePermissionCompBase(permissionList: List<String>) {
val context = LocalContext.current
// 定义需要请求的多个权限


val permissions = permissionList.toTypedArray()

var allPermissionsGranted by remember { mutableStateOf(false) }


// 检查所有权限是否已授予
allPermissionsGranted = permissions.all { permission ->
ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED
}

// 注册多权限请求 launcher
val multiplePermissionsLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestMultiplePermissions()
) { permissionsMap ->
// 检查所有权限的授予结果
allPermissionsGranted = permissionsMap.all { it.value }

if (!allPermissionsGranted) {
// 是否拒绝了权限
val result = hasPermanentlyDeniedPermissions(context, permissions)
if (result) {
openAppSettings(context)
}
}
}

when {
allPermissionsGranted -> {
Toast.makeText(context, "所有权限已授予,可以使用相关功能", Toast.LENGTH_SHORT).show()
}

else -> {
Box(
modifier = Modifier
.fillMaxSize()
.background(Color(0x33000000))
) {
Column(
modifier = Modifier
.width(300.dp)
.background(Color.White, RoundedCornerShape(8.dp))
.padding(10.dp, 20.dp)
.align(Alignment.Center),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text("需要以下权限才能继续:\n相机、存储、位置")
Button(
modifier = Modifier
.padding(top = 10.dp),
onClick = { multiplePermissionsLauncher.launch(permissions) }) {
Text("申请权限", fontSize = 12.sp)
}
}
}

}
}
}

页面中直接添加

1
ZAudioPermissionComp()

录音工具类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
import android.Manifest
import android.content.Context
import android.media.MediaRecorder
import androidx.annotation.RequiresPermission
import java.io.File
import java.io.IOException

class AudioRecorderUtil private constructor(
private val context: Context,
private val outputDir: File = context.getExternalFilesDir(null) ?: context.filesDir,
private val sampleRate: Int = 44100,
private val bitRate: Int = 128000,
private val fileNamePrefix: String = "recording"
) {

private var mediaRecorder: MediaRecorder? = null
private var currentOutputFile: File? = null
private var isRecording = false
private var onRecordListener: OnRecordListener? = null

// 单例(可选)
companion object {
@Volatile
private var INSTANCE: AudioRecorderUtil? = null

@JvmStatic
fun getInstance(context: Context): AudioRecorderUtil {
return INSTANCE ?: synchronized(this) {
INSTANCE ?: AudioRecorderUtil(context.applicationContext).also { INSTANCE = it }
}
}
}

/**
* 开始录音
* @param customFileName 自定义文件名(不含扩展名),如传 "note_2025" → note_2025.m4a
*/
@RequiresPermission(Manifest.permission.RECORD_AUDIO)
fun startRecording(customFileName: String? = null, listener: OnRecordListener? = null) {
if (isRecording) {
return
}

onRecordListener = listener

// 生成文件名
val fileName = customFileName ?: "${fileNamePrefix}_${System.currentTimeMillis()}"
currentOutputFile = File(outputDir, "$fileName.m4a")

mediaRecorder = MediaRecorder().apply {
setAudioSource(MediaRecorder.AudioSource.MIC)
setOutputFormat(MediaRecorder.OutputFormat.MPEG_4)
setAudioEncoder(MediaRecorder.AudioEncoder.AAC)
setAudioSamplingRate(sampleRate)
setAudioEncodingBitRate(bitRate)
setOutputFile(currentOutputFile!!.absolutePath)

try {
prepare()
start()
isRecording = true
onRecordListener?.onStart(currentOutputFile!!)
} catch (e: IOException) {
onRecordListener?.onError("Prepare failed: ${e.message}")
release()
} catch (e: RuntimeException) {
onRecordListener?.onError("Start failed: ${e.message}")
release()
}
}
}

/**
* 停止录音
*/
fun stopRecording() {
if (!isRecording) return

mediaRecorder?.let { recorder ->
try {
recorder.stop()
onRecordListener?.onComplete(currentOutputFile!!)
} catch (e: RuntimeException) {
// 录制时间太短可能导致 stop() 失败,删除无效文件
currentOutputFile?.delete()
onRecordListener?.onError("Recording too short or corrupted")
} finally {
release()
}
}
}

private fun release() {
mediaRecorder?.release()
mediaRecorder = null
isRecording = false
currentOutputFile = null
}

fun isRecording(): Boolean = isRecording

interface OnRecordListener {
fun onStart(file: File)
fun onComplete(file: File)
fun onError(message: String)
}
}

调用实例

1
val audioRecorder = AudioRecorderUtil.getInstance(appContext)

事件触发

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
Modifier
.pointerInput(Unit) {
awaitEachGesture {
val down = awaitFirstDown(requireUnconsumed = false)
println("Pressed down at ${down.position}")
if (appContext.checkSelfPermission(Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED) {
audioRecorder.startRecording(
customFileName = "my_voice_note",
listener = object : AudioRecorderUtil.OnRecordListener {
override fun onStart(file: File) {
println("录音开始")
vm.isRecording.value = true
}

override fun onComplete(file: File) {
println("录音结束:" + file.absolutePath)
vm.isRecording.value = false
vm.uploadAudioWithCb(file) {
println("上传成功:$it")
vm.actionEnglishChivox(it)
}
}

override fun onError(message: String) {
vm.isRecording.value = false
}

}
)
} else {
println("没有录音权限")
}

val up = waitForUpOrCancellation()
if (up != null) {
println("Released at ${up.position}")
// 在这里处理抬起后的逻辑
audioRecorder.stopRecording()
} else {
println("Gesture was cancelled")
audioRecorder.stopRecording()
}
}

}