@@ -3,6 +3,7 @@ package app.echoloop
33import android.media.AudioFormat
44import android.media.AudioRecord
55import android.media.MediaRecorder
6+ import android.os.SystemClock
67import android.util.Log
78import kotlinx.coroutines.CoroutineScope
89import kotlinx.coroutines.Dispatchers
@@ -14,6 +15,7 @@ import kotlinx.coroutines.runBlocking
1415import kotlinx.coroutines.withTimeoutOrNull
1516import java.io.File
1617import java.io.RandomAccessFile
18+ import java.util.concurrent.atomic.AtomicLong
1719import kotlin.math.max
1820import kotlin.math.min
1921import kotlin.math.sqrt
@@ -26,14 +28,25 @@ import kotlin.math.sqrt
2628 */
2729class WavRecorder {
2830
31+ data class WavFileMetrics (val fileBytes : Long , val audioDurationMs : Long )
32+
2933 /* * 每个 buffer 的回调(RMS, 帧数),在 IO 线程上调用。 */
3034 var onBuffer: ((Float , Int ) -> Unit )? = null
3135
3236 private var audioRecord: AudioRecord ? = null
3337 private var recordingJob: Job ? = null
3438 private var wavFile: RandomAccessFile ? = null
3539 private var currentFilePath: String? = null
36- private var totalDataBytes: Int = 0
40+ @Volatile private var totalDataBytes: Int = 0
41+ private val positiveReadFrames = AtomicLong (0 )
42+ private val zeroReadCount = AtomicLong (0 )
43+ private val errorReadCount = AtomicLong (0 )
44+ @Volatile private var lastNonPositiveReadResult: Int? = null
45+ @Volatile private var recordingStartElapsedMs: Long? = null
46+ @Volatile private var recordingStartState: Int? = null
47+ @Volatile private var recordingStartAttempted = false
48+ @Volatile private var recordingStarted = false
49+ @Volatile private var stopJoinTimedOut = false
3750
3851 /* * 是否已初始化 AudioRecord。 */
3952 val isInitialized: Boolean get() = audioRecord != null
@@ -97,6 +110,7 @@ class WavRecorder {
97110 * @param filePath WAV 输出路径。
98111 */
99112 fun startRecording (filePath : String ) {
113+ recordingStartAttempted = true
100114 val recorder = audioRecord ? : return
101115 currentFilePath = filePath
102116 totalDataBytes = 0
@@ -106,12 +120,25 @@ class WavRecorder {
106120 wavFile = raf
107121
108122 recorder.startRecording()
123+ recordingStarted = true
124+ recordingStartState = recorder.recordingState
125+ recordingStartElapsedMs = SystemClock .elapsedRealtime()
109126
110127 recordingJob = CoroutineScope (Dispatchers .IO ).launch {
111128 val buffer = ShortArray (BUFFER_SIZE_FRAMES )
112129 while (isActive) {
113130 val read = recorder.read(buffer, 0 , buffer.size)
114- if (read <= 0 ) continue
131+ if (read == 0 ) {
132+ zeroReadCount.incrementAndGet()
133+ lastNonPositiveReadResult = read
134+ continue
135+ }
136+ if (read < 0 ) {
137+ errorReadCount.incrementAndGet()
138+ lastNonPositiveReadResult = read
139+ continue
140+ }
141+ positiveReadFrames.addAndGet(read.toLong())
115142
116143 val rms = computeRms(buffer, read)
117144 onBuffer?.invoke(rms, read)
@@ -129,9 +156,18 @@ class WavRecorder {
129156 // 先等录音协程真正退出,再操作 AudioRecord 和文件,避免 read() 与 stop() 并发。
130157 // 录音中 read() 最多阻塞一个 buffer 周期(~64ms)必然返回,join 不会卡死;
131158 // withTimeoutOrNull 兜底:极端情况(麦克风 hang)超时后退回原 stop() 行为,不劣于现状。
132- runBlocking {
133- withTimeoutOrNull(500 ) { recordingJob?.cancelAndJoin() }
159+ val job = recordingJob
160+ val recordingJobJoined = runBlocking {
161+ if (job == null ) {
162+ true
163+ } else {
164+ withTimeoutOrNull(500 ) {
165+ job.cancelAndJoin()
166+ true
167+ } ? : false
168+ }
134169 }
170+ stopJoinTimedOut = ! recordingJobJoined
135171 recordingJob = null
136172
137173 try {
@@ -147,6 +183,56 @@ class WavRecorder {
147183 return currentFilePath
148184 }
149185
186+ /* * 返回本轮采集统计,不包含录音内容。 */
187+ fun captureDiagnostics (): String {
188+ val readFrames = positiveReadFrames.get()
189+ val writtenBytes = totalDataBytes.toLong()
190+ val elapsedMs = recordingStartElapsedMs?.let {
191+ (SystemClock .elapsedRealtime() - it).coerceAtLeast(0L )
192+ }
193+ val writtenDurationMs =
194+ (writtenBytes / BYTES_PER_SAMPLE * 1000L ) / SAMPLE_RATE
195+
196+ return " audioRecordInitialized=${isInitialized} " +
197+ " startAttempted=$recordingStartAttempted " +
198+ " startSucceeded=$recordingStarted " +
199+ " recordingStateAtStart=${recordingStartState ? : " unknown" } " +
200+ " stopJoinTimedOut=$stopJoinTimedOut " +
201+ " positiveReadFrames=$readFrames " +
202+ " zeroReadCount=${zeroReadCount.get()} " +
203+ " errorReadCount=${errorReadCount.get()} " +
204+ " lastNonPositiveRead=$lastNonPositiveReadResult " +
205+ " writtenDataBytes=$writtenBytes " +
206+ " writtenDurationMs=$writtenDurationMs " +
207+ " captureElapsedMs=${elapsedMs ? : - 1 } "
208+ }
209+
210+ /* * 读取 WAV 文件大小与 PCM 时长,供录音结束诊断使用。 */
211+ fun wavFileMetrics (filePath : String ): WavFileMetrics ? {
212+ val file = File (filePath)
213+ if (! file.exists()) return null
214+
215+ val fileBytes = file.length()
216+ val dataBytes = (fileBytes - WAV_HEADER_SIZE ).coerceAtLeast(0L )
217+ val frames = dataBytes / BYTES_PER_SAMPLE
218+ val durationMs = frames * 1000L / SAMPLE_RATE
219+ return WavFileMetrics (fileBytes = fileBytes, audioDurationMs = durationMs)
220+ }
221+
222+ /* * 新会话开始时清空上轮统计,即使本轮 AudioRecord 初始化失败也不会复用旧数据。 */
223+ fun resetCaptureDiagnostics () {
224+ totalDataBytes = 0
225+ positiveReadFrames.set(0 )
226+ zeroReadCount.set(0 )
227+ errorReadCount.set(0 )
228+ lastNonPositiveReadResult = null
229+ recordingStartElapsedMs = null
230+ recordingStartState = null
231+ recordingStartAttempted = false
232+ recordingStarted = false
233+ stopJoinTimedOut = false
234+ }
235+
150236 /* * 释放 AudioRecord 和所有资源。 */
151237 fun release () {
152238 // 同 stopRecording:先 join 录音协程,消除与 stop()/release() 的并发。
0 commit comments