Skip to content

Commit 73a660f

Browse files
committed
CHORE: 补充 Android 跟读录音诊断日志
1 parent 2c5e9f6 commit 73a660f

7 files changed

Lines changed: 142 additions & 6 deletions

File tree

‎TASKS.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
55
## 本次完成
66

7+
- [x] 删除低价值 WAV 指标公式单测(2026-10-09):移除只用固定 250ms 样本验证 PCM 字节数和时长换算的断言;保留验证录音诊断能进入持久化应用日志的行为测试。未改生产代码。**完成时间**: 2026-10-09
8+
- [x] 补充 Android 跟读录音取证日志(2026-10-09):记录 AudioRecord 初始化/启动状态、正帧数、0/负返回次数及最后非正返回值、停止时录音协程等待是否超时,并记录写入字节/时长、采集耗时和 WAV 裁剪前后文件大小/时长;通过现有持久化应用日志与日志支持包收集。只增加观测,不改变录音、裁剪、停止或识别行为,也不记录音频/识别文本;复核时增加诊断类型容错并在每轮会话清空旧统计。RecordingService 日志测试 3 项通过,4 个 Dart 文件定向分析无问题,Android `WavRecorderTrimTest` 通过,`git diff --check` 通过。未运行 `scripts/check.sh`:本次限于 Android 录音诊断日志与指标计算,定向验证覆盖改动范围。未在 vivo X100s Pro 上复现,根因仍待用户日志确认。**完成时间**: 2026-10-09 11:21
79
- [x] 修复 iOS Apple AI 合成音频无法播放(2026-10-08):平台 TTS 默认输出统一为 WAV,iOS 继续调用 `flutter_tts.synthesizeToFile`,macOS 保持现有原生合成路径;缓存命名空间升级至 v3,避免命中旧 CAF 缓存。新增默认 WAV 和旧缓存 key 失效回归测试。TTS 定向测试 28 项通过,4 个相关 Dart 文件定向分析通过,`git diff --check` 通过。未运行 `scripts/check.sh`:仅改动 TTS 格式和缓存兼容性,定向测试覆盖范围足够;未在 iPhone 上安装运行,真实设备播放需手动验证。**完成时间**: 2026-10-08 23:57
810
- [x] 删除低价值的复述状态单测(2026-10-08):移除仅检查 `RetellPlayerState.stepFinished` 默认值和 `copyWith` 行为的用例;保留段落切段及最后一段重复遍数的行为回归测试。此次仅删除测试,未运行测试。**完成时间**: 2026-10-08 23:55
911
- [x] 修复段落复述播放中无法切换段落(2026-10-08):切段和重复播放在旧播放取消后异步启动新播放并立即返回,页面仅在 Provider 标记 `stepFinished` 后处理最后一段完成。新增受控播放时序与最后一段重复遍数回归测试。Provider 25 项测试、页面 Widget 22 项通过;3 个 Dart 文件定向分析和 `git diff --check` 通过。未运行 `scripts/check.sh`:局部播放器异步边界修复由定向测试覆盖。未运行 Maestro:当前仅连接两台无线 iPhone、无模拟器;仓库 smoke flow 会清除应用数据且不覆盖复述切段,页面 Widget 测试已通过。**完成时间**: 2026-10-08 23:46

‎android/app/src/main/kotlin/app/echoloop/AndroidSpeechPracticeHandler.kt‎

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,7 @@ class AndroidSpeechPracticeHandler(
222222
}
223223

224224
private fun doStartSession(promptId: String, result: MethodChannel.Result) {
225+
wavRecorder.resetCaptureDiagnostics()
225226
cleanupSentenceState()
226227
resetSentenceState(promptId)
227228

@@ -249,10 +250,18 @@ class AndroidSpeechPracticeHandler(
249250
currentFilePath
250251
}
251252

253+
val wavBeforeTrim = filePath?.let(wavRecorder::wavFileMetrics)
254+
252255
// 裁剪首尾静音(对齐 iOS/macOS)。
253256
if (!filePath.isNullOrEmpty()) {
254257
wavRecorder.trimSilence(filePath)
255258
}
259+
val wavAfterTrim = filePath?.let(wavRecorder::wavFileMetrics)
260+
val diagnostics = buildString {
261+
append(wavRecorder.captureDiagnostics())
262+
append(" wavBeforeTrim=$wavBeforeTrim")
263+
append(" wavAfterTrim=$wavAfterTrim")
264+
}
256265

257266
// 确保 Dart 侧总能收到 finalTranscriptReady,避免等超时。
258267
if (!finalTranscriptEmitted) {
@@ -264,7 +273,12 @@ class AndroidSpeechPracticeHandler(
264273
))
265274
}
266275

