Recorder.kt
package com.shengzhiai.yugu.audio
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.os.Process
import com.shengzhiai.yugu.StreamSession
import com.shengzhiai.yugu.YuguErrors
import com.shengzhiai.yugu.YuguException
import com.shengzhiai.yugu.internal.Platform
import com.shengzhiai.yugu.internal.SerialExecutor
import java.io.ByteArrayOutputStream
import java.io.Closeable
import java.io.File
import java.util.concurrent.Executor
import java.util.concurrent.TimeUnit
import java.util.concurrent.locks.ReentrantLock
import kotlin.concurrent.withLock
import kotlin.math.log10
import kotlin.math.roundToInt
import kotlin.math.sqrt
/**
* 录音器配置。默认 16 kHz,每帧 640 字节即 20 毫秒,最长 300 秒,保留录到的 PCM。
*/
public data class RecorderConfig @JvmOverloads constructor(
val sampleRate: Int = 16_000,
val frameBytes: Int = 640,
val maxDurationMs: Long = 300_000,
val keepPcm: Boolean = true,
) {
init {
require(sampleRate >= 8_000) { "sampleRate 过低" }
require(frameBytes > 0 && frameBytes % 2 == 0) { "frameBytes 须为正偶数" }
require(maxDurationMs > 0) { "maxDurationMs 须为正数" }
}
}
/**
* 录音器:AudioRecord 采集单声道 16 位 PCM。
*
* 状态为 IDLE,RECORDING,PAUSED,STOPPED,RELEASED。[stop],[release] 与每条出错路径都会释放麦克风。
* [release] 可重复调用,释放后同时注销监听器。
*
* 线程:[Listener.onFrame] 与 [Listener.onLevel] 在采集线程上回调,应尽快返回。
* [Listener.onStateChanged] 与 [Listener.onError] 在回调线程上依次执行,默认主线程。
*
* 宿主应用须声明并在运行时申请 `RECORD_AUDIO`。传入 [Context] 时 SDK 在开始前检查该权限,
* 没有权限抛 [com.shengzhiai.yugu.PermissionException],错误码 90201。
*/
public class Recorder internal constructor(
private val sourceFactory: () -> PcmSource,
public val config: RecorderConfig,
private val context: Context?,
callbackExecutor: Executor?,
) : Closeable {
@JvmOverloads
public constructor(
context: Context? = null,
config: RecorderConfig = RecorderConfig(),
callbackExecutor: Executor? = null,
) : this({ AudioRecordSource(config.sampleRate, config.frameBytes) }, config, context?.applicationContext, callbackExecutor)
/** 录音器状态。 */
public enum class State { IDLE, RECORDING, PAUSED, STOPPED, RELEASED }
/** 录音回调,全部可选。 */
public interface Listener {
public fun onStateChanged(oldState: State, newState: State) {}
/** 一帧 PCM,默认 640 字节,在采集线程上回调。 */
public fun onFrame(pcm: ByteArray) {}
/** 音量等级 0 到 100,每帧一次,在采集线程上回调。 */
public fun onLevel(level: Int) {}
/** 录音出错,麦克风已释放,状态已回到 STOPPED。 */
public fun onError(error: YuguException) {}
}
private val lock = ReentrantLock()
private val resumed = lock.newCondition()
private val dispatcher = SerialExecutor(callbackExecutor ?: Platform.defaultCallbackExecutor())
@Volatile
private var listener: Listener? = null
// ---- guarded by lock
private var state = State.IDLE
private var source: PcmSource? = null
private var thread: Thread? = null
private var session: StreamSession? = null
private var generation = 0
private var paused = false
private val pcm = ByteArrayOutputStream()
private var capturedBytes = 0L
/** 设置监听器,传 null 即注销。 */
public fun setListener(listener: Listener?) {
this.listener = listener
}
/** 注销监听器。 */
public fun removeListener() {
listener = null
}
/** 当前状态。 */
public fun getState(): State = lock.withLock { state }
/** 开始录音。从 IDLE 或 STOPPED 开始,STOPPED 时清空上一次录到的音频。 */
public fun start() {
startInternal(null)
}
/** 开始录音,同时把每一帧送进实时评测会话。录音停止不会自动结束会话。 */
public fun start(session: StreamSession) {
startInternal(session)
}
private fun startInternal(target: StreamSession?) {
lock.withLock {
when (state) {
State.RELEASED -> throw YuguErrors.local(90009, "录音器已释放")
State.RECORDING, State.PAUSED -> throw YuguErrors.local(90009, "录音器正在录音")
else -> Unit
}
checkPermission()
val src = sourceFactory()
try {
src.start()
} catch (e: YuguException) {
src.release()
throw e
} catch (e: RuntimeException) {
src.release()
throw YuguErrors.local(90202, "麦克风不可用:${e.message}", e)
}
source = src
session = target
pcm.reset()
capturedBytes = 0
paused = false
val gen = ++generation
val t = Thread({ capture(gen, src) }, "yugu-recorder")
t.isDaemon = true
thread = t
transitionLocked(State.RECORDING)
t.start()
}
}
private fun checkPermission() {
val ctx = context ?: return
val granted = try {
ctx.checkPermission(Manifest.permission.RECORD_AUDIO, Process.myPid(), Process.myUid()) == PackageManager.PERMISSION_GRANTED
} catch (e: RuntimeException) {
true // cannot tell, let AudioRecord decide
}
if (!granted) throw YuguErrors.local(90201, "没有麦克风权限 RECORD_AUDIO")
}
/** 暂停,麦克风停止采集但不释放。 */
public fun pause() {
val src: PcmSource
lock.withLock {
if (state != State.RECORDING) return
src = source ?: return
paused = true
transitionLocked(State.PAUSED)
}
src.stop()
}
/** 从暂停处继续录音。 */
public fun resume() {
lock.withLock {
if (state != State.PAUSED) return
val src = source ?: return
try {
src.start()
} catch (e: YuguException) {
failLocked(e)
throw e
}
paused = false
resumed.signalAll()
transitionLocked(State.RECORDING)
}
}
/** 停止录音并释放麦克风,可重复调用。录到的音频用 [pcm] 或 [toWav] 读取。 */
public fun stop() {
val src: PcmSource?
val t: Thread?
lock.withLock {
if (state != State.RECORDING && state != State.PAUSED) return
generation++
paused = false
resumed.signalAll()
src = source
t = thread
source = null
thread = null
session = null
transitionLocked(State.STOPPED)
}
src?.stop()
if (t != null && t !== Thread.currentThread()) {
try {
t.join(JOIN_TIMEOUT_MS)
} catch (e: InterruptedException) {
Thread.currentThread().interrupt()
}
}
if (t == null || !t.isAlive) src?.release() // otherwise the capture thread releases on exit
}
/** 释放录音器与麦克风并注销监听器,可重复调用。 */
public fun release() {
stop()
lock.withLock {
if (state == State.RELEASED) return
transitionLocked(State.RELEASED)
}
listener = null
}
/** 等同 [release]。 */
override fun close() {
release()
}
/** 录到的 PCM,无文件头。 */
public fun pcm(): ByteArray = lock.withLock { pcm.toByteArray() }
/** 录到的音频加 WAV 头。 */
public fun toWav(): ByteArray = WavFormat.fromPcm(pcm(), config.sampleRate, 1)
/** 把录到的音频写成 WAV 文件。 */
public fun writeWav(file: File): File {
file.writeBytes(toWav())
return file
}
/** 已录时长,单位毫秒。 */
public fun durationMs(): Long = lock.withLock { capturedBytes * 1000L / (config.sampleRate * 2L) }
// ------------------------------------------------------------------------- capture thread
private fun capture(gen: Int, src: PcmSource) {
val frameBytes = config.frameBytes
val maxBytes = config.maxDurationMs * config.sampleRate * 2L / 1000L
val buf = ByteArray(frameBytes)
try {
while (true) {
lock.withLock {
while (gen == generation && paused) resumed.await(200, TimeUnit.MILLISECONDS)
if (gen != generation) return
}
var filled = 0
var error = 0
while (filled < frameBytes) {
val n = src.read(buf, filled, frameBytes - filled)
if (n < 0) {
error = n
break
}
if (n == 0) break // stopped or paused
filled += n
}
if (error < 0) {
lock.withLock {
if (gen != generation) return
failLocked(YuguErrors.local(90203, "录音读取失败,AudioRecord 错误码 $error"))
}
return
}
if (filled == 0) {
lock.withLock {
if (gen == generation && !paused) {
// read returned nothing while recording, back off briefly
resumed.await(10, TimeUnit.MILLISECONDS)
}
}
continue
}
val frame = if (filled == frameBytes) buf.copyOf() else buf.copyOf(filled)
val target: StreamSession?
var reachedMax = false
lock.withLock {
if (gen != generation) return
if (config.keepPcm) pcm.write(frame, 0, frame.size)
capturedBytes += frame.size
target = session
if (capturedBytes >= maxBytes) reachedMax = true
}
val l = listener
if (l != null) {
try {
l.onFrame(frame)
l.onLevel(level(frame))
} catch (t: Throwable) {
// a listener bug must not stop the microphone loop
}
}
if (target != null && !target.sendAudio(frame)) {
lock.withLock { if (session === target) session = null }
}
if (reachedMax) {
lock.withLock {
if (gen != generation) return
generation++
source = null
thread = null
session = null
transitionLocked(State.STOPPED)
}
return
}
}
} catch (t: Throwable) {
lock.withLock {
if (gen == generation) failLocked(YuguErrors.local(90203, "录音线程异常:$t", t))
}
} finally {
src.release()
}
}
/** Error path: back to STOPPED, microphone released by the capture thread or here. */
private fun failLocked(error: YuguException) {
val src = source
generation++
paused = false
resumed.signalAll()
source = null
val t = thread
thread = null
session = null
if (state == State.RECORDING || state == State.PAUSED) transitionLocked(State.STOPPED)
if (t == null || t === Thread.currentThread() || !t.isAlive) src?.release() else src?.stop()
val l = listener
dispatcher.execute {
try {
l?.onError(error)
} catch (_: Throwable) {
// ignore listener failures
}
}
}
private fun transitionLocked(next: State) {
val old = state
if (old == next) return
state = next
val l = listener
dispatcher.execute {
try {
l?.onStateChanged(old, next)
} catch (_: Throwable) {
// ignore listener failures
}
}
}
internal companion object {
private const val JOIN_TIMEOUT_MS = 2_000L
/** Level 0..100 from RMS, -60 dBFS and below is 0. */
fun level(frame: ByteArray): Int {
var sum = 0.0
var n = 0
var i = 0
while (i + 1 < frame.size) {
val v = ((frame[i].toInt() and 0xFF) or (frame[i + 1].toInt() shl 8)).toShort().toDouble()
sum += v * v
n++
i += 2
}
if (n == 0 || sum == 0.0) return 0
val db = 20.0 * log10(sqrt(sum / n) / 32768.0)
return ((db + 60.0) / 60.0 * 100.0).roundToInt().coerceIn(0, 100)
}
}
}