版本: 1.1 | 更新: 2026-03-20
┌──────────────────────────────────────────────────────────────────┐
│ DeepMusic System │
├──────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ iOS App │ │ Android App │ │ Web Admin │ │
│ │ (Flutter) │ │ (Flutter) │ │ (Future) │ │
│ └──────┬───────┘ └──────┬───────┘ └──────┬───────┘ │
│ │ │ │ │
│ └─────────────────┼─────────────────┘ │
│ │ HTTPS │
│ ┌──────▼───────┐ │
│ │ API Gateway │ │
│ │ (Rate Limit) │ │
│ └──────┬───────┘ │
│ │ │
│ ┌─────────────────┼─────────────────┐ │
│ │ │ │ │
│ ┌──────▼──────┐ ┌──────▼──────┐ ┌──────▼──────┐ │
│ │ Auth Routes │ │Score Routes │ │Practice Rt. │ │
│ └─────────────┘ └─────────────┘ └─────────────┘ │
│ │
│ ┌─────────────────┬─────────────────┐ │
│ │ │ │ │
│ ┌──────▼──────┐ ┌──────▼──────┐ ┌──────▼──────┐ │
│ │ PostgreSQL │ │ Redis │ │ File Store │ │
│ │ (Prisma) │ │ Cache │ │ (Scores) │ │
│ └─────────────┘ └─────────────┘ └─────────────┘ │
│ │
└──────────────────────────────────────────────────────────────────┘
┌──────────────┐
│ Yamaha P125 │
│ (BLE + USB) │
└──┬───────┬───┘
│ │
BLE MIDI │ │ USB OTG MIDI
│ │
┌────────┼───┐ │
│ │ │ │
┌──────▼───┐ ┌──▼───▼───┴───┐
│ iOS │ │ Android │
│ (BLE) │ │ (BLE + USB) │
└──────────┘ └──────────────┘
│
浏览器访问后端 API ─┘
mobile/
├── lib/
│ ├── main.dart
│ ├── app.dart
│ │
│ ├── core/ # 核心基础设施
│ │ ├── constants/
│ │ │ ├── app_colors.dart
│ │ │ ├── app_strings.dart
│ │ │ └── app_assets.dart
│ │ ├── theme/
│ │ │ └── app_theme.dart
│ │ ├── router/
│ │ │ └── app_router.dart
│ │ └── utils/
│ │ ├── midi_utils.dart
│ │ └── score_utils.dart
│ │
│ ├── features/ # 功能模块 (Feature-based)
│ │ ├── home/
│ │ │ ├── pages/home_page.dart
│ │ │ ├── widgets/
│ │ │ └── providers/
│ │ │
│ │ ├── midi/ # MIDI 连接模块
│ │ │ ├── pages/device_list_page.dart
│ │ │ ├── providers/midi_provider.dart
│ │ │ └── services/midi_service.dart
│ │ │
│ │ ├── score/ # 乐谱模块
│ │ │ ├── pages/
│ │ │ │ ├── score_library_page.dart
│ │ │ │ └── score_view_page.dart
│ │ │ ├── widgets/
│ │ │ │ ├── score_renderer.dart # WebView + OSMD 渲染
│ │ │ │ └── score_follow_highlight.dart
│ │ │ ├── providers/score_provider.dart
│ │ │ ├── models/score.dart
│ │ │ └── services/
│ │ │ ├── musicxml_parser.dart # MusicXML 解析器
│ │ │ └── score_downloader.dart # 乐谱下载/缓存
│ │ │
│ │ ├── practice/ # 练习模块 (核心)
│ │ │ ├── pages/
│ │ │ │ ├── practice_page.dart
│ │ │ │ └── practice_result_page.dart
│ │ │ ├── widgets/
│ │ │ │ ├── note_indicator.dart
│ │ │ │ └── practice_controls.dart
│ │ │ ├── providers/practice_provider.dart
│ │ │ └── services/
│ │ │ ├── score_follower.dart # 乐谱跟随引擎
│ │ │ ├── note_evaluator.dart # 音符评估器
│ │ │ └── practice_session.dart # 练习会话管理
│ │ │
│ │ └── profile/
│ │ ├── pages/profile_page.dart
│ │ └── providers/
│ │
│ ├── shared/ # 共享组件
│ │ ├── widgets/
│ │ └── services/api_client.dart
│ │
│ └── data/ # 数据层
│ ├── repositories/
│ ├── datasources/
│ └── models/
│
├── assets/
│ ├── images/
│ ├── fonts/
│ └── scores/ # 内置 MusicXML 曲谱
│ ├── beginner/
│ ├── intermediate/
│ └── popular/
│
├── pubspec.yaml
└── test/
职责:管理 MIDI 设备的扫描、连接和事件分发。支持蓝牙 BLE 和 USB OTG 两种连接方式。
/// 连接方式
enum MidiConnectionType { bluetooth, usb }
class MidiService {
// 单例模式
static final MidiService _instance = MidiService._internal();
factory MidiService() => _instance;
// 流式 API
Stream<MidiConnectionState> get connectionState;
Stream<MidiEvent> get midiStream;
Stream<List<MidiDevice>> get devices;
// 当前连接方式
MidiConnectionType? get connectionType;
// 蓝牙操作
Future<List<MidiDevice>> scanBleDevices();
Future<bool> connectBle(MidiDevice device);
Future<void> disconnectBle();
// USB 操作
Future<List<MidiDevice>> getUsbDevices();
Future<bool> connectUsb(MidiDevice device);
Future<void> disconnectUsb();
// 通用
Future<void> disconnect(); // 断开当前连接
}MidiEvent 数据结构:
class MidiEvent {
final MidiEventType type; // noteOn, noteOff, controlChange
final int note; // MIDI 音符号 (0-127)
final int velocity; // 力度 (0-127)
final int channel; // 通道 (0-15)
final DateTime timestamp;
final MidiConnectionType source; // bluetooth | usb
}蓝牙 BLE MIDI 实现要点:
- 使用
flutter_midi_command包封装 - Android 需要
BLUETOOTH_CONNECT+BLUETOOTH_SCAN权限 - iOS 需要
NSBluetoothAlwaysUsageDescription - 断线自动重连:最多 3 次,间隔 2 秒,指数退避
- 延迟目标: < 50ms
USB MIDI 实现要点:
- 使用
usb_serial或flutter_libserialport包 - Android 需要 USB Host 权限 (
android.permission.USB_HOST) + device_filter.xml - iOS 不支持 USB MIDI(系统限制)
- 支持热插拔: 监听 USB 设备接入/拔出广播
- 延迟目标: < 20ms(显著优于蓝牙)
连接策略:
- USB 连接优先于蓝牙(延迟更低、更稳定)
- 当 USB 设备接入时,自动提示用户切换连接方式
- 两种连接共享统一的
Stream<MidiEvent>分发,上层无需关心来源 - MIDI 事件通过
StreamController.broadcast()分发,支持多个订阅者
职责:实时跟踪用户弹奏位置,输出当前进度和翻页信号。
class ScoreFollower {
final Score score;
Stream<PracticeProgress> get progressStream;
void processMidiEvent(NoteEvent event);
Note? getCurrentExpectedNote();
List<Note> getUpcomingNotes({int count = 5});
void jumpToMeasure(int measureNumber);
void reset();
}跟随算法设计 (Phase 1):
输入: NoteEvent (来自 MIDI)
输出: PracticeProgress (位置、进度、翻页信号)
核心逻辑:
1. 获取当前期望音符 expectedNote
2. 比较 event.noteNumber 与 expectedNote.pitchNumber
3. 匹配分支:
a. 完全匹配 → 前进到下一个音符
b. 不匹配 → 进入容错模式
4. 容错模式:
- 等待 toleranceMs (默认 1500ms)
- 期间如果用户弹出正确音符 → 回到正常模式
- 超时仍未匹配 → 标记 expectedNote 为遗漏,跳过继续
5. 更新进度,检测翻页需求
和弦处理:
- 当下一个 expectedNote 是和弦(多个音符同一 startMs)时
- 收集同一时间窗口内(±300ms)的所有 NoteEvent
- 将集合与和弦音符集合对比
- 匹配率 > 50% 视为通过
容错机制设计:
class FollowerConfig {
final int toleranceMs; // 单音符容错等待时间 (默认 1500ms)
final int chordWindowMs; // 和弦时间窗口 (默认 300ms)
final double chordMatchRatio; // 和弦最低匹配率 (默认 0.5)
final int maxSkips; // 最大连续跳过数 (默认 3)
}职责:对比期望音符和实际弹奏,生成评估结果。
class NoteEvaluator {
NoteEvaluation evaluate({
required Note expected,
required NoteEvent played,
required int expectedStartTimeMs,
required int playedStartTimeMs,
});
PracticeReport generateReport({
required String scoreId,
required DateTime startTime,
required DateTime endTime,
required List<NoteEvaluation> evaluations,
});
}评分算法:
音准分 = (正确音符数 / 总音符数) × 100
节奏分:
偏差 <= 200ms → 1.0
偏差 <= 400ms → 0.8
偏差 <= 600ms → 0.6
偏差 > 600ms → 0.4
节奏分 = 所有音符节奏准确度的平均值 × 100
综合分 = 音准分 × 0.6 + 节奏分 × 0.4
等级: S(>=95) A(>=90) B(>=80) C(>=70) D(>=60) F(<60)
职责:将 MusicXML 文件解析为 Score 数据模型。
MusicXML File
│
├── XML 解析 (package:xml)
│ └── 提取 <score-partwise> 结构
│
├── 元数据提取
│ ├── title, composer, movement-title
│ ├── key (fifths + mode)
│ ├── time (beats + beat-type)
│ └── divisions (每四分音符的 tick 数)
│
├── 音符解析
│ ├── pitch → Note (step + alter + octave → pitchNumber)
│ ├── duration → Note.duration
│ ├── type → Note.type (whole, half, quarter, eighth...)
│ └── rest / chord 标记
│
├── 时间轴计算
│ └── 基于 divisions 和 tempo 生成每个音符的 startMs
│
└── 输出 Score 对象 (含所有 Part/Measure/Note)
关键转换:
// MusicXML pitch → MIDI pitchNumber
// step(C=0..6) + alter(-1/0/+1) + octave → (octave+1)*12 + noteIndex
// duration → 毫秒
// startMs = cumulativeDuration / divisions × (60000 / tempo)
// 示例: divisions=4, tempo=120
// 四分音符 duration=4 → 4/4 × 60000/120 = 500msPhase 1 方案: WebView + OpenSheetMusicDisplay (OSMD)
┌─────────────────────────────┐
│ Flutter WebView │
│ ┌───────────────────────┐ │
│ │ HTML + JS │ │
│ │ ┌─────────────────┐ │ │
│ │ │ OSMD │ │ │
│ │ │ (渲染 MusicXML) │ │ │
│ │ └─────────────────┘ │ │
│ │ │ │
│ │ JavaScript Bridge │ │
│ │ - render(xml) │ │
│ │ - highlight(measure) │ │
│ │ - scrollTo(position) │ │
│ │ - getPositions() │ │
│ └───────────┬───────────┘ │
│ │ JS Channel │
└──────────────┼──────────────┘
│
Flutter Dart 控制层
- score_renderer.dart
- 高亮控制 / 翻页 / 缩放
OSMD 集成要点:
- HTML 模板内嵌 OSMD JS 库
- 通过
webview_flutter的 JavaScript Channel 双向通信 - Flutter → JS: 发送 MusicXML 数据、高亮指令
- JS → Flutter: 返回音符位置坐标、渲染完成信号
dependencies:
flutter:
sdk: flutter
# 状态管理
flutter_riverpod: ^2.4.0
# MIDI (蓝牙 + USB)
flutter_midi_command: ^0.5.2
usb_serial: ^0.5.1
# 网络
dio: ^5.4.0
retrofit: ^4.0.3
# 本地存储
hive_flutter: ^1.1.0
# XML 解析
xml: ^6.5.0
# WebView (乐谱渲染)
webview_flutter: ^4.4.2
# UI
flutter_svg: ^2.0.9
cached_network_image: ^3.3.0
# 工具
uuid: ^4.2.1
path_provider: ^2.1.1
logger: ^2.0.2+1server/
├── src/
│ ├── index.ts # 入口,Express 应用配置
│ │
│ ├── routes/ # 路由层
│ │ ├── auth.ts # POST /register, /login, /logout
│ │ ├── scores.ts # CRUD + 搜索 + 上传
│ │ ├── practice.ts # ⚠️ 待实现 - 练习记录 API
│ │ └── devices.ts # 设备管理
│ │
│ ├── services/ # 业务逻辑层
│ │ ├── score.service.ts # 乐谱业务逻辑
│ │ ├── auth.service.ts # 认证逻辑 (待补充)
│ │ └── practice.service.ts # ⚠️ 待创建
│ │
│ ├── middleware/
│ │ ├── validate.ts # 请求验证中间件
│ │ ├── error.ts # 错误处理
│ │ └── notFound.ts # 404 处理
│ │
│ ├── utils/
│ │ └── logger.ts # Winston 日志
│ │
│ └── scripts/
│ ├── seed.ts # 数据库初始化
│ └── import-scores.ts # 乐谱数据导入
│
├── prisma/
│ ├── schema.prisma # 数据模型
│ └── dev.db # SQLite (开发)
│
├── .env.example
├── package.json
└── tsconfig.json
model Score {
id String @id @default(uuid())
title String
composer String
difficulty String @default("BEGINNER") // BEGINNER | INTERMEDIATE | ADVANCED
musicXmlPath String
fileSize Int
duration Int // 预估时长 (秒)
measures Int // 小节数
timeSignature String @default("4/4")
keySignature String @default("C Major")
tempo Int @default(120) // BPM
category String?
tags Tag[]
playCount Int @default(0)
favoriteCount Int @default(0)
status String @default("DRAFT") // DRAFT | PUBLISHED | ARCHIVED
isPublic Boolean @default(true)
source String?
license String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
publishedAt DateTime?
practiceRecords PracticeRecord[]
favorites User[] @relation("UserFavorites")
}
model User {
id String @id @default(uuid())
email String @unique
password String // bcrypt hash
nickname String?
avatar String?
totalPracticeTime Int @default(0)
totalSessions Int @default(0)
totalNotes Int @default(0)
practiceRecords PracticeRecord[]
favorites Score[] @relation("UserFavorites")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model PracticeRecord {
id String @id @default(uuid())
userId String
scoreId String
duration Int // 秒
notesPlayed Int
pitchScore Float // 0-100
rhythmScore Float // 0-100
overallScore Float // 0-100
grade String // S, A, B, C, D, F
details String? // JSON: 完整评估数据
startedAt DateTime
completedAt DateTime @default(now())
user User @relation(...)
score Score @relation(...)
}
model Tag {
id String @id @default(uuid())
name String @unique
scores Score[]
}| 状态 | 方法 | 路径 | 说明 |
|---|---|---|---|
| ✅ | GET | /health |
健康检查 |
| ✅ | POST | /api/auth/register |
用户注册 |
| ✅ | POST | /api/auth/login |
用户登录 |
| ✅ | POST | /api/auth/logout |
用户登出 |
| ✅ | GET | /api/scores |
乐谱列表 (分页/筛选) |
| ✅ | GET | /api/scores/recommended |
推荐乐谱 |
| ✅ | GET | /api/scores/search |
搜索乐谱 |
| ✅ | GET | /api/scores/:id |
乐谱详情 |
| ✅ | POST | /api/scores |
上传乐谱 (MusicXML) |
| ✅ | PATCH | /api/scores/:id |
更新乐谱 |
| ✅ | POST | /api/scores/:id/publish |
发布乐谱 |
| ✅ | DELETE | /api/scores/:id |
删除乐谱 |
| POST | /api/practice/start |
开始练习 - TODO | |
| POST | /api/practice/:id/note |
上传音符事件 - TODO | |
| POST | /api/practice/:id/end |
结束练习 - TODO | |
| GET | /api/practice/history |
练习历史 - TODO | |
| GET | /api/practice/stats |
统计数据 - TODO | |
| ✅ | GET | /api/devices |
设备列表 |
- Helmet: 安全 HTTP 头
- CORS: 跨域配置(可配置 origin)
- Rate Limiting:
/api/路径 15 分钟 100 次 - express-validator: 请求参数校验
- Winston: 结构化日志
- Multer: 文件上传(限 10MB,仅 MusicXML 格式)
| 环境 | 数据库 | 说明 |
|---|---|---|
| 开发 | SQLite | 轻量,prisma/dev.db |
| 生产 | PostgreSQL | 需在 .env 中配置 DATABASE_URL |
| 缓存 | Redis | 已引入 ioredis 依赖,待集成 |
迁移路径: Prisma 支持同一 schema 切换 provider,迁移文件从 SQLite → PostgreSQL 无需修改 schema,只需更换 datasource.db。
class Score {
final String id;
final String title;
final String composer;
final String difficulty; // beginner | intermediate | advanced
final List<Part> parts; // 声部
final int totalMeasures;
final Duration estimatedDuration;
final String musicXmlPath;
}
class Part {
final String name; // 如 "Piano", "P1"
final List<Measure> measures;
}
class Measure {
final int number;
final List<Note> notes;
final TimeSignature timeSignature;
final KeySignature keySignature;
}
class Note {
final String pitch; // "C4", "D#5"
final int pitchNumber; // MIDI 音符号 (0-127)
final double duration; // 以四分音符为单位 (1.0 = 四分音符)
final int startMs; // 从曲首开始的毫秒数
final int measureNumber;
}MIDI pitchNumber = (octave + 1) × 12 + noteIndex
noteIndex: C=0, C#=1, D=2, D#=3, E=4, F=5, F#=6, G=7, G#=8, A=9, A#=10, B=11
示例: A4 (440Hz) → (4+1)×12 + 9 = 69
C4 (中央C) → (4+1)×12 + 0 = 60
用户选择区间 [startMeasure, endMeasure]
│
└── ScoreFollower.setLoopRange(start, end)
│
├── 计算区间对应的 _chordGroup 起止索引
├── 设置 _loopStartGroupIndex / _loopEndGroupIndex
└── 标记 _loopEnabled = true
循环中:
processMidiEvent()
│
├── 正常匹配逻辑
│
└── _advanceToNextGroup()
│
└── if (_currentGroupIndex > _loopEndGroupIndex)
├── _loopCycle++
├── 生成本次循环评分 → LoopCycleScore
├── _currentGroupIndex = _loopStartGroupIndex
└── _emitProgress() (含 loopCycle 字段)
// 新增属性
int? _loopStartGroupIndex;
int? _loopEndGroupIndex;
bool _loopEnabled = false;
int _loopCycle = 0;
List<double> _loopCycleScores = [];
// 新增方法
void setLoopRange(int startMeasure, int endMeasure) {
_loopStartGroupIndex = _findGroupIndexForMeasure(startMeasure);
_loopEndGroupIndex = _findGroupIndexForMeasure(endMeasure);
_loopEnabled = true;
_loopCycle = 0;
_loopCycleScores.clear();
}
void clearLoopRange() {
_loopEnabled = false;
_loopStartGroupIndex = null;
_loopEndGroupIndex = null;
}
// 在 _advanceToNextGroup() 中追加:
if (_loopEnabled && _currentGroupIndex > _loopEndGroupIndex!) {
_loopCycleScores.add(currentCycleScore);
_loopCycle++;
_currentGroupIndex = _loopStartGroupIndex!;
// 重置循环内统计
}class PracticeProgress {
// ... 原有字段
final bool loopEnabled;
final int loopCycle;
final int? loopStartMeasure;
final int? loopEndMeasure;
final double? loopBestScore;
}// index.html 新增 JS 方法
function highlightLoopRange(startMeasure, endMeasure) {
clearHighlight();
const svg = document.querySelector('#score-container svg');
for (let i = startMeasure; i <= endMeasure; i++) {
if (i >= measurePositions.length) break;
const pos = measurePositions[i];
const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
rect.setAttribute('class', 'loop-highlight');
rect.setAttribute('x', pos.x);
rect.setAttribute('y', pos.y - 10);
rect.setAttribute('width', pos.width);
rect.setAttribute('height', pos.height + 20);
rect.setAttribute('fill', 'rgba(255, 193, 7, 0.15)');
rect.setAttribute('stroke', 'rgba(255, 193, 7, 0.5)');
rect.setAttribute('stroke-width', '2');
rect.setAttribute('rx', '4');
svg.insertBefore(rect, svg.firstChild);
}
}class AutoPlayer {
final Score score;
final MidiService _midiService = MidiService();
// 调度事件列表
late List<ScheduledMidiEvent> _events;
int _eventIndex = 0;
// 播放状态
bool _isPlaying = false;
bool _isPaused = false;
double _playbackRate = 1.0;
int _startMeasure = 1;
// 时间管理
final Stopwatch _stopwatch = Stopwatch();
Timer? _tickTimer;
// 状态流
final _stateController = StreamController<AutoPlayState>.broadcast();
Stream<AutoPlayState> get stateStream => _stateController.stream;
// 进度回调
final _progressController = StreamController<int>.broadcast(); // currentMeasure
Stream<int> get measureStream => _progressController.stream;
}
class ScheduledMidiEvent {
final int noteNumber;
final int velocity;
final int absoluteMs; // 从曲首开始的绝对毫秒数
final bool isNoteOn;
final int measureNumber;
ScheduledMidiEvent({ ... });
}
class AutoPlayState {
final bool isPlaying;
final bool isPaused;
final double progress; // 0.0 - 1.0
final int currentMeasure;
final double playbackRate;
final Duration position;
final Duration duration;
}void play({int fromMeasure = 1, double rate = 1.0}) {
_playbackRate = rate;
_startMeasure = fromMeasure;
// 从 Score.allNotes 生成调度事件
_events = _buildScheduledEvents();
_eventIndex = _findStartIndex(fromMeasure);
_stopwatch.reset();
_stopwatch.start();
// 10ms tick 检查待发送事件
_tickTimer = Timer.periodic(Duration(milliseconds: 10), (_) => _tick());
_isPlaying = true;
}
void _tick() {
if (!_isPlaying || _isPaused) return;
final elapsedMs = _stopwatch.elapsedMilliseconds;
final playbackMs = (elapsedMs * _playbackRate).round();
// 批量发送同一时间点的事件 (和弦支持)
while (_eventIndex < _events.length) {
final event = _events[_eventIndex];
final adjustedMs = (event.absoluteMs / _playbackRate).round();
if (adjustedMs > playbackMs) break; // 还没到时间
if (event.isNoteOn) {
_midiService.sendNoteOn(event.noteNumber, event.velocity);
} else {
_midiService.sendNoteOff(event.noteNumber);
}
// 通知 UI 更新小节位置
_progressController.add(event.measureNumber);
_eventIndex++;
}
// 播放完毕
if (_eventIndex >= _events.length) {
stop();
}
}
List<ScheduledMidiEvent> _buildScheduledEvents() {
final events = <ScheduledMidiEvent>[];
final notes = score.allNotes;
final beatMs = 60000 / (score.bpm); // 假设有 bpm 属性
for (final note in notes) {
final durationMs = (note.duration * beatMs).round();
// Note On
events.add(ScheduledMidiEvent(
noteNumber: note.pitchNumber,
velocity: 80,
absoluteMs: note.startMs,
isNoteOn: true,
measureNumber: note.measureNumber,
));
// Note Off
events.add(ScheduledMidiEvent(
noteNumber: note.pitchNumber,
velocity: 0,
absoluteMs: note.startMs + durationMs,
isNoteOn: false,
measureNumber: note.measureNumber,
));
}
events.sort((a, b) => a.absoluteMs.compareTo(b.absoluteMs));
return events;
}// AutoPlayer.measureStream → PracticePage/ScoreViewPage
// 驱动 ScoreRenderer.highlightMeasure(currentMeasure)| 平台 | 构建方式 | 分发 |
|---|---|---|
| Android | flutter build apk / flutter build appbundle |
Google Play |
| iOS | flutter build ios (需 macOS) |
App Store |
# 开发
cd server && npm run dev
# 生产构建
npm run build # tsc 编译
npm start # node dist/index.js
# 数据库
npm run db:migrate # prisma migrate dev
npm run db:seed # 初始化种子数据
npm run scores:import # 导入乐谱数据| 组件 | 推荐方案 | 备选 |
|---|---|---|
| 后端 | Railway / Vercel | 阿里云 / 腾讯云 ECS |
| 数据库 | PostgreSQL (云托管) | 自建 |
| 文件存储 | 阿里云 OSS / 腾讯云 COS | 本地 + CDN |
| Redis | 云托管 Redis | 自建 |
| 模块 | 状态 | 说明 |
|---|---|---|
| Express 服务框架 | ✅ | 完整中间件链 |
| Auth 路由 | ✅ | 注册/登录/登出 |
| Score CRUD | ✅ | 完整 REST API |
| 文件上传 | ✅ | Multer + MusicXML 过滤 |
| Prisma Schema | ✅ | 5 个模型,关系完整 |
| 乐谱种子数据 | ✅ | 30 首已导入 |
| Practice 路由 | ✅ | 完整实现 + Prisma 事务 + 会话持久化 |
| MIDI Service (蓝牙) | BLE 扫描/连接/数据接收完成,断线自动重连,待真机调试 | |
| MIDI Service (USB) | USB OTG 底层 UsbSerial 枚举/连接/协议解析,热插拔检测,待真机调试 | |
| Score Follower | ✅ | 单音符 + 和弦组匹配,容错跳过,翻页信号,手动翻页 |
| Note Evaluator | ✅ | 音准+节奏双维度,报告生成 |
| MusicXML Parser | ✅ | score-partwise + timewise,变拍号,和弦/休止符/backup/forward |
| 乐谱渲染 (OSMD) | ✅ | WebView OSMD 集成,高亮控制,滚动,缩放,练习页集成 |
| 练习会话管理 | ✅ | Prisma PracticeSession 持久化,事务化操作 |
| 区间循环练习 (F8) | ✅ | ScoreFollower 循环模式 + OSMD 区间高亮 + 独立评分 + UI |
| 自动播放 (F9) | ✅ | AutoPlayer MIDI 调度器 + 变速 + OSMD 跟随 + 播放器 UI |
架构负责人: 项目团队 | 更新: 2026-03-20 20:47