267-
result.success(mapOf("filePath" to (filePath ?: "")))
276+
result.success(
277+
mapOf(
278+
"filePath" to (filePath ?: ""),
279+
"diagnostics" to diagnostics,
280+
),
281+
)
268282
}
269283

270284
private fun cancelSession(result: MethodChannel.Result) {

‎android/app/src/main/kotlin/app/echoloop/WavRecorder.kt‎

Lines changed: 90 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package app.echoloop
33
import android.media.AudioFormat
44
import android.media.AudioRecord
55
import android.media.MediaRecorder
6+
import android.os.SystemClock
67
import android.util.Log
78
import kotlinx.coroutines.CoroutineScope
89
import kotlinx.coroutines.Dispatchers
@@ -14,6 +15,7 @@ import kotlinx.coroutines.runBlocking
1415
import kotlinx.coroutines.withTimeoutOrNull
1516
import java.io.File
1617
import java.io.RandomAccessFile
18+
import java.util.concurrent.atomic.AtomicLong
1719
import kotlin.math.max
1820
import kotlin.math.min
1921
import kotlin.math.sqrt
@@ -26,14 +28,25 @@ import kotlin.math.sqrt
2628
*/
2729
class 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() 的并发。

‎lib/models/speech_practice_models.dart‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -151,7 +151,10 @@ class SpeechPracticeStopResult {
151151
/// 临时录音文件路径。
152152
final String? filePath;
153153

154-
const SpeechPracticeStopResult({this.filePath});
154+
/// 原生录音诊断摘要;不包含录音内容。
155+
final String? diagnostics;
156+
157+
const SpeechPracticeStopResult({this.filePath, this.diagnostics});
155158
}
156159

157160
/// 文本比对结果。

‎lib/services/recording_service.dart‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -308,6 +308,10 @@ class RecordingService {
308308
);
309309
}
310310
final filePath = stopResult.filePath ?? _currentFilePath;
311+
final diagnostics = stopResult.diagnostics;
312+
if (diagnostics != null) {
313+
AppLogger.log('RecordingDiag', 'promptId=$promptId $diagnostics');
314+
}
311315
_recordingPromptId = null;
312316
_recordingStartedAt = null;
313317
AppLogger.log(

‎lib/services/speech_practice_platform.dart‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -225,8 +225,10 @@ class SpeechPracticePlatform implements SpeechPracticeBackend {
225225
_ensureSupported();
226226
AppLogger.log('SpeechPlatform', '┌ stopSession');
227227
final result = await _invokeMap('stopSession');
228+
final diagnosticsValue = result['diagnostics'];
228229
final stopResult = SpeechPracticeStopResult(
229230
filePath: result['filePath'] as String?,
231+
diagnostics: diagnosticsValue is String ? diagnosticsValue : null,
230232
);
231233
AppLogger.log(
232234
'SpeechPlatform',

‎test/services/recording_service_test.dart‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import 'dart:async';
22

33
import 'package:flutter_test/flutter_test.dart';
44
import 'package:echo_loop/models/speech_practice_models.dart';
5+
import 'package:echo_loop/services/app_logger.dart';
56
import 'package:echo_loop/services/recording_service.dart';
67
import 'package:echo_loop/services/speech_practice_platform.dart';
78

@@ -12,6 +13,7 @@ class _Backend implements SpeechPracticeBackend {
1213
int startCalls = 0;
1314
int cancelCalls = 0;
1415
int shutdownCalls = 0;
16+
String? stopDiagnostics;
1517

1618
@override
1719
bool get isSupported => true;
@@ -56,6 +58,7 @@ class _Backend implements SpeechPracticeBackend {
5658
Future<SpeechPracticeStopResult> stopSession() async =>
5759
SpeechPracticeStopResult(
5860
filePath: '/tmp/${activePromptId ?? 'none'}.caf',
61+
diagnostics: stopDiagnostics,
5962
);
6063

6164
@override
@@ -116,4 +119,26 @@ void main() {
116119
await service.dispose();
117120
await backend.dispose();
118121
});
122+
123+
test('停止录音时将原生诊断记录到应用日志', () async {
124+
const promptId = 'diagnostics:round';
125+
final backend = _Backend()..stopDiagnostics = 'errorReadCount=2';
126+
final service = RecordingService(backend);
127+
128+
await service.startRecording(promptId: promptId);
129+
await service.stopSession(promptId: promptId);
130+
131+
expect(
132+
AppLogger.instance.entries.any(
133+
(entry) =>
134+
entry.tag == 'RecordingDiag' &&
135+
entry.message.contains('promptId=$promptId') &&
136+
entry.message.contains('errorReadCount=2'),
137+
),
138+
isTrue,
139+
);
140+
141+
await service.dispose();
142+
await backend.dispose();
143+
});
119144
}

0 commit comments

Comments
 (0)