Files
English/kouyu_english/lib/core/speech_compare.dart
T
shenleiandClaude Opus 4.8 febfd30f49 feat: A1 短材料复现本单元理解词,并加入黑夜模式
课程内容
- 重写全部 21 个 A1 单元的听读材料,把本单元理解词织进听/读文本,
  单元内理解词复现率从约 20% 提升到约 77%(各单元 56–96%)。
- 修正 U01 房间号与机场大巴同为 thirty 的撞车(改为 17/30/40)。
- 校验器容差按级别读取(A1 为 8%),materials 覆盖率、字数、
  选项子串等校验全部通过;course_content_test 通过。

黑夜模式
- app_theme 拆分明/暗两套调色板,AppColors 随亮度切换;
  main 用 theme/darkTheme/themeMode + builder 镜像已解析亮度;
  主题偏好持久化到快照;进度页新增“外观主题”切换。

文档
- COURSE-PACK-JSON.md 更新 A1 词池覆盖(840/933)与材料复现约定。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-18 11:16:09 +09:00

150 lines
4.3 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/// 把设备转写和示范句逐词对照。这不是发音评分:转写只说明“机器听成了什么”,
/// 用来提示学习者哪些词没被听出来,值得再听示范、再说一次。
class SpokenWord {
const SpokenWord(this.text, {required this.heard});
/// 示范句中的原词(保留大小写和标点,便于直接显示)。
final String text;
final bool heard;
}
class SpokenComparison {
const SpokenComparison({required this.words, required this.extraWords});
final List<SpokenWord> words;
/// 转写里出现、但示范句没有的词。
final List<String> extraWords;
int get heardCount => words.where((word) => word.heard).length;
int get total => words.length;
List<String> get missedWords => [
for (final word in words)
if (!word.heard) word.text,
];
/// 全部听出且没有多余词。
bool get matches => total > 0 && heardCount == total && extraWords.isEmpty;
/// 大部分词被听出(≥ 80%)。
bool get close => total > 0 && heardCount / total >= 0.8;
}
const _digitWords = [
'zero',
'one',
'two',
'three',
'four',
'five',
'six',
'seven',
'eight',
'nine',
];
const _contractions = {
"i'm": ['i', 'am'],
"it's": ['it', 'is'],
"what's": ['what', 'is'],
"that's": ['that', 'is'],
"he's": ['he', 'is'],
"she's": ['she', 'is'],
"name's": ['name', 'is'],
"don't": ['do', 'not'],
"you're": ['you', 'are'],
};
/// 一个显示词对应的比较单位。例如 "Im" 展开为 i/am"138" 展开为
/// one/three/eight"oclock" 统一为 oclock。
List<String> _unitsOf(String raw) {
var word = raw
.toLowerCase()
.replaceAll(RegExp(r'[‘’`´]'), "'")
.replaceAll(RegExp(r"[^a-z0-9']"), '');
word = word.replaceAll(RegExp(r"^'+|'+$"), '');
if (word.isEmpty) return const [];
if (word == "o'clock") return const ['oclock'];
final expanded = _contractions[word];
if (expanded != null) return expanded;
if (RegExp(r'^\d+$').hasMatch(word)) {
return [for (final digit in word.split('')) _digitWords[int.parse(digit)]];
}
return [word.replaceAll("'", '')];
}
List<String> _displayWords(String text) => text
.replaceAll(RegExp(r'[-–—/]'), ' ')
.split(RegExp(r'\s+'))
.where((word) => _unitsOf(word).isNotEmpty)
.toList();
List<String> _heardUnits(String transcript) => [
for (final word in _displayWords(transcript)) ..._unitsOf(word),
];
SpokenComparison compareSpoken(String expected, String transcript) {
final display = _displayWords(expected);
final expectedUnits = <String>[];
final owner = <int>[];
for (var index = 0; index < display.length; index++) {
for (final unit in _unitsOf(display[index])) {
expectedUnits.add(unit);
owner.add(index);
}
}
var heard = _heardUnits(transcript);
// 拼读时转写常把字母连成一个词(S H E N → Shen),拆回单个字母。
final spellsLetters =
expectedUnits.isNotEmpty &&
expectedUnits.every((unit) => RegExp(r'^[a-z]$').hasMatch(unit));
if (spellsLetters) {
heard = [for (final unit in heard) ...unit.split('')];
}
// 最长公共子序列:保持词序,重复词也能正确对齐。
final n = expectedUnits.length;
final m = heard.length;
final table = List.generate(n + 1, (_) => List.filled(m + 1, 0));
for (var i = n - 1; i >= 0; i--) {
for (var j = m - 1; j >= 0; j--) {
table[i][j] = expectedUnits[i] == heard[j]
? table[i + 1][j + 1] + 1
: (table[i + 1][j] >= table[i][j + 1]
? table[i + 1][j]
: table[i][j + 1]);
}
}
final unitHeard = List.filled(n, false);
final heardUsed = List.filled(m, false);
var i = 0;
var j = 0;
while (i < n && j < m) {
if (expectedUnits[i] == heard[j]) {
unitHeard[i] = true;
heardUsed[j] = true;
i++;
j++;
} else if (table[i + 1][j] >= table[i][j + 1]) {
i++;
} else {
j++;
}
}
final wordHeard = List.filled(display.length, true);
for (var unit = 0; unit < n; unit++) {
if (!unitHeard[unit]) wordHeard[owner[unit]] = false;
}
return SpokenComparison(
words: [
for (var index = 0; index < display.length; index++)
SpokenWord(display[index], heard: wordHeard[index]),
],
extraWords: [
for (var index = 0; index < m; index++)
if (!heardUsed[index]) heard[index],
],
);
}