From 0c1962f4a15deaf8e00329a552433495469e3603 Mon Sep 17 00:00:00 2001 From: styayur <你的GitHub邮箱> Date: Thu, 1 Oct 2026 02:30:13 +0800 Subject: [PATCH 1/2] =?UTF-8?q?FEAT:=20=E9=9B=86=E6=88=90=20Kokoro=20?= =?UTF-8?q?=E5=A4=9A=E8=AF=AD=E8=A8=80=20TTS=20=E4=B8=8E=E6=96=87=E6=9C=AC?= =?UTF-8?q?=E8=BD=AC=E8=AF=BE=E6=97=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- PLAN.md | 2 +- README.en.md | 1 + README.md | 1 + TASKS.md | 1 + THIRD_PARTY_NOTICES.md | 66 ++++ analysis_options.yaml | 9 + integration_test/kokoro_tts_test.dart | 54 +++- .../data/text_lesson_generator.dart | 271 ++++++++++++++++ .../providers/text_lesson_provider.dart | 134 ++++++++ .../screens/text_lesson_screen.dart | 252 +++++++++++++++ lib/l10n/app_en.arb | 22 +- lib/l10n/app_localizations.dart | 120 ++++++++ lib/l10n/app_localizations_en.dart | 63 ++++ lib/l10n/app_localizations_zh.dart | 61 ++++ lib/l10n/app_zh.arb | 22 +- lib/models/intensive_listen_settings.dart | 25 ++ .../listen_and_repeat_controller.dart | 35 ++- .../tts/tts_controller_provider.dart | 19 +- lib/providers/tts/tts_settings_provider.dart | 41 ++- lib/router/app_router.dart | 9 + lib/screens/library_screen.dart | 12 + lib/screens/tts_settings_screen.dart | 38 ++- lib/services/tts/kokoro_model_catalog.dart | 28 +- lib/services/tts/kokoro_model_manager.dart | 46 ++- lib/services/tts/kokoro_synthesizer.dart | 29 +- lib/services/tts/kokoro_tts_engine.dart | 18 +- lib/services/tts/kokoro_voices.dart | 221 ++++++++++--- lib/services/tts/piper_tts_engine.dart | 5 +- lib/services/tts/text_segmenter.dart | 166 ++++++++++ lib/services/tts/tts_cache_store.dart | 13 +- lib/services/tts/tts_coordinator.dart | 23 +- lib/services/tts/tts_engine.dart | 15 +- lib/services/tts/wav_audio_assembler.dart | 290 ++++++++++++++++++ .../listen_and_repeat_settings_sheet.dart | 58 ++++ .../tts/tts_model_download_prompt_dialog.dart | 18 ++ pubspec.lock | 66 ++-- pubspec.yaml | 4 +- .../text_lesson_generator_test.dart | 139 +++++++++ .../tts/tts_controller_preview_test.dart | 30 +- .../tts/tts_settings_provider_test.dart | 66 ++-- test/screens/tts_settings_screen_test.dart | 29 +- test/screens/tts_voice_preview_row_test.dart | 9 +- test/services/tts/kokoro_real_model_test.dart | 51 +++ test/services/tts/kokoro_tts_engine_test.dart | 36 ++- test/services/tts/kokoro_voices_test.dart | 67 ++-- test/services/tts/text_segmenter_test.dart | 72 +++++ .../tts/wav_audio_assembler_test.dart | 99 ++++++ ...tts_model_download_prompt_dialog_test.dart | 8 +- 48 files changed, 2634 insertions(+), 230 deletions(-) create mode 100644 THIRD_PARTY_NOTICES.md create mode 100644 lib/features/text_to_lesson/data/text_lesson_generator.dart create mode 100644 lib/features/text_to_lesson/providers/text_lesson_provider.dart create mode 100644 lib/features/text_to_lesson/screens/text_lesson_screen.dart create mode 100644 lib/services/tts/text_segmenter.dart create mode 100644 lib/services/tts/wav_audio_assembler.dart create mode 100644 test/features/text_to_lesson/text_lesson_generator_test.dart create mode 100644 test/services/tts/kokoro_real_model_test.dart create mode 100644 test/services/tts/text_segmenter_test.dart create mode 100644 test/services/tts/wav_audio_assembler_test.dart diff --git a/PLAN.md b/PLAN.md index 22ad3a369..b61cb2b40 100644 --- a/PLAN.md +++ b/PLAN.md @@ -92,7 +92,7 @@ Echo Loop 是一个围绕“音频输入 + 句子级学习 + 间隔复习 + AI ## 关键 ADR 索引 - 媒体引擎与前台引擎分离:避免锁屏媒体会话与前台试听互相污染。 -- 统一 TTS 架构:合成 → 文件 → 缓存 → 播放,支持平台 TTS 与 Kokoro 本地 TTS。 +- 统一 TTS 架构:合成 → 文件 → 缓存 → 播放,支持平台 TTS 与 Kokoro 本地 TTS;Kokoro 使用官方 v1.1 中英多语言模型,并支持 Text → Lesson 与跟读参考音。 - 离线转录与本地模型:复用统一音频处理与模型下载能力。 - 平台 + 渠道统一识别:`platform + distribution` 决定支付实现和后端配额策略。 - 通用记忆调度基础设施:以独立调度快照与只追加复习事件建模;上层依赖应用自有接口,FSRS 仅限 adapter 内部,按逐项固定 Profile 保障可迁移与可审计性(见 [memory-scheduler-infrastructure-plan.md](./docs/memory-scheduler-infrastructure-plan.md))。 diff --git a/README.en.md b/README.en.md index 2d2631309..26084584d 100644 --- a/README.en.md +++ b/README.en.md @@ -66,6 +66,7 @@ - 📊 **Learning statistics**: Tracks practice time, input/output ratio, and unique vocabulary — see exactly how much you've practiced and spoken. - 🎙️ **AI shadowing evaluation**: Automatically aligns ASR results with the original text, highlights matched words, and gives a shadowing rating. - 🎧 **Local audio import + AI subtitles**: Batch-import local audio, import existing subtitles, or auto-transcribe with AI. +- 🗣️ **Offline Kokoro speech + text lessons**: Download the Kokoro v1.1 Chinese/English model and generate speech offline for sentences, vocabulary, shadowing references, and Text to Listening lessons with voice and 0.5x–2.0x speed controls. --- diff --git a/README.md b/README.md index 727379c81..194d64d93 100644 --- a/README.md +++ b/README.md @@ -83,6 +83,7 @@ - 📊 **学习数据统计**:记录学习时长、输入输出比例、唯一词汇量,让练了多少、说了多少一目了然。 - 🎙️ **跟读 AI 评测**:自动对齐识别结果与原文,高亮命中词,并给出跟读评级。 - 🎧 **导入本地音频 + AI 字幕**:支持批量导入本地音频,可导入本地字幕,也可用 AI 自动转录生成字幕。 +- 🗣️ **离线 Kokoro 语音 + 文本转课时**:可下载 Kokoro v1.1 中英多语言模型,在句子、生词、跟读参考音和“文本转听力”中离线生成语音;支持音色与 0.5x–2.0x 语速。 --- diff --git a/TASKS.md b/TASKS.md index ab7b17346..ed521bce8 100644 --- a/TASKS.md +++ b/TASKS.md @@ -4,6 +4,7 @@ ## 本次完成 +- [x] 完成 Kokoro 多语言本地 TTS、文本转课时与跟读参考音整合(2026-10-01):升级 `sherpa_onnx` 1.13.8;Kokoro 模型切换为官方 v1.1 中英多语言 fp32/int8(103 speakers,按需下载/校验/解包/删除);新增文本分段、WAV 流式合并、SRT 生成和 Text → Lesson 页面;句子/生词继续复用统一发音链路;跟读页新增原音/本地 TTS 参考源;设置页新增 0.5x–2.0x 语速;真实 int8 模型完成中英混合合成验证。**完成时间**: 2026-10-01 - [x] 修复 GitHub CI 难句补练测试与返回手势测试失败,并删除低价值单测(2026-09-29):为难句页面测试关闭依赖 SharedPreferences 的新手引导;媒体页面 dispose 收尾改为微任务,返回手势测试显式卸载路由树并推进收尾;删除仅验证 AppUpdateState 构造和类型的 5 项单测,保留更新信息解析边界测试。相关测试 49 项通过、5 项跳过,修改文件定向分析通过。**完成时间**: 2026-09-29 23:45 - [x] 删除低价值播放器测试(2026-09-29):移除只验证分割线固定颜色与像素宽度的 widget 用例,删去已由行为测试覆盖的内部定位参数断言;保留滚动、播放、生命周期及有明确产品要求的控件断言。相关测试共 153 项通过。**完成时间**: 2026-09-29 23:07 - [x] 修复 GitHub CI 分析失败及近期播放器回归测试异常(2026-09-29):为全文与收藏 tab 分别注入分页控制器;新手引导总开关关闭时不创建 Showcase 目标;媒体页面测试等待真实文件 IO 和对应加载状态,兼容音频及字幕仍在加载的用例。CI 相关测试共 154 项通过,修改文件定向分析通过(现存 warning/info 非致命)。**完成时间**: 2026-09-29 22:57 diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md new file mode 100644 index 000000000..10a0263d6 --- /dev/null +++ b/THIRD_PARTY_NOTICES.md @@ -0,0 +1,66 @@ +# Third-Party Notices + +Echo Loop is distributed under AGPL-3.0. The following third-party projects and +model assets are used by or adapted in the offline speech subsystem. They remain +under their respective licenses and are not relicensed by Echo Loop. + +## TinyTTS + +- Project: https://github.com/styayur/TinyTTS +- License: MIT +- Usage: The TextSegmenter behavior, bounded synthesis pipeline ideas, + cancellation boundaries, WAV output strategy, and tests were used as design + references and adapted to Dart. TinyTTS is not invoked as a subprocess and is + not a runtime dependency. + +Copyright (c) 2026 TinyTTS contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +## sherpa-onnx + +- Project: https://github.com/k2-fsa/sherpa-onnx +- License: Apache License 2.0 +- Usage: Native offline ASR and TTS inference runtime. Echo Loop does not bundle + a second sherpa-onnx runtime. + +## Kokoro-82M-v1.1-zh + +- Model: https://huggingface.co/hexgrad/Kokoro-82M-v1.1-zh +- Packaged model: https://github.com/k2-fsa/sherpa-onnx/releases/tag/tts-models +- License: Apache License 2.0 +- Usage: Optional download at runtime. The model is not bundled in the app + binary and is not distributed under AGPL-3.0. + +The sherpa-onnx release package also contains espeak-ng data used for +grapheme-to-phoneme conversion. + +## eSpeak NG data + +- Project: https://github.com/espeak-ng/espeak-ng +- License: GNU General Public License v3.0 or later +- Usage: The `espeak-ng-data` directory in the optional Kokoro model archive is + used only for local phonemization. It remains under GPL-3.0-or-later and is + compatible with Echo Loop's AGPL-3.0 distribution. + +## ONNX Runtime + +- Project: https://github.com/microsoft/onnxruntime +- License: MIT +- Usage: Native inference backend distributed through `sherpa_onnx`. diff --git a/analysis_options.yaml b/analysis_options.yaml index 822cc7444..c0bdde30a 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -7,6 +7,15 @@ # The following line activates a set of recommended lints for Flutter apps, # packages, and plugins designed to encourage good coding practices. +analyzer: + exclude: + - build/** + - android/** + - ios/** + - web/** + - windows/** + - macos/** + - linux/** include: package:flutter_lints/flutter.yaml linter: diff --git a/integration_test/kokoro_tts_test.dart b/integration_test/kokoro_tts_test.dart index e1db93912..e829f8f87 100644 --- a/integration_test/kokoro_tts_test.dart +++ b/integration_test/kokoro_tts_test.dart @@ -1,6 +1,6 @@ /// Echo Loop TTS(Kokoro)端到端集成测试。 /// -/// 从 CDN 下载真实 Kokoro int8 模型(首次较慢,缓存到 app support 跨次复用; +/// 从官方 GitHub Release 下载真实 Kokoro int8 多语言模型(首次较慢,缓存到 app support 跨次复用; /// 下载失败则跳过),跑真实 sherpa-onnx native 合成,验证: /// 1. 合成产出非空有效 wav; /// 2. 美音 / 英音音色产出**不同**音频——印证 Kokoro 在 macOS 能正确区分口音 @@ -19,6 +19,7 @@ import 'package:path_provider/path_provider.dart'; import 'package:drift/native.dart'; import 'package:echo_loop/database/app_database.dart'; import 'package:echo_loop/services/tts/kokoro_model_manager.dart'; +import 'package:echo_loop/services/tts/kokoro_model_catalog.dart'; import 'package:echo_loop/services/tts/kokoro_tts_engine.dart'; import 'package:echo_loop/services/tts/tts_cache_store.dart'; import 'package:echo_loop/services/tts/tts_coordinator.dart'; @@ -68,7 +69,11 @@ void main() { late Directory outDir; setUpAll(() async { - manager = KokoroModelManager(); + final modelRoot = Platform.environment['KOKORO_MODEL_ROOT']; + manager = KokoroModelManager( + spec: kokoroSpecOf(KokoroModelVariant.int8), + modelsRootResolver: modelRoot == null ? null : () async => modelRoot, + ); outDir = await getTemporaryDirectory(); modelReady = await manager.isModelDownloaded(); @@ -99,9 +104,9 @@ void main() { const text = 'The quick brown fox jumps over the lazy dog.'; - // 美音(af_sarah)。 + // 美音(af_sol)。 await engine.applyConfig( - const TtsSpeechConfig(languageTag: 'en-US', voiceName: 'af_sarah'), + const TtsSpeechConfig(languageTag: 'en-US', voiceName: 'af_sol'), ); final us = await engine.synthesize( text, @@ -117,9 +122,9 @@ void main() { '[Kokoro Test] US bytes=${await usFile.length()} sr=${us.sampleRate}', ); - // 英音(bf_emma)。 + // 英音(bf_vale)。 await engine.applyConfig( - const TtsSpeechConfig(languageTag: 'en-GB', voiceName: 'bf_emma'), + const TtsSpeechConfig(languageTag: 'en-GB', voiceName: 'bf_vale'), ); final uk = await engine.synthesize( text, @@ -151,6 +156,28 @@ void main() { addTearDown(engine.dispose); expect(await engine.speakLive('hello'), isFalse); }); + + testWidgets('中文与中英混合文本可合成', (tester) async { + if (!modelReady) { + markTestSkipped('Kokoro 模型不可用'); + return; + } + final engine = KokoroTtsEngine(resolvePaths: manager.kokoroConfigPaths); + addTearDown(engine.dispose); + await engine.applyConfig( + const TtsSpeechConfig(languageTag: 'zh-CN', voiceName: 'zf_001'), + ); + final result = await engine.synthesize( + '你好,我正在学习 machine learning。今天很开心!', + outputDir: outDir.path, + baseName: 'kokoro_zh_mixed', + ); + expect(result, isNotNull, reason: '中英混合合成应成功'); + expect(await File(result!.filePath).length(), greaterThan(1000)); + debugPrint( + '[Kokoro Test] ZH mixed bytes=${await File(result.filePath).length()}', + ); + }); }); group('统一管线端到端(文本 → 协调器 → 引擎 → 缓存 → 播放)', () { @@ -170,8 +197,8 @@ void main() { _CountingEngine? counting; final coordinator = TtsCoordinator( - factory: (kind) { - // 本测试只用 echoLoop。 + factory: (kind, config) { + // 本测试只用 kokoro。 final inner = KokoroTtsEngine( resolvePaths: manager.kokoroConfigPaths, ); @@ -183,11 +210,8 @@ void main() { ); addTearDown(coordinator.dispose); - const config = TtsSpeechConfig( - languageTag: 'en-US', - voiceName: 'af_sarah', - ); - await coordinator.configure(TtsEngineKind.echoLoop, config); + const config = TtsSpeechConfig(languageTag: 'en-US', voiceName: 'af_sol'); + await coordinator.configure(TtsEngineKind.kokoro, config); const text = 'Learning English with Echo Loop is really fun.'; @@ -199,9 +223,9 @@ void main() { // 缓存确实落库且文件存在。 final cacheKey = cacheStore.deriveKey( text: text, - engine: TtsEngineKind.echoLoop, + engine: TtsEngineKind.kokoro, voiceId: config.voiceId, - speed: config.rate, + speed: config.speed, ); final cachedFile = await cacheStore.lookup(cacheKey); expect(cachedFile, isNotNull, reason: '合成结果应入缓存'); diff --git a/lib/features/text_to_lesson/data/text_lesson_generator.dart b/lib/features/text_to_lesson/data/text_lesson_generator.dart new file mode 100644 index 000000000..c6e10e3f9 --- /dev/null +++ b/lib/features/text_to_lesson/data/text_lesson_generator.dart @@ -0,0 +1,271 @@ +/// Text → Lesson 编排层。 +/// +/// 把纯文本切句后逐段调用统一 TTS 协调器,将每段 PCM WAV 流式合并为一个 +/// 16-bit PCM WAV,并生成 SRT 后写入现有 AudioItem/字幕数据库模型。 +library; + +import 'dart:io'; + +import 'package:path/path.dart' as p; +import 'package:uuid/uuid.dart'; + +import '../../../database/daos/audio_item_dao.dart'; +import '../../../models/audio_item.dart'; +import '../../../models/sentence.dart'; +import '../../../services/app_logger.dart'; +import '../../../services/tts/kokoro_model_catalog.dart'; +import '../../../services/tts/text_segmenter.dart'; +import '../../../services/tts/tts_coordinator.dart'; +import '../../../services/tts/tts_engine.dart'; +import '../../../services/tts/wav_audio_assembler.dart'; +import '../../../utils/app_data_dir.dart'; +import '../../../utils/word_counter.dart'; + +/// 可取消 token;生成流程在每段边界检查。 +class TextLessonCancellationToken { + bool _cancelled = false; + + bool get isCancelled => _cancelled; + + void cancel() => _cancelled = true; +} + +/// 生成进度。 +class TextLessonProgress { + const TextLessonProgress({ + required this.completedSegments, + required this.totalSegments, + required this.message, + }); + + final int completedSegments; + final int totalSegments; + final String message; + + double get fraction => totalSegments == 0 + ? 0 + : (completedSegments / totalSegments).clamp(0.0, 1.0); +} + +/// 生成结果。 +class TextLessonResult { + const TextLessonResult({required this.audioItem, required this.audioFile}); + + final AudioItem audioItem; + final AssembledWav audioFile; +} + +class TextLessonCancelledException implements Exception { + const TextLessonCancelledException(); + + @override + String toString() => 'Text lesson generation cancelled.'; +} + +class TextLessonGenerationException implements Exception { + const TextLessonGenerationException(this.message, [this.cause]); + + final String message; + final Object? cause; + + @override + String toString() => message; +} + +/// 可注入的 TTS 文件合成端口,避免编排层直接依赖 sherpa_onnx。 +abstract interface class TextLessonSynthesizer { + Future renderToCache( + String text, { + required TtsEngineKind kind, + required TtsSpeechConfig config, + }); +} + +class CoordinatorTextLessonSynthesizer implements TextLessonSynthesizer { + const CoordinatorTextLessonSynthesizer(this._coordinator); + + final TtsCoordinator _coordinator; + + @override + Future renderToCache( + String text, { + required TtsEngineKind kind, + required TtsSpeechConfig config, + }) { + return _coordinator.renderToCache(text, kind: kind, config: config); + } +} + +typedef AddGeneratedAudioItem = Future Function(AudioItem item); + +class TextLessonGenerator { + TextLessonGenerator({ + required TextLessonSynthesizer synthesizer, + required AudioItemDao audioItemDao, + required AddGeneratedAudioItem addAudioItem, + Future Function()? dataDirectoryResolver, + TextSegmenter? segmenter, + Uuid? uuid, + }) : _synthesizer = synthesizer, + _audioItemDao = audioItemDao, + _addAudioItem = addAudioItem, + _dataDirectoryResolver = dataDirectoryResolver ?? getAppDataDirectory, + _segmenter = segmenter ?? const TextSegmenter(), + _uuid = uuid ?? const Uuid(); + + final TextLessonSynthesizer _synthesizer; + final AudioItemDao _audioItemDao; + final AddGeneratedAudioItem _addAudioItem; + final Future Function() _dataDirectoryResolver; + final TextSegmenter _segmenter; + final Uuid _uuid; + + Future generate({ + required String title, + required String text, + required String voiceId, + required double speed, + required KokoroModelVariant modelVariant, + TextLessonCancellationToken? cancellationToken, + void Function(TextLessonProgress progress)? onProgress, + }) async { + final segments = _segmenter.split(text); + if (segments.isEmpty) { + throw const TextLessonGenerationException('Text is empty.'); + } + final id = _uuid.v4(); + final dataDir = await _dataDirectoryResolver(); + final relativeAudioPath = p.join('audios', 'generated', '$id.wav'); + final absoluteAudioPath = p.join(dataDir.path, relativeAudioPath); + final assembler = WavAudioAssembler(outputPath: absoluteAudioPath); + final startedAt = DateTime.now(); + + try { + await assembler.start(); + for (var index = 0; index < segments.length; index++) { + if (cancellationToken?.isCancelled ?? false) { + throw const TextLessonCancelledException(); + } + final segment = segments[index]; + onProgress?.call( + TextLessonProgress( + completedSegments: index, + totalSegments: segments.length, + message: 'Synthesizing ${index + 1}/${segments.length}', + ), + ); + final synthesized = await _synthesizer.renderToCache( + segment, + kind: TtsEngineKind.kokoro, + config: TtsSpeechConfig( + languageTag: RegExp(r'[\u3400-\u9fff]').hasMatch(segment) + ? 'zh-CN' + : 'en-US', + voiceName: voiceId, + speed: speed.clamp(0.5, 2.0).toDouble(), + modelTag: kokoroSpecOf(modelVariant).id, + ), + ); + if (synthesized == null || synthesized.isEmpty) { + throw TextLessonGenerationException( + 'Failed to synthesize segment ${index + 1}.', + ); + } + await assembler.addWav(synthesized, segment); + onProgress?.call( + TextLessonProgress( + completedSegments: index + 1, + totalSegments: segments.length, + message: 'Synthesized ${index + 1}/${segments.length}', + ), + ); + } + + if (cancellationToken?.isCancelled ?? false) { + throw const TextLessonCancelledException(); + } + + onProgress?.call( + TextLessonProgress( + completedSegments: segments.length, + totalSegments: segments.length, + message: 'Finalizing audio', + ), + ); + final assembled = await assembler.finish(); + final srt = _buildSrt(assembled.sentences); + final audioItem = AudioItem( + id: id, + name: title.trim().isEmpty ? 'Text Lesson' : title.trim(), + audioPath: relativeAudioPath, + transcriptPath: null, + addedDate: startedAt, + totalDuration: assembled.duration.inSeconds.clamp(1, 1 << 31).toInt(), + sentenceCount: assembled.sentences.length, + wordCount: countWords(text), + transcriptSource: TranscriptSource.local, + ); + await _addAudioItem(audioItem); + await _audioItemDao.saveTranscriptContent( + id, + srt: srt, + wordTimestampsJson: null, + ); + AppLogger.log( + 'TextLesson', + 'generated id=$id segments=${segments.length} ' + 'durationMs=${assembled.duration.inMilliseconds} ' + 'bytes=${assembled.byteLength}', + ); + return TextLessonResult(audioItem: audioItem, audioFile: assembled); + } on TextLessonCancelledException { + await assembler.abort(); + rethrow; + } on TextLessonGenerationException { + await assembler.abort(); + rethrow; + } catch (error, stackTrace) { + await assembler.abort(); + AppLogger.log('TextLesson', 'generation failed: $error\n$stackTrace'); + throw TextLessonGenerationException( + 'Failed to generate lesson audio.', + error, + ); + } + } + + String _buildSrt(List sentences) { + final buffer = StringBuffer(); + for (var index = 0; index < sentences.length; index++) { + final sentence = sentences[index]; + buffer.writeln(index + 1); + buffer.writeln( + '${_formatSrtTime(sentence.start)} --> ${_formatSrtTime(sentence.end)}', + ); + buffer.writeln(sentence.text.trim()); + if (index < sentences.length - 1) buffer.writeln(); + } + return buffer.toString(); + } + + String _formatSrtTime(Duration duration) { + final hours = duration.inHours.toString().padLeft(2, '0'); + final minutes = (duration.inMinutes % 60).toString().padLeft(2, '0'); + final seconds = (duration.inSeconds % 60).toString().padLeft(2, '0'); + final millis = (duration.inMilliseconds % 1000).toString().padLeft(3, '0'); + return '$hours:$minutes:$seconds,$millis'; + } +} + +/// 供显示/测试使用的句子索引映射,保持与 SRT 顺序一致。 +List sentencesFromAssembled(List sentences) { + return [ + for (final sentence in sentences) + Sentence( + index: sentence.index, + text: sentence.text, + startTime: sentence.start, + endTime: sentence.end, + ), + ]; +} diff --git a/lib/features/text_to_lesson/providers/text_lesson_provider.dart b/lib/features/text_to_lesson/providers/text_lesson_provider.dart new file mode 100644 index 000000000..4dc82dcd8 --- /dev/null +++ b/lib/features/text_to_lesson/providers/text_lesson_provider.dart @@ -0,0 +1,134 @@ +/// Text → Lesson Riverpod 状态与依赖装配。 +library; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../database/providers.dart'; +import '../../../providers/audio_library_provider.dart'; +import '../../../providers/tts/tts_controller_provider.dart'; +import '../../../providers/tts/tts_settings_provider.dart'; +import '../../../services/tts/tts_engine.dart'; +import '../../../widgets/tts/tts_model_download_prompt_dialog.dart'; +import '../data/text_lesson_generator.dart'; + +enum TextLessonStatus { idle, generating, completed, failed, cancelled } + +class TextLessonState { + const TextLessonState({ + this.status = TextLessonStatus.idle, + this.progress, + this.result, + this.error, + }); + + final TextLessonStatus status; + final TextLessonProgress? progress; + final TextLessonResult? result; + final String? error; + + bool get isGenerating => status == TextLessonStatus.generating; +} + +final textLessonGeneratorProvider = Provider((ref) { + final coordinator = ref.read(ttsControllerProvider.notifier).coordinator; + return TextLessonGenerator( + synthesizer: CoordinatorTextLessonSynthesizer(coordinator), + audioItemDao: ref.read(audioItemDaoProvider), + addAudioItem: ref.read(audioLibraryProvider.notifier).addAudioItem, + ); +}); + +final textLessonControllerProvider = + NotifierProvider( + TextLessonController.new, + ); + +class TextLessonController extends Notifier { + int _generationToken = 0; + TextLessonCancellationToken? _cancellationToken; + + @override + TextLessonState build() => const TextLessonState(); + + /// 生成课时;模型未下载时先走统一下载门控。 + Future generate({ + required String title, + required String text, + required String voiceId, + required double speed, + }) async { + final token = ++_generationToken; + final settings = ref.read(ttsSettingsProvider); + final modelReady = await isTtsModelReadyForConfig( + ref, + engine: TtsEngineKind.kokoro, + kokoroVariant: settings.kokoroVariant, + ); + if (!modelReady) { + state = const TextLessonState( + status: TextLessonStatus.failed, + error: 'offline_model_required', + ); + return null; + } + if (token != _generationToken) return null; + + final cancellationToken = TextLessonCancellationToken(); + _cancellationToken = cancellationToken; + state = const TextLessonState(status: TextLessonStatus.generating); + try { + final result = await ref + .read(textLessonGeneratorProvider) + .generate( + title: title, + text: text, + voiceId: voiceId, + speed: speed, + modelVariant: settings.kokoroVariant, + cancellationToken: cancellationToken, + onProgress: (progress) { + if (token != _generationToken) return; + state = TextLessonState( + status: TextLessonStatus.generating, + progress: progress, + ); + }, + ); + if (token != _generationToken) return null; + state = TextLessonState( + status: TextLessonStatus.completed, + result: result, + ); + return result; + } on TextLessonCancelledException { + if (token == _generationToken) { + state = const TextLessonState(status: TextLessonStatus.cancelled); + } + return null; + } on TextLessonGenerationException catch (error) { + if (token == _generationToken) { + state = TextLessonState( + status: TextLessonStatus.failed, + error: error.message, + ); + } + return null; + } finally { + if (token == _generationToken) _cancellationToken = null; + } + } + + Future cancel() async { + _generationToken++; + _cancellationToken?.cancel(); + _cancellationToken = null; + state = const TextLessonState(status: TextLessonStatus.cancelled); + } + + void reset() { + _generationToken++; + _cancellationToken?.cancel(); + _cancellationToken = null; + state = const TextLessonState(); + } +} diff --git a/lib/features/text_to_lesson/screens/text_lesson_screen.dart b/lib/features/text_to_lesson/screens/text_lesson_screen.dart new file mode 100644 index 000000000..6ad9d4096 --- /dev/null +++ b/lib/features/text_to_lesson/screens/text_lesson_screen.dart @@ -0,0 +1,252 @@ +/// Text → Lesson 页面:粘贴/导入纯文本,选择音色与语速,生成可学习音频。 +library; + +import 'dart:convert'; + +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import '../../../l10n/app_localizations.dart'; +import '../../../providers/tts/kokoro_model_provider.dart'; +import '../../../providers/tts/tts_settings_provider.dart'; +import '../../../router/app_router.dart'; +import '../../../services/tts/kokoro_voices.dart'; +import '../../../services/tts/tts_engine.dart'; +import '../../../theme/app_theme.dart'; +import '../../../widgets/tts/tts_model_download_prompt_dialog.dart'; +import '../providers/text_lesson_provider.dart'; + +class TextLessonScreen extends ConsumerStatefulWidget { + const TextLessonScreen({super.key}); + + @override + ConsumerState createState() => _TextLessonScreenState(); +} + +class _TextLessonScreenState extends ConsumerState { + final _titleController = TextEditingController(); + final _textController = TextEditingController(); + String _voiceId = kokoroDefaultVoiceUs; + double _speed = 1.0; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + final settings = ref.read(ttsSettingsProvider); + setState(() { + _voiceId = settings.activeKokoroVoice; + _speed = settings.speed; + }); + if (settings.engine != TtsEngineKind.kokoro) { + ref.read(ttsSettingsProvider.notifier).setEngine(TtsEngineKind.kokoro); + } + }); + } + + @override + void dispose() { + _titleController.dispose(); + _textController.dispose(); + super.dispose(); + } + + Future _pickTextFile() async { + final result = await FilePicker.platform.pickFiles( + type: FileType.custom, + allowedExtensions: const ['txt'], + withData: true, + ); + final file = result?.files.singleOrNull; + if (file == null) return; + final bytes = file.bytes; + if (bytes == null) return; + final text = utf8.decode(bytes, allowMalformed: true); + if (!mounted) return; + setState(() { + _textController.text = text; + if (_titleController.text.trim().isEmpty) { + _titleController.text = file.name.replaceFirst( + RegExp(r'\.txt$', caseSensitive: false), + '', + ); + } + }); + } + + Future _generate() async { + final l10n = AppLocalizations.of(context)!; + final text = _textController.text.trim(); + if (text.isEmpty) { + _showMessage(l10n.textToLessonTextRequired); + return; + } + final settings = ref.read(ttsSettingsProvider); + if (settings.engine != TtsEngineKind.kokoro) { + await ref + .read(ttsSettingsProvider.notifier) + .setEngine(TtsEngineKind.kokoro); + } + final ready = await ensureTtsModelReadyForPlaybackFromWidget(ref); + if (!ready || !mounted) return; + + final result = await ref + .read(textLessonControllerProvider.notifier) + .generate( + title: _titleController.text, + text: text, + voiceId: _voiceId, + speed: _speed, + ); + if (!mounted || result == null) return; + if (!context.mounted) return; + context.push( + AppRoutes.audioLearningPlan(result.audioItem.id, autoStart: true), + ); + } + + void _showMessage(String message) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(message))); + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + final state = ref.watch(textLessonControllerProvider); + final model = ref + .watch(kokoroModelProvider) + .of(ref.watch(ttsSettingsProvider).kokoroVariant); + final voices = kokoroVoices; + return Scaffold( + appBar: AppBar(title: Text(l10n.textToLessonTitle)), + body: ListView( + padding: const EdgeInsets.all(AppSpacing.m), + children: [ + Text( + l10n.textToLessonDescription, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: AppSpacing.m), + TextField( + controller: _titleController, + textInputAction: TextInputAction.next, + decoration: InputDecoration( + labelText: l10n.textToLessonTitleLabel, + border: const OutlineInputBorder(), + ), + ), + const SizedBox(height: AppSpacing.m), + TextField( + controller: _textController, + minLines: 8, + maxLines: 16, + decoration: InputDecoration( + labelText: l10n.textToLessonTextLabel, + hintText: l10n.textToLessonTextHint, + border: const OutlineInputBorder(), + alignLabelWithHint: true, + ), + ), + const SizedBox(height: AppSpacing.s), + Align( + alignment: Alignment.centerLeft, + child: OutlinedButton.icon( + onPressed: state.isGenerating ? null : _pickTextFile, + icon: const Icon(Icons.upload_file), + label: Text(l10n.textToLessonImportFile), + ), + ), + const SizedBox(height: AppSpacing.m), + DropdownButtonFormField( + initialValue: voices.any((voice) => voice.id == _voiceId) + ? _voiceId + : kokoroDefaultVoiceUs, + decoration: InputDecoration( + labelText: l10n.textToLessonVoice, + border: const OutlineInputBorder(), + ), + items: [ + for (final voice in voices) + DropdownMenuItem( + value: voice.id, + child: Text( + '${voice.language == TtsLanguage.chinese ? '中文' : 'English'}' + ' · ${voice.displayName}', + ), + ), + ], + onChanged: state.isGenerating + ? null + : (value) { + if (value != null) setState(() => _voiceId = value); + }, + ), + const SizedBox(height: AppSpacing.m), + Row( + children: [ + Expanded(child: Text(l10n.textToLessonSpeed)), + Text('${_speed.toStringAsFixed(1)}x'), + ], + ), + Slider( + value: _speed, + min: 0.5, + max: 2.0, + divisions: 15, + label: '${_speed.toStringAsFixed(1)}x', + onChanged: state.isGenerating + ? null + : (value) => setState(() => _speed = value), + ), + const SizedBox(height: AppSpacing.m), + if (!model.isReady) + Card( + child: ListTile( + leading: const Icon(Icons.cloud_download_outlined), + title: Text(l10n.textToLessonModelRequired), + subtitle: Text(l10n.textToLessonModelRequiredHint), + ), + ), + if (state.progress != null) ...[ + LinearProgressIndicator(value: state.progress!.fraction), + const SizedBox(height: AppSpacing.xs), + Text(state.progress!.message), + const SizedBox(height: AppSpacing.m), + ], + if (state.error != null && state.error != 'offline_model_required') + Padding( + padding: const EdgeInsets.only(bottom: AppSpacing.m), + child: Text( + l10n.textToLessonFailed, + style: TextStyle(color: Theme.of(context).colorScheme.error), + ), + ), + FilledButton.icon( + onPressed: state.isGenerating ? null : _generate, + icon: const Icon(Icons.graphic_eq), + label: Text( + state.isGenerating + ? l10n.textToLessonGenerating + : l10n.textToLessonGenerate, + ), + ), + if (state.isGenerating) ...[ + const SizedBox(height: AppSpacing.s), + TextButton( + onPressed: () => + ref.read(textLessonControllerProvider.notifier).cancel(), + child: Text(l10n.textToLessonCancel), + ), + ], + ], + ), + ); + } +} diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 2aa7b2898..0cf32bb0b 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -2718,5 +2718,25 @@ "pronunciationLibraryDescription": "Pre-recorded word audio for dictionary lookups", "redownload": "Download again", "collapseSidebar": "Collapse sidebar", - "expandSidebar": "Expand sidebar" + "expandSidebar": "Expand sidebar", + "textToLessonTitle": "Text to Listening", + "textToLessonDescription": "Turn any English, Chinese, or mixed text into an offline lesson with sentence-level playback.", + "textToLessonImportFile": "Import .txt", + "textToLessonTitleLabel": "Lesson title", + "textToLessonTextLabel": "Text", + "textToLessonTextHint": "Paste text here, or import a .txt file.", + "textToLessonVoice": "Voice", + "textToLessonSpeed": "Speed", + "textToLessonGenerate": "Generate lesson", + "textToLessonGenerating": "Generating…", + "textToLessonCancel": "Cancel", + "textToLessonTextRequired": "Enter or import some text first.", + "textToLessonModelRequired": "Offline speech model required", + "textToLessonModelRequiredHint": "Download the Kokoro model before generating this lesson.", + "textToLessonFailed": "Lesson generation failed. Please retry.", + "ttsSpeed": "Speed", + "listenAndRepeatReferenceLabel": "Reference audio", + "listenAndRepeatReferenceOriginal": "Original", + "listenAndRepeatReferenceTts": "Local TTS", + "listenAndRepeatReferenceTtsHint": "Uses the selected Echo Loop voice for each sentence. The original media is not changed." } diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index fb320cd6b..378cb4c88 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -9799,6 +9799,126 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Expand sidebar'** String get expandSidebar; + + /// No description provided for @textToLessonTitle. + /// + /// In en, this message translates to: + /// **'Text to Listening'** + String get textToLessonTitle; + + /// No description provided for @textToLessonDescription. + /// + /// In en, this message translates to: + /// **'Turn any English, Chinese, or mixed text into an offline lesson with sentence-level playback.'** + String get textToLessonDescription; + + /// No description provided for @textToLessonImportFile. + /// + /// In en, this message translates to: + /// **'Import .txt'** + String get textToLessonImportFile; + + /// No description provided for @textToLessonTitleLabel. + /// + /// In en, this message translates to: + /// **'Lesson title'** + String get textToLessonTitleLabel; + + /// No description provided for @textToLessonTextLabel. + /// + /// In en, this message translates to: + /// **'Text'** + String get textToLessonTextLabel; + + /// No description provided for @textToLessonTextHint. + /// + /// In en, this message translates to: + /// **'Paste text here, or import a .txt file.'** + String get textToLessonTextHint; + + /// No description provided for @textToLessonVoice. + /// + /// In en, this message translates to: + /// **'Voice'** + String get textToLessonVoice; + + /// No description provided for @textToLessonSpeed. + /// + /// In en, this message translates to: + /// **'Speed'** + String get textToLessonSpeed; + + /// No description provided for @textToLessonGenerate. + /// + /// In en, this message translates to: + /// **'Generate lesson'** + String get textToLessonGenerate; + + /// No description provided for @textToLessonGenerating. + /// + /// In en, this message translates to: + /// **'Generating…'** + String get textToLessonGenerating; + + /// No description provided for @textToLessonCancel. + /// + /// In en, this message translates to: + /// **'Cancel'** + String get textToLessonCancel; + + /// No description provided for @textToLessonTextRequired. + /// + /// In en, this message translates to: + /// **'Enter or import some text first.'** + String get textToLessonTextRequired; + + /// No description provided for @textToLessonModelRequired. + /// + /// In en, this message translates to: + /// **'Offline speech model required'** + String get textToLessonModelRequired; + + /// No description provided for @textToLessonModelRequiredHint. + /// + /// In en, this message translates to: + /// **'Download the Kokoro model before generating this lesson.'** + String get textToLessonModelRequiredHint; + + /// No description provided for @textToLessonFailed. + /// + /// In en, this message translates to: + /// **'Lesson generation failed. Please retry.'** + String get textToLessonFailed; + + /// No description provided for @ttsSpeed. + /// + /// In en, this message translates to: + /// **'Speed'** + String get ttsSpeed; + + /// No description provided for @listenAndRepeatReferenceLabel. + /// + /// In en, this message translates to: + /// **'Reference audio'** + String get listenAndRepeatReferenceLabel; + + /// No description provided for @listenAndRepeatReferenceOriginal. + /// + /// In en, this message translates to: + /// **'Original'** + String get listenAndRepeatReferenceOriginal; + + /// No description provided for @listenAndRepeatReferenceTts. + /// + /// In en, this message translates to: + /// **'Local TTS'** + String get listenAndRepeatReferenceTts; + + /// No description provided for @listenAndRepeatReferenceTtsHint. + /// + /// In en, this message translates to: + /// **'Uses the selected Echo Loop voice for each sentence. The original media is not changed.'** + String get listenAndRepeatReferenceTtsHint; } class _AppLocalizationsDelegate diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index ec1e5516f..e69328cc2 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -5561,4 +5561,67 @@ class AppLocalizationsEn extends AppLocalizations { @override String get expandSidebar => 'Expand sidebar'; + + @override + String get textToLessonTitle => 'Text to Listening'; + + @override + String get textToLessonDescription => + 'Turn any English, Chinese, or mixed text into an offline lesson with sentence-level playback.'; + + @override + String get textToLessonImportFile => 'Import .txt'; + + @override + String get textToLessonTitleLabel => 'Lesson title'; + + @override + String get textToLessonTextLabel => 'Text'; + + @override + String get textToLessonTextHint => 'Paste text here, or import a .txt file.'; + + @override + String get textToLessonVoice => 'Voice'; + + @override + String get textToLessonSpeed => 'Speed'; + + @override + String get textToLessonGenerate => 'Generate lesson'; + + @override + String get textToLessonGenerating => 'Generating…'; + + @override + String get textToLessonCancel => 'Cancel'; + + @override + String get textToLessonTextRequired => 'Enter or import some text first.'; + + @override + String get textToLessonModelRequired => 'Offline speech model required'; + + @override + String get textToLessonModelRequiredHint => + 'Download the Kokoro model before generating this lesson.'; + + @override + String get textToLessonFailed => 'Lesson generation failed. Please retry.'; + + @override + String get ttsSpeed => 'Speed'; + + @override + String get listenAndRepeatReferenceLabel => 'Reference audio'; + + @override + String get listenAndRepeatReferenceOriginal => 'Original'; + + @override + String get listenAndRepeatReferenceTts => 'Local TTS'; + + @override + String get listenAndRepeatReferenceTtsHint => + 'Uses the selected Echo Loop voice for each sentence. The original media is not changed.'; } diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 33e70e60b..369e872cb 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -5314,4 +5314,65 @@ class AppLocalizationsZh extends AppLocalizations { @override String get expandSidebar => '展开侧边栏'; + + @override + String get textToLessonTitle => '文本转听力'; + + @override + String get textToLessonDescription => '把任意英文、中文或中英混合文本生成支持逐句播放的离线学习课时。'; + + @override + String get textToLessonImportFile => '导入 .txt'; + + @override + String get textToLessonTitleLabel => '课时标题'; + + @override + String get textToLessonTextLabel => '文本'; + + @override + String get textToLessonTextHint => '在此粘贴文本,或导入 .txt 文件。'; + + @override + String get textToLessonVoice => '音色'; + + @override + String get textToLessonSpeed => '语速'; + + @override + String get textToLessonGenerate => '生成课时'; + + @override + String get textToLessonGenerating => '正在生成…'; + + @override + String get textToLessonCancel => '取消'; + + @override + String get textToLessonTextRequired => '请先输入或导入文本。'; + + @override + String get textToLessonModelRequired => '需要离线语音模型'; + + @override + String get textToLessonModelRequiredHint => '生成课时前需要先下载 Kokoro 模型。'; + + @override + String get textToLessonFailed => '课时生成失败,请重试。'; + + @override + String get ttsSpeed => '语速'; + + @override + String get listenAndRepeatReferenceLabel => '参考音频'; + + @override + String get listenAndRepeatReferenceOriginal => '原音频'; + + @override + String get listenAndRepeatReferenceTts => '本地发音'; + + @override + String get listenAndRepeatReferenceTtsHint => + '使用当前 Echo Loop 音色逐句朗读,不会修改原始媒体。'; } diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 1368f08a8..ab659355d 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -2222,5 +2222,25 @@ "pronunciationLibraryDescription": "词典查词时优先播放预录的单词发音", "redownload": "重新下载", "collapseSidebar": "收起侧边栏", - "expandSidebar": "展开侧边栏" + "expandSidebar": "展开侧边栏", + "textToLessonTitle": "文本转听力", + "textToLessonDescription": "把任意英文、中文或中英混合文本生成支持逐句播放的离线学习课时。", + "textToLessonImportFile": "导入 .txt", + "textToLessonTitleLabel": "课时标题", + "textToLessonTextLabel": "文本", + "textToLessonTextHint": "在此粘贴文本,或导入 .txt 文件。", + "textToLessonVoice": "音色", + "textToLessonSpeed": "语速", + "textToLessonGenerate": "生成课时", + "textToLessonGenerating": "正在生成…", + "textToLessonCancel": "取消", + "textToLessonTextRequired": "请先输入或导入文本。", + "textToLessonModelRequired": "需要离线语音模型", + "textToLessonModelRequiredHint": "生成课时前需要先下载 Kokoro 模型。", + "textToLessonFailed": "课时生成失败,请重试。", + "ttsSpeed": "语速", + "listenAndRepeatReferenceLabel": "参考音频", + "listenAndRepeatReferenceOriginal": "原音频", + "listenAndRepeatReferenceTts": "本地发音", + "listenAndRepeatReferenceTtsHint": "使用当前 Echo Loop 音色逐句朗读,不会修改原始媒体。" } diff --git a/lib/models/intensive_listen_settings.dart b/lib/models/intensive_listen_settings.dart index 8595fa9d3..3ae7cc2ff 100644 --- a/lib/models/intensive_listen_settings.dart +++ b/lib/models/intensive_listen_settings.dart @@ -15,6 +15,15 @@ enum ShadowingControlMode { manual, } +/// 跟读参考音频来源。 +enum ShadowingReferenceSource { + /// 播放原媒体中的句子片段。 + original, + + /// 使用本地 Kokoro 朗读句子文本。 + localTts, +} + /// 停顿模式 enum PauseMode { /// 智能间隔:2 倍句子时长,最短 2 秒 @@ -52,6 +61,9 @@ class IntensiveListenSettings { /// 难句跟读 / 逐句精听 入口弹窗均会写入此字段,会话内每次播音前生效。 final double playbackSpeed; + /// 跟读参考音频来源(默认原音)。 + final ShadowingReferenceSource referenceSource; + /// 入口弹窗使用的离散速度选项 /// /// 与 [BlindListenSettings.briefingPlaybackSpeedOptions] / [RetellSettings.briefingPlaybackSpeedOptions] 保持一致。 @@ -97,6 +109,7 @@ class IntensiveListenSettings { this.pauseMultiplier = 2.0, this.controlMode = ShadowingControlMode.auto, this.playbackSpeed = 1.0, + this.referenceSource = ShadowingReferenceSource.original, }); /// 是否为手动控制模式 @@ -110,6 +123,7 @@ class IntensiveListenSettings { double? pauseMultiplier, ShadowingControlMode? controlMode, double? playbackSpeed, + ShadowingReferenceSource? referenceSource, }) { return IntensiveListenSettings( repeatCount: repeatCount ?? this.repeatCount, @@ -121,6 +135,7 @@ class IntensiveListenSettings { pauseMultiplier: pauseMultiplier ?? this.pauseMultiplier, controlMode: controlMode ?? this.controlMode, playbackSpeed: playbackSpeed ?? this.playbackSpeed, + referenceSource: referenceSource ?? this.referenceSource, ); } @@ -132,6 +147,7 @@ class IntensiveListenSettings { 'pauseMultiplier': pauseMultiplier, 'controlMode': controlMode.name, 'playbackSpeed': playbackSpeed, + 'referenceSource': referenceSource.name, }; /// 防御性解析:非法值回退默认 @@ -146,6 +162,7 @@ class IntensiveListenSettings { pauseMultiplier: _parsePauseMultiplier(json['pauseMultiplier']), controlMode: _parseControlMode(json['controlMode']), playbackSpeed: _parsePlaybackSpeed(json['playbackSpeed']), + referenceSource: _parseReferenceSource(json['referenceSource']), ); } @@ -197,4 +214,12 @@ class IntensiveListenSettings { .firstOrNull ?? ShadowingControlMode.auto; } + + static ShadowingReferenceSource _parseReferenceSource(dynamic raw) { + if (raw is! String) return ShadowingReferenceSource.original; + return ShadowingReferenceSource.values + .where((e) => e.name == raw) + .firstOrNull ?? + ShadowingReferenceSource.original; + } } diff --git a/lib/providers/listen_and_repeat/listen_and_repeat_controller.dart b/lib/providers/listen_and_repeat/listen_and_repeat_controller.dart index dc99edeb4..4d51c1dc9 100644 --- a/lib/providers/listen_and_repeat/listen_and_repeat_controller.dart +++ b/lib/providers/listen_and_repeat/listen_and_repeat_controller.dart @@ -26,6 +26,7 @@ import '../../models/sentence.dart'; import '../../models/sentence_playback_result.dart'; import '../../models/study_stage.dart'; import '../../services/app_logger.dart'; +import '../../services/pronunciation/local_audio_clip_player.dart'; import '../../services/study_session_timer.dart'; import '../../services/study_time_service.dart'; import '../audio_engine/audio_engine_provider.dart'; @@ -44,6 +45,7 @@ import '../repeat_flow/repeat_flow_phase.dart'; import '../repeat_flow/repeat_flow_state.dart'; import '../speech/speech_recording_controller.dart'; import '../listening_practice/bookmark_manager.dart'; +import '../pronunciation/pronunciation_providers.dart'; import '../favorite_sentence_lifecycle_provider.dart'; import '../intensive_listen_prefs_provider.dart'; import '../../models/stage_settings_overrides.dart'; @@ -95,7 +97,7 @@ class ListenAndRepeatController extends _$ListenAndRepeatController { onStateChanged: _onEngineStateChanged, callbacks: RepeatFlowCallbacks( // 运行时读取当前驱动:媒体初始化会替换 [_playback],不能捕获旧音频实例。 - pauseAudio: () => unawaited(_playback.pause()), + pauseAudio: _pauseAudio, playSentence: _playSentence, startRecording: _startRecording, cancelRecording: _cancelRecording, @@ -987,10 +989,28 @@ class ListenAndRepeatController extends _$ListenAndRepeatController { int flowToken, ) async { final driver = _playback; - final result = await driver.playSentenceWithSpeed( - sentence, - ref.read(listenAndRepeatSettingsProvider).playbackSpeed, - ); + final settings = ref.read(listenAndRepeatSettingsProvider); + final SentencePlaybackResult result; + if (settings.referenceSource == ShadowingReferenceSource.localTts) { + await driver.pause(); + final playback = await ref + .read(textPlaybackProvider.notifier) + .speakWithResult( + sentence.text, + key: 'listen-repeat-tts:${sentence.index}', + ); + result = switch (playback) { + AudioPlaybackResult.completed => SentencePlaybackResult.completed, + AudioPlaybackResult.cancelled => SentencePlaybackResult.cancelled, + AudioPlaybackResult.failed => SentencePlaybackResult.failed, + }; + } else { + await ref.read(textPlaybackProvider.notifier).stop(); + result = await driver.playSentenceWithSpeed( + sentence, + settings.playbackSpeed, + ); + } if (result == SentencePlaybackResult.completed && flowToken == state.flowToken) { AppLogger.log( @@ -1015,6 +1035,11 @@ class ListenAndRepeatController extends _$ListenAndRepeatController { return result; } + Future _pauseAudio() async { + await _playback.pause(); + await ref.read(textPlaybackProvider.notifier).stop(); + } + /// 开始录音 void _startRecording({ required String promptId, diff --git a/lib/providers/tts/tts_controller_provider.dart b/lib/providers/tts/tts_controller_provider.dart index 0ba87416f..90bfb72bf 100644 --- a/lib/providers/tts/tts_controller_provider.dart +++ b/lib/providers/tts/tts_controller_provider.dart @@ -18,6 +18,7 @@ import '../../database/providers.dart'; import '../../services/app_logger.dart'; import '../../services/pronunciation/local_audio_clip_player.dart'; import '../../services/tts/kokoro_tts_engine.dart'; +import '../../services/tts/kokoro_model_catalog.dart'; import '../../services/tts/kokoro_voices.dart'; import '../../services/tts/piper_tts_engine.dart'; import '../../services/tts/piper_model_catalog.dart'; @@ -82,9 +83,12 @@ TtsSpeechConfig ttsVoicePreviewConfig( KokoroModelVariant variant, ) { return TtsSpeechConfig( - languageTag: voice.accent == TtsAccent.uk ? 'en-GB' : 'en-US', + languageTag: switch (voice.language) { + TtsLanguage.chinese => 'zh-CN', + TtsLanguage.english => voice.accent == TtsAccent.uk ? 'en-GB' : 'en-US', + }, voiceName: voice.id, - modelTag: variant.name, + modelTag: kokoroSpecOf(variant).id, ); } @@ -224,8 +228,9 @@ class TtsController extends Notifier { TtsEngineKind.piper => settings.activePiperVoice, TtsEngineKind.platform => null, }, + speed: settings.speed, modelTag: effective == TtsEngineKind.kokoro - ? settings.kokoroVariant.name + ? kokoroSpecOf(settings.kokoroVariant).id : null, ); _readyCoordinator.configure(effective, config); @@ -282,6 +287,11 @@ class TtsController extends Notifier { return success ? AudioPlaybackResult.completed : AudioPlaybackResult.failed; } + /// 暴露统一协调器给需要后台产文件的系统能力(Text → Lesson)。 + /// + /// UI 仍不得直接依赖 sherpa_onnx;调用方只消费协调器的缓存/调度能力。 + TtsCoordinator get coordinator => _readyCoordinator; + /// 试听某 Kokoro 音色:用该音色(及其口音、当前模型变体)朗读示范句。 /// /// 命中预热缓存则秒播;未命中则即时合成。设 [speakingKey] 为该音色的试听 key, @@ -758,8 +768,9 @@ class TtsController extends Notifier { TtsEngineKind.piper => settings.activePiperVoice, TtsEngineKind.platform => null, }, + speed: settings.speed, modelTag: settings.engine == TtsEngineKind.kokoro - ? settings.kokoroVariant.name + ? kokoroSpecOf(settings.kokoroVariant).id : null, ); final loaded = await _readyCoordinator.ensureEngineReady( diff --git a/lib/providers/tts/tts_settings_provider.dart b/lib/providers/tts/tts_settings_provider.dart index c094e27b0..139e8a2f5 100644 --- a/lib/providers/tts/tts_settings_provider.dart +++ b/lib/providers/tts/tts_settings_provider.dart @@ -12,10 +12,14 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:shared_preferences/shared_preferences.dart'; import '../../services/app_logger.dart'; +import '../../services/tts/kokoro_model_catalog.dart'; import '../../services/tts/kokoro_voices.dart'; import '../../services/tts/piper_model_catalog.dart'; import '../../services/tts/tts_engine.dart'; +/// 本地神经 TTS 语速范围(与 sherpa-onnx 推荐范围一致)。 +double normalizeTtsSpeed(double speed) => speed.clamp(0.5, 2.0).toDouble(); + /// 同步从 SP 预读的 TTS 设置初值,由 main() 通过 override 注入。 /// /// 默认返回 [TtsSettings] 默认值(Echo Loop AI Advanced + 美音):发音是辅助功能,未注入时 @@ -36,6 +40,7 @@ abstract final class TtsSettingsKeys { static const kokoroVariant = 'tts_kokoro_variant'; static const piperVoiceUs = 'tts_piper_voice_us'; static const piperVoiceUk = 'tts_piper_voice_uk'; + static const speed = 'tts_speed'; } /// 将所有平台历史写入的 Echo Loop 产品名值迁移为 Kokoro 实现名。 @@ -83,6 +88,9 @@ class TtsSettings { /// Piper(平衡档)英音音色 id(默认 [piperDefaultVoiceUk])。 final String piperVoiceUk; + /// 本地神经 TTS 语速倍率(0.5x–2.0x,默认 1.0x)。 + final double speed; + const TtsSettings({ this.engine = TtsEngineKind.kokoro, this.accent = TtsAccent.us, @@ -91,6 +99,7 @@ class TtsSettings { this.kokoroVariant = KokoroModelVariant.fp32, this.piperVoiceUs = piperDefaultVoiceUs, this.piperVoiceUk = piperDefaultVoiceUk, + this.speed = 1.0, }); /// 当前口音对应的语言标签。 @@ -108,7 +117,8 @@ class TtsSettings { /// /// Echo Loop(Kokoro)带音色(voiceName)+ 变体标签(modelTag,fp32/int8 分桶); /// Piper 带音色(voiceName,即独立模型 id,缓存键据此分桶),无 modelTag; - /// 平台引擎不带 voiceName/modelTag(用语言标签选系统音色)。语速本期固定 0.45。 + /// 平台引擎不带 voiceName/modelTag(用语言标签选系统音色)。平台 rate 使用 + /// 默认值,本地引擎则使用用户选择的 [speed]。 TtsSpeechConfig toSpeechConfig() => TtsSpeechConfig( languageTag: languageTag, voiceName: switch (engine) { @@ -116,7 +126,10 @@ class TtsSettings { TtsEngineKind.piper => activePiperVoice, TtsEngineKind.platform => null, }, - modelTag: engine == TtsEngineKind.kokoro ? kokoroVariant.name : null, + speed: speed, + modelTag: engine == TtsEngineKind.kokoro + ? kokoroSpecOf(kokoroVariant).id + : null, ); /// 同步从 [SharedPreferences] 派生当前状态,用于启动期 override 注入。 @@ -143,6 +156,7 @@ class TtsSettings { prefs.getString(TtsSettingsKeys.piperVoiceUk), TtsAccent.uk, ), + speed: _speedFromPrefs(prefs.getDouble(TtsSettingsKeys.speed)), ); } @@ -184,6 +198,8 @@ class TtsSettings { ); } + static double _speedFromPrefs(double? raw) => normalizeTtsSpeed(raw ?? 1.0); + TtsSettings copyWith({ TtsEngineKind? engine, TtsAccent? accent, @@ -192,6 +208,7 @@ class TtsSettings { KokoroModelVariant? kokoroVariant, String? piperVoiceUs, String? piperVoiceUk, + double? speed, }) { return TtsSettings( engine: engine ?? this.engine, @@ -201,6 +218,7 @@ class TtsSettings { kokoroVariant: kokoroVariant ?? this.kokoroVariant, piperVoiceUs: piperVoiceUs ?? this.piperVoiceUs, piperVoiceUk: piperVoiceUk ?? this.piperVoiceUk, + speed: speed ?? this.speed, ); } @@ -215,7 +233,8 @@ class TtsSettings { kokoroVoiceUk == other.kokoroVoiceUk && kokoroVariant == other.kokoroVariant && piperVoiceUs == other.piperVoiceUs && - piperVoiceUk == other.piperVoiceUk; + piperVoiceUk == other.piperVoiceUk && + speed == other.speed; @override int get hashCode => Object.hash( @@ -226,6 +245,7 @@ class TtsSettings { kokoroVariant, piperVoiceUs, piperVoiceUk, + speed, ); } @@ -258,11 +278,9 @@ class TtsSettingsNotifier extends Notifier { final v = voiceById(voiceId); if (v == null || v.accent != accent) return; if (accent == TtsAccent.uk) { - if (state.kokoroVoiceUk == voiceId) return; state = state.copyWith(kokoroVoiceUk: voiceId); await _persist(TtsSettingsKeys.kokoroVoiceUk, voiceId); } else { - if (state.kokoroVoiceUs == voiceId) return; state = state.copyWith(kokoroVoiceUs: voiceId); await _persist(TtsSettingsKeys.kokoroVoiceUs, voiceId); } @@ -292,6 +310,19 @@ class TtsSettingsNotifier extends Notifier { } } + /// 设置本地神经 TTS 语速(自动夹到 0.5..2.0),写 SP + 更新 state。 + Future setSpeed(double speed) async { + final normalized = normalizeTtsSpeed(speed); + if (state.speed == normalized) return; + state = state.copyWith(speed: normalized); + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.setDouble(TtsSettingsKeys.speed, normalized); + } catch (e) { + AppLogger.log('TtsSettings', '写 SP 失败 ($speed): $e'); + } + } + Future _persist(String key, String value) async { try { final prefs = await SharedPreferences.getInstance(); diff --git a/lib/router/app_router.dart b/lib/router/app_router.dart index 5f0093150..06a6732c2 100644 --- a/lib/router/app_router.dart +++ b/lib/router/app_router.dart @@ -29,6 +29,7 @@ import '../features/podcast/screens/podcast_preview_screen.dart'; import '../features/onboarding_survey/providers/onboarding_survey_provider.dart'; import '../features/onboarding_survey/screens/onboarding_survey_screen.dart'; import '../features/subtitle_editor/subtitle_simple_editor_screen.dart'; +import '../features/text_to_lesson/screens/text_lesson_screen.dart'; import '../models/audio_item.dart'; import '../providers/audio_library_provider.dart'; import '../services/app_logger.dart'; @@ -169,6 +170,9 @@ abstract class AppRoutes { /// 收藏词汇(单词+意群)调度式复习页路径 static const favoriteVocabularyReview = '/favorite-vocabulary-review'; + /// 纯文本转学习材料页面。 + static const textToLesson = '/text-to-lesson'; + /// 活动日历页路径 static const activityCalendar = '/activity-calendar'; static const reviewStatistics = '/review-statistics'; @@ -571,6 +575,11 @@ final appRouterProvider = Provider((ref) { parentNavigatorKey: rootNavigatorKey, builder: (context, state) => const AccountScreen(), ), + GoRoute( + path: AppRoutes.textToLesson, + parentNavigatorKey: rootNavigatorKey, + builder: (context, state) => const TextLessonScreen(), + ), GoRoute( path: AppRoutes.paywall, parentNavigatorKey: rootNavigatorKey, diff --git a/lib/screens/library_screen.dart b/lib/screens/library_screen.dart index b4b50f1b8..c7ea292cd 100644 --- a/lib/screens/library_screen.dart +++ b/lib/screens/library_screen.dart @@ -4,9 +4,11 @@ // 使用 IndexedStack 保持两个视图状态。 import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; import '../features/community_collections/widgets/discover_entry_banner.dart'; import '../providers/new_user_guide_provider.dart'; import '../providers/collection_provider.dart'; +import '../router/app_router.dart'; import '../l10n/app_localizations.dart'; import '../widgets/audio_list_view.dart'; import '../widgets/guide_flow.dart'; @@ -144,6 +146,11 @@ class _LibraryScreenState extends ConsumerState { return [ // 合集排序 const CollectionSortButton(), + IconButton( + tooltip: l10n.textToLessonTitle, + icon: const Icon(Icons.text_fields), + onPressed: () => context.push(AppRoutes.textToLesson), + ), // 「发现社区合集」入口已改为列表顶部的 DiscoverEntryBanner,更醒目; // AppBar 这里不再放 compass icon,避免重复。 // 创建合集 @@ -161,6 +168,11 @@ class _LibraryScreenState extends ConsumerState { // 音频排序 const AudioSortButton(), // 添加音频 + IconButton( + tooltip: l10n.textToLessonTitle, + icon: const Icon(Icons.text_fields), + onPressed: () => context.push(AppRoutes.textToLesson), + ), IconButton( icon: const Icon(Icons.add), onPressed: () => showImportAudioSheet(context), diff --git a/lib/screens/tts_settings_screen.dart b/lib/screens/tts_settings_screen.dart index b0becb767..8cdf34014 100644 --- a/lib/screens/tts_settings_screen.dart +++ b/lib/screens/tts_settings_screen.dart @@ -247,6 +247,8 @@ class _TtsSettingsScreenState extends ConsumerState { if (ready) ...[ const Divider(height: 1, indent: 16, endIndent: 16), _VoiceDisclosure(l10n: l10n), + const Divider(height: 1, indent: 16, endIndent: 16), + _SpeedSetting(l10n: l10n), ], ], ), @@ -260,6 +262,9 @@ class _TtsSettingsScreenState extends ConsumerState { clipBehavior: Clip.antiAlias, child: _PiperVoiceList(l10n: l10n), ), + const SizedBox(height: AppSpacing.m), + _SectionLabel(text: l10n.ttsSpeed), + Card(child: _SpeedSetting(l10n: l10n)), ] else ...[ // 平台 TTS:无具名音色,仅显示口音(美/英,决定系统音色语言)。 // 点击口音即选中并试听该口音(进页已后台预热,多数秒播)。 @@ -656,7 +661,7 @@ String formatModelBytes(int bytes) { /// 音色「当前值 + 展开」单行:标题 + 当前音色(名称 · 性别)+ 雪佛龙;点开底部弹层选。 /// -/// 弹层按「美音 / 英音」分组列出全部 11 个音色。音色名自带口音,选中某音色即同时把 +/// 弹层按「美音 / 英音」分组列出 v1.1 的全部英文音色。音色名自带口音,选中某音色即同时把 /// 全局口音设为该音色的口音(见 [_openSheet]),故 Echo Loop 下无需单独的口音控件。 class _VoiceDisclosure extends ConsumerWidget { const _VoiceDisclosure({required this.l10n}); @@ -694,7 +699,7 @@ class _VoiceDisclosure extends ConsumerWidget { ); } - /// 弹出底部音色选择器(全部 11 个,按口音分组)。点任一行即提交选中并试听该音色, + /// 弹出底部音色选择器(全部英文音色,按口音分组)。点任一行即提交选中并试听该音色, /// 弹层**不关闭**——便于连续试听、重点当前项重播。用户手动下滑/返回关闭。 void _openSheet(BuildContext context, WidgetRef ref) { showModalBottomSheet( @@ -751,6 +756,35 @@ class _VoiceDisclosure extends ConsumerWidget { } } +/// 本地神经 TTS 语速设置。 +class _SpeedSetting extends ConsumerWidget { + const _SpeedSetting({required this.l10n}); + + final AppLocalizations l10n; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final speed = ref.watch(ttsSettingsProvider.select((s) => s.speed)); + return ListTile( + title: Row( + children: [ + Expanded(child: Text(l10n.ttsSpeed)), + Text('${speed.toStringAsFixed(1)}x'), + ], + ), + subtitle: Slider( + value: speed, + min: 0.5, + max: 2.0, + divisions: 15, + label: '${speed.toStringAsFixed(1)}x', + onChanged: (value) => + ref.read(ttsSettingsProvider.notifier).setSpeed(value), + ), + ); + } +} + /// 音色弹层内的单行:单选指示 + 名称 + 性别,点击即选中并试听该音色。 /// /// 自管理响应式状态:选中态读 [ttsSettingsProvider](点击实时更新),播放态读 diff --git a/lib/services/tts/kokoro_model_catalog.dart b/lib/services/tts/kokoro_model_catalog.dart index 55473746d..78ca8b79d 100644 --- a/lib/services/tts/kokoro_model_catalog.dart +++ b/lib/services/tts/kokoro_model_catalog.dart @@ -14,6 +14,12 @@ class KokoroModelSpec { final String sha256; final String modelFileName; + /// 官方发布地址;为空时回退到 Echo Loop CDN。 + final String? downloadUrl; + + /// 下载到磁盘时使用的归档文件名;为空时由 manager 按 [archivePath] 后缀生成。 + final String? archiveFileName; + /// CDN 压缩归档的预计下载大小(字节),仅用于下载前展示近似流量。 final int estimatedDownloadBytes; @@ -24,6 +30,8 @@ class KokoroModelSpec { required this.sha256, required this.modelFileName, required this.estimatedDownloadBytes, + this.downloadUrl, + this.archiveFileName, }); } @@ -42,19 +50,23 @@ const kokoroDataDirectoryName = 'espeak-ng-data'; const kokoroModelSpecs = { KokoroModelVariant.fp32: KokoroModelSpec( variant: KokoroModelVariant.fp32, - id: 'kokoro-en-v0_19', - archivePath: 'tts/kokoro-en-v0_19.tar.gz', - sha256: 'd97c85ba5777bc226eca3a40312bb29dd8fd0e77546d4100abb7243b9b6ad137', + id: 'kokoro-multi-lang-v1_1', + archivePath: 'tts/kokoro-multi-lang-v1_1.tar.bz2', + sha256: 'a3f4c73d043860e3fd2e5b06f36795eb81de0fc8e8de6df703245edddd87dbad', modelFileName: 'model.onnx', - estimatedDownloadBytes: 313785757, + estimatedDownloadBytes: 364816464, + downloadUrl: + 'https://github.com/k2-fsa/sherpa-onnx/releases/download/tts-models/kokoro-multi-lang-v1_1.tar.bz2', ), KokoroModelVariant.int8: KokoroModelSpec( variant: KokoroModelVariant.int8, - id: 'kokoro-en-v0_19-int8', - archivePath: 'tts/kokoro-en-v0_19-int8-v2.tar.gz', - sha256: '70fd7ff687d08245f9409557f58072f43eb8a5bf8a90e98dd3bb7f60e05b4b07', + id: 'kokoro-int8-multi-lang-v1_1', + archivePath: 'tts/kokoro-int8-multi-lang-v1_1.tar.bz2', + sha256: 'a1e94694776049035c4f2c6529f003aaece993c76aae9a78995831c3c4dcafc6', modelFileName: 'model.int8.onnx', - estimatedDownloadBytes: 102614756, + estimatedDownloadBytes: 147031220, + downloadUrl: + 'https://github.com/k2-fsa/sherpa-onnx/releases/download/tts-models/kokoro-int8-multi-lang-v1_1.tar.bz2', ), }; diff --git a/lib/services/tts/kokoro_model_manager.dart b/lib/services/tts/kokoro_model_manager.dart index b794e2db6..999fa5d98 100644 --- a/lib/services/tts/kokoro_model_manager.dart +++ b/lib/services/tts/kokoro_model_manager.dart @@ -1,6 +1,7 @@ /// Kokoro(Echo Loop TTS)模型下载、校验、缓存管理。 /// -/// 与 Whisper 不同,Kokoro 含 `espeak-ng-data` 目录树,故托管为单个 `tar.gz` +/// 与 Whisper 不同,Kokoro 含 `espeak-ng-data` 目录树,故托管为单个 `tar.gz` / +/// `tar.bz2` /// 归档:下载归档 → 校验整包 SHA-256 → staging 解包校验 → 原子替换模型目录。 /// 下载基于 `ReliableHttpDownloader`,网络中断时保留 `.part` 供后续 Range 续传。 library; @@ -40,11 +41,19 @@ class KokoroModelPaths { /// `espeak-ng-data` 目录绝对路径。 final String dataDir; + /// 多语言 Kokoro 的 lexicon 路径(多个文件以逗号连接);旧英文模型为空。 + final String lexicon; + + /// 多语言中文规则 FST 路径(多个文件以逗号连接);旧英文模型为空。 + final String ruleFsts; + const KokoroModelPaths({ required this.model, required this.voices, required this.tokens, required this.dataDir, + this.lexicon = '', + this.ruleFsts = '', }); } @@ -174,7 +183,9 @@ class KokoroModelManager { }) async { final root = await _modelsRoot; final baseUrl = baseUrlOverride ?? kokoroCdnBaseUrl; - final url = '$baseUrl/model/${spec.archivePath}'; + final url = baseUrlOverride == null + ? (spec.downloadUrl ?? '$baseUrl/model/${spec.archivePath}') + : '$baseUrl/model/${spec.archivePath}'; final target = Directory(await modelDir()); AppLogger.log('KokoroModel', '┌ downloadModel dir=${target.path} url=$url'); @@ -188,7 +199,7 @@ class KokoroModelManager { root: Directory(root), target: target, resourceId: spec.id, - archiveFileName: '_download_${spec.id}.tar.gz', + archiveFileName: _archiveFileName(), uri: Uri.parse(url), expectedSha256: spec.sha256, cancelToken: cancelToken, @@ -237,7 +248,7 @@ class KokoroModelManager { await _installer.discardPartial( root: Directory(await _modelsRoot), resourceId: spec.id, - archiveFileName: '_download_${spec.id}.tar.gz', + archiveFileName: _archiveFileName(), ); } @@ -257,14 +268,41 @@ class KokoroModelManager { if (model == null || voices == null || tokens == null || dataDir == null) { return null; } + final lexiconUs = await _findFile(dir, 'lexicon-us-en.txt'); + final lexiconZh = await _findFile(dir, 'lexicon-zh.txt'); + final lexicon = [ + lexiconUs, + lexiconZh, + ].whereType().where((path) => path.isNotEmpty).join(','); + final phoneZh = await _findFile(dir, 'phone-zh.fst'); + final dateZh = await _findFile(dir, 'date-zh.fst'); + final numberZh = await _findFile(dir, 'number-zh.fst'); + final ruleFsts = [ + phoneZh, + dateZh, + numberZh, + ].whereType().where((path) => path.isNotEmpty).join(','); + return KokoroModelPaths( model: model, voices: voices, tokens: tokens, dataDir: dataDir, + lexicon: lexicon, + ruleFsts: ruleFsts, ); } + /// 归档下载名保留真实压缩后缀,兼容 `.tar.gz` 测试归档与官方 `.tar.bz2`。 + String _archiveFileName() { + final configured = spec.archiveFileName; + if (configured != null && configured.isNotEmpty) return configured; + final path = spec.archivePath.toLowerCase(); + if (path.endsWith('.tar.bz2')) return '_download_${spec.id}.tar.bz2'; + if (path.endsWith('.tar.gz')) return '_download_${spec.id}.tar.gz'; + return '_download_${spec.id}.zip'; + } + Future _findFile(Directory root, String name) async { await for (final e in root.list(recursive: true, followLinks: false)) { if (e is File && p.basename(e.path) == name) return e.path; diff --git a/lib/services/tts/kokoro_synthesizer.dart b/lib/services/tts/kokoro_synthesizer.dart index 14a8de683..c894808d5 100644 --- a/lib/services/tts/kokoro_synthesizer.dart +++ b/lib/services/tts/kokoro_synthesizer.dart @@ -9,6 +9,8 @@ /// 规避 NNAPI 崩溃路径,详见 CLAUDE.md §7.4)。 library; +import 'dart:ffi'; +import 'dart:io'; import 'dart:isolate'; import 'package:sherpa_onnx/sherpa_onnx.dart' as sherpa; @@ -200,7 +202,7 @@ class _DisposeRequest { void _entryPoint(_InitPayload init) { sherpa.OfflineTts? tts; try { - sherpa.initBindings(); + _initSherpaBindings(); tts = sherpa.OfflineTts( sherpa.OfflineTtsConfig( model: sherpa.OfflineTtsModelConfig( @@ -209,11 +211,14 @@ void _entryPoint(_InitPayload init) { voices: init.paths.voices, tokens: init.paths.tokens, dataDir: init.paths.dataDir, + lexicon: init.paths.lexicon, + lang: init.paths.lexicon.isEmpty ? '' : 'zh_en', ), numThreads: init.numThreads, provider: 'cpu', debug: false, ), + ruleFsts: init.paths.ruleFsts, ), ); @@ -262,3 +267,25 @@ void _entryPoint(_InitPayload init) { init.sendPort.send('Init failed: $e'); } } + +/// 初始化 sherpa-onnx 原生库。 +/// +/// 桌面集成测试可显式设置 `SHERPA_ONNX_LIBRARY_PATH`,避免 Windows 优先加载 +/// `System32\onnxruntime.dll` 的旧版本;Flutter 应用默认沿用包内自动解析。 +void _initSherpaBindings() { + final libraryPath = Platform.environment['SHERPA_ONNX_LIBRARY_PATH']; + if (libraryPath == null || libraryPath.isEmpty) { + sherpa.initBindings(); + return; + } + if (Platform.isWindows) { + final directory = Directory(libraryPath); + final runtime = File( + '${directory.path}${Platform.pathSeparator}onnxruntime.dll', + ); + if (runtime.existsSync()) { + DynamicLibrary.open(runtime.path); + } + } + sherpa.initBindings(libraryPath); +} diff --git a/lib/services/tts/kokoro_tts_engine.dart b/lib/services/tts/kokoro_tts_engine.dart index 4369b0345..f73295e57 100644 --- a/lib/services/tts/kokoro_tts_engine.dart +++ b/lib/services/tts/kokoro_tts_engine.dart @@ -19,9 +19,6 @@ import 'kokoro_synthesizer.dart'; import 'kokoro_voices.dart'; import 'tts_engine.dart'; -/// Kokoro 合成语速(sherpa 的 speed 为倍率,1.0 = 正常)。 -const double _kokoroSpeed = 1.0; - /// Kokoro 推理线程数(纯 CPU)。 /// /// 瓶颈在 `OfflineTts.generate` 的 CPU 推理(实测 RTF≈3),线程数直接影响延迟。 @@ -87,15 +84,26 @@ class KokoroTtsEngine implements TtsEngine { // 优先用本次显式配置解析音色 sid(同步、入口处即定),不依赖跨 await 的 // 引擎环境态 [_config]——使并发的不同音色合成(试听/预热)互不串扰。 final effective = config ?? _config; + // v1.1-zh 的英文/中文发音人在同一模型内;当文本含 CJK 且调用方给的是 + // 英文音色时,自动切到中文默认音色,避免中文被英文音素器读成乱码。 + final configuredVoiceId = effective?.voiceName; + final hasCjk = RegExp(r'[\u3400-\u9fff]').hasMatch(text); + final configuredVoice = configuredVoiceId == null + ? null + : voiceById(configuredVoiceId); + final voiceId = hasCjk && configuredVoice?.language != TtsLanguage.chinese + ? kokoroDefaultVoiceZh + : configuredVoiceId; final sid = sidForVoiceId( - effective?.voiceName, + voiceId, fallbackAccent: _accentFromLanguageTag(effective?.languageTag), ); final outputPath = p.join(outputDir, '$baseName.wav'); final sampleRate = await synth.synthesize( text: text, sid: sid, - speed: _kokoroSpeed, + // sherpa 的 speed 是倍率,1.0 = 正常;用户设置范围为 0.5..2.0。 + speed: (effective?.speed ?? 1.0).clamp(0.5, 2.0).toDouble(), outputPath: outputPath, ); if (sampleRate == null) { diff --git a/lib/services/tts/kokoro_voices.dart b/lib/services/tts/kokoro_voices.dart index 67188b9de..d443a819b 100644 --- a/lib/services/tts/kokoro_voices.dart +++ b/lib/services/tts/kokoro_voices.dart @@ -1,75 +1,204 @@ -/// Kokoro(Echo Loop TTS)音色目录。 +/// Kokoro v1.1 多语言发音人目录。 /// -/// sherpa-onnx `kokoro-en-v0_19` 模型的 11 个英文发音人。发音人的口音由命名前缀 -/// 编码:`a*`=美音、`b*`=英音;`*f_`=女声、`*m_`=男声。合成时按 [KokoroVoice.sid] -/// 指定发音人(传给 `OfflineTts.generate(sid:)`),[KokoroVoice.id] 用作缓存键的 -/// voiceId(见 [TtsSpeechConfig.voiceId])。 +/// sid 顺序严格对应官方 `voices.bin` 与 ONNX metadata;前三个为英文发音人, +/// 后续 100 个为中文发音人。英文口音仍沿用 `a*=美音 / b*=英音` 约定。 library; import 'tts_engine.dart'; -/// 单个 Kokoro 发音人。 class KokoroVoice { - /// 发音人标识(如 `af_sarah`),同时作为缓存 voiceId。 final String id; - - /// 模型内的发音人序号(传给 `OfflineTts.generate(sid:)`)。 final int sid; - - /// 展示名(如 `Sarah`)。 final String displayName; + final TtsLanguage language; const KokoroVoice({ required this.id, required this.sid, required this.displayName, + this.language = TtsLanguage.english, }); - /// 口音:`a*`=美音,`b*`=英音(由 id 前缀推导)。 + /// 英文口音;中文语音不使用该字段,默认归入美音组以兼容既有设置结构。 TtsAccent get accent => id.startsWith('b') ? TtsAccent.uk : TtsAccent.us; - /// 是否女声:`*f_`=女声,`*m_`=男声(由 id 第二个字符推导)。 + /// 是否女声:`*f_`=女声,`*m_`=男声。 bool get isFemale => id.length > 1 && id[1] == 'f'; @override bool operator ==(Object other) => identical(this, other) || other is KokoroVoice && - runtimeType == other.runtimeType && id == other.id && sid == other.sid && - displayName == other.displayName; + displayName == other.displayName && + language == other.language; @override - int get hashCode => Object.hash(id, sid, displayName); + int get hashCode => Object.hash(id, sid, displayName, language); } -/// 全部 11 个 Kokoro 发音人(sid 与模型 `voices.bin` 内顺序严格一致)。 -const List kokoroVoices = [ - // 美音(af_/am_) - KokoroVoice(id: 'af', sid: 0, displayName: 'Default'), - KokoroVoice(id: 'af_bella', sid: 1, displayName: 'Bella'), - KokoroVoice(id: 'af_nicole', sid: 2, displayName: 'Nicole'), - KokoroVoice(id: 'af_sarah', sid: 3, displayName: 'Sarah'), - KokoroVoice(id: 'af_sky', sid: 4, displayName: 'Sky'), - KokoroVoice(id: 'am_adam', sid: 5, displayName: 'Adam'), - KokoroVoice(id: 'am_michael', sid: 6, displayName: 'Michael'), - // 英音(bf_/bm_) - KokoroVoice(id: 'bf_emma', sid: 7, displayName: 'Emma'), - KokoroVoice(id: 'bf_isabella', sid: 8, displayName: 'Isabella'), - KokoroVoice(id: 'bm_george', sid: 9, displayName: 'George'), - KokoroVoice(id: 'bm_lewis', sid: 10, displayName: 'Lewis'), +/// 多语言 Kokoro v1.1 的全部 103 个发音人(sid 0..102)。 +final List kokoroVoices = List.unmodifiable([ + const KokoroVoice( + id: 'af_maple', + sid: 0, + displayName: 'Maple', + language: TtsLanguage.english, + ), + const KokoroVoice( + id: 'af_sol', + sid: 1, + displayName: 'Sol', + language: TtsLanguage.english, + ), + const KokoroVoice( + id: 'bf_vale', + sid: 2, + displayName: 'Vale', + language: TtsLanguage.english, + ), + ..._buildChineseVoices(), +]); + +const List _chineseVoiceNames = [ + 'zf_001', + 'zf_002', + 'zf_003', + 'zf_004', + 'zf_005', + 'zf_006', + 'zf_007', + 'zf_008', + 'zf_017', + 'zf_018', + 'zf_019', + 'zf_021', + 'zf_022', + 'zf_023', + 'zf_024', + 'zf_026', + 'zf_027', + 'zf_028', + 'zf_032', + 'zf_036', + 'zf_038', + 'zf_039', + 'zf_040', + 'zf_042', + 'zf_043', + 'zf_044', + 'zf_046', + 'zf_047', + 'zf_048', + 'zf_049', + 'zf_051', + 'zf_059', + 'zf_060', + 'zf_067', + 'zf_070', + 'zf_071', + 'zf_072', + 'zf_073', + 'zf_074', + 'zf_075', + 'zf_076', + 'zf_077', + 'zf_078', + 'zf_079', + 'zf_083', + 'zf_084', + 'zf_085', + 'zf_086', + 'zf_087', + 'zf_088', + 'zf_090', + 'zf_092', + 'zf_093', + 'zf_094', + 'zf_099', + 'zm_009', + 'zm_010', + 'zm_011', + 'zm_012', + 'zm_013', + 'zm_014', + 'zm_015', + 'zm_016', + 'zm_020', + 'zm_025', + 'zm_029', + 'zm_030', + 'zm_031', + 'zm_033', + 'zm_034', + 'zm_035', + 'zm_037', + 'zm_041', + 'zm_045', + 'zm_050', + 'zm_052', + 'zm_053', + 'zm_054', + 'zm_055', + 'zm_056', + 'zm_057', + 'zm_058', + 'zm_061', + 'zm_062', + 'zm_063', + 'zm_064', + 'zm_065', + 'zm_066', + 'zm_068', + 'zm_069', + 'zm_080', + 'zm_081', + 'zm_082', + 'zm_089', + 'zm_091', + 'zm_095', + 'zm_096', + 'zm_097', + 'zm_098', + 'zm_100', ]; -/// 美音默认音色(`af_sarah`)。 -const String kokoroDefaultVoiceUs = 'af_sarah'; +List _buildChineseVoices() { + return [ + for (var index = 0; index < _chineseVoiceNames.length; index++) + KokoroVoice( + id: _chineseVoiceNames[index], + sid: index + 3, + displayName: _voiceDisplayName(_chineseVoiceNames[index], index + 1), + language: TtsLanguage.chinese, + ), + ]; +} + +String _voiceDisplayName(String id, int ordinal) { + return id.startsWith('zf_') + ? 'Chinese Female ${ordinal.toString().padLeft(3, '0')}' + : 'Chinese Male ${ordinal.toString().padLeft(3, '0')}'; +} + +/// 英文美音默认音色(v1.1 官方英文目录)。 +const String kokoroDefaultVoiceUs = 'af_sol'; -/// 英音默认音色(`bf_emma`)。 -const String kokoroDefaultVoiceUk = 'bf_emma'; +/// 英文英音默认音色(v1.1 官方英文目录)。 +const String kokoroDefaultVoiceUk = 'bf_vale'; -/// 返回指定口音下的全部发音人。 -List voicesForAccent(TtsAccent accent) => - kokoroVoices.where((v) => v.accent == accent).toList(growable: false); +/// 中文默认音色。 +const String kokoroDefaultVoiceZh = 'zf_001'; + +/// 返回指定语言的发音人。 +List voicesForLanguage(TtsLanguage language) => + kokoroVoices.where((v) => v.language == language).toList(growable: false); + +/// 返回指定英文口音下的全部发音人。 +List voicesForAccent(TtsAccent accent) => kokoroVoices + .where((v) => v.language == TtsLanguage.english && v.accent == accent) + .toList(growable: false); /// 按 id 查找发音人;未知 id 返回 null。 KokoroVoice? voiceById(String id) { @@ -79,7 +208,7 @@ KokoroVoice? voiceById(String id) { return null; } -/// 指定口音的默认发音人。 +/// 指定英文口音的默认发音人。 KokoroVoice defaultVoice(TtsAccent accent) { final id = accent == TtsAccent.uk ? kokoroDefaultVoiceUk @@ -87,9 +216,17 @@ KokoroVoice defaultVoice(TtsAccent accent) { return voiceById(id)!; } -/// 把 voiceId 解析为合成用的 sid;未知 id 回退到该口音默认音色。 -/// -/// [fallbackAccent] 在 [voiceId] 无法识别时决定回退到哪个口音的默认音色。 +/// 中文默认发音人。 +KokoroVoice get defaultChineseVoice => voiceById(kokoroDefaultVoiceZh)!; + +/// 按文本自动选择 default voice:含 CJK 时使用中文字典默认音色。 +KokoroVoice voiceForText(String text) { + return RegExp(r'[\u3400-\u9fff]').hasMatch(text) + ? defaultChineseVoice + : defaultVoice(TtsAccent.us); +} + +/// 把 voiceId 解析为合成用的 sid;未知 id 回退到该口音或中文默认音色。 int sidForVoiceId(String? voiceId, {required TtsAccent fallbackAccent}) { if (voiceId != null) { final v = voiceById(voiceId); diff --git a/lib/services/tts/piper_tts_engine.dart b/lib/services/tts/piper_tts_engine.dart index 0ae4eb530..17d004307 100644 --- a/lib/services/tts/piper_tts_engine.dart +++ b/lib/services/tts/piper_tts_engine.dart @@ -21,9 +21,6 @@ import 'piper_synthesizer.dart'; import 'piper_model_catalog.dart'; import 'tts_engine.dart'; -/// Piper 合成语速(sherpa 的 speed 为倍率,1.0 = 正常)。 -const double _piperSpeed = 1.0; - /// Piper 推理线程数(纯 CPU)。按设备 CPU 核心数分档(与离线 ASR 同策略, /// 见 `AsrModelConfig.recommendedThreads`): /// cores ≥ 8 → 6 线程,cores ≥ 6 → 4 线程,其他 → 2 线程。不占满 CPU。 @@ -93,7 +90,7 @@ class PiperTtsEngine implements TtsEngine { paths: paths, voiceId: voiceId, text: text, - speed: _piperSpeed, + speed: (effective?.speed ?? 1.0).clamp(0.5, 2.0).toDouble(), outputPath: outputPath, ); if (sampleRate == null) { diff --git a/lib/services/tts/text_segmenter.dart b/lib/services/tts/text_segmenter.dart new file mode 100644 index 000000000..56f2161ee --- /dev/null +++ b/lib/services/tts/text_segmenter.dart @@ -0,0 +1,166 @@ +/// 本地 TTS 长文本切分器。 +/// +/// 参考 TinyTTS 的 TextSegmenter 行为,用 Dart 重写:优先在自然句末切分, +/// 避开常见缩写、小数、URL 和文件名中的点;单句过长时再按逗号/冒号/空白软切。 +library; + +/// 单段默认最大字符数。Kokoro 对单次文本长度敏感,过长会显著增加首段延迟。 +const int kDefaultTtsMaxChars = 240; + +/// 句子级文本切分器。 +class TextSegmenter { + const TextSegmenter({this.maxChars = kDefaultTtsMaxChars}); + + final int maxChars; + + /// 把 [text] 切为有序段落;空输入返回空列表。 + List split(String text) { + final normalized = text + .replaceAll('\r\n', '\n') + .replaceAll('\r', '\n') + .trim(); + if (normalized.isEmpty) return const []; + + final rawSegments = _splitNatural(normalized) + .expand((segment) => _splitOverlong(segment, maxChars)) + .map((segment) => segment.trim()) + .where((segment) => segment.isNotEmpty) + .toList(growable: false); + return rawSegments; + } + + List _splitNatural(String text) { + final result = []; + var start = 0; + for (var index = 0; index < text.length; index++) { + final char = text[index]; + if (char == '\n') { + _appendNonEmpty(result, text.substring(start, index)); + start = index + 1; + continue; + } + if (!_isSentenceBoundary(text, index)) continue; + + var end = index + 1; + while (end < text.length && _isSentenceTerminator(text[end])) { + end++; + } + _appendNonEmpty(result, text.substring(start, end)); + start = end; + index = end - 1; + } + if (start < text.length) { + _appendNonEmpty(result, text.substring(start)); + } + return result; + } + + bool _isSentenceBoundary(String text, int index) { + final char = text[index]; + if (char == '。' || char == '!' || char == '?' || char == ';') { + return true; + } + if (char != '.' && char != '!' && char != '?' && char != ';') { + return false; + } + if (char != '.') return true; + if (_isDecimalDot(text, index)) return false; + if (_isUrlOrFileDot(text, index)) return false; + return !_isAbbreviationDot(text, index); + } + + bool _isSentenceTerminator(String char) => + char == '.' || + char == '!' || + char == '?' || + char == ';' || + char == '。' || + char == '!' || + char == '?' || + char == ';'; + + bool _isDecimalDot(String text, int index) { + if (index <= 0 || index + 1 >= text.length) return false; + final previous = text.codeUnitAt(index - 1); + final next = text.codeUnitAt(index + 1); + return _isAsciiDigit(previous) && _isAsciiDigit(next); + } + + bool _isAsciiDigit(int codeUnit) => codeUnit >= 0x30 && codeUnit <= 0x39; + + bool _isUrlOrFileDot(String text, int index) { + var start = index; + while (start > 0 && !_isWhitespace(text[start - 1])) { + start--; + } + var end = index + 1; + while (end < text.length && !_isWhitespace(text[end])) { + end++; + } + final token = text.substring(start, end).toLowerCase(); + if (token.contains('://') || token.startsWith('www.')) return true; + if (token.contains('/') || token.contains('\\')) return true; + return RegExp( + r'\.(?:com|org|net|io|cn|dev|app|pdf|txt|md|json|dart|wav|mp3|m4a)$', + ).hasMatch(token); + } + + bool _isAbbreviationDot(String text, int index) { + final prefix = text.substring(0, index); + return RegExp( + r'(?:^|[\s(])(?:mr|mrs|ms|dr|prof|sr|jr|st|vs|etc|e\.g|i\.e|u\.s|u\.k|a\.m|p\.m)$', + caseSensitive: false, + ).hasMatch(prefix); + } + + bool _isWhitespace(String char) => RegExp(r'\s').hasMatch(char); + + void _appendNonEmpty(List target, String value) { + final trimmed = value.trim(); + if (trimmed.isNotEmpty) target.add(trimmed); + } + + List _splitOverlong(String segment, int limit) { + final normalizedLimit = limit < 80 ? 80 : limit; + var current = segment.trim(); + final result = []; + while (current.length > normalizedLimit) { + final cut = _findSecondaryCut(current, normalizedLimit); + if (cut <= 0) { + final hardCut = _findHardCut(current, normalizedLimit); + result.add(current.substring(0, hardCut).trim()); + current = current.substring(hardCut).trim(); + continue; + } + result.add(current.substring(0, cut).trim()); + current = current.substring(cut).trim(); + } + if (current.isNotEmpty) result.add(current); + return result; + } + + int _findSecondaryCut(String text, int limit) { + const punctuation = {',', ',', ':', ':', '、'}; + for (var index = limit; index >= limit ~/ 2; index--) { + if (index < text.length && punctuation.contains(text[index - 1])) { + return index; + } + } + for (var index = limit; index >= limit ~/ 2; index--) { + if (index < text.length && _isWhitespace(text[index - 1])) return index; + } + return -1; + } + + int _findHardCut(String text, int limit) { + final codeUnits = text.codeUnits; + var cut = limit; + // 不要把 UTF-16 surrogate pair 从中间切开。 + if (cut < codeUnits.length && + codeUnits[cut - 1] >= 0xD800 && + codeUnits[cut - 1] <= 0xDBFF) { + cut--; + } + return cut > 0 ? cut : 1; + } +} diff --git a/lib/services/tts/tts_cache_store.dart b/lib/services/tts/tts_cache_store.dart index 7a0cae6df..189609789 100644 --- a/lib/services/tts/tts_cache_store.dart +++ b/lib/services/tts/tts_cache_store.dart @@ -12,6 +12,7 @@ library; import 'dart:io'; import 'package:crypto/crypto.dart'; +import 'package:path/path.dart' as p; import 'dart:convert'; import '../../database/daos/tts_cache_dao.dart'; @@ -198,10 +199,10 @@ class TtsCacheStore { try { final dir = await _dir(); if (!await dir.exists()) return 0; - final keep = (await _dao.allFilePaths()).toSet(); + final keep = (await _dao.allFilePaths()).map(_normalizePath).toSet(); await for (final entity in dir.list(followLinks: false)) { if (entity is! File) continue; - if (keep.contains(entity.path)) continue; + if (keep.contains(_normalizePath(entity.path))) continue; try { final size = await entity.length(); await entity.delete(); @@ -216,6 +217,14 @@ class TtsCacheStore { return freed; } + /// 统一路径表示,避免 Windows 上 `File.path` 分隔符/大小写与目录列举不一致, + /// 导致有效缓存被误判为孤儿清扫。 + String _normalizePath(String path) { + var normalized = p.normalize(p.absolute(path)); + if (Platform.isWindows) normalized = normalized.toLowerCase(); + return normalized; + } + Future _deleteFileQuietly(String path) async { try { final file = File(path); diff --git a/lib/services/tts/tts_coordinator.dart b/lib/services/tts/tts_coordinator.dart index 5b36dfada..d4324b314 100644 --- a/lib/services/tts/tts_coordinator.dart +++ b/lib/services/tts/tts_coordinator.dart @@ -504,6 +504,19 @@ class TtsCoordinator { return engine != null; } + /// 将文本渲染为可复用的本地音频文件,不触发播放。 + /// + /// 供 Text → Lesson 等后台编排使用:仍复用同一缓存键、同一条合成调度器和 + /// 模型实例;调用方按 chunk 边界检查取消,天然避免重复加载模型。 + Future renderToCache( + String text, { + required TtsEngineKind kind, + required TtsSpeechConfig config, + TtsSynthPriority priority = TtsSynthPriority.user, + }) { + return _render(text, kind, config, priority: priority); + } + /// 取消尚未开始的文本预热;运行中的初始化/推理不能被安全打断。 void cancelPendingPrewarm() { _backgroundGeneration++; @@ -527,7 +540,7 @@ class TtsCoordinator { text: text, engine: kind, voiceId: config.voiceId, - speed: config.rate, + speed: _effectiveSynthesisSpeed(kind, config), modelTag: config.modelTag, ); @@ -661,7 +674,7 @@ class TtsCoordinator { engine: kind, voiceId: config.voiceId, languageCode: config.languageTag, - speed: config.rate, + speed: _effectiveSynthesisSpeed(kind, config), result: result, ); AppLogger.log( @@ -673,6 +686,12 @@ class TtsCoordinator { return result.filePath; } + /// 平台 TTS 的 rate 与本地神经网络 speed 分属两套尺度,缓存必须按实际合成 + /// 参数分桶:平台用 rate,Kokoro/Piper 用 speed。 + double _effectiveSynthesisSpeed(TtsEngineKind kind, TtsSpeechConfig config) { + return kind == TtsEngineKind.platform ? config.rate : config.speed; + } + /// 播放主干:抢占当前播放(递增代际、停止播放/引擎)后渲染并播放。 /// /// 渲染失败时降级实时朗读([TtsEngine.speakLive],不缓存;保留 §7.15 macOS 兜底)。 diff --git a/lib/services/tts/tts_engine.dart b/lib/services/tts/tts_engine.dart index 725d9c660..7941783c5 100644 --- a/lib/services/tts/tts_engine.dart +++ b/lib/services/tts/tts_engine.dart @@ -44,6 +44,9 @@ enum TtsAccent { uk, } +/// TTS voice 语言族。Kokoro 多语言 v1.1 同时包含英文与中文发音人。 +enum TtsLanguage { english, chinese } + /// Echo Loop(Kokoro)模型精度变体。 /// /// 同一 Kokoro 82M 模型的两种打包:fp32 未量化(推理快、效果好,Apple/ARM 上 @@ -68,6 +71,12 @@ class TtsSpeechConfig { /// 语速(归一化 0..1,平台层各自映射)。 final double rate; + /// 本地神经引擎语速倍率(1.0 = 正常;建议范围 0.5..2.0)。 + /// + /// 平台 TTS 仍使用 [rate] 做归一化映射;Kokoro/Piper 使用本字段直接传给 + /// sherpa-onnx,避免把 0.45 这类平台参数误当成神经网络倍率。 + final double speed; + /// 音调。 final double pitch; @@ -84,6 +93,7 @@ class TtsSpeechConfig { const TtsSpeechConfig({ required this.languageTag, this.rate = 0.45, + this.speed = 1.0, this.pitch = 1.0, this.volume = 1.0, this.voiceName, @@ -96,6 +106,7 @@ class TtsSpeechConfig { TtsSpeechConfig copyWith({ String? languageTag, double? rate, + double? speed, double? pitch, double? volume, String? voiceName, @@ -104,6 +115,7 @@ class TtsSpeechConfig { return TtsSpeechConfig( languageTag: languageTag ?? this.languageTag, rate: rate ?? this.rate, + speed: speed ?? this.speed, pitch: pitch ?? this.pitch, volume: volume ?? this.volume, voiceName: voiceName ?? this.voiceName, @@ -118,6 +130,7 @@ class TtsSpeechConfig { runtimeType == other.runtimeType && languageTag == other.languageTag && rate == other.rate && + speed == other.speed && pitch == other.pitch && volume == other.volume && voiceName == other.voiceName && @@ -125,7 +138,7 @@ class TtsSpeechConfig { @override int get hashCode => - Object.hash(languageTag, rate, pitch, volume, voiceName, modelTag); + Object.hash(languageTag, rate, speed, pitch, volume, voiceName, modelTag); } /// 合成结果(产出的本地音频文件信息)。 diff --git a/lib/services/tts/wav_audio_assembler.dart b/lib/services/tts/wav_audio_assembler.dart new file mode 100644 index 000000000..25e512916 --- /dev/null +++ b/lib/services/tts/wav_audio_assembler.dart @@ -0,0 +1,290 @@ +/// 将多段 PCM WAV 流式合并为单个学习音频。 +/// +/// Kokoro 每段都输出 16-bit PCM WAV。本模块不引入 FFmpeg:只解析 RIFF/WAVE +/// 头、校验格式,并把各段 data 顺序追加到目标文件;写入 .part 后原子改名。 +library; + +import 'dart:io'; +import 'dart:typed_data'; + +/// 合并后的句子边界元数据。 +class AssembledSentence { + const AssembledSentence({ + required this.index, + required this.text, + required this.start, + required this.end, + }); + + final int index; + final String text; + final Duration start; + final Duration end; +} + +/// 合并结果。 +class AssembledWav { + const AssembledWav({ + required this.filePath, + required this.duration, + required this.sentences, + required this.byteLength, + }); + + final String filePath; + final Duration duration; + final List sentences; + final int byteLength; +} + +/// 流式 WAV 合并器。 +class WavAudioAssembler { + WavAudioAssembler({ + required String outputPath, + this.gap = const Duration(milliseconds: 120), + }) : _outputPath = outputPath; + + final String _outputPath; + final Duration gap; + + RandomAccessFile? _file; + int? _sampleRate; + int? _channels; + int? _bitsPerSample; + int _dataBytes = 0; + bool _finished = false; + final List _sentences = []; + + bool get isStarted => _file != null; + + /// 开始写入;目标父目录会自动创建。 + Future start() async { + if (_file != null) return; + final file = File(_outputPath); + await file.parent.create(recursive: true); + final part = File('$_outputPath.part'); + if (await part.exists()) await part.delete(); + _file = await part.open(mode: FileMode.write); + await _file!.writeFrom(Uint8List(44)); + } + + /// 追加一段 WAV(PCM 16-bit)。 + Future addWav(String filePath, String text) async { + if (_finished) throw StateError('WavAudioAssembler already finished.'); + await start(); + final bytes = await File(filePath).readAsBytes(); + final pcm = _parsePcmWav(bytes); + + if (_sampleRate == null) { + _sampleRate = pcm.sampleRate; + _channels = pcm.channels; + _bitsPerSample = pcm.bitsPerSample; + } else if (_sampleRate != pcm.sampleRate || + _channels != pcm.channels || + _bitsPerSample != pcm.bitsPerSample) { + throw StateError( + 'WAV format mismatch: expected ' + '${_sampleRate}Hz/${_channels}ch/${_bitsPerSample}bit, got ' + '${pcm.sampleRate}Hz/${pcm.channels}ch/${pcm.bitsPerSample}bit', + ); + } + + final startTime = _durationFromBytes(_dataBytes); + await _file!.writeFrom(pcm.pcm); + _dataBytes += pcm.pcm.length; + final end = _durationFromBytes(_dataBytes); + _sentences.add( + AssembledSentence( + index: _sentences.length, + text: text, + start: startTime, + end: end, + ), + ); + + if (gap > Duration.zero) { + final gapBytes = _bytesForDuration(gap); + if (gapBytes > 0) { + await _file!.writeFrom(Uint8List(gapBytes)); + _dataBytes += gapBytes; + } + } + } + + /// 写入最后一段并原子提交。 + Future finish() async { + if (_finished) { + throw StateError('WavAudioAssembler already finished.'); + } + final file = _file ?? await _openEmpty(); + try { + final sampleRate = _sampleRate; + final channels = _channels; + final bitsPerSample = _bitsPerSample; + if (sampleRate == null || channels == null || bitsPerSample == null) { + throw StateError('No WAV chunks were added.'); + } + await file.setPosition(0); + await file.writeFrom( + _buildHeader( + dataBytes: _dataBytes, + sampleRate: sampleRate, + channels: channels, + bitsPerSample: bitsPerSample, + ), + ); + await file.close(); + _file = null; + final part = File('$_outputPath.part'); + final target = File(_outputPath); + if (await target.exists()) await target.delete(); + await part.rename(_outputPath); + _finished = true; + return AssembledWav( + filePath: _outputPath, + duration: _durationFromBytes(_dataBytes), + sentences: List.unmodifiable(_sentences), + byteLength: _dataBytes, + ); + } catch (_) { + await abort(); + rethrow; + } + } + + /// 失败时清理半成品。 + Future abort() async { + try { + await _file?.close(); + } catch (_) {} + _file = null; + final part = File('$_outputPath.part'); + if (await part.exists()) await part.delete(); + } + + Future _openEmpty() async { + await start(); + return _file!; + } + + Duration _durationFromBytes(int bytes) { + final byteRate = _byteRate; + if (byteRate <= 0) return Duration.zero; + return Duration( + microseconds: (bytes * Duration.microsecondsPerSecond / byteRate).round(), + ); + } + + int _bytesForDuration(Duration duration) { + final byteRate = _byteRate; + if (byteRate <= 0) return 0; + return (byteRate * duration.inMicroseconds / Duration.microsecondsPerSecond) + .round(); + } + + int get _byteRate => + (_sampleRate ?? 0) * (_channels ?? 0) * ((_bitsPerSample ?? 0) ~/ 8); + + _PcmWav _parsePcmWav(Uint8List bytes) { + if (bytes.length < 44 || + _ascii(bytes, 0, 4) != 'RIFF' || + _ascii(bytes, 8, 4) != 'WAVE') { + throw const FormatException('Invalid WAV header.'); + } + final data = ByteData.sublistView(bytes); + var offset = 12; + int? audioFormat; + int? channels; + int? sampleRate; + int? bitsPerSample; + int? dataOffset; + int? dataLength; + while (offset + 8 <= bytes.length) { + final chunkId = _ascii(bytes, offset, 4); + final chunkSize = data.getUint32(offset + 4, Endian.little); + final chunkStart = offset + 8; + if (chunkId == 'fmt ') { + if (chunkSize < 16 || chunkStart + 16 > bytes.length) { + throw const FormatException('Invalid fmt chunk.'); + } + audioFormat = data.getUint16(chunkStart, Endian.little); + channels = data.getUint16(chunkStart + 2, Endian.little); + sampleRate = data.getUint32(chunkStart + 4, Endian.little); + bitsPerSample = data.getUint16(chunkStart + 14, Endian.little); + } else if (chunkId == 'data') { + dataOffset = chunkStart; + dataLength = chunkSize; + break; + } + offset = chunkStart + chunkSize + (chunkSize.isOdd ? 1 : 0); + } + if (audioFormat != 1 || + channels == null || + channels < 1 || + sampleRate == null || + sampleRate < 1 || + bitsPerSample != 16 || + dataOffset == null || + dataLength == null) { + throw const FormatException('Only 16-bit PCM WAV is supported.'); + } + final end = dataOffset + dataLength; + if (end > bytes.length) { + throw const FormatException('WAV data chunk is truncated.'); + } + return _PcmWav( + sampleRate: sampleRate, + channels: channels, + bitsPerSample: bitsPerSample!, + pcm: Uint8List.sublistView(bytes, dataOffset, end), + ); + } + + String _ascii(Uint8List bytes, int offset, int length) { + return String.fromCharCodes(bytes.sublist(offset, offset + length)); + } + + Uint8List _buildHeader({ + required int dataBytes, + required int sampleRate, + required int channels, + required int bitsPerSample, + }) { + final header = ByteData(44); + _writeAscii(header, 0, 'RIFF'); + header.setUint32(4, 36 + dataBytes, Endian.little); + _writeAscii(header, 8, 'WAVE'); + _writeAscii(header, 12, 'fmt '); + header.setUint32(16, 16, Endian.little); + header.setUint16(20, 1, Endian.little); + header.setUint16(22, channels, Endian.little); + header.setUint32(24, sampleRate, Endian.little); + final byteRate = sampleRate * channels * (bitsPerSample ~/ 8); + header.setUint32(28, byteRate, Endian.little); + header.setUint16(32, channels * (bitsPerSample ~/ 8), Endian.little); + header.setUint16(34, bitsPerSample, Endian.little); + _writeAscii(header, 36, 'data'); + header.setUint32(40, dataBytes, Endian.little); + return header.buffer.asUint8List(); + } + + void _writeAscii(ByteData target, int offset, String value) { + for (var index = 0; index < value.length; index++) { + target.setUint8(offset + index, value.codeUnitAt(index)); + } + } +} + +class _PcmWav { + const _PcmWav({ + required this.sampleRate, + required this.channels, + required this.bitsPerSample, + required this.pcm, + }); + + final int sampleRate; + final int channels; + final int bitsPerSample; + final Uint8List pcm; +} diff --git a/lib/widgets/listen_and_repeat/listen_and_repeat_settings_sheet.dart b/lib/widgets/listen_and_repeat/listen_and_repeat_settings_sheet.dart index 13eee4228..8a9535101 100644 --- a/lib/widgets/listen_and_repeat/listen_and_repeat_settings_sheet.dart +++ b/lib/widgets/listen_and_repeat/listen_and_repeat_settings_sheet.dart @@ -49,6 +49,10 @@ class _ListenAndRepeatSettingsSheet extends ConsumerWidget { _buildControlModeSection(l10n, theme, settings, ref), const SizedBox(height: AppSpacing.l), + // 参考音频来源:原音 / 本地 Kokoro + _buildReferenceSourceSection(l10n, theme, settings, ref), + const SizedBox(height: AppSpacing.l), + // 播放速度(手动/自动模式下都生效) _buildPlaybackSpeedSection(l10n, theme, settings, ref), @@ -81,6 +85,60 @@ class _ListenAndRepeatSettingsSheet extends ConsumerWidget { ); } + /// 参考音频来源:原媒体片段或本地 TTS 朗读。 + Widget _buildReferenceSourceSection( + AppLocalizations l10n, + ThemeData theme, + IntensiveListenSettings settings, + WidgetRef ref, + ) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + l10n.listenAndRepeatReferenceLabel, + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: AppSpacing.s), + SizedBox( + width: double.infinity, + child: SegmentedButton( + segments: [ + ButtonSegment( + value: ShadowingReferenceSource.original, + label: Text(l10n.listenAndRepeatReferenceOriginal), + icon: const Icon(Icons.audiotrack, size: 18), + ), + ButtonSegment( + value: ShadowingReferenceSource.localTts, + label: Text(l10n.listenAndRepeatReferenceTts), + icon: const Icon(Icons.record_voice_over, size: 18), + ), + ], + selected: {settings.referenceSource}, + onSelectionChanged: (selected) { + ref + .read(listenAndRepeatSettingsProvider.notifier) + .update(settings.copyWith(referenceSource: selected.first)); + }, + showSelectedIcon: false, + ), + ), + if (settings.referenceSource == ShadowingReferenceSource.localTts) ...[ + const SizedBox(height: AppSpacing.xs), + Text( + l10n.listenAndRepeatReferenceTtsHint, + style: theme.textTheme.labelSmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ], + ); + } + /// 播放速度滑块 /// /// 速度仅对当前会话生效,使用统一离散档位。 diff --git a/lib/widgets/tts/tts_model_download_prompt_dialog.dart b/lib/widgets/tts/tts_model_download_prompt_dialog.dart index 21bf9962c..40e91ec99 100644 --- a/lib/widgets/tts/tts_model_download_prompt_dialog.dart +++ b/lib/widgets/tts/tts_model_download_prompt_dialog.dart @@ -102,6 +102,24 @@ Future ensureTtsModelReadyForPlayback(Ref ref) async { return false; } +/// [ensureTtsModelReadyForPlayback] 的 WidgetRef 版本,供页面直接调用。 +Future ensureTtsModelReadyForPlaybackFromWidget(WidgetRef ref) async { + await ref + .read(ttsModelInstallationGateProvider) + .ensureInstallationStatesLoaded(); + final state = ref.read(ttsPlaybackModelStateProvider); + if (state.isReady) return true; + + final context = rootNavigatorKey.currentContext; + if (context == null || !context.mounted) return false; + await showDialog( + context: context, + barrierDismissible: true, + builder: (_) => const TtsModelDownloadDialog(), + ); + return false; +} + /// 只检查指定 TTS 配置是否可用,不弹窗、不下载,供后台 warmup 使用。 Future isTtsModelReadyForConfig( Ref ref, { diff --git a/pubspec.lock b/pubspec.lock index 53af9cbe5..aa67a59e1 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1039,10 +1039,10 @@ packages: dependency: "direct main" description: name: intl - sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5" + sha256: "1ca20c894b1717686a2319b8548763d812bc0aabdac580420a44c5178c57a867" url: "https://pub.dev" source: hosted - version: "0.20.2" + version: "0.20.3" io: dependency: transitive description: @@ -1175,10 +1175,10 @@ packages: dependency: transitive description: name: matcher - sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd" url: "https://pub.dev" source: hosted - version: "0.12.19" + version: "0.12.20" material_color_utilities: dependency: transitive description: @@ -1255,10 +1255,10 @@ packages: dependency: transitive description: name: meta - sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + sha256: "307249ce4ff29d58a18e97f6345f539382eb9c9c29ecda628900f31de0443dd9" url: "https://pub.dev" source: hosted - version: "1.17.0" + version: "1.19.0" mime: dependency: transitive description: @@ -1783,74 +1783,82 @@ packages: dependency: "direct main" description: name: sherpa_onnx - sha256: "889c03cf7a8788795e3a6d35bf9f20b66d1862ea7a73a1b6aaf6e450715c870a" + sha256: b7e65d5956f8c9213fd339c68ea058cdec7ad24bd35f9bfc5c18178d3d73b2f9 url: "https://pub.dev" source: hosted - version: "1.13.4" + version: "1.13.8" sherpa_onnx_android_arm64: dependency: transitive description: name: sherpa_onnx_android_arm64 - sha256: "0337650bc2357f39b751f1b9ced37770de3b60026effe66473b557346b4e3f97" + sha256: "29db5572735afb1bc29f0f95ab160f7fb38b4b3f926f48e9113457fd740264f4" url: "https://pub.dev" source: hosted - version: "1.13.4" + version: "1.13.8" sherpa_onnx_android_armeabi: dependency: transitive description: name: sherpa_onnx_android_armeabi - sha256: "9e96729d99567c3f64fc6f4c232ef04b57ae4ef3bf1b21364ee6e461103cb7cf" + sha256: fe81837bd12f67d136a456c6d2b39876caaba09edd77761cb527cb170dac2d2a url: "https://pub.dev" source: hosted - version: "1.13.4" + version: "1.13.8" sherpa_onnx_android_x86: dependency: transitive description: name: sherpa_onnx_android_x86 - sha256: "6eaa462a24bf881c8ee2b3ab5b9fad3d310b714fc9d3ac4b9f650d0a90c9d0ab" + sha256: "8ddbd35982d4bb15648fc818253f2711be18448db50a2edb05c56a6c745fcea7" url: "https://pub.dev" source: hosted - version: "1.13.4" + version: "1.13.8" sherpa_onnx_android_x86_64: dependency: transitive description: name: sherpa_onnx_android_x86_64 - sha256: "181aa0f0968adf2cf2dcb369c879ea372653864538b82772877e22748a80254f" + sha256: d20c06c0edc92286609f256d04c295cad510bbb270068c0c79877f4a10f00581 url: "https://pub.dev" source: hosted - version: "1.13.4" + version: "1.13.8" sherpa_onnx_ios: dependency: transitive description: name: sherpa_onnx_ios - sha256: "9586b7ddae9a11fd10457367ff5e8a153e0993a8c588828bc31f6cf7afbaf2d2" + sha256: "044e3a614d1847c0ff9b6475201c6e5d023cd6ca6cd17f14c16332081c01e1d2" url: "https://pub.dev" source: hosted - version: "1.13.4" + version: "1.13.8" sherpa_onnx_linux: dependency: transitive description: name: sherpa_onnx_linux - sha256: eb2666480e48e002e0dd634c1bbe1ef52162a0821cd9eada39d40a3e12f76c48 + sha256: "22fb91a1c50b7bc24d93d0b67c87cf2a3a38f7564d7f14343d704a1201848960" url: "https://pub.dev" source: hosted - version: "1.13.4" + version: "1.13.8" sherpa_onnx_macos: dependency: transitive description: name: sherpa_onnx_macos - sha256: "55164fa38db3de870dc834b855be6b6b5cc0acb7663d74cea76c3de4e7bd7a47" + sha256: bdedc34ac0acccee7ae73a5599f66e0081137c00467c565a92f72b67428837bb url: "https://pub.dev" source: hosted - version: "1.13.4" + version: "1.13.8" + sherpa_onnx_web: + dependency: transitive + description: + name: sherpa_onnx_web + sha256: e25a3813eb080636280b23dd4b0098252902675158b66dceda1efba061adf5d7 + url: "https://pub.dev" + source: hosted + version: "1.13.8" sherpa_onnx_windows: dependency: transitive description: name: sherpa_onnx_windows - sha256: "9bbf2b43308446a894c685d5fe1a147df457b9e400afed7aefc271f72f9290b9" + sha256: d91fb9c4baac2594cf1ae6955f5e9f51113d354de8c75543ea8481d431040a71 url: "https://pub.dev" source: hosted - version: "1.13.4" + version: "1.13.8" showcaseview: dependency: "direct main" description: @@ -2084,10 +2092,10 @@ packages: dependency: transitive description: name: test_api - sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" + sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11" url: "https://pub.dev" source: hosted - version: "0.7.10" + version: "0.7.12" timeago: dependency: "direct main" description: @@ -2260,10 +2268,10 @@ packages: dependency: transitive description: name: vector_math - sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b + sha256: "92b9910f66ed1057fd4da7b040ae7c74cafacf885bdc81be496928d5049b032d" url: "https://pub.dev" source: hosted - version: "2.2.0" + version: "2.4.3" vm_service: dependency: transitive description: @@ -2377,5 +2385,5 @@ packages: source: hosted version: "2.1.0" sdks: - dart: ">=3.10.0 <4.0.0" + dart: ">=3.11.0-0 <4.0.0" flutter: ">=3.38.1" diff --git a/pubspec.yaml b/pubspec.yaml index a8b1fe2eb..ce22f04b6 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 1.0.38 +version: 1.0.39 environment: sdk: ^3.9.2 @@ -95,7 +95,7 @@ dependencies: share_plus: ^10.1.4 table_calendar: ^3.2.0 timeago: ^3.7.1 - sherpa_onnx: ^1.13.4 + sherpa_onnx: ^1.13.8 showcaseview: ^5.0.1 # 认证:复用 fluency-frontend 的 Supabase 项目(auth.users 共享), # mobile 端走 native ID Token 流(Apple/Google)+ 邮箱密码。详见 docs / plan。 diff --git a/test/features/text_to_lesson/text_lesson_generator_test.dart b/test/features/text_to_lesson/text_lesson_generator_test.dart new file mode 100644 index 000000000..cac0e14a9 --- /dev/null +++ b/test/features/text_to_lesson/text_lesson_generator_test.dart @@ -0,0 +1,139 @@ +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; + +import 'package:echo_loop/database/daos/audio_item_dao.dart'; +import 'package:echo_loop/features/text_to_lesson/data/text_lesson_generator.dart'; +import 'package:echo_loop/models/audio_item.dart'; +import 'package:echo_loop/services/tts/tts_engine.dart'; + +class _MockAudioItemDao extends Mock implements AudioItemDao {} + +class _FakeSynthesizer implements TextLessonSynthesizer { + final calls = []; + + @override + Future renderToCache( + String text, { + required TtsEngineKind kind, + required TtsSpeechConfig config, + }) async { + calls.add(text); + final file = File( + '${Directory.systemTemp.path}/text_lesson_chunk_${calls.length}.wav', + ); + await file.writeAsBytes(_pcm16Wav(samples: 1000)); + return file.path; + } +} + +Uint8List _pcm16Wav({required int samples, int sampleRate = 1000}) { + final payload = ByteData(samples * 2); + final header = ByteData(44); + void ascii(int offset, String text) { + for (var i = 0; i < text.length; i++) { + header.setUint8(offset + i, text.codeUnitAt(i)); + } + } + + final bytes = payload.buffer.asUint8List(); + ascii(0, 'RIFF'); + header.setUint32(4, 36 + bytes.length, Endian.little); + ascii(8, 'WAVE'); + ascii(12, 'fmt '); + header.setUint32(16, 16, Endian.little); + header.setUint16(20, 1, Endian.little); + header.setUint16(22, 1, Endian.little); + header.setUint32(24, sampleRate, Endian.little); + header.setUint32(28, sampleRate * 2, Endian.little); + header.setUint16(32, 2, Endian.little); + header.setUint16(34, 16, Endian.little); + ascii(36, 'data'); + header.setUint32(40, bytes.length, Endian.little); + return Uint8List.fromList([...header.buffer.asUint8List(), ...bytes]); +} + +void main() { + late Directory dir; + late _MockAudioItemDao dao; + late List addedItems; + + setUp(() async { + dir = await Directory.systemTemp.createTemp('text-lesson-test'); + dao = _MockAudioItemDao(); + addedItems = []; + when( + () => dao.saveTranscriptContent( + any(), + srt: any(named: 'srt'), + wordTimestampsJson: any(named: 'wordTimestampsJson'), + ), + ).thenAnswer((_) async {}); + }); + + tearDown(() async { + if (await dir.exists()) await dir.delete(recursive: true); + }); + + TextLessonGenerator build(_FakeSynthesizer synth) => TextLessonGenerator( + synthesizer: synth, + audioItemDao: dao, + addAudioItem: (item) async => addedItems.add(item), + dataDirectoryResolver: () async => dir, + ); + + test('生成完整 WAV、SRT 与 AudioItem', () async { + final synth = _FakeSynthesizer(); + final result = await build(synth).generate( + title: 'My Lesson', + text: 'Hello world. How are you?', + voiceId: 'af_sol', + speed: 1.2, + modelVariant: KokoroModelVariant.int8, + ); + + expect(synth.calls, ['Hello world.', 'How are you?']); + expect(addedItems, hasLength(1)); + expect(addedItems.single.name, 'My Lesson'); + expect(addedItems.single.transcriptSource, TranscriptSource.local); + expect(addedItems.single.sentenceCount, 2); + expect(await File(result.audioFile.filePath).exists(), isTrue); + verify( + () => dao.saveTranscriptContent( + result.audioItem.id, + srt: any(named: 'srt'), + wordTimestampsJson: null, + ), + ).called(1); + }); + + test('取消后不写入 AudioItem 并清理 part', () async { + final synth = _FakeSynthesizer(); + final token = TextLessonCancellationToken(); + final generator = build(synth); + await expectLater( + generator.generate( + title: 'Cancel', + text: 'One sentence. Two sentence.', + voiceId: 'af_sol', + speed: 1.0, + modelVariant: KokoroModelVariant.int8, + cancellationToken: token, + onProgress: (progress) { + if (progress.completedSegments == 1) token.cancel(); + }, + ), + throwsA(isA()), + ); + + expect(synth.calls, ['One sentence.']); + expect(addedItems, isEmpty); + final parts = await dir + .list(recursive: true) + .where((entity) => entity.path.endsWith('.part')) + .toList(); + expect(parts, isEmpty); + }); +} diff --git a/test/providers/tts/tts_controller_preview_test.dart b/test/providers/tts/tts_controller_preview_test.dart index a19cc62ff..359cce0c4 100644 --- a/test/providers/tts/tts_controller_preview_test.dart +++ b/test/providers/tts/tts_controller_preview_test.dart @@ -19,6 +19,7 @@ import 'package:echo_loop/providers/tts/tts_settings_provider.dart'; import 'package:echo_loop/services/tts/kokoro_model_manager.dart' show AsrModelDownloadStatus; import 'package:echo_loop/services/tts/kokoro_voices.dart'; +import 'package:echo_loop/services/tts/kokoro_model_catalog.dart'; import 'package:echo_loop/services/tts/piper_model_catalog.dart'; import 'package:echo_loop/services/tts/tts_engine.dart'; @@ -203,13 +204,13 @@ void main() { ); addTearDown(c.dispose); - final voice = voiceById('am_adam')!; + final voice = voiceById('af_sol')!; final future = c.read(ttsControllerProvider.notifier).previewVoice(voice); // 同步阶段(await speakWith 之前)已置 speakingKey。 expect( c.read(ttsControllerProvider).speakingKey, - ttsVoicePreviewKey('am_adam'), + ttsVoicePreviewKey('af_sol'), ); await future; @@ -320,9 +321,7 @@ void main() { test('模型下载完成后 ready 变化会自动加载当前引擎', () async { SharedPreferences.setMockInitialValues({}); final factory = _RecordingFactory(); - final kokoroNotifier = _FixedKokoroNotifier( - KokoroModelsState.initial(), - ); + final kokoroNotifier = _FixedKokoroNotifier(KokoroModelsState.initial()); final c = ProviderContainer( overrides: [ initialTtsSettingsProvider.overrideWithValue( @@ -344,9 +343,9 @@ void main() { kokoroNotifier.updateState(_ready()); await expectLater( - Stream.periodic(const Duration(milliseconds: 1)).map( - (_) => factory.calls, - ), + Stream.periodic( + const Duration(milliseconds: 1), + ).map((_) => factory.calls), emitsThrough(greaterThanOrEqualTo(1)), ); }); @@ -502,19 +501,20 @@ void main() { test('对每个音色:单一来源构造,逐字段确定(口音→语言、音色 id、变体标签)', () { for (final voice in kokoroVoices) { final cfg = ttsVoicePreviewConfig(voice, KokoroModelVariant.int8); - expect( - cfg.languageTag, - voice.accent == TtsAccent.uk ? 'en-GB' : 'en-US', - ); + expect(cfg.languageTag, switch (voice.language) { + TtsLanguage.chinese => 'zh-CN', + TtsLanguage.english => + voice.accent == TtsAccent.uk ? 'en-GB' : 'en-US', + }); expect(cfg.voiceName, voice.id); - expect(cfg.modelTag, KokoroModelVariant.int8.name); + expect(cfg.modelTag, kokoroSpecOf(KokoroModelVariant.int8).id); // voiceId(缓存键用)= voiceName,非语言标签。 expect(cfg.voiceId, voice.id); } }); test('变体不同 → modelTag 不同(fp32/int8 分桶,缓存键不串)', () { - final v = voiceById('am_adam')!; + final v = voiceById('af_sol')!; final fp32 = ttsVoicePreviewConfig(v, KokoroModelVariant.fp32); final int8 = ttsVoicePreviewConfig(v, KokoroModelVariant.int8); expect(fp32.modelTag, isNot(int8.modelTag)); @@ -522,7 +522,7 @@ void main() { expect(fp32.languageTag, int8.languageTag); expect(fp32.voiceName, int8.voiceName); // 语速默认一致(cacheKey 的 speed 段)。 - expect(fp32.rate, int8.rate); + expect(fp32.speed, int8.speed); }); }); } diff --git a/test/providers/tts/tts_settings_provider_test.dart b/test/providers/tts/tts_settings_provider_test.dart index 2ce84125d..8f452188a 100644 --- a/test/providers/tts/tts_settings_provider_test.dart +++ b/test/providers/tts/tts_settings_provider_test.dart @@ -167,30 +167,30 @@ void main() { addTearDown(c.dispose); final notifier = c.read(ttsSettingsProvider.notifier); - await notifier.setKokoroVoice(TtsAccent.us, 'am_adam'); - expect(c.read(ttsSettingsProvider).kokoroVoiceUs, 'am_adam'); - await notifier.setKokoroVoice(TtsAccent.uk, 'bm_lewis'); - expect(c.read(ttsSettingsProvider).kokoroVoiceUk, 'bm_lewis'); + await notifier.setKokoroVoice(TtsAccent.us, 'af_maple'); + expect(c.read(ttsSettingsProvider).kokoroVoiceUs, 'af_maple'); + await notifier.setKokoroVoice(TtsAccent.uk, 'bf_vale'); + expect(c.read(ttsSettingsProvider).kokoroVoiceUk, 'bf_vale'); // 美音口音传英音音色 → 忽略。 - await notifier.setKokoroVoice(TtsAccent.us, 'bm_lewis'); - expect(c.read(ttsSettingsProvider).kokoroVoiceUs, 'am_adam'); + await notifier.setKokoroVoice(TtsAccent.us, 'bf_vale'); + expect(c.read(ttsSettingsProvider).kokoroVoiceUs, 'af_maple'); final saved = await SharedPreferences.getInstance(); - expect(saved.getString(TtsSettingsKeys.kokoroVoiceUs), 'am_adam'); - expect(saved.getString(TtsSettingsKeys.kokoroVoiceUk), 'bm_lewis'); + expect(saved.getString(TtsSettingsKeys.kokoroVoiceUs), 'af_maple'); + expect(saved.getString(TtsSettingsKeys.kokoroVoiceUk), 'bf_vale'); }); }); group('Kokoro 音色 / toSpeechConfig', () { - test('默认音色:美音 af_sarah,英音 bf_emma', () { + test('默认音色:美音 af_sol,英音 bf_vale', () { const s = TtsSettings(); - expect(s.kokoroVoiceUs, 'af_sarah'); - expect(s.kokoroVoiceUk, 'bf_emma'); - expect(s.activeKokoroVoice, 'af_sarah'); + expect(s.kokoroVoiceUs, 'af_sol'); + expect(s.kokoroVoiceUk, 'bf_vale'); + expect(s.activeKokoroVoice, 'af_sol'); expect( const TtsSettings(accent: TtsAccent.uk).activeKokoroVoice, - 'bf_emma', + 'bf_vale', ); }); @@ -198,9 +198,9 @@ void main() { const s = TtsSettings( engine: TtsEngineKind.kokoro, accent: TtsAccent.uk, - kokoroVoiceUk: 'bm_george', + kokoroVoiceUk: 'bf_vale', ); - expect(s.toSpeechConfig().voiceName, 'bm_george'); + expect(s.toSpeechConfig().voiceName, 'bf_vale'); }); test('平台引擎 → config 不带 voiceName', () { @@ -210,13 +210,13 @@ void main() { test('fromPrefsSync 非法/不匹配音色 → 回退该口音默认', () async { SharedPreferences.setMockInitialValues({ - TtsSettingsKeys.kokoroVoiceUs: 'bm_lewis', // 英音 id 放美音槽 → 回退 + TtsSettingsKeys.kokoroVoiceUs: 'bm_lewis', // 已移除的英文 id → 回退 TtsSettingsKeys.kokoroVoiceUk: 'bogus', }); final prefs = await SharedPreferences.getInstance(); final s = TtsSettings.fromPrefsSync(prefs); - expect(s.kokoroVoiceUs, 'af_sarah'); - expect(s.kokoroVoiceUk, 'bf_emma'); + expect(s.kokoroVoiceUs, 'af_sol'); + expect(s.kokoroVoiceUk, 'bf_vale'); }); }); @@ -225,14 +225,14 @@ void main() { expect(const TtsSettings().kokoroVariant, KokoroModelVariant.fp32); }); - test('kokoro → config.modelTag 为变体名;平台 → null', () { + test('kokoro → config.modelTag 为模型 ID;平台 → null', () { const fp = TtsSettings(engine: TtsEngineKind.kokoro); - expect(fp.toSpeechConfig().modelTag, 'fp32'); + expect(fp.toSpeechConfig().modelTag, 'kokoro-multi-lang-v1_1'); const i8 = TtsSettings( engine: TtsEngineKind.kokoro, kokoroVariant: KokoroModelVariant.int8, ); - expect(i8.toSpeechConfig().modelTag, 'int8'); + expect(i8.toSpeechConfig().modelTag, 'kokoro-int8-multi-lang-v1_1'); const plat = TtsSettings(engine: TtsEngineKind.platform); expect(plat.toSpeechConfig().modelTag, isNull); }); @@ -269,6 +269,30 @@ void main() { }); }); + group('本地 TTS 语速', () { + test('默认 1.0,toSpeechConfig 透传 speed', () { + const s = TtsSettings(engine: TtsEngineKind.kokoro, speed: 1.3); + expect(s.speed, 1.3); + expect(s.toSpeechConfig().speed, 1.3); + }); + + test('setSpeed 夹到 0.5..2.0 并写 SP', () async { + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + final c = makeContainer(prefs); + addTearDown(c.dispose); + final notifier = c.read(ttsSettingsProvider.notifier); + + await notifier.setSpeed(3.0); + expect(c.read(ttsSettingsProvider).speed, 2.0); + await notifier.setSpeed(0.1); + expect(c.read(ttsSettingsProvider).speed, 0.5); + + final saved = await SharedPreferences.getInstance(); + expect(saved.getDouble(TtsSettingsKeys.speed), 0.5); + }); + }); + group('Piper 音色 / toSpeechConfig', () { test('默认音色:美音/英音各为对应默认', () { const s = TtsSettings(); diff --git a/test/screens/tts_settings_screen_test.dart b/test/screens/tts_settings_screen_test.dart index 2c2e0eb0b..9f0e6b979 100644 --- a/test/screens/tts_settings_screen_test.dart +++ b/test/screens/tts_settings_screen_test.dart @@ -264,7 +264,7 @@ void main() { ); }); - testWidgets('就绪(fp32 选中)→ 音色行 + 点开弹层显全部 11 个(分组),使用中变体不显删除', (tester) async { + testWidgets('就绪(fp32 选中)→ 音色行 + 点开弹层显全部 3 个英文音色(分组)', (tester) async { await tester.pumpWidget( _wrap( const TtsSettings(engine: TtsEngineKind.kokoro), @@ -274,9 +274,9 @@ void main() { await tester.pumpAndSettle(); expect(find.textContaining('Ready'), findsOneWidget); - // 音色收成单行 disclosure:标题 + 口音 + 当前音色(默认 American · Sarah · Female)。 + // 音色收成单行 disclosure:标题 + 口音 + 当前音色(默认 American · Sol · Female)。 expect(find.text('Voice'), findsOneWidget); - expect(find.text('American · Sarah · Female'), findsOneWidget); + expect(find.text('American · Sol · Female'), findsOneWidget); // 使用中(fp32 选中)不显删除(不删正在用的语音);int8 未下载也无删除。 expect(find.byTooltip('Delete model'), findsNothing); // Echo Loop 下无独立口音卡,弹层未开时音色列表与口音标题都不在屏上。 @@ -284,16 +284,15 @@ void main() { expect(find.text('American'), findsNothing); expect(find.text('British'), findsNothing); - // 点开音色弹层 → 全部 11 个(美音 7 + 英音 4),按口音分组。 + // 点开音色弹层 → v1.1 英文 3 个(美音 2 + 英音 1),按口音分组。 await tester.tap(find.text('Voice')); await tester.pumpAndSettle(); - expect(find.byType(Radio), findsNWidgets(11)); + expect(find.byType(Radio), findsNWidgets(3)); expect(find.text('American'), findsOneWidget); expect(find.text('British'), findsOneWidget); - expect(find.text('Sarah'), findsOneWidget); - expect(find.text('Adam'), findsOneWidget); - expect(find.text('Emma'), findsOneWidget); - expect(find.text('George'), findsOneWidget); + expect(find.text('Maple'), findsOneWidget); + expect(find.text('Sol'), findsOneWidget); + expect(find.text('Vale'), findsOneWidget); }); testWidgets('就绪 + 非使用中的已下载变体可删除', (tester) async { @@ -319,16 +318,16 @@ void main() { ); await tester.pumpAndSettle(); - // 默认美音。点开音色弹层选英音 Emma → 口音随之切英音,音色写入英音槽。 + // 默认美音。点开音色弹层选英音 Vale → 口音随之切英音,音色写入英音槽。 expect(_containerOf(tester).read(ttsSettingsProvider).accent, TtsAccent.us); await tester.tap(find.text('Voice')); await tester.pumpAndSettle(); - await tester.tap(find.text('Emma')); + await tester.tap(find.text('Vale')); await tester.pumpAndSettle(); final settings = _containerOf(tester).read(ttsSettingsProvider); expect(settings.accent, TtsAccent.uk); - expect(settings.kokoroVoiceUk, 'bf_emma'); + expect(settings.kokoroVoiceUk, 'bf_vale'); }); testWidgets('就绪 + 弹层点音色 → setKokoroVoice 更新', (tester) async { @@ -340,13 +339,13 @@ void main() { ); await tester.pumpAndSettle(); - // 点开音色弹层后选美音 Adam → 音色写入美音槽,口音保持美音。 + // 点开音色弹层后选美音 Maple → 音色写入美音槽,口音保持美音。 await tester.tap(find.text('Voice')); await tester.pumpAndSettle(); - await tester.tap(find.text('Adam')); + await tester.tap(find.text('Maple')); await tester.pumpAndSettle(); final settings = _containerOf(tester).read(ttsSettingsProvider); - expect(settings.kokoroVoiceUs, 'am_adam'); + expect(settings.kokoroVoiceUs, 'af_maple'); expect(settings.accent, TtsAccent.us); }); diff --git a/test/screens/tts_voice_preview_row_test.dart b/test/screens/tts_voice_preview_row_test.dart index e48d345e7..46e061a72 100644 --- a/test/screens/tts_voice_preview_row_test.dart +++ b/test/screens/tts_voice_preview_row_test.dart @@ -70,14 +70,14 @@ void main() { testWidgets('正在试听的音色行显播放图标(volume_up),其余不显', (tester) async { SharedPreferences.setMockInitialValues({}); - await tester.pumpWidget(_wrap(ttsVoicePreviewKey('am_adam'))); + await tester.pumpWidget(_wrap(ttsVoicePreviewKey('af_maple'))); await tester.pumpAndSettle(); // 打开音色弹层。 await tester.tap(find.text('Voice')); await tester.pumpAndSettle(); - // 仅 Adam(正在试听)行显喇叭图标。 + // 仅 Maple(正在试听)行显喇叭图标。 expect(find.byIcon(Icons.volume_up), findsOneWidget); }); @@ -95,10 +95,7 @@ void main() { testWidgets('平台引擎:正在试听的口音行显播放图标(volume_up)', (tester) async { SharedPreferences.setMockInitialValues({}); await tester.pumpWidget( - _wrap( - ttsAccentPreviewKey(TtsAccent.us), - engine: TtsEngineKind.platform, - ), + _wrap(ttsAccentPreviewKey(TtsAccent.us), engine: TtsEngineKind.platform), ); await tester.pumpAndSettle(); diff --git a/test/services/tts/kokoro_real_model_test.dart b/test/services/tts/kokoro_real_model_test.dart new file mode 100644 index 000000000..db9caee64 --- /dev/null +++ b/test/services/tts/kokoro_real_model_test.dart @@ -0,0 +1,51 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; + +import 'package:echo_loop/services/tts/kokoro_model_catalog.dart'; +import 'package:echo_loop/services/tts/kokoro_model_manager.dart'; +import 'package:echo_loop/services/tts/kokoro_tts_engine.dart'; +import 'package:echo_loop/services/tts/tts_engine.dart'; + +void main() { + final modelRoot = Platform.environment['KOKORO_MODEL_ROOT']; + + test( + '真实 Kokoro int8 多语言模型可合成中英混合音频', + () async { + final manager = KokoroModelManager( + spec: kokoroSpecOf(KokoroModelVariant.int8), + modelsRootResolver: () async => modelRoot!, + ); + addTearDown(manager.dispose); + expect(await manager.isModelDownloaded(), isTrue); + + final engine = KokoroTtsEngine(resolvePaths: manager.kokoroConfigPaths); + addTearDown(engine.dispose); + await engine.applyConfig( + const TtsSpeechConfig( + languageTag: 'zh-CN', + voiceName: 'zf_001', + speed: 1.2, + ), + ); + final outDir = await Directory.systemTemp.createTemp( + 'kokoro-real-model-test', + ); + addTearDown(() async { + if (await outDir.exists()) await outDir.delete(recursive: true); + }); + final result = await engine.synthesize( + '你好,我正在学习 machine learning。今天很开心!', + outputDir: outDir.path, + baseName: 'real_zh_mixed', + ); + expect(result, isNotNull); + expect(result!.sampleRate, greaterThan(0)); + expect(await File(result.filePath).length(), greaterThan(1000)); + }, + skip: modelRoot == null + ? 'Set KOKORO_MODEL_ROOT to run real-model test.' + : null, + ); +} diff --git a/test/services/tts/kokoro_tts_engine_test.dart b/test/services/tts/kokoro_tts_engine_test.dart index 4f664d40d..4a6a7ed51 100644 --- a/test/services/tts/kokoro_tts_engine_test.dart +++ b/test/services/tts/kokoro_tts_engine_test.dart @@ -17,6 +17,7 @@ class _FakeSynth implements KokoroNativeSynthesizer { int initCount = 0; int disposeCount = 0; int? lastSid; + double? lastSpeed; String? lastText; String? lastOutputPath; @@ -34,6 +35,7 @@ class _FakeSynth implements KokoroNativeSynthesizer { }) async { lastText = text; lastSid = sid; + lastSpeed = speed; lastOutputPath = outputPath; if (failSynthesis) return null; await File(outputPath).writeAsBytes(const [0, 1, 2, 3]); @@ -90,10 +92,10 @@ void main() { final synth = _FakeSynth(); final engine = build(synth); await engine.applyConfig( - const TtsSpeechConfig(languageTag: 'en-GB', voiceName: 'bm_george'), + const TtsSpeechConfig(languageTag: 'en-GB', voiceName: 'bf_vale'), ); await engine.synthesize('x', outputDir: outDir.path, baseName: 'b'); - expect(synth.lastSid, 9); // bm_george + expect(synth.lastSid, 2); // bf_vale }); test('无 voiceName:按语言标签回退该口音默认音色 sid', () async { @@ -101,11 +103,37 @@ void main() { final engine = build(synth); await engine.applyConfig(const TtsSpeechConfig(languageTag: 'en-GB')); await engine.synthesize('x', outputDir: outDir.path, baseName: 'b'); - expect(synth.lastSid, 7); // bf_emma(英音默认) + expect(synth.lastSid, 2); // bf_vale(英音默认) await engine.applyConfig(const TtsSpeechConfig(languageTag: 'en-US')); await engine.synthesize('x', outputDir: outDir.path, baseName: 'c'); - expect(synth.lastSid, 3); // af_sarah(美音默认) + expect(synth.lastSid, 1); // af_sol(美音默认) + }); + + test('speed 透传到 native synthesizer 并夹在 0.5..2.0', () async { + final synth = _FakeSynth(); + final engine = build(synth); + await engine.applyConfig( + const TtsSpeechConfig(languageTag: 'en-US', speed: 1.7), + ); + await engine.synthesize('x', outputDir: outDir.path, baseName: 'speed'); + expect(synth.lastSpeed, 1.7); + + await engine.applyConfig( + const TtsSpeechConfig(languageTag: 'en-US', speed: 3.0), + ); + await engine.synthesize('y', outputDir: outDir.path, baseName: 'speed2'); + expect(synth.lastSpeed, 2.0); + }); + + test('中文文本自动切到中文默认音色 sid', () async { + final synth = _FakeSynth(); + final engine = build(synth); + await engine.applyConfig( + const TtsSpeechConfig(languageTag: 'en-US', voiceName: 'af_sol'), + ); + await engine.synthesize('你好,world', outputDir: outDir.path, baseName: 'zh'); + expect(synth.lastSid, 3); }); test('合成失败(synth 返回 null)→ engine 返回 null', () async { diff --git a/test/services/tts/kokoro_voices_test.dart b/test/services/tts/kokoro_voices_test.dart index 9eb03c710..fb80f5726 100644 --- a/test/services/tts/kokoro_voices_test.dart +++ b/test/services/tts/kokoro_voices_test.dart @@ -4,46 +4,45 @@ import 'package:echo_loop/services/tts/tts_engine.dart'; void main() { group('kokoroVoices 目录', () { - test('共 11 个发音人,sid 连续覆盖 0..10 且唯一', () { - expect(kokoroVoices.length, 11); + test('共 103 个发音人,sid 连续覆盖 0..102 且唯一', () { + expect(kokoroVoices.length, 103); final sids = kokoroVoices.map((v) => v.sid).toList()..sort(); - expect(sids, List.generate(11, (i) => i)); + expect(sids, List.generate(103, (i) => i)); }); test('id 唯一', () { - final ids = kokoroVoices.map((v) => v.id).toSet(); - expect(ids.length, 11); + expect(kokoroVoices.map((v) => v.id).toSet().length, 103); }); - test('口音分组:美音 7 个、英音 4 个', () { - expect(voicesForAccent(TtsAccent.us).length, 7); - expect(voicesForAccent(TtsAccent.uk).length, 4); + test('语言分组:英文 3 个、中文 100 个', () { + expect(voicesForLanguage(TtsLanguage.english).length, 3); + expect(voicesForLanguage(TtsLanguage.chinese).length, 100); }); - test('口音由 id 前缀推导:a*=美音 b*=英音', () { - expect(voiceById('af_sarah')!.accent, TtsAccent.us); - expect(voiceById('am_adam')!.accent, TtsAccent.us); - expect(voiceById('bf_emma')!.accent, TtsAccent.uk); - expect(voiceById('bm_lewis')!.accent, TtsAccent.uk); + test('英文口音分组:美音 2 个、英音 1 个', () { + expect(voicesForAccent(TtsAccent.us).length, 2); + expect(voicesForAccent(TtsAccent.uk).length, 1); + }); + + test('英文口音由 id 前缀推导:a*=美音 b*=英音', () { + expect(voiceById('af_maple')!.accent, TtsAccent.us); + expect(voiceById('af_sol')!.accent, TtsAccent.us); + expect(voiceById('bf_vale')!.accent, TtsAccent.uk); }); test('性别由第二字符推导:*f_=女声 *m_=男声', () { - expect(voiceById('af_sarah')!.isFemale, isTrue); - expect(voiceById('am_adam')!.isFemale, isFalse); - expect(voiceById('bf_emma')!.isFemale, isTrue); - expect(voiceById('bm_george')!.isFemale, isFalse); - // 'af'(无下划线)默认女声。 - expect(voiceById('af')!.isFemale, isTrue); + expect(voiceById('af_maple')!.isFemale, isTrue); + expect(voiceById('zf_001')!.isFemale, isTrue); + expect(voiceById('zm_009')!.isFemale, isFalse); }); }); group('voiceById', () { test('已知 id 往返', () { - final v = voiceById('bf_emma'); + final v = voiceById('zf_001'); expect(v, isNotNull); - expect(v!.id, 'bf_emma'); - expect(v.sid, 7); - expect(v.displayName, 'Emma'); + expect(v!.sid, 3); + expect(v.language, TtsLanguage.chinese); }); test('未知 id 返回 null', () { @@ -52,33 +51,39 @@ void main() { }); group('defaultVoice', () { - test('美音默认 af_sarah,且属于美音', () { + test('美音默认 af_sol,且属于美音', () { final v = defaultVoice(TtsAccent.us); expect(v.id, kokoroDefaultVoiceUs); - expect(v.id, 'af_sarah'); + expect(v.id, 'af_sol'); expect(v.accent, TtsAccent.us); }); - test('英音默认 bf_emma,且属于英音', () { + test('英音默认 bf_vale,且属于英音', () { final v = defaultVoice(TtsAccent.uk); expect(v.id, kokoroDefaultVoiceUk); - expect(v.id, 'bf_emma'); + expect(v.id, 'bf_vale'); expect(v.accent, TtsAccent.uk); }); + + test('中文默认 zf_001', () { + expect(defaultChineseVoice.id, kokoroDefaultVoiceZh); + expect(defaultChineseVoice.sid, 3); + }); }); group('sidForVoiceId', () { test('已知 voiceId → 对应 sid', () { - expect(sidForVoiceId('am_michael', fallbackAccent: TtsAccent.us), 6); + expect(sidForVoiceId('bf_vale', fallbackAccent: TtsAccent.us), 2); + expect(sidForVoiceId('zf_002', fallbackAccent: TtsAccent.us), 4); }); test('null → 回退到该口音默认音色 sid', () { - expect(sidForVoiceId(null, fallbackAccent: TtsAccent.us), 3); // af_sarah - expect(sidForVoiceId(null, fallbackAccent: TtsAccent.uk), 7); // bf_emma + expect(sidForVoiceId(null, fallbackAccent: TtsAccent.us), 1); + expect(sidForVoiceId(null, fallbackAccent: TtsAccent.uk), 2); }); test('未知 voiceId → 回退默认', () { - expect(sidForVoiceId('ghost', fallbackAccent: TtsAccent.uk), 7); + expect(sidForVoiceId('ghost', fallbackAccent: TtsAccent.uk), 2); }); }); } diff --git a/test/services/tts/text_segmenter_test.dart b/test/services/tts/text_segmenter_test.dart new file mode 100644 index 000000000..c2b01e32e --- /dev/null +++ b/test/services/tts/text_segmenter_test.dart @@ -0,0 +1,72 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:echo_loop/services/tts/text_segmenter.dart'; + +void main() { + const segmenter = TextSegmenter(maxChars: 80); + + group('TextSegmenter', () { + test('空输入返回空列表', () { + expect(segmenter.split(' \n '), isEmpty); + }); + + test('英文按句末切分并保留标点', () { + expect(segmenter.split('Hello world. How are you? Fine!'), [ + 'Hello world.', + 'How are you?', + 'Fine!', + ]); + }); + + test('中文按中文标点切分', () { + expect(segmenter.split('你好。今天好吗?很好!'), ['你好。', '今天好吗?', '很好!']); + }); + + test('中英混合按各自标点切分', () { + expect(segmenter.split('I am learning 机器学习。它很有趣!'), [ + 'I am learning 机器学习。', + '它很有趣!', + ]); + }); + + test('小数不误切', () { + expect(segmenter.split('Pi is 3.14 now.'), ['Pi is 3.14 now.']); + }); + + test('常见缩写不误切', () { + expect(segmenter.split('Mr. Smith met Dr. Brown. Then he left.'), [ + 'Mr. Smith met Dr. Brown.', + 'Then he left.', + ]); + }); + + test('URL 与文件名不误切', () { + expect(segmenter.split('Open https://example.com/docs.txt now. Done.'), [ + 'Open https://example.com/docs.txt now.', + 'Done.', + ]); + }); + + test('重复标点作为同一句结尾', () { + expect(segmenter.split('Really?! Yes...'), ['Really?!', 'Yes...']); + }); + + test('长句优先按逗号软切', () { + final text = + 'alpha, beta, gamma, delta, epsilon, zeta, eta, theta, iota, ' + 'kappa, lambda, mu, nu, xi, omicron, pi, rho, sigma'; + final segments = segmenter.split(text); + expect(segments.length, greaterThan(1)); + expect(segments.every((s) => s.length <= 80), isTrue); + expect(segments.join(' '), text); + }); + + test('没有标点的超长文本按空白或硬边界切分', () { + final text = 'a' * 201; + final segments = segmenter.split(text); + expect(segments.length, greaterThan(1)); + expect(segments.join(), text); + expect(segments.every((s) => s.length <= 80), isTrue); + }); + }); +} diff --git a/test/services/tts/wav_audio_assembler_test.dart b/test/services/tts/wav_audio_assembler_test.dart new file mode 100644 index 000000000..012e4b017 --- /dev/null +++ b/test/services/tts/wav_audio_assembler_test.dart @@ -0,0 +1,99 @@ +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; + +import 'package:echo_loop/services/tts/wav_audio_assembler.dart'; + +Uint8List _pcm16Wav({ + required int sampleRate, + required int samples, + int value = 1000, +}) { + final pcm = ByteData(samples * 2); + for (var i = 0; i < samples; i++) { + pcm.setInt16(i * 2, value, Endian.little); + } + final bytes = pcm.buffer.asUint8List(); + final header = ByteData(44); + void ascii(int offset, String text) { + for (var i = 0; i < text.length; i++) { + header.setUint8(offset + i, text.codeUnitAt(i)); + } + } + + ascii(0, 'RIFF'); + header.setUint32(4, 36 + bytes.length, Endian.little); + ascii(8, 'WAVE'); + ascii(12, 'fmt '); + header.setUint32(16, 16, Endian.little); + header.setUint16(20, 1, Endian.little); + header.setUint16(22, 1, Endian.little); + header.setUint32(24, sampleRate, Endian.little); + header.setUint32(28, sampleRate * 2, Endian.little); + header.setUint16(32, 2, Endian.little); + header.setUint16(34, 16, Endian.little); + ascii(36, 'data'); + header.setUint32(40, bytes.length, Endian.little); + return Uint8List.fromList([...header.buffer.asUint8List(), ...bytes]); +} + +void main() { + late Directory dir; + + setUp(() async { + dir = await Directory.systemTemp.createTemp('wav-assembler-test'); + }); + + tearDown(() async { + if (await dir.exists()) await dir.delete(recursive: true); + }); + + test('合并多段 WAV 并生成句子时间边界', () async { + final one = File('${dir.path}/one.wav'); + final two = File('${dir.path}/two.wav'); + await one.writeAsBytes( + _pcm16Wav(sampleRate: 1000, samples: 1000, value: 100), + ); + await two.writeAsBytes( + _pcm16Wav(sampleRate: 1000, samples: 500, value: 200), + ); + final out = '${dir.path}/lesson.wav'; + final assembler = WavAudioAssembler( + outputPath: out, + gap: const Duration(milliseconds: 100), + ); + await assembler.addWav(one.path, 'one'); + await assembler.addWav(two.path, 'two'); + final result = await assembler.finish(); + + expect(await File(out).exists(), isTrue); + expect(result.sentences.length, 2); + expect(result.sentences[0].start, Duration.zero); + expect(result.sentences[0].end, const Duration(seconds: 1)); + expect(result.sentences[1].start, const Duration(milliseconds: 1100)); + expect(result.sentences[1].end, const Duration(milliseconds: 1600)); + expect(result.duration, const Duration(milliseconds: 1700)); + final bytes = await File(out).readAsBytes(); + expect(String.fromCharCodes(bytes.take(4)), 'RIFF'); + expect(bytes.length, greaterThan(44)); + }); + + test('格式不一致时拒绝合并', () async { + final one = File('${dir.path}/one.wav'); + final two = File('${dir.path}/two.wav'); + await one.writeAsBytes(_pcm16Wav(sampleRate: 1000, samples: 10)); + await two.writeAsBytes(_pcm16Wav(sampleRate: 2000, samples: 10)); + final assembler = WavAudioAssembler(outputPath: '${dir.path}/out.wav'); + await assembler.addWav(one.path, 'one'); + expect(() => assembler.addWav(two.path, 'two'), throwsA(isA())); + await assembler.abort(); + }); + + test('没有音频段时 finish 失败并清理 part', () async { + final assembler = WavAudioAssembler(outputPath: '${dir.path}/out.wav'); + await assembler.start(); + await expectLater(assembler.finish(), throwsA(isA())); + expect(await File('${dir.path}/out.wav.part').exists(), isFalse); + }); +} diff --git a/test/widgets/tts/tts_model_download_prompt_dialog_test.dart b/test/widgets/tts/tts_model_download_prompt_dialog_test.dart index e5b6bc359..662d46511 100644 --- a/test/widgets/tts/tts_model_download_prompt_dialog_test.dart +++ b/test/widgets/tts/tts_model_download_prompt_dialog_test.dart @@ -54,7 +54,7 @@ void main() { expect(state.engine, TtsEngineKind.kokoro); expect(state.status, AsrModelDownloadStatus.downloading); expect(state.progress, 0.42); - expect(state.estimatedDownloadBytes, 313785757); + expect(state.estimatedDownloadBytes, 364816464); expect(state.isReady, isFalse); }); @@ -105,7 +105,7 @@ void main() { await tester.pumpAndSettle(); expect(find.text('Speech Synthesis Model Required'), findsOneWidget); - expect(find.text('Estimated download: ~299.2 MB'), findsOneWidget); + expect(find.text('Estimated download: ~347.9 MB'), findsOneWidget); expect( find.text('You can also choose another speech model in Settings.'), findsOneWidget, @@ -147,7 +147,7 @@ void main() { await tester.pumpAndSettle(); expect(find.text('Downloading Speech Synthesis Model'), findsOneWidget); - expect(find.text('Estimated download: ~299.2 MB'), findsOneWidget); + expect(find.text('Estimated download: ~347.9 MB'), findsOneWidget); expect(find.text('42% complete'), findsOneWidget); expect(find.byType(LinearProgressIndicator), findsOneWidget); expect(find.text('Cancel Download'), findsOneWidget); @@ -186,7 +186,7 @@ void main() { await tester.pumpAndSettle(); expect( - find.text('The download did not finish. Estimated download: ~299.2 MB'), + find.text('The download did not finish. Estimated download: ~347.9 MB'), findsOneWidget, ); expect( From a28e2a31503e33065e25136b630763c880e98325 Mon Sep 17 00:00:00 2001 From: Yur Stya Date: Sat, 3 Oct 2026 20:21:31 +0800 Subject: [PATCH 2/2] chore: add CodeForge regression-only CI (#1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: add CodeForge regression-only CI * fix: build unsigned Android APK when signing secrets are absent * fix: use dev flavor for unsigned Android fallback --------- Co-authored-by: styayur <你的GitHub邮箱> --- .codeforge.toml | 19 + .codeforge/baseline.json | 2848 ++++++++++++++++++++ .github/workflows/ci.yml | 21 +- .github/workflows/codeforge-regression.yml | 63 + 4 files changed, 2949 insertions(+), 2 deletions(-) create mode 100644 .codeforge.toml create mode 100644 .codeforge/baseline.json create mode 100644 .github/workflows/codeforge-regression.yml diff --git a/.codeforge.toml b/.codeforge.toml new file mode 100644 index 000000000..24adac4e4 --- /dev/null +++ b/.codeforge.toml @@ -0,0 +1,19 @@ +[commands.format] +program = "dart" +args = ["format", "--output=none", "--set-exit-if-changed", "."] +timeout_secs = 300 + +[commands.lint] +program = "flutter" +args = ["analyze", "--no-fatal-warnings", "--no-fatal-infos", "lib", "test"] +timeout_secs = 900 + +[commands.test] +program = "flutter" +args = ["test", "--concurrency=1"] +timeout_secs = 1800 + +[commands.build] +program = "flutter" +args = ["build", "apk", "--debug"] +timeout_secs = 1800 diff --git a/.codeforge/baseline.json b/.codeforge/baseline.json new file mode 100644 index 000000000..980ca72cc --- /dev/null +++ b/.codeforge/baseline.json @@ -0,0 +1,2848 @@ +{ + "schema_version": 1, + "codeforge_version": "0.2.1", + "entries": [ + { + "fingerprint": "26357c974649a3cd3275683fa6cff0f45301ee0e48a39e685658635a37add091", + "rule_id": "CF-DART-001", + "path": "lib/database/migration/sp_to_drift_migration.dart", + "symbol": null, + "context_hash": null, + "source_context": "production", + "disposition": "human_review", + "reason": "Tool gap (not a source finding): New adapter heuristic requires native tool or human review before auto-apply.", + "first_seen": "2026-10-03T15:27:54.5454032+08:00", + "last_reviewed": "2026-10-03T09:30:14.998539Z", + "rule_version": "0.2.1", + "lifecycle": "active" + }, + { + "fingerprint": "14656b9bd07fa4b308498a0aa0b3b975049d0a3ccd0a03239820d808c4716f12", + "rule_id": "CF-DART-001", + "path": "lib/database/migration/sp_to_drift_migration.dart", + "symbol": null, + "context_hash": null, + "source_context": "production", + "disposition": "human_review", + "reason": "Tool gap (not a source finding): New adapter heuristic requires native tool or human review before auto-apply.", + "first_seen": "2026-10-03T15:27:54.5541790+08:00", + "last_reviewed": "2026-10-03T09:30:14.998539Z", + "rule_version": "0.2.1", + "lifecycle": "active" + }, + { + "fingerprint": "e9258c9b0e6405fb6b6530a6c661a01bfcc9f378cae68fa919ea10a8b2881d16", + "rule_id": "CF-DART-001", + "path": "lib/database/migration/sp_to_drift_migration.dart", + "symbol": null, + "context_hash": null, + "source_context": "production", + "disposition": "human_review", + "reason": "Tool gap (not a source finding): New adapter heuristic requires native tool or human review before auto-apply.", + "first_seen": "2026-10-03T15:27:54.5631089+08:00", + "last_reviewed": "2026-10-03T09:30:14.998539Z", + "rule_version": "0.2.1", + "lifecycle": "active" + }, + { + "fingerprint": "e79349d1b443d04604af7f446896bc430250ba64bba3ac27612f6d32746dee04", + "rule_id": "CF-DART-001", + "path": "lib/database/migration/sp_to_drift_migration.dart", + "symbol": null, + "context_hash": null, + "source_context": "production", + "disposition": "human_review", + "reason": "Tool gap (not a source finding): New adapter heuristic requires native tool or human review before auto-apply.", + "first_seen": "2026-10-03T15:27:54.5721991+08:00", + "last_reviewed": "2026-10-03T09:30:14.998539Z", + "rule_version": "0.2.1", + "lifecycle": "active" + }, + { + "fingerprint": "b267565c3b7dc12129607e8269765358d0237f21061e9c2c91f2c8875825fa29", + "rule_id": "CF-DART-001", + "path": "lib/database/migration/sp_to_drift_migration.dart", + "symbol": null, + "context_hash": null, + "source_context": "production", + "disposition": "human_review", + "reason": "Tool gap (not a source finding): New adapter heuristic requires native tool or human review before auto-apply.", + "first_seen": "2026-10-03T15:27:54.5809870+08:00", + "last_reviewed": "2026-10-03T09:30:14.998539Z", + "rule_version": "0.2.1", + "lifecycle": "active" + }, + { + "fingerprint": "07910ab9f4f2a38c82bb711fe410afeca9193226344aa8a756a7ac29bd67354e", + "rule_id": "CF-DART-001", + "path": "lib/features/audio_import/audio_finalization_service.dart", + "symbol": null, + "context_hash": null, + "source_context": "production", + "disposition": "human_review", + "reason": "Tool gap (not a source finding): New adapter heuristic requires native tool or human review before auto-apply.", + "first_seen": "2026-10-03T15:27:54.6034677+08:00", + "last_reviewed": "2026-10-03T09:30:14.998539Z", + "rule_version": "0.2.1", + "lifecycle": "active" + }, + { + "fingerprint": "eff53f1ae53963dad0ed24e4a2c82616b87e14dd67a442d85ab68c878df3d388", + "rule_id": "CF-DART-001", + "path": "lib/features/audio_import/audio_finalization_service.dart", + "symbol": null, + "context_hash": null, + "source_context": "production", + "disposition": "human_review", + "reason": "Tool gap (not a source finding): New adapter heuristic requires native tool or human review before auto-apply.", + "first_seen": "2026-10-03T15:27:54.6133839+08:00", + "last_reviewed": "2026-10-03T09:30:14.998539Z", + "rule_version": "0.2.1", + "lifecycle": "active" + }, + { + "fingerprint": "fd2cbdc9f1ef87bd99c8b6845f8c5210a51c1b7c57753e6485c0938342f0491f", + "rule_id": "CF-DART-001", + "path": "lib/features/audio_import/audio_finalization_service.dart", + "symbol": null, + "context_hash": null, + "source_context": "production", + "disposition": "human_review", + "reason": "Tool gap (not a source finding): New adapter heuristic requires native tool or human review before auto-apply.", + "first_seen": "2026-10-03T15:27:54.6211072+08:00", + "last_reviewed": "2026-10-03T09:30:14.998539Z", + "rule_version": "0.2.1", + "lifecycle": "active" + }, + { + "fingerprint": "9a92cea59f5631bba09a17879a732d13e498220a3863581f0d74fa29d84ace5b", + "rule_id": "CF-DART-001", + "path": "lib/services/app_logger.dart", + "symbol": null, + "context_hash": null, + "source_context": "production", + "disposition": "human_review", + "reason": "Tool gap (not a source finding): New adapter heuristic requires native tool or human review before auto-apply.", + "first_seen": "2026-10-03T15:27:55.3307525+08:00", + "last_reviewed": "2026-10-03T09:30:14.998539Z", + "rule_version": "0.2.1", + "lifecycle": "active" + }, + { + "fingerprint": "7f8abb5884f9399f227a09b9a4d9e096895b7ca2cfe7f646e561db0505a7fbb6", + "rule_id": "CF-DART-001", + "path": "lib/services/app_logger.dart", + "symbol": null, + "context_hash": null, + "source_context": "production", + "disposition": "human_review", + "reason": "Tool gap (not a source finding): New adapter heuristic requires native tool or human review before auto-apply.", + "first_seen": "2026-10-03T15:27:55.3372722+08:00", + "last_reviewed": "2026-10-03T09:30:14.998539Z", + "rule_version": "0.2.1", + "lifecycle": "active" + }, + { + "fingerprint": "f6f29aaccaac04281ff307f943c9c7bbbfb5d81fb8a553f46826cd26f47ee397", + "rule_id": "CF-DART-001", + "path": "lib/services/app_logger.dart", + "symbol": null, + "context_hash": null, + "source_context": "production", + "disposition": "human_review", + "reason": "Tool gap (not a source finding): New adapter heuristic requires native tool or human review before auto-apply.", + "first_seen": "2026-10-03T15:27:55.3429813+08:00", + "last_reviewed": "2026-10-03T09:30:14.998539Z", + "rule_version": "0.2.1", + "lifecycle": "active" + }, + { + "fingerprint": "782f55a3112fbb7cc189edaee5f406797b075271309a0d92861d9f75bf0a021b", + "rule_id": "CF-DART-001", + "path": "lib/services/app_logger.dart", + "symbol": null, + "context_hash": null, + "source_context": "production", + "disposition": "human_review", + "reason": "Tool gap (not a source finding): New adapter heuristic requires native tool or human review before auto-apply.", + "first_seen": "2026-10-03T15:27:55.3503858+08:00", + "last_reviewed": "2026-10-03T09:30:14.998539Z", + "rule_version": "0.2.1", + "lifecycle": "active" + }, + { + "fingerprint": "bf764a122357db4f0aa9b32229e76b12002a82c901a36d2e6b206ab0ace4f492", + "rule_id": "CF-DART-001", + "path": "lib/services/asr/sherpa_onnx_engine.dart", + "symbol": null, + "context_hash": null, + "source_context": "production", + "disposition": "human_review", + "reason": "Tool gap (not a source finding): New adapter heuristic requires native tool or human review before auto-apply.", + "first_seen": "2026-10-03T15:27:55.3848654+08:00", + "last_reviewed": "2026-10-03T09:30:14.998539Z", + "rule_version": "0.2.1", + "lifecycle": "active" + }, + { + "fingerprint": "2759da255844133708ef909b28bdf038c310a18dd68e36d67e28654c70f46a73", + "rule_id": "CF-DART-001", + "path": "lib/services/media_kit_debug_initializer.dart", + "symbol": null, + "context_hash": null, + "source_context": "production", + "disposition": "human_review", + "reason": "Tool gap (not a source finding): New adapter heuristic requires native tool or human review before auto-apply.", + "first_seen": "2026-10-03T15:27:55.4007098+08:00", + "last_reviewed": "2026-10-03T09:30:14.998539Z", + "rule_version": "0.2.1", + "lifecycle": "active" + }, + { + "fingerprint": "386aea899530da99e7eed3fd398d51100e03fe7e92a4c0fd67cd78a3de3a7bcf", + "rule_id": "CF-DART-001", + "path": "lib/services/media_kit_debug_initializer.dart", + "symbol": null, + "context_hash": null, + "source_context": "production", + "disposition": "human_review", + "reason": "Tool gap (not a source finding): New adapter heuristic requires native tool or human review before auto-apply.", + "first_seen": "2026-10-03T15:27:55.4041069+08:00", + "last_reviewed": "2026-10-03T09:30:14.998539Z", + "rule_version": "0.2.1", + "lifecycle": "active" + }, + { + "fingerprint": "de0f1358e1d7db0c7d8ffd0f90a65083264047f83171d4823eddde0593e4a7db", + "rule_id": "CF-DART-001", + "path": "lib/services/storage_service.dart", + "symbol": null, + "context_hash": null, + "source_context": "production", + "disposition": "human_review", + "reason": "Tool gap (not a source finding): New adapter heuristic requires native tool or human review before auto-apply.", + "first_seen": "2026-10-03T15:27:55.4208040+08:00", + "last_reviewed": "2026-10-03T09:30:14.998539Z", + "rule_version": "0.2.1", + "lifecycle": "active" + }, + { + "fingerprint": "14b50b25be6d038bda427f477aa434bf667bf193399e23665b190d6e09b3c25d", + "rule_id": "CF-DART-001", + "path": "lib/services/subtitle_parser.dart", + "symbol": null, + "context_hash": null, + "source_context": "production", + "disposition": "human_review", + "reason": "Tool gap (not a source finding): New adapter heuristic requires native tool or human review before auto-apply.", + "first_seen": "2026-10-03T15:27:55.4375542+08:00", + "last_reviewed": "2026-10-03T09:30:14.998539Z", + "rule_version": "0.2.1", + "lifecycle": "active" + }, + { + "fingerprint": "fac5205063f2f5023a0b303990989984062bbae76b30c11005c667e0ae777b4f", + "rule_id": "CF-DART-001", + "path": "lib/services/subtitle_parser.dart", + "symbol": null, + "context_hash": null, + "source_context": "production", + "disposition": "human_review", + "reason": "Tool gap (not a source finding): New adapter heuristic requires native tool or human review before auto-apply.", + "first_seen": "2026-10-03T15:27:55.4442750+08:00", + "last_reviewed": "2026-10-03T09:30:14.998539Z", + "rule_version": "0.2.1", + "lifecycle": "active" + }, + { + "fingerprint": "3b0893f0a8a0ec1633ea028338ea2c6256a91dd706d23f82e972187d78acbf08", + "rule_id": "CF-DART-002", + "path": "integration_test/groups/stats_display_tests.dart", + "symbol": null, + "context_hash": null, + "source_context": "production", + "disposition": "human_review", + "reason": "Tool gap (not a source finding): New adapter heuristic requires native tool or human review before auto-apply.", + "first_seen": "2026-10-03T15:27:54.4544700+08:00", + "last_reviewed": "2026-10-03T09:30:14.998539Z", + "rule_version": "0.2.1", + "lifecycle": "active" + }, + { + "fingerprint": "c14092ea82e484456170a477a07d39cda28f72bc3369c7adf1341787671ef6bc", + "rule_id": "CF-DART-002", + "path": "integration_test/groups/stats_display_tests.dart", + "symbol": null, + "context_hash": null, + "source_context": "production", + "disposition": "human_review", + "reason": "Tool gap (not a source finding): New adapter heuristic requires native tool or human review before auto-apply.", + "first_seen": "2026-10-03T15:27:54.4621850+08:00", + "last_reviewed": "2026-10-03T09:30:14.998539Z", + "rule_version": "0.2.1", + "lifecycle": "active" + }, + { + "fingerprint": "6155f0cb6ba80fec437b29556e73b11c380ecc34529b54d58d856ae15bf03f86", + "rule_id": "CF-DART-002", + "path": "lib/analytics/channels/umeng_channel.dart", + "symbol": null, + "context_hash": null, + "source_context": "production", + "disposition": "human_review", + "reason": "Tool gap (not a source finding): New adapter heuristic requires native tool or human review before auto-apply.", + "first_seen": "2026-10-03T15:27:54.5108004+08:00", + "last_reviewed": "2026-10-03T09:30:14.998539Z", + "rule_version": "0.2.1", + "lifecycle": "active" + }, + { + "fingerprint": "a35693736c8c84efbfee3322839ed25ea05dcd1dca3507c99de41319cd29faea", + "rule_id": "CF-DART-002", + "path": "lib/analytics/channels/umeng_channel.dart", + "symbol": null, + "context_hash": null, + "source_context": "production", + "disposition": "human_review", + "reason": "Tool gap (not a source finding): New adapter heuristic requires native tool or human review before auto-apply.", + "first_seen": "2026-10-03T15:27:54.5151845+08:00", + "last_reviewed": "2026-10-03T09:30:14.998539Z", + "rule_version": "0.2.1", + "lifecycle": "active" + }, + { + "fingerprint": "353e527c5f98b8c83f73d668f25cf6a8cbe5f51aac0f747a6e177d42d15fa5f5", + "rule_id": "CF-DART-002", + "path": "lib/providers/learning_session/learning_session_provider.dart", + "symbol": null, + "context_hash": null, + "source_context": "production", + "disposition": "human_review", + "reason": "Tool gap (not a source finding): New adapter heuristic requires native tool or human review before auto-apply.", + "first_seen": "2026-10-03T15:27:55.2615049+08:00", + "last_reviewed": "2026-10-03T09:30:14.998539Z", + "rule_version": "0.2.1", + "lifecycle": "active" + }, + { + "fingerprint": "b4c25b80126096ac5831517d821368d4cee49bc91943d46afda187e81fd839e3", + "rule_id": "CF-DART-002", + "path": "lib/screens/library_screen.dart", + "symbol": null, + "context_hash": null, + "source_context": "production", + "disposition": "human_review", + "reason": "Tool gap (not a source finding): New adapter heuristic requires native tool or human review before auto-apply.", + "first_seen": "2026-10-03T15:27:55.3107373+08:00", + "last_reviewed": "2026-10-03T09:30:14.998539Z", + "rule_version": "0.2.1", + "lifecycle": "active" + }, + { + "fingerprint": "23d3f184a841477bd1bafe83c8e5cd18c21173814deb63a66621f1db5d0560b5", + "rule_id": "CF-DART-002", + "path": "lib/services/tts/piper_model_catalog.dart", + "symbol": null, + "context_hash": null, + "source_context": "production", + "disposition": "human_review", + "reason": "Tool gap (not a source finding): New adapter heuristic requires native tool or human review before auto-apply.", + "first_seen": "2026-10-03T15:27:55.4612819+08:00", + "last_reviewed": "2026-10-03T09:30:14.998539Z", + "rule_version": "0.2.1", + "lifecycle": "active" + }, + { + "fingerprint": "92f571d5ed2a6ab6aab8678a6e9bdbd4f0339f68fd131ca7975a30a81a5bfbd7", + "rule_id": "CF-DART-002", + "path": "lib/services/tts/piper_voices.dart", + "symbol": null, + "context_hash": null, + "source_context": "production", + "disposition": "human_review", + "reason": "Tool gap (not a source finding): New adapter heuristic requires native tool or human review before auto-apply.", + "first_seen": "2026-10-03T15:27:55.4786944+08:00", + "last_reviewed": "2026-10-03T09:30:14.998539Z", + "rule_version": "0.2.1", + "lifecycle": "active" + }, + { + "fingerprint": "2876535c151fded44a02125e485610081e40ca4f57d48f6a3adcde28215f4f25", + "rule_id": "CF-DART-002", + "path": "lib/widgets/retell/retell_settings_sheet.dart", + "symbol": null, + "context_hash": null, + "source_context": "production", + "disposition": "human_review", + "reason": "Tool gap (not a source finding): New adapter heuristic requires native tool or human review before auto-apply.", + "first_seen": "2026-10-03T15:27:55.5037651+08:00", + "last_reviewed": "2026-10-03T09:30:14.998539Z", + "rule_version": "0.2.1", + "lifecycle": "active" + }, + { + "fingerprint": "c7d152141f419a6b89ca64d818db751c70bdcbd446175449db58896e445e7ffb", + "rule_id": "CF-DART-002", + "path": "test/providers/learning_session/learning_session_provider_test.dart", + "symbol": null, + "context_hash": null, + "source_context": "test", + "disposition": "false_positive", + "reason": "Finding is in test, fixture, generated, vendor, or build-only content.", + "first_seen": "2026-10-03T15:27:55.5049871+08:00", + "last_reviewed": "2026-10-03T09:30:14.998539Z", + "rule_version": "0.2.1", + "lifecycle": "active" + }, + { + "fingerprint": "09f021b1aeafff861391e18b91abb093c855dc8630b75073546358385aafbc79", + "rule_id": "CF-DART-002", + "path": "test/providers/learning_session/learning_session_provider_test.dart", + "symbol": null, + "context_hash": null, + "source_context": "test", + "disposition": "false_positive", + "reason": "Finding is in test, fixture, generated, vendor, or build-only content.", + "first_seen": "2026-10-03T15:27:55.5051803+08:00", + "last_reviewed": "2026-10-03T09:30:14.998539Z", + "rule_version": "0.2.1", + "lifecycle": "active" + }, + { + "fingerprint": "d3db59046e74b669991b39bebcc4a11beb935dfe7235cda8cdb8f60152aa0b5b", + "rule_id": "CF-DART-002", + "path": "test/providers/learning_session/learning_session_provider_test.dart", + "symbol": null, + "context_hash": null, + "source_context": "test", + "disposition": "false_positive", + "reason": "Finding is in test, fixture, generated, vendor, or build-only content.", + "first_seen": "2026-10-03T15:27:55.5053238+08:00", + "last_reviewed": "2026-10-03T09:30:14.998539Z", + "rule_version": "0.2.1", + "lifecycle": "active" + }, + { + "fingerprint": "e715e06ffe09eb4b5a35dfe669829dea536ddd721826158d5ab6b4b24fc38a13", + "rule_id": "CF-DART-002", + "path": "test/providers/learning_session/learning_session_provider_test.dart", + "symbol": null, + "context_hash": null, + "source_context": "test", + "disposition": "false_positive", + "reason": "Finding is in test, fixture, generated, vendor, or build-only content.", + "first_seen": "2026-10-03T15:27:55.5054481+08:00", + "last_reviewed": "2026-10-03T09:30:14.998539Z", + "rule_version": "0.2.1", + "lifecycle": "active" + }, + { + "fingerprint": "c80df4fe1a07fcde25ffff31eba1ebc9b27b1c634c70ff2b288f2a855f14ea9b", + "rule_id": "CF-DART-003", + "path": "integration_test/helpers/test_notifiers.dart", + "symbol": null, + "context_hash": null, + "source_context": "production", + "disposition": "human_review", + "reason": "Tool gap (not a source finding): New adapter heuristic requires native tool or human review before auto-apply.", + "first_seen": "2026-10-03T15:27:54.4904770+08:00", + "last_reviewed": "2026-10-03T09:30:14.998539Z", + "rule_version": "0.2.1", + "lifecycle": "active" + }, + { + "fingerprint": "c49c3fb10c510489fe78f6f20a05908e3814c0ba4473bf73a6c6f0e750ef47f3", + "rule_id": "CF-DART-003", + "path": "lib/models/app_update_info.dart", + "symbol": null, + "context_hash": null, + "source_context": "production", + "disposition": "human_review", + "reason": "Tool gap (not a source finding): New adapter heuristic requires native tool or human review before auto-apply.", + "first_seen": "2026-10-03T15:27:54.6426186+08:00", + "last_reviewed": "2026-10-03T09:30:14.998539Z", + "rule_version": "0.2.1", + "lifecycle": "active" + }, + { + "fingerprint": "34caf1cda9e7ba38713bb8159dd2f39c878511e3ada20a5adc1b5f9226598fb0", + "rule_id": "CF-DART-003", + "path": "lib/models/app_update_info.dart", + "symbol": null, + "context_hash": null, + "source_context": "production", + "disposition": "human_review", + "reason": "Tool gap (not a source finding): New adapter heuristic requires native tool or human review before auto-apply.", + "first_seen": "2026-10-03T15:27:54.6477850+08:00", + "last_reviewed": "2026-10-03T09:30:14.998539Z", + "rule_version": "0.2.1", + "lifecycle": "active" + }, + { + "fingerprint": "b84646c354d678b7cfde5e8ef862286f596b2cbe844778a90dc9aced24a41180", + "rule_id": "CF-DART-003", + "path": "lib/models/app_update_info.dart", + "symbol": null, + "context_hash": null, + "source_context": "production", + "disposition": "human_review", + "reason": "Tool gap (not a source finding): New adapter heuristic requires native tool or human review before auto-apply.", + "first_seen": "2026-10-03T15:27:54.6528664+08:00", + "last_reviewed": "2026-10-03T09:30:14.998539Z", + "rule_version": "0.2.1", + "lifecycle": "active" + }, + { + "fingerprint": "c1e38fab35f2c380deff1d1747791b0831ba75d08cfdb8c83cc2203eed6e7012", + "rule_id": "CF-DART-003", + "path": "lib/models/app_update_info.dart", + "symbol": null, + "context_hash": null, + "source_context": "production", + "disposition": "human_review", + "reason": "Tool gap (not a source finding): New adapter heuristic requires native tool or human review before auto-apply.", + "first_seen": "2026-10-03T15:27:54.6589973+08:00", + "last_reviewed": "2026-10-03T09:30:14.998539Z", + "rule_version": "0.2.1", + "lifecycle": "active" + }, + { + "fingerprint": "2fbdc78e13d04f9357439e5511da3e1c284ac4f05b7bf79e933ac6e8ab5a9759", + "rule_id": "CF-DART-003", + "path": "lib/models/app_update_info.dart", + "symbol": null, + "context_hash": null, + "source_context": "production", + "disposition": "human_review", + "reason": "Tool gap (not a source finding): New adapter heuristic requires native tool or human review before auto-apply.", + "first_seen": "2026-10-03T15:27:54.6660720+08:00", + "last_reviewed": "2026-10-03T09:30:14.998539Z", + "rule_version": "0.2.1", + "lifecycle": "active" + }, + { + "fingerprint": "0e9a750e398101e3fa455f2b19f7fb75622d6e8947ec76dafe46d656625fa2f0", + "rule_id": "CF-DART-003", + "path": "lib/models/app_update_info.dart", + "symbol": null, + "context_hash": null, + "source_context": "production", + "disposition": "human_review", + "reason": "Tool gap (not a source finding): New adapter heuristic requires native tool or human review before auto-apply.", + "first_seen": "2026-10-03T15:27:54.6737787+08:00", + "last_reviewed": "2026-10-03T09:30:14.998539Z", + "rule_version": "0.2.1", + "lifecycle": "active" + }, + { + "fingerprint": "cefd19876531de209c936fd041f751ba3a1df6d64ba3e245a55611595a238f34", + "rule_id": "CF-DART-003", + "path": "lib/models/app_update_info.dart", + "symbol": null, + "context_hash": null, + "source_context": "production", + "disposition": "human_review", + "reason": "Tool gap (not a source finding): New adapter heuristic requires native tool or human review before auto-apply.", + "first_seen": "2026-10-03T15:27:54.6814757+08:00", + "last_reviewed": "2026-10-03T09:30:14.998539Z", + "rule_version": "0.2.1", + "lifecycle": "active" + }, + { + "fingerprint": "012c7d7b1c6499f05f8baeaf7c4c7bbcee711e3aa69ba8be1915180f934b2147", + "rule_id": "CF-DART-003", + "path": "lib/models/blind_listen_prefs.dart", + "symbol": null, + "context_hash": null, + "source_context": "production", + "disposition": "human_review", + "reason": "Tool gap (not a source finding): New adapter heuristic requires native tool or human review before auto-apply.", + "first_seen": "2026-10-03T15:27:54.7025297+08:00", + "last_reviewed": "2026-10-03T09:30:14.998539Z", + "rule_version": "0.2.1", + "lifecycle": "active" + }, + { + "fingerprint": "c19e0b118f77095508b9e35b4e3cd5cb9d8369c3ccfc5368a63ff76fb2f0363c", + "rule_id": "CF-DART-003", + "path": "lib/models/blind_listen_prefs.dart", + "symbol": null, + "context_hash": null, + "source_context": "production", + "disposition": "human_review", + "reason": "Tool gap (not a source finding): New adapter heuristic requires native tool or human review before auto-apply.", + "first_seen": "2026-10-03T15:27:54.7095963+08:00", + "last_reviewed": "2026-10-03T09:30:14.998539Z", + "rule_version": "0.2.1", + "lifecycle": "active" + }, + { + "fingerprint": "c3cd818aba12d9e0bc98607c2ad276ae32d53e0ed7bd1bc8defc80d05ce55965", + "rule_id": "CF-DART-003", + "path": "lib/models/blind_listen_prefs.dart", + "symbol": null, + "context_hash": null, + "source_context": "production", + "disposition": "human_review", + "reason": "Tool gap (not a source finding): New adapter heuristic requires native tool or human review before auto-apply.", + "first_seen": "2026-10-03T15:27:54.7165047+08:00", + "last_reviewed": "2026-10-03T09:30:14.998539Z", + "rule_version": "0.2.1", + "lifecycle": "active" + }, + { + "fingerprint": "f064941f99d69b31fd4585e39556c24dd16e5121a5c25ee6c471962d4ad3f76c", + "rule_id": "CF-DART-003", + "path": "lib/models/blind_listen_prefs.dart", + "symbol": null, + "context_hash": null, + "source_context": "production", + "disposition": "human_review", + "reason": "Tool gap (not a source finding): New adapter heuristic requires native tool or human review before auto-apply.", + "first_seen": "2026-10-03T15:27:54.7220439+08:00", + "last_reviewed": "2026-10-03T09:30:14.998539Z", + "rule_version": "0.2.1", + "lifecycle": "active" + }, + { + "fingerprint": "733ca82ed9b4be27929f066409bdb0e609e2c7e2870aa95df411f5375f8abcce", + "rule_id": "CF-DART-003", + "path": "lib/models/blind_listen_prefs.dart", + "symbol": null, + "context_hash": null, + "source_context": "production", + "disposition": "human_review", + "reason": "Tool gap (not a source finding): New adapter heuristic requires native tool or human review before auto-apply.", + "first_seen": "2026-10-03T15:27:54.7267966+08:00", + "last_reviewed": "2026-10-03T09:30:14.998539Z", + "rule_version": "0.2.1", + "lifecycle": "active" + }, + { + "fingerprint": "65d0e4bc530ddb5e9e0fb3d5e01d0a8313ac219dfa3f936004ebd5251c8ced62", + "rule_id": "CF-DART-003", + "path": "lib/models/blind_listen_prefs.dart", + "symbol": null, + "context_hash": null, + "source_context": "production", + "disposition": "human_review", + "reason": "Tool gap (not a source finding): New adapter heuristic requires native tool or human review before auto-apply.", + "first_seen": "2026-10-03T15:27:54.7314799+08:00", + "last_reviewed": "2026-10-03T09:30:14.998539Z", + "rule_version": "0.2.1", + "lifecycle": "active" + }, + { + "fingerprint": "8db0171723546e54a58d80d883f32648e365ff08076bf1c49d991d0e2a8ef2d0", + "rule_id": "CF-DART-003", + "path": "lib/models/blind_listen_settings.dart", + "symbol": null, + "context_hash": null, + "source_context": "production", + "disposition": "human_review", + "reason": "Tool gap (not a source finding): New adapter heuristic requires native tool or human review before auto-apply.", + "first_seen": "2026-10-03T15:27:54.7516778+08:00", + "last_reviewed": "2026-10-03T09:30:14.998539Z", + "rule_version": "0.2.1", + "lifecycle": "active" + }, + { + "fingerprint": "480374c8fe761adff50ab2b59dbd157a2b51f9971de1b8e8d65e6a847ced08ec", + "rule_id": "CF-DART-003", + "path": "lib/models/blind_listen_settings.dart", + "symbol": null, + "context_hash": null, + "source_context": "production", + "disposition": "human_review", + "reason": "Tool gap (not a source finding): New adapter heuristic requires native tool or human review before auto-apply.", + "first_seen": "2026-10-03T15:27:54.7563322+08:00", + "last_reviewed": "2026-10-03T09:30:14.998539Z", + "rule_version": "0.2.1", + "lifecycle": "active" + }, + { + "fingerprint": "befed4ad1477046060f8637416015c2adc5c1479b4873371428e2266d1a8d9aa", + "rule_id": "CF-DART-003", + "path": "lib/models/blind_listen_settings.dart", + "symbol": null, + "context_hash": null, + "source_context": "production", + "disposition": "human_review", + "reason": "Tool gap (not a source finding): New adapter heuristic requires native tool or human review before auto-apply.", + "first_seen": "2026-10-03T15:27:54.7604805+08:00", + "last_reviewed": "2026-10-03T09:30:14.998539Z", + "rule_version": "0.2.1", + "lifecycle": "active" + }, + { + "fingerprint": "ac2652c3e5237f61b918b7e0bfb01beebd700493dbee3b2a66b255d614592a01", + "rule_id": "CF-DART-003", + "path": "lib/models/blind_listen_settings.dart", + "symbol": null, + "context_hash": null, + "source_context": "production", + "disposition": "human_review", + "reason": "Tool gap (not a source finding): New adapter heuristic requires native tool or human review before auto-apply.", + "first_seen": "2026-10-03T15:27:54.7648829+08:00", + "last_reviewed": "2026-10-03T09:30:14.998539Z", + "rule_version": "0.2.1", + "lifecycle": "active" + }, + { + "fingerprint": "82906aa0f044142d45345ef8f6e835284a71cc19e27df4c015e15eb17ad02fe1", + "rule_id": "CF-DART-003", + "path": "lib/models/blind_listen_settings.dart", + "symbol": null, + "context_hash": null, + "source_context": "production", + "disposition": "human_review", + "reason": "Tool gap (not a source finding): New adapter heuristic requires native tool or human review before auto-apply.", + "first_seen": "2026-10-03T15:27:54.7707235+08:00", + "last_reviewed": "2026-10-03T09:30:14.998539Z", + "rule_version": "0.2.1", + "lifecycle": "active" + }, + { + "fingerprint": "450a3b88cb886bfb7de1f170aa7a85051e0c8d06bb4f9136250d12f6b9f316cf", + "rule_id": "CF-DART-003", + "path": "lib/models/blind_listen_settings.dart", + "symbol": null, + "context_hash": null, + "source_context": "production", + "disposition": "human_review", + "reason": "Tool gap (not a source finding): New adapter heuristic requires native tool or human review before auto-apply.", + "first_seen": "2026-10-03T15:27:54.7749120+08:00", + "last_reviewed": "2026-10-03T09:30:14.998539Z", + "rule_version": "0.2.1", + "lifecycle": "active" + }, + { + "fingerprint": "7c03f1455fa7661ed6b4e8c68cdb51ac044d40d53acd23969cd8eafc4bf3eec4", + "rule_id": "CF-DART-003", + "path": "lib/models/difficult_practice_prefs.dart", + "symbol": null, + "context_hash": null, + "source_context": "production", + "disposition": "human_review", + "reason": "Tool gap (not a source finding): New adapter heuristic requires native tool or human review before auto-apply.", + "first_seen": "2026-10-03T15:27:54.7948155+08:00", + "last_reviewed": "2026-10-03T09:30:14.998539Z", + "rule_version": "0.2.1", + "lifecycle": "active" + }, + { + "fingerprint": "63e11b8a0e7a6bded7894c1699ffc5ee6c71105b9c5e32cf43f2ae74f6efa00b", + "rule_id": "CF-DART-003", + "path": "lib/models/difficult_practice_prefs.dart", + "symbol": null, + "context_hash": null, + "source_context": "production", + "disposition": "human_review", + "reason": "Tool gap (not a source finding): New adapter heuristic requires native tool or human review before auto-apply.", + "first_seen": "2026-10-03T15:27:54.8017401+08:00", + "last_reviewed": "2026-10-03T09:30:14.998539Z", + "rule_version": "0.2.1", + "lifecycle": "active" + }, + { + "fingerprint": "36bed63b907b8fcfc9e07a16cc121143690d36614725310c231a9253a480d0ca", + "rule_id": "CF-DART-003", + "path": "lib/models/difficult_practice_prefs.dart", + "symbol": null, + "context_hash": null, + "source_context": "production", + "disposition": "human_review", + "reason": "Tool gap (not a source finding): New adapter heuristic requires native tool or human review before auto-apply.", + "first_seen": "2026-10-03T15:27:54.8075620+08:00", + "last_reviewed": "2026-10-03T09:30:14.998539Z", + "rule_version": "0.2.1", + "lifecycle": "active" + }, + { + "fingerprint": "c98d7a05019150870ee46157da46ee20f531c8b28370fcbec7b405bd82c89b2d", + "rule_id": "CF-DART-003", + "path": "lib/models/difficult_practice_prefs.dart", + "symbol": null, + "context_hash": null, + "source_context": "production", + "disposition": "human_review", + "reason": "Tool gap (not a source finding): New adapter heuristic requires native tool or human review before auto-apply.", + "first_seen": "2026-10-03T15:27:54.8122583+08:00", + "last_reviewed": "2026-10-03T09:30:14.998539Z", + "rule_version": "0.2.1", + "lifecycle": "active" + }, + { + "fingerprint": "34a59b3ca5ca6ebac94c7ee4f0e53af116353b7760dfc7e2c344a0420ddd6398", + "rule_id": "CF-DART-003", + "path": "lib/models/difficult_practice_prefs.dart", + "symbol": null, + "context_hash": null, + "source_context": "production", + "disposition": "human_review", + "reason": "Tool gap (not a source finding): New adapter heuristic requires native tool or human review before auto-apply.", + "first_seen": "2026-10-03T15:27:54.8170641+08:00", + "last_reviewed": "2026-10-03T09:30:14.998539Z", + "rule_version": "0.2.1", + "lifecycle": "active" + }, + { + "fingerprint": "0722480d04a392010b0aa7b30bdeecb4d16cc17c3382c9ae19a7ad666a5ad783", + "rule_id": "CF-DART-003", + "path": "lib/models/difficult_practice_prefs.dart", + "symbol": null, + "context_hash": null, + "source_context": "production", + "disposition": "human_review", + "reason": "Tool gap (not a source finding): New adapter heuristic requires native tool or human review before auto-apply.", + "first_seen": "2026-10-03T15:27:54.8216424+08:00", + "last_reviewed": "2026-10-03T09:30:14.998539Z", + "rule_version": "0.2.1", + "lifecycle": "active" + }, + { + "fingerprint": "22f6b4f64d71378bec119488f7e6e61aa7736a02ed57a95ae1dacf07fc3d958d", + "rule_id": "CF-DART-003", + "path": "lib/models/difficult_practice_settings.dart", + "symbol": null, + "context_hash": null, + "source_context": "production", + "disposition": "human_review", + "reason": "Tool gap (not a source finding): New adapter heuristic requires native tool or human review before auto-apply.", + "first_seen": "2026-10-03T15:27:54.8502507+08:00", + "last_reviewed": "2026-10-03T09:30:14.998539Z", + "rule_version": "0.2.1", + "lifecycle": "active" + }, + { + "fingerprint": "2a2d81af04a802277cdbbefeebcfccdcf324e434bba634850542c14f838a5058", + "rule_id": "CF-DART-003", + "path": "lib/models/difficult_practice_settings.dart", + "symbol": null, + "context_hash": null, + "source_context": "production", + "disposition": "human_review", + "reason": "Tool gap (not a source finding): New adapter heuristic requires native tool or human review before auto-apply.", + "first_seen": "2026-10-03T15:27:54.8566161+08:00", + "last_reviewed": "2026-10-03T09:30:14.998539Z", + "rule_version": "0.2.1", + "lifecycle": "active" + }, + { + "fingerprint": "8961e3371f7e54a474f8f62cc78c992860edbc35c8434f81e1d325337c8a53f2", + "rule_id": "CF-DART-003", + "path": "lib/models/difficult_practice_settings.dart", + "symbol": null, + "context_hash": null, + "source_context": "production", + "disposition": "human_review", + "reason": "Tool gap (not a source finding): New adapter heuristic requires native tool or human review before auto-apply.", + "first_seen": "2026-10-03T15:27:54.8648549+08:00", + "last_reviewed": "2026-10-03T09:30:14.998539Z", + "rule_version": "0.2.1", + "lifecycle": "active" + }, + { + "fingerprint": "cbd84637874ad6931e7dded2b69de7c234cd8a71d6bf9d1ebf8f75c20bda7f5a", + "rule_id": "CF-DART-003", + "path": "lib/models/difficult_practice_settings.dart", + "symbol": null, + "context_hash": null, + "source_context": "production", + "disposition": "human_review", + "reason": "Tool gap (not a source finding): New adapter heuristic requires native tool or human review before auto-apply.", + "first_seen": "2026-10-03T15:27:54.8690355+08:00", + "last_reviewed": "2026-10-03T09:30:14.998539Z", + "rule_version": "0.2.1", + "lifecycle": "active" + }, + { + "fingerprint": "b8baf53132a834d173497e681b955aa827a3c8aa88f447876ba6f3a378870404", + "rule_id": "CF-DART-003", + "path": "lib/models/difficult_practice_settings.dart", + "symbol": null, + "context_hash": null, + "source_context": "production", + "disposition": "human_review", + "reason": "Tool gap (not a source finding): New adapter heuristic requires native tool or human review before auto-apply.", + "first_seen": "2026-10-03T15:27:54.8737639+08:00", + "last_reviewed": "2026-10-03T09:30:14.998539Z", + "rule_version": "0.2.1", + "lifecycle": "active" + }, + { + "fingerprint": "8259ea965a5b95e590996ede652d909a1f6353b2001504c87d3af5c8832dafcc", + "rule_id": "CF-DART-003", + "path": "lib/models/difficult_practice_settings.dart", + "symbol": null, + "context_hash": null, + "source_context": "production", + "disposition": "human_review", + "reason": "Tool gap (not a source finding): New adapter heuristic requires native tool or human review before auto-apply.", + "first_seen": "2026-10-03T15:27:54.8775975+08:00", + "last_reviewed": "2026-10-03T09:30:14.998539Z", + "rule_version": "0.2.1", + "lifecycle": "active" + }, + { + "fingerprint": "488783b68e4e62e3f4882deca16e8f017fc80ec20ecb80cc847c2820743ec09a", + "rule_id": "CF-DART-003", + "path": "lib/models/intensive_listen_prefs.dart", + "symbol": null, + "context_hash": null, + "source_context": "production", + "disposition": "human_review", + "reason": "Tool gap (not a source finding): New adapter heuristic requires native tool or human review before auto-apply.", + "first_seen": "2026-10-03T15:27:54.8941724+08:00", + "last_reviewed": "2026-10-03T09:30:14.998539Z", + "rule_version": "0.2.1", + "lifecycle": "active" + }, + { + "fingerprint": "fe2e794dae796bf941620e7045d213b65edbb69ebf950d3deaf7cd2126f57044", + "rule_id": "CF-DART-003", + "path": "lib/models/intensive_listen_prefs.dart", + "symbol": null, + "context_hash": null, + "source_context": "production", + "disposition": "human_review", + "reason": "Tool gap (not a source finding): New adapter heuristic requires native tool or human review before auto-apply.", + "first_seen": "2026-10-03T15:27:54.9011387+08:00", + "last_reviewed": "2026-10-03T09:30:14.998539Z", + "rule_version": "0.2.1", + "lifecycle": "active" + }, + { + "fingerprint": "b670818cfe13586bbc6693254327cd2440c7d7676b04fb0959fa3f47d152d858", + "rule_id": "CF-DART-003", + "path": "lib/models/intensive_listen_prefs.dart", + "symbol": null, + "context_hash": null, + "source_context": "production", + "disposition": "human_review", + "reason": "Tool gap (not a source finding): New adapter heuristic requires native tool or human review before auto-apply.", + "first_seen": "2026-10-03T15:27:54.9088553+08:00", + "last_reviewed": "2026-10-03T09:30:14.998539Z", + "rule_version": "0.2.1", + "lifecycle": "active" + }, + { + "fingerprint": "1707672bc9578bcd60635927d316ca635942da4e433b9b4f693d9a2fa014fb8f", + "rule_id": "CF-DART-003", + "path": "lib/models/intensive_listen_prefs.dart", + "symbol": null, + "context_hash": null, + "source_context": "production", + "disposition": "human_review", + "reason": "Tool gap (not a source finding): New adapter heuristic requires native tool or human review before auto-apply.", + "first_seen": "2026-10-03T15:27:54.9163101+08:00", + "last_reviewed": "2026-10-03T09:30:14.998539Z", + "rule_version": "0.2.1", + "lifecycle": "active" + }, + { + "fingerprint": "dffdb88fc5c41334bf129db0fcc1ca9606e4ca4efca968dff9e7b81d9fac6dec", + "rule_id": "CF-DART-003", + "path": "lib/models/intensive_listen_prefs.dart", + "symbol": null, + "context_hash": null, + "source_context": "production", + "disposition": "human_review", + "reason": "Tool gap (not a source finding): New adapter heuristic requires native tool or human review before auto-apply.", + "first_seen": "2026-10-03T15:27:54.9237811+08:00", + "last_reviewed": "2026-10-03T09:30:14.998539Z", + "rule_version": "0.2.1", + "lifecycle": "active" + }, + { + "fingerprint": "c24259e238c69126e5d1cf3f44344a529cf8bff8f68455bd8188395efbd2da80", + "rule_id": "CF-DART-003", + "path": "lib/models/intensive_listen_prefs.dart", + "symbol": null, + "context_hash": null, + "source_context": "production", + "disposition": "human_review", + "reason": "Tool gap (not a source finding): New adapter heuristic requires native tool or human review before auto-apply.", + "first_seen": "2026-10-03T15:27:54.9311358+08:00", + "last_reviewed": "2026-10-03T09:30:14.998539Z", + "rule_version": "0.2.1", + "lifecycle": "active" + }, + { + "fingerprint": "fe78224af58a29907f5ccd23a0c801bf77755818a5a7c88a5c0c67a8b4148f87", + "rule_id": "CF-DART-003", + "path": "lib/models/intensive_listen_prefs.dart", + "symbol": null, + "context_hash": null, + "source_context": "production", + "disposition": "human_review", + "reason": "Tool gap (not a source finding): New adapter heuristic requires native tool or human review before auto-apply.", + "first_seen": "2026-10-03T15:27:54.9401189+08:00", + "last_reviewed": "2026-10-03T09:30:14.998539Z", + "rule_version": "0.2.1", + "lifecycle": "active" + }, + { + "fingerprint": "b0865699b4fa3c5047114dc778892bce5c049ad3a27f53af8106c24b6684f29b", + "rule_id": "CF-DART-003", + "path": "lib/models/intensive_listen_prefs.dart", + "symbol": null, + "context_hash": null, + "source_context": "production", + "disposition": "human_review", + "reason": "Tool gap (not a source finding): New adapter heuristic requires native tool or human review before auto-apply.", + "first_seen": "2026-10-03T15:27:54.9452587+08:00", + "last_reviewed": "2026-10-03T09:30:14.998539Z", + "rule_version": "0.2.1", + "lifecycle": "active" + }, + { + "fingerprint": "6c5a4ff96feb207cf9e7f06915d12cd8c84046788a0212290a1563b2f6c1e309", + "rule_id": "CF-DART-003", + "path": "lib/models/intensive_listen_settings.dart", + "symbol": null, + "context_hash": null, + "source_context": "production", + "disposition": "human_review", + "reason": "Tool gap (not a source finding): New adapter heuristic requires native tool or human review before auto-apply.", + "first_seen": "2026-10-03T15:27:54.9684822+08:00", + "last_reviewed": "2026-10-03T09:30:14.998539Z", + "rule_version": "0.2.1", + "lifecycle": "active" + }, + { + "fingerprint": "6d603be09ff84b3d6475adff67fa232c7d9c9b0147cff34aaf9dd36c3a4788d2", + "rule_id": "CF-DART-003", + "path": "lib/models/intensive_listen_settings.dart", + "symbol": null, + "context_hash": null, + "source_context": "production", + "disposition": "human_review", + "reason": "Tool gap (not a source finding): New adapter heuristic requires native tool or human review before auto-apply.", + "first_seen": "2026-10-03T15:27:54.9764927+08:00", + "last_reviewed": "2026-10-03T09:30:14.998539Z", + "rule_version": "0.2.1", + "lifecycle": "active" + }, + { + "fingerprint": "37c41fa307140d91f5bc471401600334f0951132ef3a79e5d5609337759f6507", + "rule_id": "CF-DART-003", + "path": "lib/models/intensive_listen_settings.dart", + "symbol": null, + "context_hash": null, + "source_context": "production", + "disposition": "human_review", + "reason": "Tool gap (not a source finding): New adapter heuristic requires native tool or human review before auto-apply.", + "first_seen": "2026-10-03T15:27:54.9841811+08:00", + "last_reviewed": "2026-10-03T09:30:14.998539Z", + "rule_version": "0.2.1", + "lifecycle": "active" + }, + { + "fingerprint": "88f05ae5485091d410dba7c9a67dcae4a0e813b78d651fd2e61b2967054ecc7f", + "rule_id": "CF-DART-003", + "path": "lib/models/intensive_listen_settings.dart", + "symbol": null, + "context_hash": null, + "source_context": "production", + "disposition": "human_review", + "reason": "Tool gap (not a source finding): New adapter heuristic requires native tool or human review before auto-apply.", + "first_seen": "2026-10-03T15:27:54.9915851+08:00", + "last_reviewed": "2026-10-03T09:30:14.998539Z", + "rule_version": "0.2.1", + "lifecycle": "active" + }, + { + "fingerprint": "5a58dd6ea8077708a47b1f02d5736d7b7f588afa915c2a0857476a7a63143ed1", + "rule_id": "CF-DART-003", + "path": "lib/models/intensive_listen_settings.dart", + "symbol": null, + "context_hash": null, + "source_context": "production", + "disposition": "human_review", + "reason": "Tool gap (not a source finding): New adapter heuristic requires native tool or human review before auto-apply.", + "first_seen": "2026-10-03T15:27:54.9991629+08:00", + "last_reviewed": "2026-10-03T09:30:14.998539Z", + "rule_version": "0.2.1", + "lifecycle": "active" + }, + { + "fingerprint": "8393bc7cb5f95b848a9aafa4f466cd58b97a83089ebf0a4b98c269c207bd9c4b", + "rule_id": "CF-DART-003", + "path": "lib/models/intensive_listen_settings.dart", + "symbol": null, + "context_hash": null, + "source_context": "production", + "disposition": "human_review", + "reason": "Tool gap (not a source finding): New adapter heuristic requires native tool or human review before auto-apply.", + "first_seen": "2026-10-03T15:27:55.0067349+08:00", + "last_reviewed": "2026-10-03T09:30:14.998539Z", + "rule_version": "0.2.1", + "lifecycle": "active" + }, + { + "fingerprint": "9bd9159cdde9e608b36dcbbc91f2f64f77d441616d3e6bb5d47941eef110dcf9", + "rule_id": "CF-DART-003", + "path": "lib/models/intensive_listen_settings.dart", + "symbol": null, + "context_hash": null, + "source_context": "production", + "disposition": "human_review", + "reason": "Tool gap (not a source finding): New adapter heuristic requires native tool or human review before auto-apply.", + "first_seen": "2026-10-03T15:27:55.0162280+08:00", + "last_reviewed": "2026-10-03T09:30:14.998539Z", + "rule_version": "0.2.1", + "lifecycle": "active" + }, + { + "fingerprint": "4ff501e64767639f2cf92b5b6b12ce7c289f0bd09abe51ea694170503a2fc000", + "rule_id": "CF-DART-003", + "path": "lib/models/intensive_listen_settings.dart", + "symbol": null, + "context_hash": null, + "source_context": "production", + "disposition": "human_review", + "reason": "Tool gap (not a source finding): New adapter heuristic requires native tool or human review before auto-apply.", + "first_seen": "2026-10-03T15:27:55.0240922+08:00", + "last_reviewed": "2026-10-03T09:30:14.998539Z", + "rule_version": "0.2.1", + "lifecycle": "active" + }, + { + "fingerprint": "21cef777c588ce8ac6f7fe4b1c2f22abd0c690877c2d5ef953563164b593f462", + "rule_id": "CF-DART-003", + "path": "lib/models/playback_settings.dart", + "symbol": null, + "context_hash": null, + "source_context": "production", + "disposition": "human_review", + "reason": "Tool gap (not a source finding): New adapter heuristic requires native tool or human review before auto-apply.", + "first_seen": "2026-10-03T15:27:55.0479445+08:00", + "last_reviewed": "2026-10-03T09:30:14.998539Z", + "rule_version": "0.2.1", + "lifecycle": "active" + }, + { + "fingerprint": "dfcfce74e2d283493ba3d33e5ab679f0f5ece4877492b9c2d559b73f34438022", + "rule_id": "CF-DART-003", + "path": "lib/models/playback_settings.dart", + "symbol": null, + "context_hash": null, + "source_context": "production", + "disposition": "human_review", + "reason": "Tool gap (not a source finding): New adapter heuristic requires native tool or human review before auto-apply.", + "first_seen": "2026-10-03T15:27:55.0531262+08:00", + "last_reviewed": "2026-10-03T09:30:14.998539Z", + "rule_version": "0.2.1", + "lifecycle": "active" + }, + { + "fingerprint": "432555f345b995e4f115d114beef4b70cdb765646aa05b98acf4731149797389", + "rule_id": "CF-DART-003", + "path": "lib/models/reminder_settings.dart", + "symbol": null, + "context_hash": null, + "source_context": "production", + "disposition": "human_review", + "reason": "Tool gap (not a source finding): New adapter heuristic requires native tool or human review before auto-apply.", + "first_seen": "2026-10-03T15:27:55.0756675+08:00", + "last_reviewed": "2026-10-03T09:30:14.998539Z", + "rule_version": "0.2.1", + "lifecycle": "active" + }, + { + "fingerprint": "778263005cad3645ffa34e4c05c29563eab8a75e277fa85c48928b8aeb198485", + "rule_id": "CF-DART-003", + "path": "lib/models/reminder_settings.dart", + "symbol": null, + "context_hash": null, + "source_context": "production", + "disposition": "human_review", + "reason": "Tool gap (not a source finding): New adapter heuristic requires native tool or human review before auto-apply.", + "first_seen": "2026-10-03T15:27:55.0813720+08:00", + "last_reviewed": "2026-10-03T09:30:14.998539Z", + "rule_version": "0.2.1", + "lifecycle": "active" + }, + { + "fingerprint": "7ae8f56dceb5252fd216ff66fc9c7c6adeb727614f725a56ec4802e2d193b543", + "rule_id": "CF-DART-003", + "path": "lib/models/retell_prefs.dart", + "symbol": null, + "context_hash": null, + "source_context": "production", + "disposition": "human_review", + "reason": "Tool gap (not a source finding): New adapter heuristic requires native tool or human review before auto-apply.", + "first_seen": "2026-10-03T15:27:55.1020562+08:00", + "last_reviewed": "2026-10-03T09:30:14.998539Z", + "rule_version": "0.2.1", + "lifecycle": "active" + }, + { + "fingerprint": "8f556fb47392c8d62bd1748a1798bf924389b201bb64f9db3c41cfc176f5f8f8", + "rule_id": "CF-DART-003", + "path": "lib/models/retell_prefs.dart", + "symbol": null, + "context_hash": null, + "source_context": "production", + "disposition": "human_review", + "reason": "Tool gap (not a source finding): New adapter heuristic requires native tool or human review before auto-apply.", + "first_seen": "2026-10-03T15:27:55.1085744+08:00", + "last_reviewed": "2026-10-03T09:30:14.998539Z", + "rule_version": "0.2.1", + "lifecycle": "active" + }, + { + "fingerprint": "9e58748595e1a22502e66ca5cd15fa99b73c8facd13890f5fa2ac131d89ed9b9", + "rule_id": "CF-DART-003", + "path": "lib/models/retell_prefs.dart", + "symbol": null, + "context_hash": null, + "source_context": "production", + "disposition": "human_review", + "reason": "Tool gap (not a source finding): New adapter heuristic requires native tool or human review before auto-apply.", + "first_seen": "2026-10-03T15:27:55.1134455+08:00", + "last_reviewed": "2026-10-03T09:30:14.998539Z", + "rule_version": "0.2.1", + "lifecycle": "active" + }, + { + "fingerprint": "df1ff262619c89555418ff3380efbacb2e926b30f1aa94ec5fcde0d3c36bc7cd", + "rule_id": "CF-DART-003", + "path": "lib/models/retell_prefs.dart", + "symbol": null, + "context_hash": null, + "source_context": "production", + "disposition": "human_review", + "reason": "Tool gap (not a source finding): New adapter heuristic requires native tool or human review before auto-apply.", + "first_seen": "2026-10-03T15:27:55.1182089+08:00", + "last_reviewed": "2026-10-03T09:30:14.998539Z", + "rule_version": "0.2.1", + "lifecycle": "active" + }, + { + "fingerprint": "9ef2c6eb8f5ebf80a798268153950b9f1775387332aee178decc6dc0ff40d803", + "rule_id": "CF-DART-003", + "path": "lib/models/retell_prefs.dart", + "symbol": null, + "context_hash": null, + "source_context": "production", + "disposition": "human_review", + "reason": "Tool gap (not a source finding): New adapter heuristic requires native tool or human review before auto-apply.", + "first_seen": "2026-10-03T15:27:55.1250117+08:00", + "last_reviewed": "2026-10-03T09:30:14.998539Z", + "rule_version": "0.2.1", + "lifecycle": "active" + }, + { + "fingerprint": "c5c74b6a65a6831d582ba2eef5fd0daa5e06c36e5dfed569149409941c9ea6fb", + "rule_id": "CF-DART-003", + "path": "lib/models/retell_prefs.dart", + "symbol": null, + "context_hash": null, + "source_context": "production", + "disposition": "human_review", + "reason": "Tool gap (not a source finding): New adapter heuristic requires native tool or human review before auto-apply.", + "first_seen": "2026-10-03T15:27:55.1323540+08:00", + "last_reviewed": "2026-10-03T09:30:14.998539Z", + "rule_version": "0.2.1", + "lifecycle": "active" + }, + { + "fingerprint": "8a7fb26740a75fa0dd148140d1a46f5d76c58e709aadf91d5632ea5ad9085f32", + "rule_id": "CF-DART-003", + "path": "lib/models/retell_prefs.dart", + "symbol": null, + "context_hash": null, + "source_context": "production", + "disposition": "human_review", + "reason": "Tool gap (not a source finding): New adapter heuristic requires native tool or human review before auto-apply.", + "first_seen": "2026-10-03T15:27:55.1394735+08:00", + "last_reviewed": "2026-10-03T09:30:14.998539Z", + "rule_version": "0.2.1", + "lifecycle": "active" + }, + { + "fingerprint": "d545f041fd5ed5736b8fc63407db1a2039d07b1769312443c9514cc5a04ab57e", + "rule_id": "CF-DART-003", + "path": "lib/models/retell_prefs.dart", + "symbol": null, + "context_hash": null, + "source_context": "production", + "disposition": "human_review", + "reason": "Tool gap (not a source finding): New adapter heuristic requires native tool or human review before auto-apply.", + "first_seen": "2026-10-03T15:27:55.1444301+08:00", + "last_reviewed": "2026-10-03T09:30:14.998539Z", + "rule_version": "0.2.1", + "lifecycle": "active" + }, + { + "fingerprint": "a8ccccd7f4e62c002cec2029148493d2bbed0b616363be680616ad8578be3da8", + "rule_id": "CF-DART-003", + "path": "lib/models/retell_settings.dart", + "symbol": null, + "context_hash": null, + "source_context": "production", + "disposition": "human_review", + "reason": "Tool gap (not a source finding): New adapter heuristic requires native tool or human review before auto-apply.", + "first_seen": "2026-10-03T15:27:55.1671739+08:00", + "last_reviewed": "2026-10-03T09:30:14.998539Z", + "rule_version": "0.2.1", + "lifecycle": "active" + }, + { + "fingerprint": "f8d52d99e1bc0ac6d2b4004c24909669d0ac6f92178d6a8b6443e3b69e7028b5", + "rule_id": "CF-DART-003", + "path": "lib/models/retell_settings.dart", + "symbol": null, + "context_hash": null, + "source_context": "production", + "disposition": "human_review", + "reason": "Tool gap (not a source finding): New adapter heuristic requires native tool or human review before auto-apply.", + "first_seen": "2026-10-03T15:27:55.1749917+08:00", + "last_reviewed": "2026-10-03T09:30:14.998539Z", + "rule_version": "0.2.1", + "lifecycle": "active" + }, + { + "fingerprint": "f0a15dadbdb7f05c4338f4e2c79f90b39329478aabc8e5473b05a5ca08aa584a", + "rule_id": "CF-DART-003", + "path": "lib/models/retell_settings.dart", + "symbol": null, + "context_hash": null, + "source_context": "production", + "disposition": "human_review", + "reason": "Tool gap (not a source finding): New adapter heuristic requires native tool or human review before auto-apply.", + "first_seen": "2026-10-03T15:27:55.1822857+08:00", + "last_reviewed": "2026-10-03T09:30:14.998539Z", + "rule_version": "0.2.1", + "lifecycle": "active" + }, + { + "fingerprint": "9529693a448049913ea8ed0ce44c958718b52f01f7d07bde602efbf8c9f2d274", + "rule_id": "CF-DART-003", + "path": "lib/models/retell_settings.dart", + "symbol": null, + "context_hash": null, + "source_context": "production", + "disposition": "human_review", + "reason": "Tool gap (not a source finding): New adapter heuristic requires native tool or human review before auto-apply.", + "first_seen": "2026-10-03T15:27:55.1891421+08:00", + "last_reviewed": "2026-10-03T09:30:14.998539Z", + "rule_version": "0.2.1", + "lifecycle": "active" + }, + { + "fingerprint": "fc891b8e46f06feb73274ae28d6309d5abf3d7ff3fd71d8d9c9f749247cfe392", + "rule_id": "CF-DART-003", + "path": "lib/models/retell_settings.dart", + "symbol": null, + "context_hash": null, + "source_context": "production", + "disposition": "human_review", + "reason": "Tool gap (not a source finding): New adapter heuristic requires native tool or human review before auto-apply.", + "first_seen": "2026-10-03T15:27:55.1977610+08:00", + "last_reviewed": "2026-10-03T09:30:14.998539Z", + "rule_version": "0.2.1", + "lifecycle": "active" + }, + { + "fingerprint": "1eabdc3388f9858ad7a1ba558f0fbed87c42c6d447fcf3fea7059d9812132af9", + "rule_id": "CF-DART-003", + "path": "lib/models/retell_settings.dart", + "symbol": null, + "context_hash": null, + "source_context": "production", + "disposition": "human_review", + "reason": "Tool gap (not a source finding): New adapter heuristic requires native tool or human review before auto-apply.", + "first_seen": "2026-10-03T15:27:55.2046074+08:00", + "last_reviewed": "2026-10-03T09:30:14.998539Z", + "rule_version": "0.2.1", + "lifecycle": "active" + }, + { + "fingerprint": "7d62faf3a1d937c011d07f6f13c3f09df78ea0c1a0a2dcf711b13795e7a52d73", + "rule_id": "CF-DART-003", + "path": "lib/models/retell_settings.dart", + "symbol": null, + "context_hash": null, + "source_context": "production", + "disposition": "human_review", + "reason": "Tool gap (not a source finding): New adapter heuristic requires native tool or human review before auto-apply.", + "first_seen": "2026-10-03T15:27:55.2123639+08:00", + "last_reviewed": "2026-10-03T09:30:14.998539Z", + "rule_version": "0.2.1", + "lifecycle": "active" + }, + { + "fingerprint": "14882868eab1e29510f92ea3c2eb0ec6f7ad65064d77489456fbf4affa2b4c4e", + "rule_id": "CF-DART-003", + "path": "lib/models/retell_settings.dart", + "symbol": null, + "context_hash": null, + "source_context": "production", + "disposition": "human_review", + "reason": "Tool gap (not a source finding): New adapter heuristic requires native tool or human review before auto-apply.", + "first_seen": "2026-10-03T15:27:55.2191849+08:00", + "last_reviewed": "2026-10-03T09:30:14.998539Z", + "rule_version": "0.2.1", + "lifecycle": "active" + }, + { + "fingerprint": "f6e98cdd1ab37107a82f8068a397e83f357c342aeac7e475415fda90be07c15c", + "rule_id": "CF-DART-003", + "path": "lib/screens/activity_calendar_screen.dart", + "symbol": null, + "context_hash": null, + "source_context": "production", + "disposition": "human_review", + "reason": "Tool gap (not a source finding): New adapter heuristic requires native tool or human review before auto-apply.", + "first_seen": "2026-10-03T15:27:55.2839370+08:00", + "last_reviewed": "2026-10-03T09:30:14.998539Z", + "rule_version": "0.2.1", + "lifecycle": "active" + }, + { + "fingerprint": "ca5e51219e5a9494a7c89468489703d1473f4b2084bf1b8073b10519f6a28f79", + "rule_id": "CF-DART-003", + "path": "lib/screens/activity_calendar_screen.dart", + "symbol": null, + "context_hash": null, + "source_context": "production", + "disposition": "human_review", + "reason": "Tool gap (not a source finding): New adapter heuristic requires native tool or human review before auto-apply.", + "first_seen": "2026-10-03T15:27:55.2907794+08:00", + "last_reviewed": "2026-10-03T09:30:14.998539Z", + "rule_version": "0.2.1", + "lifecycle": "active" + }, + { + "fingerprint": "8f1e03c987cadc2003874c3ebe56c05928855157cdc3f90adf5cedf33582301e", + "rule_id": "CF-DART-003", + "path": "test/helpers/mock_providers.dart", + "symbol": null, + "context_hash": null, + "source_context": "test", + "disposition": "false_positive", + "reason": "Finding is in test, fixture, generated, vendor, or build-only content.", + "first_seen": "2026-10-03T15:27:55.5040019+08:00", + "last_reviewed": "2026-10-03T09:30:14.998539Z", + "rule_version": "0.2.1", + "lifecycle": "active" + }, + { + "fingerprint": "567469efb6302a660b3746c65d290cb0eaaf53be1ac6146c5e38cab350ba970a", + "rule_id": "CF-DART-003", + "path": "test/helpers/shared/fake_daos.dart", + "symbol": null, + "context_hash": null, + "source_context": "test", + "disposition": "false_positive", + "reason": "Finding is in test, fixture, generated, vendor, or build-only content.", + "first_seen": "2026-10-03T15:27:55.5041368+08:00", + "last_reviewed": "2026-10-03T09:30:14.998539Z", + "rule_version": "0.2.1", + "lifecycle": "active" + }, + { + "fingerprint": "832af08611e0e39b5dd641bdd8b85254f143eeaaac7f295f57724ab1d8be433a", + "rule_id": "CF-DART-003", + "path": "test/helpers/shared/fake_daos.dart", + "symbol": null, + "context_hash": null, + "source_context": "test", + "disposition": "false_positive", + "reason": "Finding is in test, fixture, generated, vendor, or build-only content.", + "first_seen": "2026-10-03T15:27:55.5042639+08:00", + "last_reviewed": "2026-10-03T09:30:14.998539Z", + "rule_version": "0.2.1", + "lifecycle": "active" + }, + { + "fingerprint": "ee006bb62e35d521842474a4e9979d3723fb52a1e93e261849f2ba54de234f06", + "rule_id": "CF-DART-003", + "path": "test/helpers/shared/fake_daos.dart", + "symbol": null, + "context_hash": null, + "source_context": "test", + "disposition": "false_positive", + "reason": "Finding is in test, fixture, generated, vendor, or build-only content.", + "first_seen": "2026-10-03T15:27:55.5044052+08:00", + "last_reviewed": "2026-10-03T09:30:14.998539Z", + "rule_version": "0.2.1", + "lifecycle": "active" + }, + { + "fingerprint": "06f546129ba912c881f8eca654fd0e2c2b6782bac9d4e11677ffa4e98bd6ce70", + "rule_id": "CF-DART-003", + "path": "test/helpers/shared/fake_daos.dart", + "symbol": null, + "context_hash": null, + "source_context": "test", + "disposition": "false_positive", + "reason": "Finding is in test, fixture, generated, vendor, or build-only content.", + "first_seen": "2026-10-03T15:27:55.5045314+08:00", + "last_reviewed": "2026-10-03T09:30:14.998539Z", + "rule_version": "0.2.1", + "lifecycle": "active" + }, + { + "fingerprint": "19f592fc8d3b1f75008f299ee2d2c4cac1f3d14740332fc8643b9212f35a3b74", + "rule_id": "CF-DART-003", + "path": "test/providers/learning_session/bookmark_review_test.dart", + "symbol": null, + "context_hash": null, + "source_context": "test", + "disposition": "false_positive", + "reason": "Finding is in test, fixture, generated, vendor, or build-only content.", + "first_seen": "2026-10-03T15:27:55.5046541+08:00", + "last_reviewed": "2026-10-03T09:30:14.998539Z", + "rule_version": "0.2.1", + "lifecycle": "active" + }, + { + "fingerprint": "3723d56cdb123e11552e94feae8a1e4f9f00debfec50cb00c34cf98c8a3feb84", + "rule_id": "CF-DART-003", + "path": "test/providers/learning_session/learning_session_provider_test.dart", + "symbol": null, + "context_hash": null, + "source_context": "test", + "disposition": "false_positive", + "reason": "Finding is in test, fixture, generated, vendor, or build-only content.", + "first_seen": "2026-10-03T15:27:55.5048375+08:00", + "last_reviewed": "2026-10-03T09:30:14.998539Z", + "rule_version": "0.2.1", + "lifecycle": "active" + }, + { + "fingerprint": "0ac5d7bcada37987654cd7d2504ac4ae6eb28d05ed068586b6c64dbca5ec8c5c", + "rule_id": "CF-DART-003", + "path": "test/providers/retell_review_evaluation_provider_test.dart", + "symbol": null, + "context_hash": null, + "source_context": "test", + "disposition": "false_positive", + "reason": "Finding is in test, fixture, generated, vendor, or build-only content.", + "first_seen": "2026-10-03T15:27:55.5055696+08:00", + "last_reviewed": "2026-10-03T09:30:14.998539Z", + "rule_version": "0.2.1", + "lifecycle": "active" + }, + { + "fingerprint": "1006466d795e1c9e4cd37f000f761629a0c0475e31d6cacc77345c692c895598", + "rule_id": "CF-DART-003", + "path": "test/providers/tts/tts_controller_dict_prewarm_test.dart", + "symbol": null, + "context_hash": null, + "source_context": "test", + "disposition": "false_positive", + "reason": "Finding is in test, fixture, generated, vendor, or build-only content.", + "first_seen": "2026-10-03T15:27:55.5056881+08:00", + "last_reviewed": "2026-10-03T09:30:14.998539Z", + "rule_version": "0.2.1", + "lifecycle": "active" + }, + { + "fingerprint": "a49f615aad4a65d73e3ede9d7314578649895863b95dced8d96488d20ff4d851", + "rule_id": "CF-DART-003", + "path": "test/providers/tts/tts_controller_preview_test.dart", + "symbol": null, + "context_hash": null, + "source_context": "test", + "disposition": "false_positive", + "reason": "Finding is in test, fixture, generated, vendor, or build-only content.", + "first_seen": "2026-10-03T15:27:55.5058076+08:00", + "last_reviewed": "2026-10-03T09:30:14.998539Z", + "rule_version": "0.2.1", + "lifecycle": "active" + }, + { + "fingerprint": "41c89bfa52148acaead6d83f6a3d6e419bb61458a114edfee194ce238a3eec1e", + "rule_id": "CF-DART-003", + "path": "test/screens/bookmark_review_screen_test.dart", + "symbol": null, + "context_hash": null, + "source_context": "test", + "disposition": "false_positive", + "reason": "Finding is in test, fixture, generated, vendor, or build-only content.", + "first_seen": "2026-10-03T15:27:55.5059449+08:00", + "last_reviewed": "2026-10-03T09:30:14.998539Z", + "rule_version": "0.2.1", + "lifecycle": "active" + }, + { + "fingerprint": "0a1a1d15c367867c447d40144bc6d6ad7c998f985c8af47e4c598ed9bb008d7e", + "rule_id": "CF-DART-003", + "path": "test/screens/favorites_prewarm_test.dart", + "symbol": null, + "context_hash": null, + "source_context": "test", + "disposition": "false_positive", + "reason": "Finding is in test, fixture, generated, vendor, or build-only content.", + "first_seen": "2026-10-03T15:27:55.5063217+08:00", + "last_reviewed": "2026-10-03T09:30:14.998539Z", + "rule_version": "0.2.1", + "lifecycle": "active" + }, + { + "fingerprint": "3174b525eecb02278e865f9cedcba5c7ce7c35a5925bd4abc16b9b170c5497a5", + "rule_id": "CF-DART-003", + "path": "test/screens/favorites_prewarm_test.dart", + "symbol": null, + "context_hash": null, + "source_context": "test", + "disposition": "false_positive", + "reason": "Finding is in test, fixture, generated, vendor, or build-only content.", + "first_seen": "2026-10-03T15:27:55.5065553+08:00", + "last_reviewed": "2026-10-03T09:30:14.998539Z", + "rule_version": "0.2.1", + "lifecycle": "active" + }, + { + "fingerprint": "ca4736776ce47080f6763b30d47a155b6aec1aafe20c5c42c022bff58914c65f", + "rule_id": "CF-DART-003", + "path": "test/screens/favorites_prewarm_test.dart", + "symbol": null, + "context_hash": null, + "source_context": "test", + "disposition": "false_positive", + "reason": "Finding is in test, fixture, generated, vendor, or build-only content.", + "first_seen": "2026-10-03T15:27:55.5067787+08:00", + "last_reviewed": "2026-10-03T09:30:14.998539Z", + "rule_version": "0.2.1", + "lifecycle": "active" + }, + { + "fingerprint": "59c0146ae5a240c2328b610b3abd1c60a51410cd1e5d9916780cb21b5df3fbf3", + "rule_id": "CF-DART-003", + "path": "test/screens/favorites_screen_test.dart", + "symbol": null, + "context_hash": null, + "source_context": "test", + "disposition": "false_positive", + "reason": "Finding is in test, fixture, generated, vendor, or build-only content.", + "first_seen": "2026-10-03T15:27:55.5069889+08:00", + "last_reviewed": "2026-10-03T09:30:14.998539Z", + "rule_version": "0.2.1", + "lifecycle": "active" + }, + { + "fingerprint": "c20e93b24637a4f0903e7313bff73711138b039844b534fbfab5c9149fd998a8", + "rule_id": "CF-DART-003", + "path": "test/screens/favorites_screen_test.dart", + "symbol": null, + "context_hash": null, + "source_context": "test", + "disposition": "false_positive", + "reason": "Finding is in test, fixture, generated, vendor, or build-only content.", + "first_seen": "2026-10-03T15:27:55.5071918+08:00", + "last_reviewed": "2026-10-03T09:30:14.998539Z", + "rule_version": "0.2.1", + "lifecycle": "active" + }, + { + "fingerprint": "b84d1e2fbc44a1a6f4d40620638d333409032f7416430ec3290ace4a51c2ea0a", + "rule_id": "CF-DART-003", + "path": "test/screens/favorites_screen_test.dart", + "symbol": null, + "context_hash": null, + "source_context": "test", + "disposition": "false_positive", + "reason": "Finding is in test, fixture, generated, vendor, or build-only content.", + "first_seen": "2026-10-03T15:27:55.5073906+08:00", + "last_reviewed": "2026-10-03T09:30:14.998539Z", + "rule_version": "0.2.1", + "lifecycle": "active" + }, + { + "fingerprint": "2810582028a5aa5418cdd7ffd5d25799c5bcb4f7b83faddb0cd0d5dd78e93506", + "rule_id": "CF-DART-003", + "path": "test/screens/intensive_listen_player_screen_test.dart", + "symbol": null, + "context_hash": null, + "source_context": "test", + "disposition": "false_positive", + "reason": "Finding is in test, fixture, generated, vendor, or build-only content.", + "first_seen": "2026-10-03T15:27:55.5075915+08:00", + "last_reviewed": "2026-10-03T09:30:14.998539Z", + "rule_version": "0.2.1", + "lifecycle": "active" + }, + { + "fingerprint": "b70b7590972a9e5cb4201e567630993c2f8962b0df578b7f3daac997ee2311cd", + "rule_id": "CF-DART-003", + "path": "test/screens/intensive_listen_player_screen_test.dart", + "symbol": null, + "context_hash": null, + "source_context": "test", + "disposition": "false_positive", + "reason": "Finding is in test, fixture, generated, vendor, or build-only content.", + "first_seen": "2026-10-03T15:27:55.5078287+08:00", + "last_reviewed": "2026-10-03T09:30:14.998539Z", + "rule_version": "0.2.1", + "lifecycle": "active" + }, + { + "fingerprint": "f4fb1c7d662a7189b70dceb8a6310c7ba75b6827ca19a165f2f8e71dd10cb1b5", + "rule_id": "CF-DART-003", + "path": "test/screens/learning_plan_screen_test.dart", + "symbol": null, + "context_hash": null, + "source_context": "test", + "disposition": "false_positive", + "reason": "Finding is in test, fixture, generated, vendor, or build-only content.", + "first_seen": "2026-10-03T15:27:55.5080288+08:00", + "last_reviewed": "2026-10-03T09:30:14.998539Z", + "rule_version": "0.2.1", + "lifecycle": "active" + }, + { + "fingerprint": "18d0c575a800f7d3f3a09d14060b066f1029c3ac9252784ba4405da382ead7e2", + "rule_id": "CF-DART-003", + "path": "test/screens/player_screen_test.dart", + "symbol": null, + "context_hash": null, + "source_context": "test", + "disposition": "false_positive", + "reason": "Finding is in test, fixture, generated, vendor, or build-only content.", + "first_seen": "2026-10-03T15:27:55.5082272+08:00", + "last_reviewed": "2026-10-03T09:30:14.998539Z", + "rule_version": "0.2.1", + "lifecycle": "active" + }, + { + "fingerprint": "769fc56574713d4508ee7e2da1511e9fe6e348ce564b1346c2b5ed5db52a7ca5", + "rule_id": "CF-DART-003", + "path": "test/screens/player_screen_test.dart", + "symbol": null, + "context_hash": null, + "source_context": "test", + "disposition": "false_positive", + "reason": "Finding is in test, fixture, generated, vendor, or build-only content.", + "first_seen": "2026-10-03T15:27:55.5084385+08:00", + "last_reviewed": "2026-10-03T09:30:14.998539Z", + "rule_version": "0.2.1", + "lifecycle": "active" + }, + { + "fingerprint": "2dbc8be8b762ad9e5c0ec273b7df6bb4d82b00aedf2a09f85f080036ea8b9a4e", + "rule_id": "CF-DART-003", + "path": "test/screens/retell_player_screen_test.dart", + "symbol": null, + "context_hash": null, + "source_context": "test", + "disposition": "false_positive", + "reason": "Finding is in test, fixture, generated, vendor, or build-only content.", + "first_seen": "2026-10-03T15:27:55.5086585+08:00", + "last_reviewed": "2026-10-03T09:30:14.998539Z", + "rule_version": "0.2.1", + "lifecycle": "active" + }, + { + "fingerprint": "17246044943abb8424b28471257114f3b76b9f454576d774ce9934641aba2e67", + "rule_id": "CF-DART-003", + "path": "test/screens/review_difficult_practice_screen_test.dart", + "symbol": null, + "context_hash": null, + "source_context": "test", + "disposition": "false_positive", + "reason": "Finding is in test, fixture, generated, vendor, or build-only content.", + "first_seen": "2026-10-03T15:27:55.5088745+08:00", + "last_reviewed": "2026-10-03T09:30:14.998539Z", + "rule_version": "0.2.1", + "lifecycle": "active" + }, + { + "fingerprint": "7637bdc80472ad58baaae7bbefeae6d8c79a73d38f3d5fd15d8ba11d1af84bfb", + "rule_id": "CF-DART-003", + "path": "test/services/background_file_download_service_test.dart", + "symbol": null, + "context_hash": null, + "source_context": "test", + "disposition": "false_positive", + "reason": "Finding is in test, fixture, generated, vendor, or build-only content.", + "first_seen": "2026-10-03T15:27:55.5090794+08:00", + "last_reviewed": "2026-10-03T09:30:14.998539Z", + "rule_version": "0.2.1", + "lifecycle": "active" + }, + { + "fingerprint": "e4552b78ddc1f93664e7266b9dc1c77d16ea0341524ec3cf18ef913a89584efb", + "rule_id": "CF-DART-003", + "path": "test/services/background_file_download_service_test.dart", + "symbol": null, + "context_hash": null, + "source_context": "test", + "disposition": "false_positive", + "reason": "Finding is in test, fixture, generated, vendor, or build-only content.", + "first_seen": "2026-10-03T15:27:55.5092892+08:00", + "last_reviewed": "2026-10-03T09:30:14.998539Z", + "rule_version": "0.2.1", + "lifecycle": "active" + }, + { + "fingerprint": "17a1d3e45e063bab1ad120a24b2de783d252f656341d37b3d145f42879e826ba", + "rule_id": "CF-DART-003", + "path": "test/services/background_file_download_service_test.dart", + "symbol": null, + "context_hash": null, + "source_context": "test", + "disposition": "false_positive", + "reason": "Finding is in test, fixture, generated, vendor, or build-only content.", + "first_seen": "2026-10-03T15:27:55.5094914+08:00", + "last_reviewed": "2026-10-03T09:30:14.998539Z", + "rule_version": "0.2.1", + "lifecycle": "active" + }, + { + "fingerprint": "e9f1792c782c7655a8be1d2f7033cb2b8204d95f4fffe6c3119677cf7f96e6c5", + "rule_id": "CF-DART-003", + "path": "test/services/background_file_download_service_test.dart", + "symbol": null, + "context_hash": null, + "source_context": "test", + "disposition": "false_positive", + "reason": "Finding is in test, fixture, generated, vendor, or build-only content.", + "first_seen": "2026-10-03T15:27:55.5097258+08:00", + "last_reviewed": "2026-10-03T09:30:14.998539Z", + "rule_version": "0.2.1", + "lifecycle": "active" + }, + { + "fingerprint": "3d265b0a07c4cf3c1e666829b5f7714c4dacd823b24855cc49a4a154fd4d0b71", + "rule_id": "CF-DART-003", + "path": "test/services/background_file_download_service_test.dart", + "symbol": null, + "context_hash": null, + "source_context": "test", + "disposition": "false_positive", + "reason": "Finding is in test, fixture, generated, vendor, or build-only content.", + "first_seen": "2026-10-03T15:27:55.5099511+08:00", + "last_reviewed": "2026-10-03T09:30:14.998539Z", + "rule_version": "0.2.1", + "lifecycle": "active" + }, + { + "fingerprint": "c5399287e4a6ec7a06828ff43a9b695dd4897df1a316ed5a7a6422b9b08cdccd", + "rule_id": "CF-DART-003", + "path": "test/services/media_kit_player_backend_test.dart", + "symbol": null, + "context_hash": null, + "source_context": "test", + "disposition": "false_positive", + "reason": "Finding is in test, fixture, generated, vendor, or build-only content.", + "first_seen": "2026-10-03T15:27:55.5101697+08:00", + "last_reviewed": "2026-10-03T09:30:14.998539Z", + "rule_version": "0.2.1", + "lifecycle": "active" + }, + { + "fingerprint": "b139a1b84dbec423cad84a1b07e98683f05d38c0ab8f84eb51f37fef0a238416", + "rule_id": "CF-DART-003", + "path": "test/widgets/app_update_dialog_test.dart", + "symbol": null, + "context_hash": null, + "source_context": "test", + "disposition": "false_positive", + "reason": "Finding is in test, fixture, generated, vendor, or build-only content.", + "first_seen": "2026-10-03T15:27:55.5103841+08:00", + "last_reviewed": "2026-10-03T09:30:14.998539Z", + "rule_version": "0.2.1", + "lifecycle": "active" + }, + { + "fingerprint": "6ffb4a8f8ea8c89db9212b32d674c36bf29d0638268b4a2d1a117c86fef2eda0", + "rule_id": "CF-DART-003", + "path": "test/widgets/app_update_dialog_test.dart", + "symbol": null, + "context_hash": null, + "source_context": "test", + "disposition": "false_positive", + "reason": "Finding is in test, fixture, generated, vendor, or build-only content.", + "first_seen": "2026-10-03T15:27:55.5106102+08:00", + "last_reviewed": "2026-10-03T09:30:14.998539Z", + "rule_version": "0.2.1", + "lifecycle": "active" + }, + { + "fingerprint": "fc275df6bf1d426070088f5249c2eaec440a48d2e351162f6b5e35afd2cc36d9", + "rule_id": "CF-DART-003", + "path": "test/widgets/practice/selectable_sentence_text_test.dart", + "symbol": null, + "context_hash": null, + "source_context": "test", + "disposition": "false_positive", + "reason": "Finding is in test, fixture, generated, vendor, or build-only content.", + "first_seen": "2026-10-03T15:27:55.5108255+08:00", + "last_reviewed": "2026-10-03T09:30:14.998539Z", + "rule_version": "0.2.1", + "lifecycle": "active" + }, + { + "fingerprint": "c7d511e56cfd60dec3e4e8e90a3d0a7887d99dff1b09b9ecd8cb33895378952e", + "rule_id": "CF-DART-003", + "path": "test/widgets/practice/selectable_sentence_text_test.dart", + "symbol": null, + "context_hash": null, + "source_context": "test", + "disposition": "false_positive", + "reason": "Finding is in test, fixture, generated, vendor, or build-only content.", + "first_seen": "2026-10-03T15:27:55.5110513+08:00", + "last_reviewed": "2026-10-03T09:30:14.998539Z", + "rule_version": "0.2.1", + "lifecycle": "active" + }, + { + "fingerprint": "aec19dbff31a6b681aabab53fd18585154c0a22d022e277d3368d9a0ec22f0cb", + "rule_id": "CF-MD-001", + "path": "docs/ios-app-store-checklist.md", + "symbol": null, + "context_hash": null, + "source_context": "production", + "disposition": "human_review", + "reason": "Tool gap (not a source finding): New adapter heuristic requires native tool or human review before auto-apply.", + "first_seen": "2026-10-03T15:27:53.7798027+08:00", + "last_reviewed": "2026-10-03T09:30:14.998539Z", + "rule_version": "0.2.1", + "lifecycle": "active" + }, + { + "fingerprint": "4347d25438121605f1dd558515ee19da31b6efd10057879f532dc4a0239d11a2", + "rule_id": "CF-MD-001", + "path": "docs/ios-app-store-checklist.md", + "symbol": null, + "context_hash": null, + "source_context": "production", + "disposition": "human_review", + "reason": "Tool gap (not a source finding): New adapter heuristic requires native tool or human review before auto-apply.", + "first_seen": "2026-10-03T15:27:53.7841437+08:00", + "last_reviewed": "2026-10-03T09:30:14.998539Z", + "rule_version": "0.2.1", + "lifecycle": "active" + }, + { + "fingerprint": "028db2b3badd76c1d77ba1ee85d69dc7729d32277cd4206d1d0daedc8652a687", + "rule_id": "CF-MD-001", + "path": "docs/ios-release-publish.md", + "symbol": null, + "context_hash": null, + "source_context": "production", + "disposition": "human_review", + "reason": "Tool gap (not a source finding): New adapter heuristic requires native tool or human review before auto-apply.", + "first_seen": "2026-10-03T15:27:53.8054464+08:00", + "last_reviewed": "2026-10-03T09:30:14.998539Z", + "rule_version": "0.2.1", + "lifecycle": "active" + }, + { + "fingerprint": "c5856bec59917704d27dae03884e45cb0d58cc3e0011593917d49386623bbb35", + "rule_id": "CF-MD-001", + "path": "docs/ios-release-publish.md", + "symbol": null, + "context_hash": null, + "source_context": "production", + "disposition": "human_review", + "reason": "Tool gap (not a source finding): New adapter heuristic requires native tool or human review before auto-apply.", + "first_seen": "2026-10-03T15:27:53.8198489+08:00", + "last_reviewed": "2026-10-03T09:30:14.998539Z", + "rule_version": "0.2.1", + "lifecycle": "active" + }, + { + "fingerprint": "7f3fa1838264a94f90837cc3cbb1a790fdd2e6aef99e807c8c67378bbf1aede4", + "rule_id": "CF-MD-001", + "path": "docs/ios-universal-links.md", + "symbol": null, + "context_hash": null, + "source_context": "production", + "disposition": "human_review", + "reason": "Tool gap (not a source finding): New adapter heuristic requires native tool or human review before auto-apply.", + "first_seen": "2026-10-03T15:27:53.8383815+08:00", + "last_reviewed": "2026-10-03T09:30:14.998539Z", + "rule_version": "0.2.1", + "lifecycle": "active" + }, + { + "fingerprint": "f0c33c4debd734e567336be0291ae6776580c0be991b3314a2643db0141cf402", + "rule_id": "CF-MD-001", + "path": "docs/tasks-archive/tasks-2026-07-12-full.md", + "symbol": null, + "context_hash": null, + "source_context": "production", + "disposition": "human_review", + "reason": "Tool gap (not a source finding): New adapter heuristic requires native tool or human review before auto-apply.", + "first_seen": "2026-10-03T15:27:53.9265767+08:00", + "last_reviewed": "2026-10-03T09:30:14.998539Z", + "rule_version": "0.2.1", + "lifecycle": "active" + }, + { + "fingerprint": "076f208dbaf9ce686ad135b88a2b8603c3015caabb2a01483d92dd20d0aacb65", + "rule_id": "CF-MD-001", + "path": "docs/tasks-archive/tasks-2026-07-12-full.md", + "symbol": null, + "context_hash": null, + "source_context": "production", + "disposition": "human_review", + "reason": "Tool gap (not a source finding): New adapter heuristic requires native tool or human review before auto-apply.", + "first_seen": "2026-10-03T15:27:53.9668263+08:00", + "last_reviewed": "2026-10-03T09:30:14.998539Z", + "rule_version": "0.2.1", + "lifecycle": "active" + }, + { + "fingerprint": "ac510c727735ff455f4c4122d0e0a93e749bf8b4a8bc3e4432f7c1509b7c71c4", + "rule_id": "CF-MD-001", + "path": "docs/tasks-archive/tasks-2026-07-12-full.md", + "symbol": null, + "context_hash": null, + "source_context": "production", + "disposition": "human_review", + "reason": "Tool gap (not a source finding): New adapter heuristic requires native tool or human review before auto-apply.", + "first_seen": "2026-10-03T15:27:54.0089316+08:00", + "last_reviewed": "2026-10-03T09:30:14.998539Z", + "rule_version": "0.2.1", + "lifecycle": "active" + }, + { + "fingerprint": "53d62289924ff908ecc2aa75a8038b6a030898d6ad6d237f3c0a0b21a786ecd0", + "rule_id": "CF-MD-001", + "path": "docs/tasks-archive/tasks-2026-07-12-full.md", + "symbol": null, + "context_hash": null, + "source_context": "production", + "disposition": "human_review", + "reason": "Tool gap (not a source finding): New adapter heuristic requires native tool or human review before auto-apply.", + "first_seen": "2026-10-03T15:27:54.0513278+08:00", + "last_reviewed": "2026-10-03T09:30:14.998539Z", + "rule_version": "0.2.1", + "lifecycle": "active" + }, + { + "fingerprint": "55a6fa50e3ee5b67fc34ff60d32ef52481ef1ce30bf001bba4d8f636a33adece", + "rule_id": "CF-MD-001", + "path": "docs/tasks-archive/tasks-2026-07-28-full.md", + "symbol": null, + "context_hash": null, + "source_context": "production", + "disposition": "human_review", + "reason": "Tool gap (not a source finding): New adapter heuristic requires native tool or human review before auto-apply.", + "first_seen": "2026-10-03T15:27:54.0701728+08:00", + "last_reviewed": "2026-10-03T09:30:14.998539Z", + "rule_version": "0.2.1", + "lifecycle": "active" + }, + { + "fingerprint": "a3bd6981c09cbc6ff7d85716c93cd8fbecc848b47edbcc092b0969f5a93197a1", + "rule_id": "CF-MD-001", + "path": "docs/tasks-archive/tasks-2026-07-28-full.md", + "symbol": null, + "context_hash": null, + "source_context": "production", + "disposition": "human_review", + "reason": "Tool gap (not a source finding): New adapter heuristic requires native tool or human review before auto-apply.", + "first_seen": "2026-10-03T15:27:54.0785187+08:00", + "last_reviewed": "2026-10-03T09:30:14.998539Z", + "rule_version": "0.2.1", + "lifecycle": "active" + }, + { + "fingerprint": "1601d165248a9fddf985ada3155d79b83255463ab87a4cc0813d767be787cacb", + "rule_id": "CF-MD-001", + "path": "docs/tasks-archive/tasks-2026-07-28-full.md", + "symbol": null, + "context_hash": null, + "source_context": "production", + "disposition": "human_review", + "reason": "Tool gap (not a source finding): New adapter heuristic requires native tool or human review before auto-apply.", + "first_seen": "2026-10-03T15:27:54.0895502+08:00", + "last_reviewed": "2026-10-03T09:30:14.998539Z", + "rule_version": "0.2.1", + "lifecycle": "active" + }, + { + "fingerprint": "20225a7b6f767489a138aab6dcf9e9581569b12dc8df347f513ad528830cc8d9", + "rule_id": "CF-MD-001", + "path": "docs/tasks-archive/tasks-2026-07-28-full.md", + "symbol": null, + "context_hash": null, + "source_context": "production", + "disposition": "human_review", + "reason": "Tool gap (not a source finding): New adapter heuristic requires native tool or human review before auto-apply.", + "first_seen": "2026-10-03T15:27:54.0974983+08:00", + "last_reviewed": "2026-10-03T09:30:14.998539Z", + "rule_version": "0.2.1", + "lifecycle": "active" + }, + { + "fingerprint": "b3401c428b5407e368ec05a23e2edcc7089b3e87a1520e524eb6af88267367fd", + "rule_id": "CF-MD-001", + "path": "docs/tasks-archive/tasks-2026-07-28-full.md", + "symbol": null, + "context_hash": null, + "source_context": "production", + "disposition": "human_review", + "reason": "Tool gap (not a source finding): New adapter heuristic requires native tool or human review before auto-apply.", + "first_seen": "2026-10-03T15:27:54.1053570+08:00", + "last_reviewed": "2026-10-03T09:30:14.998539Z", + "rule_version": "0.2.1", + "lifecycle": "active" + }, + { + "fingerprint": "5b8248e58899ca6120d32970125e452e26cb4611cf17c14a09219ced54448dfc", + "rule_id": "CF-MD-001", + "path": "docs/tasks-archive/tasks-2026-07-28-full.md", + "symbol": null, + "context_hash": null, + "source_context": "production", + "disposition": "human_review", + "reason": "Tool gap (not a source finding): New adapter heuristic requires native tool or human review before auto-apply.", + "first_seen": "2026-10-03T15:27:54.1131221+08:00", + "last_reviewed": "2026-10-03T09:30:14.998539Z", + "rule_version": "0.2.1", + "lifecycle": "active" + }, + { + "fingerprint": "dbfca66bec32f5b3f9859a5da899ef3a7e24d141932fdba61bb97a43b7bd2bb4", + "rule_id": "CF-MD-001", + "path": "docs/tasks-archive/tasks-2026-07-28-full.md", + "symbol": null, + "context_hash": null, + "source_context": "production", + "disposition": "human_review", + "reason": "Tool gap (not a source finding): New adapter heuristic requires native tool or human review before auto-apply.", + "first_seen": "2026-10-03T15:27:54.1207967+08:00", + "last_reviewed": "2026-10-03T09:30:14.998539Z", + "rule_version": "0.2.1", + "lifecycle": "active" + }, + { + "fingerprint": "2f3e29d4c53d14bb1a5e4ba579123a52e06cea61f37891b066aa80232ab578a2", + "rule_id": "CF-MD-001", + "path": "docs/tasks-archive/tasks-2026-07-28-full.md", + "symbol": null, + "context_hash": null, + "source_context": "production", + "disposition": "human_review", + "reason": "Tool gap (not a source finding): New adapter heuristic requires native tool or human review before auto-apply.", + "first_seen": "2026-10-03T15:27:54.1287242+08:00", + "last_reviewed": "2026-10-03T09:30:14.998539Z", + "rule_version": "0.2.1", + "lifecycle": "active" + }, + { + "fingerprint": "c50c8639316518a38619c48c34f10ff8c35201f50f497bd03b6db52191a73362", + "rule_id": "CF-MD-001", + "path": "docs/tasks-archive/tasks-2026-08-28-full.md", + "symbol": null, + "context_hash": null, + "source_context": "production", + "disposition": "human_review", + "reason": "Tool gap (not a source finding): New adapter heuristic requires native tool or human review before auto-apply.", + "first_seen": "2026-10-03T15:27:54.1699989+08:00", + "last_reviewed": "2026-10-03T09:30:14.998539Z", + "rule_version": "0.2.1", + "lifecycle": "active" + }, + { + "fingerprint": "566cb180a07a78b9d7f455ed3bc85e14a61622ede1d650f4f9cbf945b67a59c6", + "rule_id": "CF-MD-001", + "path": "docs/tasks-archive/tasks-2026-08-28-full.md", + "symbol": null, + "context_hash": null, + "source_context": "production", + "disposition": "human_review", + "reason": "Tool gap (not a source finding): New adapter heuristic requires native tool or human review before auto-apply.", + "first_seen": "2026-10-03T15:27:54.2002440+08:00", + "last_reviewed": "2026-10-03T09:30:14.998539Z", + "rule_version": "0.2.1", + "lifecycle": "active" + }, + { + "fingerprint": "da80524a7e6955ce370ca8700fd76e6af1483282fa2f637f906d7f35bf4315ff", + "rule_id": "CF-MD-001", + "path": "docs/tasks-archive/tasks-2026-08-28-full.md", + "symbol": null, + "context_hash": null, + "source_context": "production", + "disposition": "human_review", + "reason": "Tool gap (not a source finding): New adapter heuristic requires native tool or human review before auto-apply.", + "first_seen": "2026-10-03T15:27:54.2300241+08:00", + "last_reviewed": "2026-10-03T09:30:14.998539Z", + "rule_version": "0.2.1", + "lifecycle": "active" + }, + { + "fingerprint": "27b1ab37b2e7d58c7ce7300af1c26c2d49519783de25d02fe0a5621482194fbe", + "rule_id": "CF-MD-001", + "path": "docs/tasks-archive/tasks-2026-08-28-full.md", + "symbol": null, + "context_hash": null, + "source_context": "production", + "disposition": "human_review", + "reason": "Tool gap (not a source finding): New adapter heuristic requires native tool or human review before auto-apply.", + "first_seen": "2026-10-03T15:27:54.2586410+08:00", + "last_reviewed": "2026-10-03T09:30:14.998539Z", + "rule_version": "0.2.1", + "lifecycle": "active" + }, + { + "fingerprint": "df405d8cc04872654a3e3d6eb3a6edd15655eecfce4752dd66b2975769c516ea", + "rule_id": "CF-MD-001", + "path": "docs/tasks-archive/tasks-2026-08-28-full.md", + "symbol": null, + "context_hash": null, + "source_context": "production", + "disposition": "human_review", + "reason": "Tool gap (not a source finding): New adapter heuristic requires native tool or human review before auto-apply.", + "first_seen": "2026-10-03T15:27:54.2986985+08:00", + "last_reviewed": "2026-10-03T09:30:14.998539Z", + "rule_version": "0.2.1", + "lifecycle": "active" + }, + { + "fingerprint": "5339f3d817b13e2c05a57e5714360dc2f4770335c1cca95bd3abf062f7b8a84d", + "rule_id": "CF-MD-001", + "path": "docs/tasks-archive/tasks-2026-08-28-full.md", + "symbol": null, + "context_hash": null, + "source_context": "production", + "disposition": "human_review", + "reason": "Tool gap (not a source finding): New adapter heuristic requires native tool or human review before auto-apply.", + "first_seen": "2026-10-03T15:27:54.3430895+08:00", + "last_reviewed": "2026-10-03T09:30:14.998539Z", + "rule_version": "0.2.1", + "lifecycle": "active" + }, + { + "fingerprint": "de1b6ffe0f6b3721a8b79a928f30912f2769a3723dae79437b133690edc372b2", + "rule_id": "CF-MD-001", + "path": "docs/tasks-archive/tasks-2026-08-28-full.md", + "symbol": null, + "context_hash": null, + "source_context": "production", + "disposition": "human_review", + "reason": "Tool gap (not a source finding): New adapter heuristic requires native tool or human review before auto-apply.", + "first_seen": "2026-10-03T15:27:54.3856803+08:00", + "last_reviewed": "2026-10-03T09:30:14.998539Z", + "rule_version": "0.2.1", + "lifecycle": "active" + }, + { + "fingerprint": "0a802ee043e73f4a81e8e4b87baab77c18d3287b60a840c11566bc573e2e8bce", + "rule_id": "CF-MD-001", + "path": "docs/tasks-archive/tasks-2026-08-28-full.md", + "symbol": null, + "context_hash": null, + "source_context": "production", + "disposition": "human_review", + "reason": "Tool gap (not a source finding): New adapter heuristic requires native tool or human review before auto-apply.", + "first_seen": "2026-10-03T15:27:54.4312010+08:00", + "last_reviewed": "2026-10-03T09:30:14.998539Z", + "rule_version": "0.2.1", + "lifecycle": "active" + }, + { + "fingerprint": "35772f18de4af1e786ad22d0f4eed6a233f1b3890e8d5f80eee6b732863cbed6", + "rule_id": "PARSE-001", + "path": "linux/runner/my_application.h", + "symbol": null, + "context_hash": null, + "source_context": "generated", + "disposition": "human_review", + "reason": "Tool gap (not a source finding): Parser flagged invalid fixture content or valid syntax outside the active tree-sitter grammar.", + "first_seen": "2026-10-03T15:27:53.4982442+08:00", + "last_reviewed": "2026-10-03T09:30:14.998539Z", + "rule_version": "0.2.1", + "lifecycle": "active" + }, + { + "fingerprint": "765d9b3e6580dd595eb931fb5864bcd6231b4fb173b9d1bf8f1376c30250017e", + "rule_id": "PARSE-001", + "path": "third_party/charset_converter/linux/include/charset_converter/charset_converter_plugin.h", + "symbol": null, + "context_hash": null, + "source_context": "vendor", + "disposition": "false_positive", + "reason": "Finding is in test, fixture, generated, vendor, or build-only content.", + "first_seen": "2026-10-03T15:27:53.5136172+08:00", + "last_reviewed": "2026-10-03T09:30:14.998539Z", + "rule_version": "0.2.1", + "lifecycle": "active" + }, + { + "fingerprint": "f32afed866912e52abb21efdcc3f48262867bcce9cedda17086b7fc6bf76add9", + "rule_id": "PARSE-001", + "path": "third_party/charset_converter/linux/include/charset_converter/charset_converter_plugin.h", + "symbol": null, + "context_hash": null, + "source_context": "vendor", + "disposition": "false_positive", + "reason": "Finding is in test, fixture, generated, vendor, or build-only content.", + "first_seen": "2026-10-03T15:27:53.5171403+08:00", + "last_reviewed": "2026-10-03T09:30:14.998539Z", + "rule_version": "0.2.1", + "lifecycle": "active" + }, + { + "fingerprint": "ba06865c1692759c5273e6aceddc3e661ee13fd84221d2c5425e48bc42b7f0d4", + "rule_id": "PARSE-001", + "path": "third_party/charset_converter/linux/include/charset_converter/charset_converter_plugin.h", + "symbol": null, + "context_hash": null, + "source_context": "vendor", + "disposition": "false_positive", + "reason": "Finding is in test, fixture, generated, vendor, or build-only content.", + "first_seen": "2026-10-03T15:27:53.5202744+08:00", + "last_reviewed": "2026-10-03T09:30:14.998539Z", + "rule_version": "0.2.1", + "lifecycle": "active" + }, + { + "fingerprint": "c814a69c038ec55c19fff398b14864f7e67cf4913aca8b59701dc11c81cd9331", + "rule_id": "PARSE-001", + "path": "third_party/charset_converter/linux/include/charset_converter/charset_converter_plugin.h", + "symbol": null, + "context_hash": null, + "source_context": "vendor", + "disposition": "false_positive", + "reason": "Finding is in test, fixture, generated, vendor, or build-only content.", + "first_seen": "2026-10-03T15:27:53.5231910+08:00", + "last_reviewed": "2026-10-03T09:30:14.998539Z", + "rule_version": "0.2.1", + "lifecycle": "active" + }, + { + "fingerprint": "e613025b7897369d8e210c7d884d3ee06841b19cf694c3e06c60c86156b6bd6d", + "rule_id": "PARSE-001", + "path": "third_party/charset_converter/windows/include/charset_converter/charset_converter_plugin.h", + "symbol": null, + "context_hash": null, + "source_context": "vendor", + "disposition": "false_positive", + "reason": "Finding is in test, fixture, generated, vendor, or build-only content.", + "first_seen": "2026-10-03T15:27:53.5388811+08:00", + "last_reviewed": "2026-10-03T09:30:14.998539Z", + "rule_version": "0.2.1", + "lifecycle": "active" + }, + { + "fingerprint": "e5ad4513cd8999dbd631e69b760a3e0baac08680b6310af465736a187dad388b", + "rule_id": "PARSE-001", + "path": "third_party/charset_converter/windows/include/charset_converter/charset_converter_plugin.h", + "symbol": null, + "context_hash": null, + "source_context": "vendor", + "disposition": "false_positive", + "reason": "Finding is in test, fixture, generated, vendor, or build-only content.", + "first_seen": "2026-10-03T15:27:53.5422513+08:00", + "last_reviewed": "2026-10-03T09:30:14.998539Z", + "rule_version": "0.2.1", + "lifecycle": "active" + }, + { + "fingerprint": "9e8bdebe7371a7fb8b610fc8f61d9218a599109e1e02066e97a8eda236e2da47", + "rule_id": "PARSE-001", + "path": "windows/flutter/generated_plugin_registrant.h", + "symbol": null, + "context_hash": null, + "source_context": "generated", + "disposition": "human_review", + "reason": "Tool gap (not a source finding): Parser flagged invalid fixture content or valid syntax outside the active tree-sitter grammar.", + "first_seen": "2026-10-03T15:27:53.5607366+08:00", + "last_reviewed": "2026-10-03T09:30:14.998539Z", + "rule_version": "0.2.1", + "lifecycle": "active" + }, + { + "fingerprint": "b567bb755a35adf4a14bf8d12f2b721300853c4dec1b240a32fe50fc64cfd783", + "rule_id": "PARSE-001", + "path": "windows/runner/flutter_window.h", + "symbol": null, + "context_hash": null, + "source_context": "generated", + "disposition": "human_review", + "reason": "Tool gap (not a source finding): Parser flagged invalid fixture content or valid syntax outside the active tree-sitter grammar.", + "first_seen": "2026-10-03T15:27:53.5801024+08:00", + "last_reviewed": "2026-10-03T09:30:14.998539Z", + "rule_version": "0.2.1", + "lifecycle": "active" + }, + { + "fingerprint": "c25d4884373b98bcc7ae84efb34ef0f515b61ae15ca03cd442fec2928ed1868f", + "rule_id": "PARSE-001", + "path": "windows/runner/flutter_window.h", + "symbol": null, + "context_hash": null, + "source_context": "generated", + "disposition": "human_review", + "reason": "Tool gap (not a source finding): Parser flagged invalid fixture content or valid syntax outside the active tree-sitter grammar.", + "first_seen": "2026-10-03T15:27:53.5834639+08:00", + "last_reviewed": "2026-10-03T09:30:14.998539Z", + "rule_version": "0.2.1", + "lifecycle": "active" + }, + { + "fingerprint": "7b2dda022619e2c823bcfbfcf0bc860122afd6fc443e978ca73865a8c0a6f38d", + "rule_id": "PARSE-001", + "path": "windows/runner/flutter_window.h", + "symbol": null, + "context_hash": null, + "source_context": "generated", + "disposition": "human_review", + "reason": "Tool gap (not a source finding): Parser flagged invalid fixture content or valid syntax outside the active tree-sitter grammar.", + "first_seen": "2026-10-03T15:27:53.5871107+08:00", + "last_reviewed": "2026-10-03T09:30:14.998539Z", + "rule_version": "0.2.1", + "lifecycle": "active" + }, + { + "fingerprint": "889abe4ebf6b03f64da8efd073cc885bb99818f7a8fe6c11d60bb54a4ec1d8e7", + "rule_id": "PARSE-001", + "path": "windows/runner/flutter_window.h", + "symbol": null, + "context_hash": null, + "source_context": "generated", + "disposition": "human_review", + "reason": "Tool gap (not a source finding): Parser flagged invalid fixture content or valid syntax outside the active tree-sitter grammar.", + "first_seen": "2026-10-03T15:27:53.5906808+08:00", + "last_reviewed": "2026-10-03T09:30:14.998539Z", + "rule_version": "0.2.1", + "lifecycle": "active" + }, + { + "fingerprint": "bdec5b9d43a774f80b2f090b979d283b81741f5f26519c60ff2c22df0cf42d2b", + "rule_id": "PARSE-001", + "path": "windows/runner/flutter_window.h", + "symbol": null, + "context_hash": null, + "source_context": "generated", + "disposition": "human_review", + "reason": "Tool gap (not a source finding): Parser flagged invalid fixture content or valid syntax outside the active tree-sitter grammar.", + "first_seen": "2026-10-03T15:27:53.5943500+08:00", + "last_reviewed": "2026-10-03T09:30:14.998539Z", + "rule_version": "0.2.1", + "lifecycle": "active" + }, + { + "fingerprint": "15bece7c85582fe233ce7547f0c9db5e6e5b53297706da0da8fd7f66f125389d", + "rule_id": "PARSE-001", + "path": "windows/runner/flutter_window.h", + "symbol": null, + "context_hash": null, + "source_context": "generated", + "disposition": "human_review", + "reason": "Tool gap (not a source finding): Parser flagged invalid fixture content or valid syntax outside the active tree-sitter grammar.", + "first_seen": "2026-10-03T15:27:53.5978858+08:00", + "last_reviewed": "2026-10-03T09:30:14.998539Z", + "rule_version": "0.2.1", + "lifecycle": "active" + }, + { + "fingerprint": "4478fc8e1f7d87d892ab77a386ae92e9f16f418233772e24d2c12f256b4fe986", + "rule_id": "PARSE-001", + "path": "windows/runner/flutter_window.h", + "symbol": null, + "context_hash": null, + "source_context": "generated", + "disposition": "human_review", + "reason": "Tool gap (not a source finding): Parser flagged invalid fixture content or valid syntax outside the active tree-sitter grammar.", + "first_seen": "2026-10-03T15:27:53.6001811+08:00", + "last_reviewed": "2026-10-03T09:30:14.998539Z", + "rule_version": "0.2.1", + "lifecycle": "active" + }, + { + "fingerprint": "e9913b72b090fe457fba3e1f238e03080de0784b4744d50fadfe3a154f95075c", + "rule_id": "PARSE-001", + "path": "windows/runner/flutter_window.h", + "symbol": null, + "context_hash": null, + "source_context": "generated", + "disposition": "human_review", + "reason": "Tool gap (not a source finding): Parser flagged invalid fixture content or valid syntax outside the active tree-sitter grammar.", + "first_seen": "2026-10-03T15:27:53.6028413+08:00", + "last_reviewed": "2026-10-03T09:30:14.998539Z", + "rule_version": "0.2.1", + "lifecycle": "active" + }, + { + "fingerprint": "5fe13d053dff8a24f45980d690340ec225f3a435a36a3b6229e2e7b00cb1fa94", + "rule_id": "PARSE-001", + "path": "windows/runner/utils.h", + "symbol": null, + "context_hash": null, + "source_context": "generated", + "disposition": "human_review", + "reason": "Tool gap (not a source finding): Parser flagged invalid fixture content or valid syntax outside the active tree-sitter grammar.", + "first_seen": "2026-10-03T15:27:53.6257761+08:00", + "last_reviewed": "2026-10-03T09:30:14.998539Z", + "rule_version": "0.2.1", + "lifecycle": "active" + }, + { + "fingerprint": "5b9d933eac52c50d8ff46749d1a5452a0279252a37c2b35058fbd8a3db2afa19", + "rule_id": "PARSE-001", + "path": "windows/runner/utils.h", + "symbol": null, + "context_hash": null, + "source_context": "generated", + "disposition": "human_review", + "reason": "Tool gap (not a source finding): Parser flagged invalid fixture content or valid syntax outside the active tree-sitter grammar.", + "first_seen": "2026-10-03T15:27:53.6284692+08:00", + "last_reviewed": "2026-10-03T09:30:14.998539Z", + "rule_version": "0.2.1", + "lifecycle": "active" + }, + { + "fingerprint": "93ce6ea81deb6e930d18d4cd80c9bea840759da26ccaf92a4544edb9baec60dc", + "rule_id": "PARSE-001", + "path": "windows/runner/win32_window.h", + "symbol": null, + "context_hash": null, + "source_context": "generated", + "disposition": "human_review", + "reason": "Tool gap (not a source finding): Parser flagged invalid fixture content or valid syntax outside the active tree-sitter grammar.", + "first_seen": "2026-10-03T15:27:53.6482712+08:00", + "last_reviewed": "2026-10-03T09:30:14.998539Z", + "rule_version": "0.2.1", + "lifecycle": "active" + }, + { + "fingerprint": "954788fddca45394b4fdc931f86b2127136eb04128d6a42e4afc09823cfe54cf", + "rule_id": "PARSE-001", + "path": "windows/runner/win32_window.h", + "symbol": null, + "context_hash": null, + "source_context": "generated", + "disposition": "human_review", + "reason": "Tool gap (not a source finding): Parser flagged invalid fixture content or valid syntax outside the active tree-sitter grammar.", + "first_seen": "2026-10-03T15:27:53.6528366+08:00", + "last_reviewed": "2026-10-03T09:30:14.998539Z", + "rule_version": "0.2.1", + "lifecycle": "active" + }, + { + "fingerprint": "c4f1146a9f55766cb24a4f83d782b690ff211a341f3573888f3a41e55b0f3ede", + "rule_id": "PARSE-001", + "path": "windows/runner/win32_window.h", + "symbol": null, + "context_hash": null, + "source_context": "generated", + "disposition": "human_review", + "reason": "Tool gap (not a source finding): Parser flagged invalid fixture content or valid syntax outside the active tree-sitter grammar.", + "first_seen": "2026-10-03T15:27:53.6581563+08:00", + "last_reviewed": "2026-10-03T09:30:14.998539Z", + "rule_version": "0.2.1", + "lifecycle": "active" + }, + { + "fingerprint": "1cac0eb9bb83809daceb469abf9f6e5b40c4bee080388f7c3f84a5f3a0e36499", + "rule_id": "PARSE-001", + "path": "windows/runner/win32_window.h", + "symbol": null, + "context_hash": null, + "source_context": "generated", + "disposition": "human_review", + "reason": "Tool gap (not a source finding): Parser flagged invalid fixture content or valid syntax outside the active tree-sitter grammar.", + "first_seen": "2026-10-03T15:27:53.6637277+08:00", + "last_reviewed": "2026-10-03T09:30:14.998539Z", + "rule_version": "0.2.1", + "lifecycle": "active" + }, + { + "fingerprint": "1cac0eb9bb83809daceb469abf9f6e5b40c4bee080388f7c3f84a5f3a0e36499", + "rule_id": "PARSE-001", + "path": "windows/runner/win32_window.h", + "symbol": null, + "context_hash": null, + "source_context": "generated", + "disposition": "human_review", + "reason": "Tool gap (not a source finding): Parser flagged invalid fixture content or valid syntax outside the active tree-sitter grammar.", + "first_seen": "2026-10-03T15:27:53.6680635+08:00", + "last_reviewed": "2026-10-03T09:30:14.998539Z", + "rule_version": "0.2.1", + "lifecycle": "active" + }, + { + "fingerprint": "303d2632a2e5ae6eee894d56eafb2708c730ef257fea496f0d31934840946e8b", + "rule_id": "PARSE-001", + "path": "windows/runner/win32_window.h", + "symbol": null, + "context_hash": null, + "source_context": "generated", + "disposition": "human_review", + "reason": "Tool gap (not a source finding): Parser flagged invalid fixture content or valid syntax outside the active tree-sitter grammar.", + "first_seen": "2026-10-03T15:27:53.6736843+08:00", + "last_reviewed": "2026-10-03T09:30:14.998539Z", + "rule_version": "0.2.1", + "lifecycle": "active" + }, + { + "fingerprint": "3486c70247af5be8387b6f2896804011d08398040f77a1ad6838580785a75661", + "rule_id": "PARSE-001", + "path": "windows/runner/win32_window.h", + "symbol": null, + "context_hash": null, + "source_context": "generated", + "disposition": "human_review", + "reason": "Tool gap (not a source finding): Parser flagged invalid fixture content or valid syntax outside the active tree-sitter grammar.", + "first_seen": "2026-10-03T15:27:53.6770951+08:00", + "last_reviewed": "2026-10-03T09:30:14.998539Z", + "rule_version": "0.2.1", + "lifecycle": "active" + }, + { + "fingerprint": "7ff88332bf058b848ba290dc9cd1e22adc20020df1b5f083c4e48e450ee5adb3", + "rule_id": "PARSE-001", + "path": "windows/runner/win32_window.h", + "symbol": null, + "context_hash": null, + "source_context": "generated", + "disposition": "human_review", + "reason": "Tool gap (not a source finding): Parser flagged invalid fixture content or valid syntax outside the active tree-sitter grammar.", + "first_seen": "2026-10-03T15:27:53.6806772+08:00", + "last_reviewed": "2026-10-03T09:30:14.998539Z", + "rule_version": "0.2.1", + "lifecycle": "active" + }, + { + "fingerprint": "520f3995881545faa67f05bf46b4bfdb7bf0bea3357ce30599887f38b7337a53", + "rule_id": "PARSE-001", + "path": "windows/runner/win32_window.h", + "symbol": null, + "context_hash": null, + "source_context": "generated", + "disposition": "human_review", + "reason": "Tool gap (not a source finding): Parser flagged invalid fixture content or valid syntax outside the active tree-sitter grammar.", + "first_seen": "2026-10-03T15:27:53.6838325+08:00", + "last_reviewed": "2026-10-03T09:30:14.998539Z", + "rule_version": "0.2.1", + "lifecycle": "active" + }, + { + "fingerprint": "eae2bd59549ca34d0d1ef40960698b3c0c3f8fbd5f33bb263f87a75395cd33c9", + "rule_id": "PARSE-001", + "path": "windows/runner/win32_window.h", + "symbol": null, + "context_hash": null, + "source_context": "generated", + "disposition": "human_review", + "reason": "Tool gap (not a source finding): Parser flagged invalid fixture content or valid syntax outside the active tree-sitter grammar.", + "first_seen": "2026-10-03T15:27:53.6871284+08:00", + "last_reviewed": "2026-10-03T09:30:14.998539Z", + "rule_version": "0.2.1", + "lifecycle": "active" + }, + { + "fingerprint": "6d4db5b136ef518d2fc844221aa6a7cb9acfc787f4cdb7546d9cc2d9c64c169b", + "rule_id": "PARSE-001", + "path": "windows/runner/win32_window.h", + "symbol": null, + "context_hash": null, + "source_context": "generated", + "disposition": "human_review", + "reason": "Tool gap (not a source finding): Parser flagged invalid fixture content or valid syntax outside the active tree-sitter grammar.", + "first_seen": "2026-10-03T15:27:53.6906171+08:00", + "last_reviewed": "2026-10-03T09:30:14.998539Z", + "rule_version": "0.2.1", + "lifecycle": "active" + }, + { + "fingerprint": "050cc84481af19291500d867e6ff2846fe5bf48605a6a7c6e59baf49c6557863", + "rule_id": "PARSE-001", + "path": "windows/runner/win32_window.h", + "symbol": null, + "context_hash": null, + "source_context": "generated", + "disposition": "human_review", + "reason": "Tool gap (not a source finding): Parser flagged invalid fixture content or valid syntax outside the active tree-sitter grammar.", + "first_seen": "2026-10-03T15:27:53.6938171+08:00", + "last_reviewed": "2026-10-03T09:30:14.998539Z", + "rule_version": "0.2.1", + "lifecycle": "active" + }, + { + "fingerprint": "050cc84481af19291500d867e6ff2846fe5bf48605a6a7c6e59baf49c6557863", + "rule_id": "PARSE-001", + "path": "windows/runner/win32_window.h", + "symbol": null, + "context_hash": null, + "source_context": "generated", + "disposition": "human_review", + "reason": "Tool gap (not a source finding): Parser flagged invalid fixture content or valid syntax outside the active tree-sitter grammar.", + "first_seen": "2026-10-03T15:27:53.6979079+08:00", + "last_reviewed": "2026-10-03T09:30:14.998539Z", + "rule_version": "0.2.1", + "lifecycle": "active" + }, + { + "fingerprint": "050cc84481af19291500d867e6ff2846fe5bf48605a6a7c6e59baf49c6557863", + "rule_id": "PARSE-001", + "path": "windows/runner/win32_window.h", + "symbol": null, + "context_hash": null, + "source_context": "generated", + "disposition": "human_review", + "reason": "Tool gap (not a source finding): Parser flagged invalid fixture content or valid syntax outside the active tree-sitter grammar.", + "first_seen": "2026-10-03T15:27:53.7011144+08:00", + "last_reviewed": "2026-10-03T09:30:14.998539Z", + "rule_version": "0.2.1", + "lifecycle": "active" + }, + { + "fingerprint": "d6998dff7f961d29f7a491fa1516f5e65b154e532a7a91365d289d01f206c912", + "rule_id": "PARSE-001", + "path": "windows/runner/win32_window.h", + "symbol": null, + "context_hash": null, + "source_context": "generated", + "disposition": "human_review", + "reason": "Tool gap (not a source finding): Parser flagged invalid fixture content or valid syntax outside the active tree-sitter grammar.", + "first_seen": "2026-10-03T15:27:53.7151039+08:00", + "last_reviewed": "2026-10-03T09:30:14.998539Z", + "rule_version": "0.2.1", + "lifecycle": "active" + }, + { + "fingerprint": "2767c2431c0e5fde8e214d1c70907832d49e333ed1ea7104534d709673316436", + "rule_id": "PARSE-001", + "path": "windows/runner/win32_window.h", + "symbol": null, + "context_hash": null, + "source_context": "generated", + "disposition": "human_review", + "reason": "Tool gap (not a source finding): Parser flagged invalid fixture content or valid syntax outside the active tree-sitter grammar.", + "first_seen": "2026-10-03T15:27:53.7185082+08:00", + "last_reviewed": "2026-10-03T09:30:14.998539Z", + "rule_version": "0.2.1", + "lifecycle": "active" + }, + { + "fingerprint": "3dc36b7b5d78e600629df9fe62b6868f4b91442ff4334b966da6dd3b751fe386", + "rule_id": "PARSE-001", + "path": "windows/runner/win32_window.h", + "symbol": null, + "context_hash": null, + "source_context": "generated", + "disposition": "human_review", + "reason": "Tool gap (not a source finding): Parser flagged invalid fixture content or valid syntax outside the active tree-sitter grammar.", + "first_seen": "2026-10-03T15:27:53.7218737+08:00", + "last_reviewed": "2026-10-03T09:30:14.998539Z", + "rule_version": "0.2.1", + "lifecycle": "active" + }, + { + "fingerprint": "3ca66571246c5bfa4e75ea6d68722d875cf80fa9e3847bad682fab7098f3fab0", + "rule_id": "PARSE-001", + "path": "windows/runner/win32_window.h", + "symbol": null, + "context_hash": null, + "source_context": "generated", + "disposition": "human_review", + "reason": "Tool gap (not a source finding): Parser flagged invalid fixture content or valid syntax outside the active tree-sitter grammar.", + "first_seen": "2026-10-03T15:27:53.7265778+08:00", + "last_reviewed": "2026-10-03T09:30:14.998539Z", + "rule_version": "0.2.1", + "lifecycle": "active" + }, + { + "fingerprint": "d18c8e2af43da4ca907e3c107224d1ff35b55b957fa9cfde037300428d6c067c", + "rule_id": "PARSE-001", + "path": "windows/runner/win32_window.h", + "symbol": null, + "context_hash": null, + "source_context": "generated", + "disposition": "human_review", + "reason": "Tool gap (not a source finding): Parser flagged invalid fixture content or valid syntax outside the active tree-sitter grammar.", + "first_seen": "2026-10-03T15:27:53.7315061+08:00", + "last_reviewed": "2026-10-03T09:30:14.998539Z", + "rule_version": "0.2.1", + "lifecycle": "active" + }, + { + "fingerprint": "f7a28e67506eee624d077e3d69adbe330e70a49896a980f6dc1030fb3a36a621", + "rule_id": "PARSE-001", + "path": "windows/runner/win32_window.h", + "symbol": null, + "context_hash": null, + "source_context": "generated", + "disposition": "human_review", + "reason": "Tool gap (not a source finding): Parser flagged invalid fixture content or valid syntax outside the active tree-sitter grammar.", + "first_seen": "2026-10-03T15:27:53.7367018+08:00", + "last_reviewed": "2026-10-03T09:30:14.998539Z", + "rule_version": "0.2.1", + "lifecycle": "active" + }, + { + "fingerprint": "841baee48434c689c808cc17e2636fc70782b9aa120a8c1a21f537396dd211b2", + "rule_id": "PARSE-001", + "path": "windows/runner/win32_window.h", + "symbol": null, + "context_hash": null, + "source_context": "generated", + "disposition": "human_review", + "reason": "Tool gap (not a source finding): Parser flagged invalid fixture content or valid syntax outside the active tree-sitter grammar.", + "first_seen": "2026-10-03T15:27:53.7416416+08:00", + "last_reviewed": "2026-10-03T09:30:14.998539Z", + "rule_version": "0.2.1", + "lifecycle": "active" + }, + { + "fingerprint": "be5be8a672b4c286df288f7f59ef3129bd3d43f3ca55b710118af6dcd9200901", + "rule_id": "PARSE-001", + "path": "windows/runner/win32_window.h", + "symbol": null, + "context_hash": null, + "source_context": "generated", + "disposition": "human_review", + "reason": "Tool gap (not a source finding): Parser flagged invalid fixture content or valid syntax outside the active tree-sitter grammar.", + "first_seen": "2026-10-03T15:27:53.7464583+08:00", + "last_reviewed": "2026-10-03T09:30:14.998539Z", + "rule_version": "0.2.1", + "lifecycle": "active" + }, + { + "fingerprint": "da0a3c101e25098cf2b4dd25266f829a9497ec8f5997bf1bdfa51688136d8313", + "rule_id": "PARSE-001", + "path": "windows/runner/win32_window.h", + "symbol": null, + "context_hash": null, + "source_context": "generated", + "disposition": "human_review", + "reason": "Tool gap (not a source finding): Parser flagged invalid fixture content or valid syntax outside the active tree-sitter grammar.", + "first_seen": "2026-10-03T15:27:53.7515543+08:00", + "last_reviewed": "2026-10-03T09:30:14.998539Z", + "rule_version": "0.2.1", + "lifecycle": "active" + }, + { + "fingerprint": "454b53e7e7cd4583363cdc1650abae7efbeff38760f02b98d41a558b25a62286", + "rule_id": "PARSE-001", + "path": "windows/runner/win32_window.h", + "symbol": null, + "context_hash": null, + "source_context": "generated", + "disposition": "human_review", + "reason": "Tool gap (not a source finding): Parser flagged invalid fixture content or valid syntax outside the active tree-sitter grammar.", + "first_seen": "2026-10-03T15:27:53.7565386+08:00", + "last_reviewed": "2026-10-03T09:30:14.998539Z", + "rule_version": "0.2.1", + "lifecycle": "active" + }, + { + "fingerprint": "e9a9e9893ecbb3f2a8f14d4a528d129de1ea923b46203f3f03c41a4d39a7533b", + "rule_id": "PARSE-001", + "path": "windows/runner/win32_window.h", + "symbol": null, + "context_hash": null, + "source_context": "generated", + "disposition": "human_review", + "reason": "Tool gap (not a source finding): Parser flagged invalid fixture content or valid syntax outside the active tree-sitter grammar.", + "first_seen": "2026-10-03T15:27:53.7625252+08:00", + "last_reviewed": "2026-10-03T09:30:14.998539Z", + "rule_version": "0.2.1", + "lifecycle": "active" + } + ] +} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b30050db4..01c082aa1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -148,6 +148,11 @@ jobs: runner: ubuntu-latest runs-on: ${{ matrix.runner }} + env: + ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }} + ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }} + ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }} + ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }} steps: - uses: actions/checkout@v4 @@ -197,7 +202,7 @@ jobs: java-version: '17' - name: Restore Android signing config - if: matrix.platform == 'android' + if: matrix.platform == 'android' && env.ANDROID_KEYSTORE_BASE64 != '' env: ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }} ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }} @@ -213,7 +218,7 @@ jobs: EOF - name: Build Android APK - if: matrix.platform == 'android' + if: matrix.platform == 'android' && env.ANDROID_KEYSTORE_BASE64 != '' run: | flutter clean flutter build apk --release --flavor=prod \ @@ -223,3 +228,15 @@ jobs: --dart-define=SUPABASE_URL=${{ vars.SUPABASE_URL }} \ --dart-define=SUPABASE_PUBLISHABLE_KEY=${{ vars.SUPABASE_PUBLISHABLE_KEY }} \ --dart-define=GOOGLE_WEB_CLIENT_ID=${{ vars.GOOGLE_WEB_CLIENT_ID }} + + - name: Build Android debug APK without signing secrets + if: matrix.platform == 'android' && env.ANDROID_KEYSTORE_BASE64 == '' + run: | + flutter clean + flutter build apk --debug --flavor=dev \ + --target-platform android-arm64 \ + --dart-define=API_BASE_URL=${{ vars.API_BASE_URL }} \ + --dart-define=POSTHOG_HOST=${{ vars.POSTHOG_HOST }} \ + --dart-define=SUPABASE_URL=${{ vars.SUPABASE_URL }} \ + --dart-define=SUPABASE_PUBLISHABLE_KEY=${{ vars.SUPABASE_PUBLISHABLE_KEY }} \ + --dart-define=GOOGLE_WEB_CLIENT_ID=${{ vars.GOOGLE_WEB_CLIENT_ID }} diff --git a/.github/workflows/codeforge-regression.yml b/.github/workflows/codeforge-regression.yml new file mode 100644 index 000000000..3e9560923 --- /dev/null +++ b/.github/workflows/codeforge-regression.yml @@ -0,0 +1,63 @@ +name: CodeForge regression + +on: + pull_request: + +permissions: + contents: read + +concurrency: + group: codeforge-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + codeforge: + name: CodeForge regression + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.sha }} + fetch-depth: 0 + + - name: Expose pull request diff to CodeForge --changed + shell: bash + run: | + git fetch --no-tags origin "${{ github.event.pull_request.base.sha }}" + git reset --soft "${{ github.event.pull_request.base.sha }}" + + - uses: dtolnay/rust-toolchain@stable + + - name: Cache CodeForge v0.2.1 build inputs + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + key: codeforge-v0.2.1-${{ runner.os }}-${{ runner.arch }} + + - name: Install CodeForge Engine v0.2.1 + shell: bash + run: | + if ! command -v codeforge >/dev/null 2>&1 || [ "$(codeforge --version)" != "codeforge 0.2.1" ]; then + cargo install --git https://github.com/styayur/codeforge-engine --tag v0.2.1 --locked codeforge-cli + fi + + - name: Run CodeForge regression gate + shell: bash + run: | + codeforge ci \ + --changed \ + --baseline .codeforge/baseline.json \ + --fail-on new \ + --sarif codeforge.sarif \ + --new-only + + - name: Upload CodeForge SARIF + if: always() + uses: actions/upload-artifact@v4 + with: + name: codeforge-sarif + path: codeforge.sarif + if-no-files-found: ignore