Skip to content

Commit afa64d2

Browse files
committed
FIX: 跟读实时 VAD 改为自适应门槛,修复小声用户说话中途被自动结束录音
- 固定门槛 0.015(增益前 RMS)高于部分用户整段电平,说话被判为静音后触发 5s 静音兜底 - 新增 AdaptiveVadThreshold(Android / iOS / macOS 三端同源):底噪取判为静音的非零 buffer 的 10% 分位,门槛 = 底噪 × 2.5,限制在 [0.004, 0.015] - stopSession 诊断输出 vadThreshold / vadNoiseFloor / vadVoicedBuffers - 三端各 8 项单测;更新 TASKS.md 与 CLAUDE.md §7.30
1 parent a90e78d commit afa64d2

13 files changed

Lines changed: 581 additions & 62 deletions

File tree

‎CLAUDE.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -437,3 +437,9 @@ flutter test integration_test -d macos
437437
三份 arb 逐 key diff,`HEAD == ece1a4da^ 且 ece1a4da 改过` 即判定为被静默回退,按 `ece1a4da`
438438
的值恢复;同一方法可扩展到检测任意「长分支落地覆盖主干独立改动」的问题,不限于 l10n。
439439
- **完成时间**:2026-08-06
440+
441+
### 7.30 跟读实时 VAD:固定 RMS 门槛把小声用户判成静音 → 说话中途被自动结束录音
442+
443+
- 根因:Android `AndroidSpeechPracticeHandler` 与 iOS/macOS `AppDelegate.swift` 都用固定门槛 0.015(**增益前** RMS;写入 WAV 时才 ×2.2,离线 ASR 日志里的 rms² 是增益后的值,换算时别搞混)。Android 离线 ASR 录音中无实时转写,自动停止只靠声学静音兜底,判错即截断。
444+
- 规则:门槛按底噪自适应(`AdaptiveVadThreshold`,Kotlin + Swift 三端同源,改参数必须三端同步),**必须有上限(= 旧门槛)和下限**;底噪只统计判为静音的 buffer。排查时对比 `[RecordingDiag]` 的 `vadThreshold/vadNoiseFloor/vadVoicedBuffers` 与 `[ASREngine] VAD: x → y` 是否矛盾。
445+
- 相关:跟读引擎的 `referenceDuration`/`maxDuration` 必须按播放倍速换算(`RepeatFlowConfig.playbackSpeed`),否则慢速播放时动态兜底降到 1s。

