Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .codeforge.toml
Original file line number Diff line number Diff line change
@@ -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
2,848 changes: 2,848 additions & 0 deletions .codeforge/baseline.json

Large diffs are not rendered by default.

21 changes: 19 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 }}
Expand All @@ -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 \
Expand All @@ -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 }}
63 changes: 63 additions & 0 deletions .github/workflows/codeforge-regression.yml
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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))。
Expand Down
1 change: 1 addition & 0 deletions README.en.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

---

Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@
- 📊 **学习数据统计**:记录学习时长、输入输出比例、唯一词汇量,让练了多少、说了多少一目了然。
- 🎙️ **跟读 AI 评测**:自动对齐识别结果与原文,高亮命中词,并给出跟读评级。
- 🎧 **导入本地音频 + AI 字幕**:支持批量导入本地音频,可导入本地字幕,也可用 AI 自动转录生成字幕。
- 🗣️ **离线 Kokoro 语音 + 文本转课时**:可下载 Kokoro v1.1 中英多语言模型,在句子、生词、跟读参考音和“文本转听力”中离线生成语音;支持音色与 0.5x–2.0x 语速。

---

Expand Down
1 change: 1 addition & 0 deletions TASKS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
66 changes: 66 additions & 0 deletions THIRD_PARTY_NOTICES.md
Original file line number Diff line number Diff line change
@@ -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`.
9 changes: 9 additions & 0 deletions analysis_options.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
54 changes: 39 additions & 15 deletions integration_test/kokoro_tts_test.dart
Original file line number Diff line number Diff line change
@@ -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 能正确区分口音
Expand All @@ -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';
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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('统一管线端到端(文本 → 协调器 → 引擎 → 缓存 → 播放)', () {
Expand All @@ -170,8 +197,8 @@ void main() {

_CountingEngine? counting;
final coordinator = TtsCoordinator(
factory: (kind) {
// 本测试只用 echoLoop。
factory: (kind, config) {
// 本测试只用 kokoro。
final inner = KokoroTtsEngine(
resolvePaths: manager.kokoroConfigPaths,
);
Expand All @@ -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.';

Expand All @@ -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: '合成结果应入缓存');
Expand Down
Loading