/// 把设备转写和示范句逐词对照。这不是发音评分:转写只说明“机器听成了什么”, /// 用来提示学习者哪些词没被听出来,值得再听示范、再说一次。 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 words; /// 转写里出现、但示范句没有的词。 final List extraWords; int get heardCount => words.where((word) => word.heard).length; int get total => words.length; List 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'], }; /// 一个显示词对应的比较单位。例如 "I’m" 展开为 i/am,"138" 展开为 /// one/three/eight,"o’clock" 统一为 oclock。 List _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 _displayWords(String text) => text .replaceAll(RegExp(r'[-–—/]'), ' ') .split(RegExp(r'\s+')) .where((word) => _unitsOf(word).isNotEmpty) .toList(); List _heardUnits(String transcript) => [ for (final word in _displayWords(transcript)) ..._unitsOf(word), ]; SpokenComparison compareSpoken(String expected, String transcript) { final display = _displayWords(expected); final expectedUnits = []; final owner = []; 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], ], ); }