‎TASKS.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
> 当前待办与本次完成记录;此前完成任务“随心听字幕列表按当前句定位和自动聚焦”(2026-09-29)已归档,详见 [2026-09-29 归档](./docs/tasks-archive/tasks-2026-09-29-completed.md)。
44
55
## 本次完成
6+
- [x] 修复跟读录音在用户仍在说话时自动结束(Android / iOS / macOS,2026-10-28):按用户日志(单句跟读 "So every year… annual monkey feast" 三次录音均在 "annual" 附近截断),实时 VAD 判为 5s 静音的区间被离线 ASR 判为连续人声;根因是 Android 固定门槛 0.015(增益前 RMS)高于该用户整段平均电平(约 0.007–0.011)。新增 `AdaptiveVadThreshold`:底噪取近 30s 内「判为静音」的非零 buffer 的 10% 分位,门槛 = 底噪 × 2.5(取向「宁可多录几秒,也不要截断」),限制在 [0.004, 0.015],上限即旧门槛,嘈杂环境不会比修复前更严格;有声 buffer 不进入底噪统计,长时间连续说话不会抬高门槛;stopSession 诊断追加 `vadThreshold/vadNoiseFloor/vadVoicedBuffers`。iOS / macOS 原生端同样使用固定 0.015 且 Dart 侧 5s 静音兜底在有实时转写时也生效,同步移植为 Swift `AdaptiveVadThreshold`(三端算法与参数一致),并将该文件同时编入 RunnerTests target 以免依赖随 configuration 变化的 App 模块名。同时修复跟读引擎录音参考时长与最大时长忽略播放倍速(0.8x 时有声时长很快超过原句时长,动态兜底被压到 1s):`RepeatFlowConfig.playbackSpeed`,单句跟读与难句跟读接入,按「原句时长 ÷ 倍速」计算。新增 Kotlin 与 Swift 单测各 7 项(含小声说话回归、上下限、全零 buffer、连续说话)与引擎倍速回归测试;Android JVM 单测 34 项、macOS RunnerTests、iOS 模拟器 RunnerTests 均通过,相关 Dart 测试 312 项通过(9 项跳过),定向 `flutter analyze` 无问题。未运行全量 `flutter test`(既有失败基线)与 macOS 集成测试(本机环境问题);iOS 构建前执行了 `pod install`(`Podfile.lock` 补上此前漏同步的 `in_app_review`)。需 Android / iPhone 真机验证小声跟读。**完成时间**: 2026-10-28
67
- [x] 开发者选项增加服务地区切换(2026-10-28):开发者选项新增「服务地区」行(自动 / 中国区 / 全球区),持久化到 SharedPreferences,下次启动生效;强制地区时启动只请求该区域的 Config API,忽略系统 Region 与返回的国家码,Config 拉取失败也保持所选地区并使用缓存配置;关闭开发者选项时恢复为自动。行内显示本次启动实际生效地区。按 macOS 运行日志复核:强制全球区后 Config、偏好、权益、合集与 CDN 请求均走全球域名;修复强制地区下启动已拉到 Config 仍被首轮定时刷新重复强制请求的问题(启动结果新增 `configFetched`,拉取失败时仍强制刷新)。新增 bootstrapper 强制地区(含失败路径)、覆盖值存取/无效值、启动 Provider 接线到路由器、设置行交互共 6 项测试;user_region 与设置页测试全部通过,相关文件定向 `flutter analyze` 无问题,`dart format` 与 `git diff --check` 通过。未运行全量 `flutter test`(既有失败基线)与 macOS 集成测试(本机环境问题)。**完成时间**: 2026-10-28
78
- [x] 在社区合集详情素材行展示并优化简介层级(2026-10-25):标题提升为主信息,简介使用更小字号、常规字重和浅化的次级色,最多显示两行,行高根据实际简介行数自适应;标题前增加固定 24dp 的音频/视频图标,并提供本地化语义标签,音频使用主题蓝、视频使用适配深浅主题的紫色,图标与简介共用原左边距,图标在保留 24dp 槽宽的同时左对齐,避免额外居中留白且不影响标题和简介位置;图形尺寸调整为 22dp,缩小图标与标题间的可见空隙;将单条预览行提取为独立 widget,确保切换主题时标题、简介、时长和媒体类型图标即时刷新;移除素材行前置播放图标及“名称 / 时长”列标题,保留行内时长与难度信息;在合集信息卡与素材列表间增加分割线;空白简介不显示。新增 Widget 回归覆盖简介层级、音频/视频图标颜色及语义标签、图标标题顺序和对齐、停留详情页切换主题时行内容颜色即时刷新、无前置播放图标、无列标题、分割线、空白简介、单行/多行简介行高、两行省略和 390dp 窄屏无溢出。合集详情测试 7 项通过;相关 Dart 文件定向分析无问题,`dart format` 与 `git diff --check` 通过。未运行 `scripts/check.sh`:改动局限于详情列表 UI,定向测试与分析覆盖本次范围。未运行 Maestro:Flutter 设备枚举无法写入 SDK `engine.stamp`,CoreSimulator/CoreDevice 初始化失败,ADB 服务启动被权限阻止,没有可用目标设备。**完成时间**: 2026-10-25 17:47
89
- [x] 升级应用版本至 1.0.44(2026-10-25):将 `pubspec.yaml` 中的应用版本由 1.0.43 提升至 1.0.44。版本元数据变更,无需运行测试。**完成时间**: 2026-10-25 15:56
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
package app.echoloop
2+
3+
/**
4+
* 自适应 VAD 门槛:按当前环境底噪动态决定「有声」判定门槛。
5+
*
6+
* 与 iOS/macOS `AdaptiveVadThreshold.swift` 算法、参数保持一致(三端同源,改动需同步)。
7+
*
8+
* 旧实现使用固定门槛 0.015(增益前原始 RMS),说话小声或麦克风灵敏度低的用户
9+
* 整句电平都贴着甚至低于该值,说话中途被判为静音,触发静音兜底提前结束录音。
10+
*
11+
* 算法:
12+
* - 底噪 = 最近 [WINDOW_SIZE] 个「判为静音」的非零 buffer RMS 的 [FLOOR_PERCENTILE] 分位数。
13+
* 判为有声的 buffer 不进入统计,连续长时间说话也不会把底噪抬到语音电平;
14+
* 低分位进一步压低贴近门槛的弱语音对底噪的影响。
15+
* - 门槛 = 底噪 × [NOISE_MULTIPLIER],并限制在 [[MIN_THRESHOLD], [MAX_THRESHOLD]]。
16+
* - 上限 = 旧固定门槛:嘈杂环境下门槛最多回到旧行为,判定不会比修复前更严格。
17+
* - 下限:极安静环境下避免把微弱电流声/呼吸声当成说话,导致录音迟迟不停。
18+
* - 每个 buffer 用「加入本 buffer 之前」的门槛判定,自身不影响自身判定;
19+
* 尚无样本时使用上限门槛。
20+
*
21+
* 线程:由录音 IO 线程调用 [isVoiced],主线程调用 [reset]/[describe],方法均加锁。
22+
*/
23+
class AdaptiveVadThreshold {
24+
25+
companion object {
26+
/** 最低门槛(原始 RMS ≈ -48 dBFS)。 */
27+
const val MIN_THRESHOLD = 0.004f
28+
29+
/** 最高门槛,等于旧固定门槛。 */
30+
const val MAX_THRESHOLD = 0.015f
31+
32+
/**
33+
* 门槛相对底噪的倍数(约 +8 dB)。
34+
*
35+
* 取向「宁可多录几秒,也不要截断」:倍数偏低,弱语音更容易判为有声;
36+
* 代价是中等嘈杂环境下自动停止略慢(最终由最大录音时长兜底)。
37+
*/
38+
const val NOISE_MULTIPLIER = 2.5f
39+
40+
/** 底噪统计窗口:1024 帧 @16kHz ≈ 64ms/buffer,470 个 ≈ 30s。 */
41+
const val WINDOW_SIZE = 470
42+
43+
/** 底噪取窗口内的低分位数。 */
44+
const val FLOOR_PERCENTILE = 0.1
45+
}
46+
47+
private val window = ArrayDeque<Float>()
48+
private var voicedCount = 0
49+
private var totalCount = 0
50+
51+
/** 当前估计的底噪;尚无有效样本时为 null。 */
52+
var noiseFloor: Float? = null
53+
@Synchronized get
54+
private set
55+
56+
/** 当前判定门槛。 */
57+
var threshold: Float = MAX_THRESHOLD
58+
@Synchronized get
59+
private set
60+
61+
/** 判定本 buffer 是否有声,并用它更新底噪估计。 */
62+
@Synchronized
63+
fun isVoiced(rms: Float): Boolean {
64+
val voiced = rms >= threshold
65+
totalCount++
66+
if (voiced) voicedCount++
67+
// 只用静音 buffer 估计底噪;全零 buffer 是 AudioRecord 启动阶段的数字静音,不代表环境底噪。
68+
if (!voiced && rms > 0f) {
69+
window.addLast(rms)
70+
if (window.size > WINDOW_SIZE) window.removeFirst()
71+
updateThreshold()
72+
}
73+
return voiced
74+
}
75+
76+
/** 新录音回合开始时重置。 */
77+
@Synchronized
78+
fun reset() {
79+
window.clear()
80+
noiseFloor = null
81+
threshold = MAX_THRESHOLD
82+
voicedCount = 0
83+
totalCount = 0
84+
}
85+
86+
/** 诊断信息,随 stopSession 的 diagnostics 输出到 Dart 日志。 */
87+
@Synchronized
88+
fun describe(): String =
89+
"vadThreshold=${"%.4f".format(threshold)} " +
90+
"vadNoiseFloor=${noiseFloor?.let { "%.4f".format(it) } ?: "null"} " +
91+
"vadVoicedBuffers=$voicedCount/$totalCount"
92+
93+
private fun updateThreshold() {
94+
val sorted = window.sorted()
95+
val index = ((sorted.size - 1) * FLOOR_PERCENTILE).toInt()
96+
val floor = sorted[index]
97+
noiseFloor = floor
98+
threshold = (floor * NOISE_MULTIPLIER).coerceIn(MIN_THRESHOLD, MAX_THRESHOLD)
99+
}
100+
}

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

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,13 +56,15 @@ class AndroidSpeechPracticeHandler(
5656
private var firstDetectedSpeechMs: Double? = null
5757
private var lastDetectedSpeechMs: Double? = null
5858

59+
/** 有声判定门槛,按环境底噪自适应(替代旧固定门槛 0.015)。 */
60+
private val vadThreshold = AdaptiveVadThreshold()
61+
5962
// 权限请求回调
6063
private var pendingPermissionResult: MethodChannel.Result? = null
6164

6265
companion object {
6366
private const val TAG = "SpeechPractice"
6467
private const val PERMISSION_REQUEST_CODE = 9001
65-
private const val RMS_THRESHOLD = 0.015f
6668
}
6769

6870
init {
@@ -259,6 +261,7 @@ class AndroidSpeechPracticeHandler(
259261
val wavAfterTrim = filePath?.let(wavRecorder::wavFileMetrics)
260262
val diagnostics = buildString {
261263
append(wavRecorder.captureDiagnostics())
264+
append(" ${vadThreshold.describe()}")
262265
append(" wavBeforeTrim=$wavBeforeTrim")
263266
append(" wavAfterTrim=$wavAfterTrim")
264267
}
@@ -315,7 +318,7 @@ class AndroidSpeechPracticeHandler(
315318
val bufferStartMs = recordedDurationMs
316319
val bufferEndMs = bufferStartMs + bufferDurationMs
317320

318-
if (rms >= RMS_THRESHOLD) {
321+
if (vadThreshold.isVoiced(rms)) {
319322
if (!hasDetectedSpeech) {
320323
hasDetectedSpeech = true
321324
emitEvent(mapOf("type" to "speechStarted", "promptId" to promptId))
@@ -367,6 +370,7 @@ class AndroidSpeechPracticeHandler(
367370
recordedDurationMs = 0.0
368371
firstDetectedSpeechMs = null
369372
lastDetectedSpeechMs = null
373+
vadThreshold.reset()
370374
}
371375

372376
private fun cleanupSentenceState() {
@@ -381,6 +385,7 @@ class AndroidSpeechPracticeHandler(
381385
recordedDurationMs = 0.0
382386
firstDetectedSpeechMs = null
383387
lastDetectedSpeechMs = null
388+
vadThreshold.reset()
384389
}
385390

386391
private fun emitEvent(event: Map<String, Any>) {
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
package app.echoloop
2+
3+
import org.junit.Assert.assertEquals
4+
import org.junit.Assert.assertFalse
5+
import org.junit.Assert.assertTrue
6+
import org.junit.Test
7+
8+
/**
9+
* AdaptiveVadThreshold 的 JVM 单元测试。
10+
*
11+
* RMS 数值均为增益前的原始电平(与 WavRecorder.onBuffer 回调一致)。
12+
*/
13+
class AdaptiveVadThresholdTest {
14+
15+
private fun AdaptiveVadThreshold.feed(rms: Float, count: Int): List<Boolean> =
16+
List(count) { isVoiced(rms) }
17+
18+
@Test
19+
fun `没有任何样本时使用最高门槛(等同旧固定门槛)`() {
20+
val vad = AdaptiveVadThreshold()
21+
assertEquals(AdaptiveVadThreshold.MAX_THRESHOLD, vad.threshold, 0f)
22+
assertFalse(vad.isVoiced(0.014f))
23+
assertTrue(AdaptiveVadThreshold().isVoiced(0.015f))
24+
}
25+
26+
// 回归:用户日志中整段原始 RMS 仅约 0.007–0.011,旧固定门槛 0.015
27+
// 把正在说话的小声用户判成静音,5s 兜底提前结束录音。
28+
@Test
29+
fun `安静环境下小声说话被判为有声`() {
30+
val vad = AdaptiveVadThreshold()
31+
vad.feed(0.0015f, 8) // 开口前约 0.5s 环境底噪
32+
33+
val voiced = vad.feed(0.008f, 30)
34+
35+
assertTrue("小声语音应全部判为有声", voiced.all { it })
36+
assertTrue(vad.threshold < 0.008f)
37+
}
38+
39+
@Test
40+
fun `嘈杂环境门槛封顶在最高值,不会无限升高`() {
41+
val vad = AdaptiveVadThreshold()
42+
vad.feed(0.012f, 20) // 底噪 × 倍数 = 0.036,远超上限
43+
44+
assertEquals(AdaptiveVadThreshold.MAX_THRESHOLD, vad.threshold, 0f)
45+
assertFalse("底噪本身不应判为有声", vad.isVoiced(0.012f))
46+
assertTrue("达到旧门槛的语音必须判为有声", vad.isVoiced(0.02f))
47+
}
48+
49+
@Test
50+
fun `极安静环境门槛不低于最低值,避免微弱噪声被当成说话`() {
51+
val vad = AdaptiveVadThreshold()
52+
vad.feed(0.0003f, 20)
53+
54+
assertEquals(AdaptiveVadThreshold.MIN_THRESHOLD, vad.threshold, 0f)
55+
assertFalse(vad.isVoiced(0.003f))
56+
}
57+
58+
@Test
59+
fun `录音启动时的全零 buffer 不参与底噪估计`() {
60+
val vad = AdaptiveVadThreshold()
61+
vad.feed(0f, 5)
62+
vad.feed(0.003f, 10)
63+
64+
// 底噪按 0.003 估计:门槛 = 0.0075,而不是被全零拉到最低值
65+
assertEquals(0.0075f, vad.threshold, 1e-6f)
66+
}
67+
68+
// 取向「宁可多录几秒,也不要截断」:中等底噪下,略高于门槛的弱语音仍判为有声。
69+
@Test
70+
fun `中等底噪下弱语音仍判为有声`() {
71+
val vad = AdaptiveVadThreshold()
72+
vad.feed(0.004f, 10)
73+
74+
assertEquals(0.010f, vad.threshold, 1e-6f)
75+
assertTrue(vad.isVoiced(0.011f))
76+
assertFalse(vad.isVoiced(0.004f))
77+
}
78+
79+
@Test
80+
fun `开口后长时间连续说话门槛不会被语音抬高`() {
81+
val vad = AdaptiveVadThreshold()
82+
vad.feed(0.0015f, 8)
83+
84+
// 约 19s 连续语音(无明显停顿)
85+
val voiced = vad.feed(0.008f, 300)
86+
87+
assertTrue(voiced.all { it })
88+
}
89+
90+
@Test
91+
fun `reset 后恢复初始门槛`() {
92+
val vad = AdaptiveVadThreshold()
93+
vad.feed(0.0015f, 10)
94+
vad.reset()
95+
96+
assertEquals(AdaptiveVadThreshold.MAX_THRESHOLD, vad.threshold, 0f)
97+
assertEquals(null, vad.noiseFloor)
98+
}
99+
}

0 commit comments

Comments
 (0)