mirror of
https://github.com/aiclientproxy/proxycast.git
synced 2026-09-24 23:10:56 +08:00
chore: bump version to 0.43.0
This commit is contained in:
@@ -1,218 +0,0 @@
|
||||
# Terminal Scrollback Tool 实现文档
|
||||
|
||||
## 概述
|
||||
|
||||
`term_get_scrollback` 工具允许 AI 读取终端的输出历史,而不是直接执行命令。这是一个**只读工具**,完全避免了 AI 重复执行命令的问题。
|
||||
|
||||
## 架构设计
|
||||
|
||||
### 后端(Rust)
|
||||
|
||||
1. **TermScrollbackTool** (`src-tauri/src/agent/tools/term_scrollback.rs`)
|
||||
- 实现 `Tool` trait
|
||||
- 使用响应通道机制(类似 TerminalTool)
|
||||
- 通过 Tauri 事件系统与前端通信
|
||||
|
||||
2. **全局实例管理**
|
||||
- 使用 `once_cell::sync::Lazy` 创建全局单例
|
||||
- 在 `setup.rs` 和 `runner.rs` 中初始化 AppHandle
|
||||
- 提供 `get_term_scrollback_tool()` 获取全局实例
|
||||
|
||||
3. **Tauri 命令**
|
||||
- `agent_term_scrollback_response`: 接收前端的响应
|
||||
|
||||
### 前端(TypeScript/React)
|
||||
|
||||
1. **API 层** (`src/lib/api/agent.ts`)
|
||||
- `TermScrollbackRequest`: 请求类型
|
||||
- `TermScrollbackResponse`: 响应类型
|
||||
- `sendTermScrollbackResponse()`: 发送响应函数
|
||||
|
||||
2. **Hook 层** (`src/components/terminal/ai/useTerminalAI.ts`)
|
||||
- 监听 `term_get_scrollback_request` 事件
|
||||
- 读取终端输出历史
|
||||
- 发送响应给后端
|
||||
|
||||
## 工作流程
|
||||
|
||||
```
|
||||
AI Agent
|
||||
↓ (调用 term_get_scrollback 工具)
|
||||
TermScrollbackTool
|
||||
↓ (发送 term_get_scrollback_request 事件)
|
||||
前端 useTerminalAI
|
||||
↓ (读取终端输出)
|
||||
↓ (调用 sendTermScrollbackResponse)
|
||||
Tauri 命令 agent_term_scrollback_response
|
||||
↓ (调用 handle_term_scrollback_response)
|
||||
TermScrollbackTool
|
||||
↓ (通过响应通道返回结果)
|
||||
AI Agent (收到终端输出)
|
||||
```
|
||||
|
||||
## 工具参数
|
||||
|
||||
```json
|
||||
{
|
||||
"session_id": "终端会话 ID",
|
||||
"line_start": 0, // 可选,起始行号(从 0 开始)
|
||||
"count": 50 // 可选,读取行数
|
||||
}
|
||||
```
|
||||
|
||||
## 工具描述(给 AI 的说明)
|
||||
|
||||
```
|
||||
Read terminal output history without executing commands.
|
||||
|
||||
This tool allows you to view the terminal's scrollback buffer (output history)
|
||||
without executing any commands. Use this to:
|
||||
- Check the results of previously executed commands
|
||||
- Review terminal output before suggesting next steps
|
||||
- Understand the current state of the terminal session
|
||||
|
||||
Parameters:
|
||||
- session_id: Terminal session ID (required)
|
||||
- line_start: Starting line number (optional, default: 0)
|
||||
- count: Number of lines to read (optional, default: all lines)
|
||||
|
||||
Returns the terminal output as plain text.
|
||||
|
||||
IMPORTANT: This is a READ-ONLY tool. It does NOT execute commands.
|
||||
```
|
||||
|
||||
## 使用场景
|
||||
|
||||
### 场景 1:纯只读模式(推荐)
|
||||
|
||||
**配置:**
|
||||
- 移除 `terminal` 工具
|
||||
- 只保留 `term_get_scrollback` 工具
|
||||
|
||||
**优势:**
|
||||
- AI 只能读取输出,不能执行命令
|
||||
- 完全避免重复执行问题
|
||||
- 用户完全控制命令执行
|
||||
|
||||
**工作流程:**
|
||||
1. 用户手动在终端执行命令
|
||||
2. AI 使用 `term_get_scrollback` 读取输出
|
||||
3. AI 根据输出提供建议
|
||||
4. 用户决定是否执行 AI 的建议
|
||||
|
||||
### 场景 2:混合模式(当前实现)
|
||||
|
||||
**配置:**
|
||||
- 保留 `terminal` 工具(需要审批)
|
||||
- 添加 `term_get_scrollback` 工具
|
||||
|
||||
**优势:**
|
||||
- AI 可以建议命令(需要审批)
|
||||
- AI 也可以读取历史输出
|
||||
- 灵活性更高
|
||||
|
||||
**工作流程:**
|
||||
1. AI 建议命令(通过 `terminal` 工具)
|
||||
2. 用户审批并执行
|
||||
3. AI 使用 `term_get_scrollback` 读取输出
|
||||
4. AI 根据输出继续工作
|
||||
|
||||
## 测试步骤
|
||||
|
||||
### 1. 编译项目
|
||||
|
||||
```bash
|
||||
cargo build --manifest-path src-tauri/Cargo.toml
|
||||
```
|
||||
|
||||
### 2. 启动应用
|
||||
|
||||
```bash
|
||||
npm run tauri dev
|
||||
```
|
||||
|
||||
### 3. 测试工具
|
||||
|
||||
1. 打开终端
|
||||
2. 执行一些命令(例如:`ls`, `pwd`, `echo hello`)
|
||||
3. 打开 AI 面板
|
||||
4. 发送消息:`请读取终端的输出历史`
|
||||
5. AI 应该使用 `term_get_scrollback` 工具读取输出
|
||||
6. 检查 AI 是否正确显示了终端输出
|
||||
|
||||
### 4. 验证日志
|
||||
|
||||
**后端日志:**
|
||||
```
|
||||
[TermScrollbackTool] 创建全局实例
|
||||
[TermScrollbackTool] 设置全局 AppHandle
|
||||
[TermScrollbackTool] AppHandle 设置成功,已验证
|
||||
[TermScrollbackTool] 请求获取滚动缓冲区: session_id=xxx, request_id=xxx
|
||||
[TermScrollbackTool] 已发送请求到前端: xxx
|
||||
[TermScrollbackTool] 收到响应: request_id=xxx, success=true
|
||||
```
|
||||
|
||||
**前端日志:**
|
||||
```
|
||||
[useTerminalAI] 收到终端滚动缓冲区请求: {...}
|
||||
[useTerminalAI] 已发送滚动缓冲区响应: 0-50/100 行
|
||||
```
|
||||
|
||||
## 故障排查
|
||||
|
||||
### 问题 1:AppHandle 未设置
|
||||
|
||||
**症状:**
|
||||
```
|
||||
[TermScrollbackTool] 警告:AppHandle 未设置!工具将无法正常工作
|
||||
```
|
||||
|
||||
**解决方案:**
|
||||
- 检查 `setup.rs` 和 `runner.rs` 中是否调用了 `set_term_scrollback_tool_app_handle()`
|
||||
- 确保在工具注册之前设置 AppHandle
|
||||
|
||||
### 问题 2:前端未收到事件
|
||||
|
||||
**症状:**
|
||||
- 后端发送了事件,但前端没有日志
|
||||
|
||||
**解决方案:**
|
||||
- 检查 `useTerminalAI` 是否正确监听 `term_get_scrollback_request` 事件
|
||||
- 确保 `terminalSessionId` 不为空
|
||||
- 检查浏览器控制台是否有错误
|
||||
|
||||
### 问题 3:响应超时
|
||||
|
||||
**症状:**
|
||||
```
|
||||
[TermScrollbackTool] 请求超时: xxx
|
||||
```
|
||||
|
||||
**解决方案:**
|
||||
- 检查前端是否正确发送响应
|
||||
- 增加超时时间:`TermScrollbackTool::new().with_timeout(60)`
|
||||
- 检查 `getTerminalOutput()` 函数是否正常工作
|
||||
|
||||
## 下一步改进
|
||||
|
||||
1. **添加过滤功能**
|
||||
- 支持正则表达式过滤
|
||||
- 支持关键词搜索
|
||||
|
||||
2. **添加格式化选项**
|
||||
- 支持 ANSI 颜色代码
|
||||
- 支持纯文本输出
|
||||
|
||||
3. **添加缓存机制**
|
||||
- 缓存最近的输出
|
||||
- 减少重复读取
|
||||
|
||||
4. **添加增量读取**
|
||||
- 只读取新增的输出
|
||||
- 支持实时监控
|
||||
|
||||
## 参考资料
|
||||
|
||||
- [Waveterm Terminal AI](https://github.com/wavetermdev/waveterm)
|
||||
- [Tauri Event System](https://tauri.app/v1/guides/features/events/)
|
||||
- [Tokio Oneshot Channel](https://docs.rs/tokio/latest/tokio/sync/oneshot/)
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 100 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 67 B |
Binary file not shown.
|
After Width: | Height: | Size: 68 B |
+4
-2
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "proxycast",
|
||||
"private": true,
|
||||
"version": "0.42.0",
|
||||
"version": "0.43.0",
|
||||
"type": "module",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -45,6 +45,7 @@
|
||||
"@tauri-apps/plugin-dialog": "^2.4.2",
|
||||
"@tauri-apps/plugin-global-shortcut": "^2",
|
||||
"@tauri-apps/plugin-shell": "^2.0.0",
|
||||
"@tonejs/midi": "^2.0.28",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@types/styled-components": "^5.1.36",
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
@@ -75,7 +76,8 @@
|
||||
"remark-math": "^6.0.0",
|
||||
"sonner": "^2.0.7",
|
||||
"styled-components": "^6.1.19",
|
||||
"tailwind-merge": "^2.6.0"
|
||||
"tailwind-merge": "^2.6.0",
|
||||
"tone": "^15.1.22"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/plugin-transform-react-jsx-source": "^7.27.1",
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
{
|
||||
"meta": {
|
||||
"version": "1.0",
|
||||
"description": "和弦进行数据库 - 覆盖多种音乐风格的和弦进行模式",
|
||||
"created_for": "Musicify Music Theory Skill"
|
||||
},
|
||||
|
||||
"basic_progressions": {
|
||||
"pop_progressions": [
|
||||
{
|
||||
"name": "流行四和弦",
|
||||
"pattern": "vi-IV-I-V",
|
||||
"chords": ["Am", "F", "C", "G"],
|
||||
"roman_numeral": ["vi", "IV", "I", "V"],
|
||||
"emotion": "感人、朗朗上口",
|
||||
"difficulty": 1,
|
||||
"usage": "最常用的流行歌曲进行,适合主歌和副歌",
|
||||
"examples": ["《Someone Like You》", "《Let It Be》"],
|
||||
"variations": [
|
||||
{"pattern": "vi-V-IV-I", "description": "更加流畅的解决"},
|
||||
{"pattern": "vi-IV-I-V-vi", "description": "添加循环"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "卡农进行",
|
||||
"pattern": "I-V-vi-IV",
|
||||
"chords": ["C", "G", "Am", "F"],
|
||||
"roman_numeral": ["I", "V", "vi", "IV"],
|
||||
"emotion": "优美、经典、温暖",
|
||||
"difficulty": 1,
|
||||
"usage": "经典抒情歌曲,主歌部分特别适用",
|
||||
"examples": ["《Canon in D》", "《Five Hundred Miles》"],
|
||||
"variations": [
|
||||
{"pattern": "I-V-vi-iii-IV", "description": "加入三级和弦增加色彩"},
|
||||
{"pattern": "I-V-vi-IV-V", "description": "强调属功能"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "循环进行",
|
||||
"pattern": "I-V-vi-iii-IV-I-IV-V",
|
||||
"chords": ["C", "G", "Am", "Em", "F", "C", "F", "G"],
|
||||
"roman_numeral": ["I", "V", "vi", "iii", "IV", "I", "IV", "V"],
|
||||
"emotion": "流动、持续、丰富",
|
||||
"difficulty": 2,
|
||||
"usage": "适合较长的歌曲段落,创造持续的动力",
|
||||
"examples": ["《Autumn Leaves》", "《Fly Me to the Moon》"]
|
||||
}
|
||||
],
|
||||
|
||||
"rock_progressions": [
|
||||
{
|
||||
"name": "力量三和弦",
|
||||
"pattern": "I-♭VII-IV",
|
||||
"chords": ["C", "Bb", "F"],
|
||||
"roman_numeral": ["I", "♭VII", "IV"],
|
||||
"emotion": "有力、激昂、叛逆",
|
||||
"difficulty": 2,
|
||||
"usage": "摇滚歌曲副歌,营造强烈的推动力",
|
||||
"examples": ["《Sweet Caroline》", "《Free Fallin'》"],
|
||||
"guitar_tips": "使用强力和弦(power chords),强调根音和五度音"
|
||||
},
|
||||
{
|
||||
"name": "蓝调进行",
|
||||
"pattern": "I-I-I-I-IV-IV-I-I-V-IV-I-I",
|
||||
"chords": ["C7", "C7", "C7", "C7", "F7", "F7", "C7", "C7", "G7", "F7", "C7", "C7"],
|
||||
"roman_numeral": ["I7", "I7", "I7", "I7", "IV7", "IV7", "I7", "I7", "V7", "IV7", "I7", "I7"],
|
||||
"emotion": "忧郁、深沉、表达性强",
|
||||
"difficulty": 2,
|
||||
"usage": "12小节蓝调,摇滚和布鲁斯的基础",
|
||||
"examples": ["《Johnny B. Goode》", "《Stormy Monday》"]
|
||||
}
|
||||
],
|
||||
|
||||
"jazz_progressions": [
|
||||
{
|
||||
"name": "ii-V-I 进行",
|
||||
"pattern": "ii7-V7-Imaj7",
|
||||
"chords": ["Dm7", "G7", "Cmaj7"],
|
||||
"roman_numeral": ["ii7", "V7", "Imaj7"],
|
||||
"emotion": "成熟、精致、和谐",
|
||||
"difficulty": 3,
|
||||
"usage": "爵士乐最基本的进行,用于歌曲的解决",
|
||||
"examples": ["《All The Things You Are》", "《Autumn Leaves》"],
|
||||
"extensions": [
|
||||
{"pattern": "ii7(b5)-V7alt-i7", "description": "小调ii-V-i"},
|
||||
{"pattern": "IImaj7-V7-Imaj7", "description": "大二级替代"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Circle of Fifths",
|
||||
"pattern": "I-vi-ii-V",
|
||||
"chords": ["Cmaj7", "Am7", "Dm7", "G7"],
|
||||
"roman_numeral": ["Imaj7", "vi7", "ii7", "V7"],
|
||||
"emotion": "流动、自然、渐进",
|
||||
"difficulty": 3,
|
||||
"usage": "爵士标准曲常用,创造平滑的和声运动",
|
||||
"examples": ["《I Got Rhythm》", "《All of Me》"]
|
||||
}
|
||||
],
|
||||
|
||||
"chinese_style": [
|
||||
{
|
||||
"name": "五声音阶进行",
|
||||
"pattern": "I-III-vi-IV",
|
||||
"chords": ["C", "E", "Am", "F"],
|
||||
"roman_numeral": ["I", "III", "vi", "IV"],
|
||||
"emotion": "古典、东方韵味、祥和",
|
||||
"difficulty": 2,
|
||||
"usage": "中国风歌曲,古风音乐",
|
||||
"examples": ["《青花瓷》", "《菊花台》"],
|
||||
"scales": ["C-D-E-G-A (宫商角徵羽)"],
|
||||
"instruments": ["古筝", "二胡", "笛子", "琵琶"]
|
||||
},
|
||||
{
|
||||
"name": "宫调式进行",
|
||||
"pattern": "I-V-vi-IV-ii-V-I",
|
||||
"chords": ["C", "G", "Am", "F", "Dm", "G", "C"],
|
||||
"roman_numeral": ["I", "V", "vi", "IV", "ii", "V", "I"],
|
||||
"emotion": "庄重、典雅、传统",
|
||||
"difficulty": 2,
|
||||
"usage": "古风歌曲的主歌部分,营造古典氛围",
|
||||
"traditional_harmony": "以宫音为主,强调五度圈运动"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
"advanced_techniques": {
|
||||
"chord_substitutions": [
|
||||
{
|
||||
"name": "三全音替代",
|
||||
"original": "V7",
|
||||
"substitute": "♭II7",
|
||||
"example": {"original": "G7", "substitute": "D♭7"},
|
||||
"effect": "增加色彩和张力",
|
||||
"usage": "爵士乐中常用,特别在ii-V-I进行中"
|
||||
},
|
||||
{
|
||||
"name": "相对和弦替代",
|
||||
"original": "I",
|
||||
"substitute": "vi",
|
||||
"example": {"original": "C", "substitute": "Am"},
|
||||
"effect": "从大调转向小调色彩",
|
||||
"usage": "营造忧郁或内省的情绪"
|
||||
},
|
||||
{
|
||||
"name": "二级和弦",
|
||||
"technique": "目标和弦前加入其二级和弦",
|
||||
"example": "C-Dm-G7-C (在G7前加入Dm)",
|
||||
"effect": "增强和声运动感",
|
||||
"usage": "增加进行的丰富性"
|
||||
}
|
||||
],
|
||||
|
||||
"modal_progressions": [
|
||||
{
|
||||
"mode": "Dorian",
|
||||
"characteristic": "自然六度,♭七度",
|
||||
"progression": "i-IV-♭VII-i",
|
||||
"chords": ["Dm", "G", "C", "Dm"],
|
||||
"emotion": "神秘、中性、民族色彩",
|
||||
"examples": ["《Scarborough Fair》", "《Eleanor Rigby》"]
|
||||
},
|
||||
{
|
||||
"mode": "Mixolydian",
|
||||
"characteristic": "♭七度",
|
||||
"progression": "I-♭VII-IV-I",
|
||||
"chords": ["G", "F", "C", "G"],
|
||||
"emotion": "开朗中带有忧郁",
|
||||
"examples": ["《Sweet Caroline》", "《Norwegian Wood》"]
|
||||
},
|
||||
{
|
||||
"mode": "Aeolian (Natural Minor)",
|
||||
"characteristic": "♭三度,♭六度,♭七度",
|
||||
"progression": "i-♭VII-♭VI-♭VII",
|
||||
"chords": ["Am", "G", "F", "G"],
|
||||
"emotion": "忧郁、深沉、戏剧性",
|
||||
"examples": ["《House of the Rising Sun》", "《Stairway to Heaven》"]
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
"rhythm_patterns": {
|
||||
"basic_strumming": [
|
||||
{
|
||||
"name": "基础4/4拍型",
|
||||
"pattern": "D-D-U-U-D-U",
|
||||
"notation": "下-下-上-上-下-上",
|
||||
"tempo": "适中速度 (120 BPM)",
|
||||
"usage": "最基础的吉他扫弦模式",
|
||||
"songs": ["《Hotel California》", "《Wonderwall》"]
|
||||
},
|
||||
{
|
||||
"name": "民谣分解",
|
||||
"pattern": "1-3-2-3-1-3-2-3",
|
||||
"fingers": "拇指-食指-中指-食指",
|
||||
"usage": "指弹民谣,抒情歌曲",
|
||||
"songs": ["《Dust in the Wind》", "《Blackbird》"]
|
||||
}
|
||||
],
|
||||
|
||||
"advanced_patterns": [
|
||||
{
|
||||
"name": "放克节奏",
|
||||
"pattern": "重音在16分音符的切分位置",
|
||||
"characteristic": "强调反拍,使用切音技巧",
|
||||
"instruments": ["电吉他", "贝斯", "鼓"],
|
||||
"examples": ["《Superstition》", "《I Want Your Love》"]
|
||||
},
|
||||
{
|
||||
"name": "雷鬼节奏",
|
||||
"pattern": "强调2、4拍的后半拍",
|
||||
"characteristic": "轻松摇摆,强调上拍",
|
||||
"tempo": "中慢速度 (70-90 BPM)",
|
||||
"examples": ["《No Woman No Cry》", "《Three Little Birds》"]
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
"song_structure_templates": {
|
||||
"pop_structure": {
|
||||
"sections": ["Intro", "Verse 1", "Pre-Chorus", "Chorus", "Verse 2", "Pre-Chorus", "Chorus", "Bridge", "Chorus", "Outro"],
|
||||
"chord_suggestions": {
|
||||
"Verse": "较为平静的进行,如 vi-IV-I-V",
|
||||
"Pre-Chorus": "建立张力,如 ii-V 或 IV-V",
|
||||
"Chorus": "强有力的进行,如 I-V-vi-IV",
|
||||
"Bridge": "对比性进行,可尝试不同调性"
|
||||
}
|
||||
},
|
||||
|
||||
"ballad_structure": {
|
||||
"sections": ["Intro", "Verse 1", "Chorus", "Verse 2", "Chorus", "Bridge", "Chorus", "Outro"],
|
||||
"dynamic": "从安静开始,逐渐建立到高潮",
|
||||
"chord_suggestions": {
|
||||
"Verse": "简单温柔的进行",
|
||||
"Chorus": "情感饱满的和弦"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
"key_relationships": {
|
||||
"circle_of_fifths": {
|
||||
"major_keys": ["C", "G", "D", "A", "E", "B", "F#", "Db", "Ab", "Eb", "Bb", "F"],
|
||||
"relative_minors": ["Am", "Em", "Bm", "F#m", "C#m", "G#m", "D#m", "Bbm", "Fm", "Cm", "Gm", "Dm"],
|
||||
"modulation_techniques": [
|
||||
"共同和弦转调",
|
||||
"属和弦转调",
|
||||
"半音阶下行转调"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
{
|
||||
"meta": {
|
||||
"version": "1.0",
|
||||
"description": "国风旋律模式库 - 定义情绪映射、结构差异和常用旋律模式",
|
||||
"created_for": "Musicify 国风旋律生成 Skill"
|
||||
},
|
||||
|
||||
"emotions": {
|
||||
"sorrowful": {
|
||||
"name": "忧伤",
|
||||
"preferredMode": "yu",
|
||||
"contour": "descending",
|
||||
"intervalRange": 3,
|
||||
"tempo": "慢板 (60-72 BPM)",
|
||||
"characteristics": [
|
||||
"多用下行旋律线",
|
||||
"羽调式为主,偶用商调式",
|
||||
"音程以级进为主,偶有小跳",
|
||||
"多用滑音和颤音装饰",
|
||||
"句尾常落在羽音(6)或商音(2)"
|
||||
],
|
||||
"typicalPatterns": ["weeping", "sighing"]
|
||||
},
|
||||
"joyful": {
|
||||
"name": "欢快",
|
||||
"preferredMode": "gong",
|
||||
"contour": "ascending",
|
||||
"intervalRange": 5,
|
||||
"tempo": "快板 (120-140 BPM)",
|
||||
"characteristics": [
|
||||
"多用上行旋律线",
|
||||
"宫调式为主,偶用徵调式",
|
||||
"音程跳进较多,节奏活泼",
|
||||
"装饰音轻快,多用波音",
|
||||
"句尾常落在宫音(1)或徵音(5)"
|
||||
],
|
||||
"typicalPatterns": ["celebration", "dance"]
|
||||
},
|
||||
"peaceful": {
|
||||
"name": "平静",
|
||||
"preferredMode": "jue",
|
||||
"contour": "stable",
|
||||
"intervalRange": 2,
|
||||
"tempo": "中板 (80-96 BPM)",
|
||||
"characteristics": [
|
||||
"旋律走向平稳",
|
||||
"角调式为主,音色空灵",
|
||||
"以级进为主,避免大跳",
|
||||
"装饰音少而精",
|
||||
"音域范围较窄"
|
||||
],
|
||||
"typicalPatterns": ["flowing", "meditation"]
|
||||
},
|
||||
"passionate": {
|
||||
"name": "激昂",
|
||||
"preferredMode": "zhi",
|
||||
"contour": "wave",
|
||||
"intervalRange": 6,
|
||||
"tempo": "快板 (116-132 BPM)",
|
||||
"characteristics": [
|
||||
"旋律起伏大,波浪式进行",
|
||||
"徵调式为主,热情奔放",
|
||||
"音程跳进多,张力强",
|
||||
"高音区使用频繁",
|
||||
"句尾常有力度强调"
|
||||
],
|
||||
"typicalPatterns": ["heroic", "climax"]
|
||||
},
|
||||
"nostalgic": {
|
||||
"name": "思念",
|
||||
"preferredMode": "shang",
|
||||
"contour": "wave",
|
||||
"intervalRange": 4,
|
||||
"tempo": "慢板 (66-80 BPM)",
|
||||
"characteristics": [
|
||||
"旋律婉转起伏",
|
||||
"商调式为主,深沉内敛",
|
||||
"上行后下行,欲言又止",
|
||||
"多用滑音表达情感",
|
||||
"句尾常有延长音"
|
||||
],
|
||||
"typicalPatterns": ["longing", "sighing"]
|
||||
},
|
||||
"ethereal": {
|
||||
"name": "空灵",
|
||||
"preferredMode": "jue",
|
||||
"contour": "ascending",
|
||||
"intervalRange": 5,
|
||||
"tempo": "自由节拍",
|
||||
"characteristics": [
|
||||
"旋律飘逸,多用高音区",
|
||||
"角调式为主,清新脱俗",
|
||||
"音符稀疏,留白较多",
|
||||
"颤音装饰增加空灵感",
|
||||
"节奏自由,不拘一格"
|
||||
],
|
||||
"typicalPatterns": ["floating", "meditation"]
|
||||
}
|
||||
},
|
||||
|
||||
"structures": {
|
||||
"verse": {
|
||||
"name": "主歌",
|
||||
"range": 6,
|
||||
"contour": "stable",
|
||||
"characteristics": [
|
||||
"音域较窄,一般在六度以内",
|
||||
"旋律平稳,以叙述为主",
|
||||
"节奏规整,便于歌词表达",
|
||||
"情感内敛,为副歌做铺垫"
|
||||
],
|
||||
"typicalStartNotes": [5, 3, 1],
|
||||
"typicalEndNotes": [1, 5, 6]
|
||||
},
|
||||
"chorus": {
|
||||
"name": "副歌",
|
||||
"range": 10,
|
||||
"contour": "wave",
|
||||
"characteristics": [
|
||||
"音域扩展,可达十度",
|
||||
"旋律起伏大,情感爆发",
|
||||
"常有高潮点设计",
|
||||
"节奏可更自由或更强烈"
|
||||
],
|
||||
"typicalStartNotes": [1, 5, 6],
|
||||
"typicalEndNotes": [1, 5]
|
||||
},
|
||||
"bridge": {
|
||||
"name": "桥段",
|
||||
"range": 8,
|
||||
"contour": "ascending",
|
||||
"characteristics": [
|
||||
"音域介于主歌和副歌之间",
|
||||
"旋律上行为主,推向高潮",
|
||||
"可转调或变化调式",
|
||||
"为副歌再现做准备"
|
||||
],
|
||||
"typicalStartNotes": [6, 3, 2],
|
||||
"typicalEndNotes": [5, 1]
|
||||
}
|
||||
},
|
||||
|
||||
"patterns": {
|
||||
"weeping": {
|
||||
"name": "哭腔模式",
|
||||
"sequence": [6, 5, 3, 2, 1, 6],
|
||||
"contour": "descending",
|
||||
"usage": "表达悲伤、哀怨情绪",
|
||||
"examples": ["《青花瓷》副歌", "《烟花易冷》"],
|
||||
"ornaments": ["滑音", "颤音"]
|
||||
},
|
||||
"sighing": {
|
||||
"name": "叹息模式",
|
||||
"sequence": [5, 6, 5, 3, 2],
|
||||
"contour": "wave",
|
||||
"usage": "表达思念、无奈情绪",
|
||||
"examples": ["《千里之外》", "《菊花台》"],
|
||||
"ornaments": ["滑音"]
|
||||
},
|
||||
"celebration": {
|
||||
"name": "欢庆模式",
|
||||
"sequence": [1, 3, 5, 6, 5, 3, 1],
|
||||
"contour": "ascending",
|
||||
"usage": "表达喜悦、庆祝情绪",
|
||||
"examples": ["《好日子》", "《恭喜发财》"],
|
||||
"ornaments": ["波音"]
|
||||
},
|
||||
"dance": {
|
||||
"name": "舞曲模式",
|
||||
"sequence": [5, 1, 3, 5, 6, 5],
|
||||
"contour": "wave",
|
||||
"usage": "活泼的舞蹈节奏",
|
||||
"examples": ["《最炫民族风》"],
|
||||
"ornaments": ["波音", "倚音"]
|
||||
},
|
||||
"flowing": {
|
||||
"name": "流水模式",
|
||||
"sequence": [3, 2, 1, 2, 3, 5],
|
||||
"contour": "stable",
|
||||
"usage": "平静、流畅的叙述",
|
||||
"examples": ["《高山流水》", "《渔舟唱晚》"],
|
||||
"ornaments": ["滑音"]
|
||||
},
|
||||
"meditation": {
|
||||
"name": "禅意模式",
|
||||
"sequence": [5, 3, 5, 6, 5],
|
||||
"contour": "stable",
|
||||
"usage": "空灵、冥想的意境",
|
||||
"examples": ["《大悲咒》", "《心经》"],
|
||||
"ornaments": ["颤音"]
|
||||
},
|
||||
"heroic": {
|
||||
"name": "英雄模式",
|
||||
"sequence": [5, 1, 5, 6, 1, 5],
|
||||
"contour": "ascending",
|
||||
"usage": "激昂、豪迈的情绪",
|
||||
"examples": ["《精忠报国》", "《男儿当自强》"],
|
||||
"ornaments": ["倚音"]
|
||||
},
|
||||
"climax": {
|
||||
"name": "高潮模式",
|
||||
"sequence": [1, 3, 5, 6, 1, 6, 5],
|
||||
"contour": "wave",
|
||||
"usage": "歌曲高潮部分",
|
||||
"examples": ["副歌高潮段落"],
|
||||
"ornaments": ["颤音", "滑音"]
|
||||
},
|
||||
"longing": {
|
||||
"name": "思念模式",
|
||||
"sequence": [2, 3, 5, 3, 2, 1, 2],
|
||||
"contour": "wave",
|
||||
"usage": "表达思念、期盼情绪",
|
||||
"examples": ["《但愿人长久》", "《明月几时有》"],
|
||||
"ornaments": ["滑音", "颤音"]
|
||||
},
|
||||
"floating": {
|
||||
"name": "飘逸模式",
|
||||
"sequence": [3, 5, 6, 5, 3],
|
||||
"contour": "ascending",
|
||||
"usage": "空灵、超脱的意境",
|
||||
"examples": ["《沧海一声笑》"],
|
||||
"ornaments": ["颤音"]
|
||||
}
|
||||
},
|
||||
|
||||
"references": {
|
||||
"classicSongs": [
|
||||
{
|
||||
"name": "青花瓷",
|
||||
"mode": "yu",
|
||||
"emotion": "nostalgic",
|
||||
"features": "羽调式为主,旋律婉转,多用下行和滑音"
|
||||
},
|
||||
{
|
||||
"name": "菊花台",
|
||||
"mode": "yu",
|
||||
"emotion": "sorrowful",
|
||||
"features": "羽调式,忧伤婉转,句尾多用延长音"
|
||||
},
|
||||
{
|
||||
"name": "千里之外",
|
||||
"mode": "shang",
|
||||
"emotion": "nostalgic",
|
||||
"features": "商调式,深沉内敛,旋律起伏适中"
|
||||
},
|
||||
{
|
||||
"name": "沧海一声笑",
|
||||
"mode": "zhi",
|
||||
"emotion": "passionate",
|
||||
"features": "徵调式,豪迈奔放,音域宽广"
|
||||
},
|
||||
{
|
||||
"name": "高山流水",
|
||||
"mode": "gong",
|
||||
"emotion": "peaceful",
|
||||
"features": "宫调式,典雅庄重,旋律流畅"
|
||||
},
|
||||
{
|
||||
"name": "茉莉花",
|
||||
"mode": "gong",
|
||||
"emotion": "peaceful",
|
||||
"features": "宫调式,清新优美,级进为主"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
{
|
||||
"meta": {
|
||||
"version": "1.0",
|
||||
"description": "MIDI 解析规则库 - 定义音符映射、时值解析、节奏型识别和调式推断规则",
|
||||
"created_for": "Musicify 旋律风格学习 Skill"
|
||||
},
|
||||
|
||||
"noteMapping": {
|
||||
"midiToName": {
|
||||
"36": "C2", "37": "C#2", "38": "D2", "39": "D#2", "40": "E2", "41": "F2",
|
||||
"42": "F#2", "43": "G2", "44": "G#2", "45": "A2", "46": "A#2", "47": "B2",
|
||||
"48": "C3", "49": "C#3", "50": "D3", "51": "D#3", "52": "E3", "53": "F3",
|
||||
"54": "F#3", "55": "G3", "56": "G#3", "57": "A3", "58": "A#3", "59": "B3",
|
||||
"60": "C4", "61": "C#4", "62": "D4", "63": "D#4", "64": "E4", "65": "F4",
|
||||
"66": "F#4", "67": "G4", "68": "G#4", "69": "A4", "70": "A#4", "71": "B4",
|
||||
"72": "C5", "73": "C#5", "74": "D5", "75": "D#5", "76": "E5", "77": "F5",
|
||||
"78": "F#5", "79": "G5", "80": "G#5", "81": "A5", "82": "A#5", "83": "B5",
|
||||
"84": "C6", "85": "C#6", "86": "D6", "87": "D#6", "88": "E6", "89": "F6",
|
||||
"90": "F#6", "91": "G6", "92": "G#6", "93": "A6", "94": "A#6", "95": "B6"
|
||||
},
|
||||
"midiToJianpu": {
|
||||
"description": "基于 C 大调的简谱映射,实际使用时需根据调号偏移",
|
||||
"baseKey": "C",
|
||||
"mapping": {
|
||||
"0": "1", "2": "2", "4": "3", "5": "4", "7": "5", "9": "6", "11": "7"
|
||||
},
|
||||
"octaveMarkers": {
|
||||
"-2": ",,", "-1": ",", "0": "", "1": "'", "2": "''"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
"durationMapping": {
|
||||
"ticksPerBeat": 480,
|
||||
"durationNames": {
|
||||
"1920": { "name": "全音符", "symbol": "○", "beats": 4 },
|
||||
"1440": { "name": "附点二分音符", "symbol": "●.", "beats": 3 },
|
||||
"960": { "name": "二分音符", "symbol": "●", "beats": 2 },
|
||||
"720": { "name": "附点四分音符", "symbol": "♩.", "beats": 1.5 },
|
||||
"480": { "name": "四分音符", "symbol": "♩", "beats": 1 },
|
||||
"360": { "name": "附点八分音符", "symbol": "♪.", "beats": 0.75 },
|
||||
"240": { "name": "八分音符", "symbol": "♪", "beats": 0.5 },
|
||||
"180": { "name": "附点十六分音符", "symbol": "♬.", "beats": 0.375 },
|
||||
"120": { "name": "十六分音符", "symbol": "♬", "beats": 0.25 },
|
||||
"160": { "name": "三连音(四分)", "symbol": "♩³", "beats": 0.333 },
|
||||
"80": { "name": "三连音(八分)", "symbol": "♪³", "beats": 0.167 }
|
||||
},
|
||||
"tolerancePercent": 10
|
||||
},
|
||||
|
||||
"rhythmPatterns": {
|
||||
"quarter": {
|
||||
"name": "四分音符型",
|
||||
"durations": [480],
|
||||
"description": "稳定的四分音符节奏,常用于叙述性段落",
|
||||
"category": "basic"
|
||||
},
|
||||
"eighth": {
|
||||
"name": "八分音符型",
|
||||
"durations": [240, 240],
|
||||
"description": "连续八分音符,增加流动感",
|
||||
"category": "basic"
|
||||
},
|
||||
"dotted_quarter_eighth": {
|
||||
"name": "附点四分+八分",
|
||||
"durations": [720, 240],
|
||||
"description": "附点节奏,增加推动力",
|
||||
"category": "dotted"
|
||||
},
|
||||
"eighth_dotted_quarter": {
|
||||
"name": "八分+附点四分",
|
||||
"durations": [240, 720],
|
||||
"description": "切分感的附点节奏",
|
||||
"category": "dotted"
|
||||
},
|
||||
"syncopation_basic": {
|
||||
"name": "基本切分",
|
||||
"durations": [240, 480, 240],
|
||||
"description": "基本切分节奏,强拍弱化",
|
||||
"category": "syncopation"
|
||||
},
|
||||
"syncopation_offbeat": {
|
||||
"name": "后半拍切分",
|
||||
"durations": [240, 240, 480],
|
||||
"description": "后半拍强调的切分",
|
||||
"category": "syncopation"
|
||||
},
|
||||
"triplet_quarter": {
|
||||
"name": "四分三连音",
|
||||
"durations": [160, 160, 160],
|
||||
"description": "三连音节奏,增加流畅感",
|
||||
"category": "triplet"
|
||||
},
|
||||
"long_short": {
|
||||
"name": "长短型",
|
||||
"durations": [960, 480],
|
||||
"description": "二分+四分,舒缓的节奏",
|
||||
"category": "basic"
|
||||
},
|
||||
"sixteenth_group": {
|
||||
"name": "十六分音符组",
|
||||
"durations": [120, 120, 120, 120],
|
||||
"description": "快速的十六分音符,增加紧张感",
|
||||
"category": "fast"
|
||||
}
|
||||
},
|
||||
|
||||
"modeDetection": {
|
||||
"description": "基于五声音阶特征音检测调式",
|
||||
"pentatonic": {
|
||||
"gong": {
|
||||
"name": "宫调式",
|
||||
"characteristicDegrees": [0, 2, 4, 7, 9],
|
||||
"rootDegree": 0,
|
||||
"endingNotes": [0, 7],
|
||||
"weight": { "root": 3, "fifth": 2, "others": 1 }
|
||||
},
|
||||
"shang": {
|
||||
"name": "商调式",
|
||||
"characteristicDegrees": [0, 2, 4, 7, 9],
|
||||
"rootDegree": 2,
|
||||
"endingNotes": [2, 0],
|
||||
"weight": { "root": 3, "fifth": 2, "others": 1 }
|
||||
},
|
||||
"jue": {
|
||||
"name": "角调式",
|
||||
"characteristicDegrees": [0, 2, 4, 7, 9],
|
||||
"rootDegree": 4,
|
||||
"endingNotes": [4, 2],
|
||||
"weight": { "root": 3, "fifth": 2, "others": 1 }
|
||||
},
|
||||
"zhi": {
|
||||
"name": "徵调式",
|
||||
"characteristicDegrees": [0, 2, 4, 7, 9],
|
||||
"rootDegree": 7,
|
||||
"endingNotes": [7, 9],
|
||||
"weight": { "root": 3, "fifth": 2, "others": 1 }
|
||||
},
|
||||
"yu": {
|
||||
"name": "羽调式",
|
||||
"characteristicDegrees": [0, 2, 4, 7, 9],
|
||||
"rootDegree": 9,
|
||||
"endingNotes": [9, 7],
|
||||
"weight": { "root": 3, "fifth": 2, "others": 1 }
|
||||
}
|
||||
},
|
||||
"keySignatures": {
|
||||
"C": 0, "C#": 1, "Db": 1, "D": 2, "D#": 3, "Eb": 3,
|
||||
"E": 4, "F": 5, "F#": 6, "Gb": 6, "G": 7, "G#": 8,
|
||||
"Ab": 8, "A": 9, "A#": 10, "Bb": 10, "B": 11
|
||||
}
|
||||
},
|
||||
|
||||
"trackMatching": {
|
||||
"vocalRangeMin": 48,
|
||||
"vocalRangeMax": 84,
|
||||
"vocalRangeDescription": "人声音域范围 C3(48) 到 C6(84)",
|
||||
"vocalRangeZones": {
|
||||
"belowVocal": { "min": 0, "max": 47, "description": "低于人声范围,可能是贝斯" },
|
||||
"vocalLow": { "min": 48, "max": 59, "description": "C3-B3,男声常用区" },
|
||||
"vocalMid": { "min": 60, "max": 71, "description": "C4-B4,男女声共用区" },
|
||||
"vocalHigh": { "min": 72, "max": 84, "description": "C5-C6,女声常用区" },
|
||||
"aboveVocal": { "min": 85, "max": 127, "description": "高于人声范围,可能是装饰音" }
|
||||
},
|
||||
"tolerancePercent": 15,
|
||||
"minVocalRangeOverlap": 0.5,
|
||||
"priorityKeywords": ["vocal", "melody", "voice", "lead", "主旋律", "人声"],
|
||||
"matchingRules": [
|
||||
{
|
||||
"rule": "keyword_match",
|
||||
"description": "音轨名称包含人声关键词时优先选择",
|
||||
"priority": 1,
|
||||
"scoreBonus": 30
|
||||
},
|
||||
{
|
||||
"rule": "note_count_match",
|
||||
"description": "音符数量与歌词字数最接近的音轨",
|
||||
"priority": 2,
|
||||
"maxScore": 40,
|
||||
"toleranceLevels": {
|
||||
"exact": { "tolerance": 0, "score": 40 },
|
||||
"close": { "tolerance": 0.05, "score": 35 },
|
||||
"acceptable": { "tolerance": 0.15, "score": 28 }
|
||||
}
|
||||
},
|
||||
{
|
||||
"rule": "pitch_range_filter",
|
||||
"description": "过滤音域超出人声范围的音轨",
|
||||
"priority": 3,
|
||||
"maxScore": 30,
|
||||
"overlapScoring": {
|
||||
"full": { "minOverlap": 1.0, "score": 30 },
|
||||
"high": { "minOverlap": 0.75, "score": 22 },
|
||||
"medium": { "minOverlap": 0.5, "score": 15 },
|
||||
"low": { "minOverlap": 0, "score": 0 }
|
||||
}
|
||||
}
|
||||
],
|
||||
"confidenceThresholds": {
|
||||
"high": 90,
|
||||
"medium": 70,
|
||||
"low": 50
|
||||
},
|
||||
"confidenceDescriptions": {
|
||||
"high": "自动选择,无需确认",
|
||||
"medium": "建议选择,请求确认",
|
||||
"low": "需要用户手动确认",
|
||||
"noMatch": "不推荐,列出供参考"
|
||||
},
|
||||
"conflictResolution": {
|
||||
"scoreDifferenceThreshold": 5,
|
||||
"priorityOrder": ["keyword_match", "note_count_match", "pitch_range_filter"]
|
||||
}
|
||||
},
|
||||
|
||||
"intervalClassification": {
|
||||
"stepwise": {
|
||||
"name": "级进",
|
||||
"semitones": [1, 2],
|
||||
"description": "相邻音级的进行,旋律流畅"
|
||||
},
|
||||
"smallLeap": {
|
||||
"name": "小跳",
|
||||
"semitones": [3, 4],
|
||||
"description": "三度或四度跳进,增加起伏"
|
||||
},
|
||||
"largeLeap": {
|
||||
"name": "大跳",
|
||||
"semitones": [5, 6, 7, 8, 9, 10, 11, 12],
|
||||
"description": "五度及以上跳进,戏剧性强"
|
||||
}
|
||||
},
|
||||
|
||||
"contourAnalysis": {
|
||||
"ascending": {
|
||||
"name": "上行",
|
||||
"condition": "后一音高于前一音",
|
||||
"emotion": "积极、上升、期待"
|
||||
},
|
||||
"descending": {
|
||||
"name": "下行",
|
||||
"condition": "后一音低于前一音",
|
||||
"emotion": "忧伤、下沉、释放"
|
||||
},
|
||||
"stable": {
|
||||
"name": "平稳",
|
||||
"condition": "音高变化在二度以内",
|
||||
"emotion": "平静、叙述、稳定"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
{
|
||||
"meta": {
|
||||
"version": "1.0",
|
||||
"description": "五声音阶规则库 - 定义中国传统五声音阶的调式结构、装饰音和音程规则",
|
||||
"created_for": "Musicify 国风旋律生成 Skill"
|
||||
},
|
||||
|
||||
"scales": {
|
||||
"gong": {
|
||||
"name": "宫调式",
|
||||
"notes": [1, 2, 3, 5, 6],
|
||||
"root": 1,
|
||||
"characteristic": "以宫音(1)为主音,音阶明亮开阔,具有庄重典雅的特点",
|
||||
"emotion": "庄重、明亮、欢快、积极向上",
|
||||
"typicalCadence": [5, 1],
|
||||
"avoidNotes": [4, 7]
|
||||
},
|
||||
"shang": {
|
||||
"name": "商调式",
|
||||
"notes": [1, 2, 3, 5, 6],
|
||||
"root": 2,
|
||||
"characteristic": "以商音(2)为主音,音阶略带忧郁,具有深沉内敛的特点",
|
||||
"emotion": "深沉、内敛、略带忧郁、思念",
|
||||
"typicalCadence": [1, 2],
|
||||
"avoidNotes": [4, 7]
|
||||
},
|
||||
"jue": {
|
||||
"name": "角调式",
|
||||
"notes": [1, 2, 3, 5, 6],
|
||||
"root": 3,
|
||||
"characteristic": "以角音(3)为主音,音阶清新脱俗,具有空灵飘逸的特点",
|
||||
"emotion": "清新、空灵、飘逸、超脱",
|
||||
"typicalCadence": [2, 3],
|
||||
"avoidNotes": [4, 7]
|
||||
},
|
||||
"zhi": {
|
||||
"name": "徵调式",
|
||||
"notes": [1, 2, 3, 5, 6],
|
||||
"root": 5,
|
||||
"characteristic": "以徵音(5)为主音,音阶热情奔放,具有激昂豪迈的特点",
|
||||
"emotion": "热情、奔放、激昂、豪迈",
|
||||
"typicalCadence": [6, 5],
|
||||
"avoidNotes": [4, 7]
|
||||
},
|
||||
"yu": {
|
||||
"name": "羽调式",
|
||||
"notes": [1, 2, 3, 5, 6],
|
||||
"root": 6,
|
||||
"characteristic": "以羽音(6)为主音,音阶柔和婉转,具有忧伤哀怨的特点",
|
||||
"emotion": "忧伤、婉转、哀怨、柔美",
|
||||
"typicalCadence": [5, 6],
|
||||
"avoidNotes": [4, 7]
|
||||
}
|
||||
},
|
||||
|
||||
"ornaments": {
|
||||
"huayin": {
|
||||
"name": "滑音",
|
||||
"notation": "↗ 或 ↘",
|
||||
"description": "从一个音滑向另一个音,常用于表达情感的流动",
|
||||
"usage": "句尾延长音、情感转折处、模仿人声哭腔",
|
||||
"examples": ["5↗6", "3↘2"]
|
||||
},
|
||||
"chanyin": {
|
||||
"name": "颤音",
|
||||
"notation": "~",
|
||||
"description": "在主音上快速交替相邻音,增加音色的丰富性",
|
||||
"usage": "长音装饰、情感强调、模仿弦乐器效果",
|
||||
"examples": ["5~", "6~"]
|
||||
},
|
||||
"yiyin": {
|
||||
"name": "倚音",
|
||||
"notation": "小音符标记",
|
||||
"description": "在主音前快速演奏的装饰音,增加旋律的流畅性",
|
||||
"usage": "乐句开头、强拍装饰、增加韵味",
|
||||
"examples": ["(3)5", "(6)1"]
|
||||
},
|
||||
"boyin": {
|
||||
"name": "波音",
|
||||
"notation": "∿",
|
||||
"description": "主音与上方或下方相邻音快速交替一次",
|
||||
"usage": "轻快段落、活泼情绪、增加灵动感",
|
||||
"examples": ["5∿", "3∿"]
|
||||
}
|
||||
},
|
||||
|
||||
"intervals": {
|
||||
"allowed": [1, 2, 3, 4, 5],
|
||||
"preferred": [1, 2],
|
||||
"descriptions": {
|
||||
"1": "同度/八度 - 稳定、强调",
|
||||
"2": "二度 - 级进,最常用,流畅自然",
|
||||
"3": "三度 - 小跳进,增加起伏",
|
||||
"4": "四度 - 中跳进,增加张力",
|
||||
"5": "五度 - 大跳进,戏剧性强,慎用"
|
||||
},
|
||||
"rules": [
|
||||
"优先使用级进(二度)保持旋律流畅",
|
||||
"跳进后宜用级进反向进行",
|
||||
"避免连续大跳进",
|
||||
"句尾常用下行级进解决到主音"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
{
|
||||
"meta": {
|
||||
"version": "1.0",
|
||||
"description": "中文歌词押韵数据库 - 基于拼音和声调的押韵分析",
|
||||
"created_for": "Musicify Skill System"
|
||||
},
|
||||
|
||||
"rhyme_patterns": {
|
||||
"AABB": {
|
||||
"description": "两行一韵,连续押韵",
|
||||
"difficulty": 1,
|
||||
"usage": "适合流行歌曲,容易上口"
|
||||
},
|
||||
"ABAB": {
|
||||
"description": "交错押韵",
|
||||
"difficulty": 2,
|
||||
"usage": "增加节奏变化,适合抒情歌曲"
|
||||
},
|
||||
"ABCB": {
|
||||
"description": "隔行押韵",
|
||||
"difficulty": 2,
|
||||
"usage": "常用于民谣和说唱"
|
||||
},
|
||||
"AAAA": {
|
||||
"description": "通韵到底",
|
||||
"difficulty": 3,
|
||||
"usage": "适合短小精悍的段落"
|
||||
}
|
||||
},
|
||||
|
||||
"common_rhymes": {
|
||||
"爱情主题": [
|
||||
{"group": "ai", "words": ["爱", "在", "来", "开", "怀", "猜", "陪", "等待"]},
|
||||
{"group": "ing", "words": ["情", "心", "真", "深", "亲", "信", "认", "永恒"]},
|
||||
{"group": "ou", "words": ["走", "久", "守", "有", "后", "手", "温柔", "拥有"]},
|
||||
{"group": "an", "words": ["伴", "暖", "看", "汗", "伞", "岸", "陪伴", "温暖"]}
|
||||
],
|
||||
|
||||
"励志主题": [
|
||||
{"group": "eng", "words": ["梦", "能", "成", "风", "空", "勇", "冲", "成功"]},
|
||||
{"group": "iang", "words": ["想", "强", "光", "方", "向", "长", "希望", "力量"]},
|
||||
{"group": "u", "words": ["路", "步", "住", "哭", "努", "苦", "付出", "坚持"]},
|
||||
{"group": "i", "words": ["力", "立", "起", "地", "意", "义", "坚毅", "奇迹"]}
|
||||
],
|
||||
|
||||
"青春回忆": [
|
||||
{"group": "ian", "words": ["年", "天", "前", "甜", "变", "见", "青春", "遇见"]},
|
||||
{"group": "ao", "words": ["好", "老", "少", "跑", "闹", "笑", "美好", "年少"]},
|
||||
{"group": "ei", "words": ["美", "回", "累", "醉", "泪", "岁", "珍贵", "无悔"]},
|
||||
{"group": "ong", "words": ["梦", "中", "空", "痛", "重", "懂", "朦胧", "感动"]}
|
||||
],
|
||||
|
||||
"离别思念": [
|
||||
{"group": "ie", "words": ["别", "夜", "雪", "月", "切", "说", "离别", "永别"]},
|
||||
{"group": "iao", "words": ["远", "想", "飘", "桥", "料", "瞧", "思念", "遥远"]},
|
||||
{"group": "iu", "words": ["留", "久", "流", "愁", "求", "收", "停留", "不朽"]},
|
||||
{"group": "eng", "words": ["等", "朋", "冷", "疼", "能", "层", "等候", "心疼"]}
|
||||
],
|
||||
|
||||
"家乡故土": [
|
||||
{"group": "ang", "words": ["乡", "长", "方", "香", "窗", "望", "故乡", "远方"]},
|
||||
{"group": "ou", "words": ["家", "花", "话", "画", "挂", "牵挂", "变化"]},
|
||||
{"group": "i", "words": ["地", "里", "起", "记", "意", "立", "土地", "回忆"]},
|
||||
{"group": "an", "words": ["山", "田", "甘", "看", "暖", "伴", "青山", "温暖"]}
|
||||
]
|
||||
},
|
||||
|
||||
"emotion_vocabulary": {
|
||||
"欢快": {
|
||||
"adjectives": ["明亮", "轻快", "绚烂", "灿烂", "活泼", "欢乐", "愉悦", "畅快"],
|
||||
"verbs": ["跳跃", "飞扬", "奔跑", "舞蹈", "歌唱", "欢笑", "庆祝", "绽放"],
|
||||
"nouns": ["阳光", "彩虹", "花朵", "蝴蝶", "鸟儿", "春风", "笑声", "节拍"]
|
||||
},
|
||||
|
||||
"忧伤": {
|
||||
"adjectives": ["黯然", "凄凉", "孤独", "冷清", "沉重", "苦涩", "惆怅", "迷茫"],
|
||||
"verbs": ["凋零", "飘零", "消散", "哭泣", "叹息", "怀念", "失去", "离开"],
|
||||
"nouns": ["雨滴", "落叶", "寒风", "夜晚", "眼泪", "回忆", "阴霾", "孤影"]
|
||||
},
|
||||
|
||||
"温暖": {
|
||||
"adjectives": ["温柔", "暖和", "亲切", "慈爱", "安详", "舒适", "贴心", "甜蜜"],
|
||||
"verbs": ["拥抱", "守护", "陪伴", "关怀", "温暖", "照亮", "安慰", "包容"],
|
||||
"nouns": ["怀抱", "家", "母亲", "暖阳", "火炉", "热茶", "羽毛", "港湾"]
|
||||
},
|
||||
|
||||
"励志": {
|
||||
"adjectives": ["坚强", "勇敢", "坚定", "不屈", "执着", "顽强", "无畏", "坚毅"],
|
||||
"verbs": ["奋斗", "追求", "坚持", "突破", "攀登", "拼搏", "冲刺", "征服"],
|
||||
"nouns": ["梦想", "目标", "理想", "信念", "勇气", "力量", "意志", "希望"]
|
||||
},
|
||||
|
||||
"浪漫": {
|
||||
"adjectives": ["浪漫", "梦幻", "迷人", "优雅", "柔美", "诗意", "唯美", "动人"],
|
||||
"verbs": ["邂逅", "心动", "倾心", "眷恋", "凝视", "等候", "思念", "相拥"],
|
||||
"nouns": ["月光", "星空", "玫瑰", "诗歌", "约定", "信物", "回音", "倩影"]
|
||||
}
|
||||
},
|
||||
|
||||
"rhyme_quality_metrics": {
|
||||
"perfect_match": {
|
||||
"score": 95,
|
||||
"description": "完全押韵,音调和韵母都匹配"
|
||||
},
|
||||
"near_rhyme": {
|
||||
"score": 80,
|
||||
"description": "近似押韵,韵母相同音调略不同"
|
||||
},
|
||||
"assonance": {
|
||||
"score": 65,
|
||||
"description": "元音押韵,主要元音相同"
|
||||
},
|
||||
"consonance": {
|
||||
"score": 50,
|
||||
"description": "辅音押韵,结尾辅音相同"
|
||||
},
|
||||
"weak_rhyme": {
|
||||
"score": 30,
|
||||
"description": "弱押韵,仅部分音素相似"
|
||||
},
|
||||
"no_rhyme": {
|
||||
"score": 0,
|
||||
"description": "无押韵关系"
|
||||
}
|
||||
},
|
||||
|
||||
"songwriting_tips": {
|
||||
"rhyme_techniques": [
|
||||
{
|
||||
"name": "内部押韵",
|
||||
"description": "在同一行或相邻行的内部创造押韵效果",
|
||||
"example": "心中的梦想如星光闪亮"
|
||||
},
|
||||
{
|
||||
"name": "重复押韵",
|
||||
"description": "使用相同的韵脚增强记忆点",
|
||||
"example": "爱你的心永不改变,爱你到永远"
|
||||
},
|
||||
{
|
||||
"name": "多重押韵",
|
||||
"description": "在一行中使用多个押韵点",
|
||||
"example": "阳光灿烂照人间,温暖如春风拂面"
|
||||
}
|
||||
],
|
||||
|
||||
"rhythm_patterns": [
|
||||
{
|
||||
"name": "七字句",
|
||||
"pattern": "2-2-3",
|
||||
"example": "青春/如梦/多美好",
|
||||
"usage": "经典中文歌词节奏"
|
||||
},
|
||||
{
|
||||
"name": "五字句",
|
||||
"pattern": "2-3",
|
||||
"example": "思君/不见君",
|
||||
"usage": "古风歌曲常用"
|
||||
},
|
||||
{
|
||||
"name": "九字句",
|
||||
"pattern": "3-3-3",
|
||||
"example": "走过了/春夏秋冬/多少年",
|
||||
"usage": "适合叙事性歌曲"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
"advanced_features": {
|
||||
"tone_analysis": {
|
||||
"first_tone": {"description": "阴平,高平调", "compatibility": ["first_tone", "second_tone"]},
|
||||
"second_tone": {"description": "阳平,中升调", "compatibility": ["first_tone", "second_tone"]},
|
||||
"third_tone": {"description": "上声,低降升调", "compatibility": ["third_tone", "fourth_tone"]},
|
||||
"fourth_tone": {"description": "去声,高降调", "compatibility": ["third_tone", "fourth_tone"]}
|
||||
},
|
||||
|
||||
"syllable_structure": {
|
||||
"monosyllabic": {"description": "单音节词", "usage": "适合快节奏部分"},
|
||||
"disyllabic": {"description": "双音节词", "usage": "最常用的词汇结构"},
|
||||
"trisyllabic": {"description": "三音节词", "usage": "适合慢节奏抒情"},
|
||||
"polysyllabic": {"description": "多音节词", "usage": "用于特殊效果"}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,367 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
MP3 转 MIDI 工具
|
||||
使用 Demucs 分离人声 + Basic Pitch 转换 MIDI
|
||||
|
||||
用法:
|
||||
python audio_to_midi.py <input_mp3> [output_dir]
|
||||
python audio_to_midi.py --check # 检查依赖和硬件
|
||||
|
||||
输出:
|
||||
JSON 格式的处理结果
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
import json
|
||||
import subprocess
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
def output_json(data):
|
||||
"""输出 JSON 格式结果"""
|
||||
print(json.dumps(data, ensure_ascii=False, indent=2))
|
||||
|
||||
|
||||
def detect_hardware():
|
||||
"""检测可用硬件加速"""
|
||||
try:
|
||||
import torch
|
||||
if torch.cuda.is_available():
|
||||
device_name = torch.cuda.get_device_name(0)
|
||||
return {
|
||||
"device": "cuda",
|
||||
"name": device_name,
|
||||
"description": f"NVIDIA GPU 加速 ({device_name})",
|
||||
"estimated_time": "1-2 分钟"
|
||||
}
|
||||
elif hasattr(torch.backends, 'mps') and torch.backends.mps.is_available():
|
||||
return {
|
||||
"device": "mps",
|
||||
"name": "Apple Silicon",
|
||||
"description": "Apple Silicon 加速 (MPS)",
|
||||
"estimated_time": "2-3 分钟"
|
||||
}
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
return {
|
||||
"device": "cpu",
|
||||
"name": "CPU",
|
||||
"description": "CPU 模式 (较慢)",
|
||||
"estimated_time": "8-15 分钟"
|
||||
}
|
||||
|
||||
|
||||
def check_dependencies():
|
||||
"""检查依赖是否安装"""
|
||||
dependencies = {
|
||||
"demucs": {"installed": False, "version": None},
|
||||
"basic_pitch": {"installed": False, "version": None},
|
||||
"torch": {"installed": False, "version": None},
|
||||
}
|
||||
|
||||
try:
|
||||
import demucs
|
||||
dependencies["demucs"]["installed"] = True
|
||||
dependencies["demucs"]["version"] = getattr(demucs, '__version__', 'unknown')
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
try:
|
||||
import basic_pitch
|
||||
dependencies["basic_pitch"]["installed"] = True
|
||||
dependencies["basic_pitch"]["version"] = getattr(basic_pitch, '__version__', 'unknown')
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
try:
|
||||
import torch
|
||||
dependencies["torch"]["installed"] = True
|
||||
dependencies["torch"]["version"] = torch.__version__
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
return dependencies
|
||||
|
||||
|
||||
def check_command_available(cmd):
|
||||
"""检查命令行工具是否可用"""
|
||||
return shutil.which(cmd) is not None
|
||||
|
||||
|
||||
def separate_vocals(input_mp3, output_dir, device="cpu"):
|
||||
"""
|
||||
使用 Demucs 分离人声
|
||||
|
||||
Args:
|
||||
input_mp3: 输入 MP3 文件路径
|
||||
output_dir: 输出目录
|
||||
device: 使用的设备 (cuda/mps/cpu)
|
||||
|
||||
Returns:
|
||||
vocals_path: 人声文件路径
|
||||
"""
|
||||
input_path = Path(input_mp3)
|
||||
output_path = Path(output_dir)
|
||||
|
||||
# 构建 demucs 命令
|
||||
cmd = [
|
||||
sys.executable, "-m", "demucs",
|
||||
"--two-stems=vocals", # 只分离人声和伴奏
|
||||
"-o", str(output_path),
|
||||
"--device", device if device != "mps" else "mps",
|
||||
]
|
||||
|
||||
# 添加输入文件
|
||||
cmd.append(str(input_path))
|
||||
|
||||
# 执行命令
|
||||
try:
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=1800 # 30 分钟超时
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
return None, f"Demucs 执行失败: {result.stderr}"
|
||||
|
||||
# 查找输出的人声文件
|
||||
# Demucs 输出格式: output_dir/htdemucs/song_name/vocals.wav
|
||||
song_name = input_path.stem
|
||||
vocals_path = output_path / "htdemucs" / song_name / "vocals.wav"
|
||||
|
||||
if not vocals_path.exists():
|
||||
# 尝试其他可能的路径
|
||||
for model_dir in output_path.iterdir():
|
||||
if model_dir.is_dir():
|
||||
possible_path = model_dir / song_name / "vocals.wav"
|
||||
if possible_path.exists():
|
||||
vocals_path = possible_path
|
||||
break
|
||||
|
||||
if vocals_path.exists():
|
||||
return str(vocals_path), None
|
||||
else:
|
||||
return None, f"未找到人声文件,请检查 {output_path} 目录"
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
return None, "Demucs 处理超时 (超过 30 分钟)"
|
||||
except Exception as e:
|
||||
return None, f"Demucs 执行异常: {str(e)}"
|
||||
|
||||
|
||||
def convert_to_midi(vocals_wav, output_dir):
|
||||
"""
|
||||
使用 Basic Pitch 将人声转换为 MIDI
|
||||
|
||||
Args:
|
||||
vocals_wav: 人声 WAV 文件路径
|
||||
output_dir: 输出目录
|
||||
|
||||
Returns:
|
||||
midi_path: MIDI 文件路径
|
||||
"""
|
||||
vocals_path = Path(vocals_wav)
|
||||
output_path = Path(output_dir)
|
||||
|
||||
# 构建 basic-pitch 命令
|
||||
cmd = [
|
||||
sys.executable, "-m", "basic_pitch",
|
||||
str(output_path),
|
||||
str(vocals_path)
|
||||
]
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=300 # 5 分钟超时
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
return None, f"Basic Pitch 执行失败: {result.stderr}"
|
||||
|
||||
# 查找输出的 MIDI 文件
|
||||
# Basic Pitch 输出格式: output_dir/vocals_basic_pitch.mid
|
||||
midi_name = vocals_path.stem + "_basic_pitch.mid"
|
||||
midi_path = output_path / midi_name
|
||||
|
||||
if midi_path.exists():
|
||||
return str(midi_path), None
|
||||
else:
|
||||
# 尝试查找任何 .mid 文件
|
||||
for f in output_path.glob("*.mid"):
|
||||
return str(f), None
|
||||
return None, f"未找到 MIDI 文件,请检查 {output_path} 目录"
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
return None, "Basic Pitch 处理超时 (超过 5 分钟)"
|
||||
except Exception as e:
|
||||
return None, f"Basic Pitch 执行异常: {str(e)}"
|
||||
|
||||
|
||||
def process_audio(input_mp3, output_dir=None):
|
||||
"""
|
||||
完整的音频处理流程
|
||||
|
||||
Args:
|
||||
input_mp3: 输入 MP3 文件路径
|
||||
output_dir: 输出目录 (默认为输入文件所在目录)
|
||||
|
||||
Returns:
|
||||
处理结果字典
|
||||
"""
|
||||
input_path = Path(input_mp3)
|
||||
|
||||
if not input_path.exists():
|
||||
return {
|
||||
"status": "error",
|
||||
"error": f"输入文件不存在: {input_mp3}"
|
||||
}
|
||||
|
||||
if output_dir is None:
|
||||
output_dir = input_path.parent
|
||||
|
||||
output_path = Path(output_dir)
|
||||
output_path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 检测硬件
|
||||
hardware = detect_hardware()
|
||||
|
||||
# 检查依赖
|
||||
deps = check_dependencies()
|
||||
missing_deps = [name for name, info in deps.items()
|
||||
if not info["installed"] and name != "torch"]
|
||||
|
||||
if missing_deps:
|
||||
return {
|
||||
"status": "error",
|
||||
"error": "缺少必要依赖",
|
||||
"missing_dependencies": missing_deps,
|
||||
"install_command": f"pip install {' '.join(missing_deps).replace('_', '-')}",
|
||||
"alternative": "或使用在线工具: https://basicpitch.spotify.com"
|
||||
}
|
||||
|
||||
result = {
|
||||
"status": "processing",
|
||||
"input_file": str(input_path),
|
||||
"output_dir": str(output_path),
|
||||
"hardware": hardware,
|
||||
"steps": []
|
||||
}
|
||||
|
||||
# Step 1: 分离人声
|
||||
result["steps"].append({
|
||||
"step": 1,
|
||||
"name": "分离人声",
|
||||
"status": "in_progress",
|
||||
"tool": "Demucs"
|
||||
})
|
||||
|
||||
vocals_path, error = separate_vocals(
|
||||
input_mp3,
|
||||
output_path,
|
||||
hardware["device"]
|
||||
)
|
||||
|
||||
if error:
|
||||
result["status"] = "error"
|
||||
result["steps"][-1]["status"] = "failed"
|
||||
result["steps"][-1]["error"] = error
|
||||
return result
|
||||
|
||||
result["steps"][-1]["status"] = "completed"
|
||||
result["steps"][-1]["output"] = vocals_path
|
||||
result["vocals_file"] = vocals_path
|
||||
|
||||
# Step 2: 转换为 MIDI
|
||||
result["steps"].append({
|
||||
"step": 2,
|
||||
"name": "转换 MIDI",
|
||||
"status": "in_progress",
|
||||
"tool": "Basic Pitch"
|
||||
})
|
||||
|
||||
midi_path, error = convert_to_midi(vocals_path, output_path)
|
||||
|
||||
if error:
|
||||
result["status"] = "error"
|
||||
result["steps"][-1]["status"] = "failed"
|
||||
result["steps"][-1]["error"] = error
|
||||
return result
|
||||
|
||||
result["steps"][-1]["status"] = "completed"
|
||||
result["steps"][-1]["output"] = midi_path
|
||||
result["midi_file"] = midi_path
|
||||
|
||||
# 重命名 MIDI 文件为更友好的名称
|
||||
final_midi_name = input_path.stem + ".mid"
|
||||
final_midi_path = output_path / final_midi_name
|
||||
|
||||
if str(midi_path) != str(final_midi_path):
|
||||
try:
|
||||
shutil.move(midi_path, final_midi_path)
|
||||
result["midi_file"] = str(final_midi_path)
|
||||
except Exception:
|
||||
pass # 保持原文件名
|
||||
|
||||
result["status"] = "success"
|
||||
result["message"] = "MP3 转 MIDI 完成"
|
||||
result["completed_at"] = datetime.now().isoformat()
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def main():
|
||||
"""主函数"""
|
||||
if len(sys.argv) < 2:
|
||||
output_json({
|
||||
"status": "error",
|
||||
"error": "缺少参数",
|
||||
"usage": "python audio_to_midi.py <input_mp3> [output_dir]",
|
||||
"examples": [
|
||||
"python audio_to_midi.py song.mp3",
|
||||
"python audio_to_midi.py song.mp3 ./output",
|
||||
"python audio_to_midi.py --check"
|
||||
]
|
||||
})
|
||||
sys.exit(1)
|
||||
|
||||
# 检查模式
|
||||
if sys.argv[1] == "--check":
|
||||
deps = check_dependencies()
|
||||
hardware = detect_hardware()
|
||||
|
||||
all_installed = all(
|
||||
info["installed"]
|
||||
for name, info in deps.items()
|
||||
if name != "torch"
|
||||
)
|
||||
|
||||
output_json({
|
||||
"status": "ready" if all_installed else "missing_dependencies",
|
||||
"dependencies": deps,
|
||||
"hardware": hardware,
|
||||
"install_command": "pip install demucs basic-pitch" if not all_installed else None,
|
||||
"online_alternative": "https://basicpitch.spotify.com"
|
||||
})
|
||||
sys.exit(0 if all_installed else 1)
|
||||
|
||||
# 处理模式
|
||||
input_mp3 = sys.argv[1]
|
||||
output_dir = sys.argv[2] if len(sys.argv) > 2 else None
|
||||
|
||||
result = process_audio(input_mp3, output_dir)
|
||||
output_json(result)
|
||||
|
||||
sys.exit(0 if result["status"] == "success" else 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,677 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
MIDI 音乐分析器 - 专业级旋律风格分析
|
||||
从"太简单"的文件检查升级为专业 MIDI 分析和特征提取
|
||||
|
||||
支持功能:
|
||||
- 智能人声音轨识别
|
||||
- 深度旋律特征分析(节奏型、音程、调式)
|
||||
- 音乐理论分析(五声音阶、调式推断)
|
||||
- AI 风格学习准备
|
||||
"""
|
||||
|
||||
import sys
|
||||
import json
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Any, Optional, Tuple
|
||||
from dataclasses import dataclass, asdict
|
||||
import traceback
|
||||
|
||||
# 检查并导入依赖
|
||||
try:
|
||||
import mido
|
||||
import music21
|
||||
import numpy as np
|
||||
except ImportError as e:
|
||||
print(json.dumps({
|
||||
"status": "error",
|
||||
"error_type": "missing_dependency",
|
||||
"message": f"缺少必需的 Python 库: {str(e)}",
|
||||
"solution": "请安装依赖: pip install mido music21 numpy",
|
||||
"dependencies": {
|
||||
"mido": "MIDI 文件解析",
|
||||
"music21": "音乐理论分析",
|
||||
"numpy": "数值计算"
|
||||
}
|
||||
}, ensure_ascii=False, indent=2))
|
||||
sys.exit(1)
|
||||
|
||||
@dataclass
|
||||
class VocalTrackCandidate:
|
||||
"""人声音轨候选"""
|
||||
track_index: int
|
||||
track_name: str
|
||||
note_count: int
|
||||
note_range: Tuple[int, int] # (min_pitch, max_pitch)
|
||||
confidence_score: float
|
||||
reasons: List[str]
|
||||
|
||||
@dataclass
|
||||
class MelodyFeatures:
|
||||
"""旋律特征分析结果"""
|
||||
# 基本信息
|
||||
total_notes: int
|
||||
note_range: Tuple[int, int]
|
||||
duration_beats: float
|
||||
|
||||
# 节奏特征
|
||||
rhythm_complexity: float
|
||||
rhythm_patterns: Dict[str, float] # 节奏型分布
|
||||
syncopation_level: float
|
||||
|
||||
# 音程特征
|
||||
interval_distribution: Dict[str, float]
|
||||
stepwise_ratio: float
|
||||
leap_ratio: float
|
||||
|
||||
# 调式特征
|
||||
key_signature: str
|
||||
mode_analysis: Dict[str, float]
|
||||
scale_notes: List[str]
|
||||
|
||||
# 旋律轮廓
|
||||
contour_vector: List[int]
|
||||
phrase_structure: List[Tuple[int, int]]
|
||||
|
||||
class ProfessionalMidiAnalyzer:
|
||||
"""专业级 MIDI 分析器"""
|
||||
|
||||
def __init__(self):
|
||||
# 人声音域范围 (MIDI note numbers)
|
||||
self.vocal_range = (48, 84) # C3 to C6
|
||||
|
||||
# 五声音阶映射
|
||||
self.pentatonic_scales = {
|
||||
'C': [0, 2, 4, 7, 9], # C D E G A
|
||||
'G': [7, 9, 11, 2, 4], # G A B D E
|
||||
'D': [2, 4, 6, 9, 11], # D E F# A B
|
||||
'A': [9, 11, 1, 4, 6], # A B C# E F#
|
||||
'E': [4, 6, 8, 11, 1], # E F# G# B C#
|
||||
'B': [11, 1, 3, 6, 8], # B C# D# F# G#
|
||||
'F#': [6, 8, 10, 1, 3], # F# G# A# C# D#
|
||||
'Db': [1, 3, 5, 8, 10], # Db Eb F Ab Bb
|
||||
'Ab': [8, 10, 0, 3, 5], # Ab Bb C Eb F
|
||||
'Eb': [3, 5, 7, 10, 0], # Eb F G Bb C
|
||||
'Bb': [10, 0, 2, 5, 7], # Bb C D F G
|
||||
'F': [5, 7, 9, 0, 2] # F G A C D
|
||||
}
|
||||
|
||||
# 节奏模式识别
|
||||
self.rhythm_patterns = {
|
||||
'quarter': 480, # 四分音符
|
||||
'eighth': 240, # 八分音符
|
||||
'dotted_quarter': 720, # 附点四分音符
|
||||
'sixteenth': 120, # 十六分音符
|
||||
'triplet': 160 # 三连音
|
||||
}
|
||||
|
||||
def analyze_midi_file(self, midi_path: str, lyrics_path: Optional[str] = None) -> Dict[str, Any]:
|
||||
"""分析 MIDI 文件的主入口"""
|
||||
try:
|
||||
# 基本文件检查
|
||||
if not Path(midi_path).exists():
|
||||
raise FileNotFoundError(f"MIDI 文件不存在: {midi_path}")
|
||||
|
||||
# 加载 MIDI 文件
|
||||
midi_file = mido.MidiFile(midi_path)
|
||||
|
||||
# 分析歌词信息
|
||||
lyrics_info = self._analyze_lyrics(lyrics_path) if lyrics_path else None
|
||||
|
||||
# 识别人声音轨
|
||||
vocal_candidates = self._identify_vocal_tracks(midi_file, lyrics_info)
|
||||
|
||||
if not vocal_candidates:
|
||||
return self._create_error_result("no_vocal_track", "未找到合适的人声音轨")
|
||||
|
||||
# 选择最佳人声音轨
|
||||
best_vocal = max(vocal_candidates, key=lambda x: x.confidence_score)
|
||||
|
||||
# 提取音轨的音符数据
|
||||
notes = self._extract_notes_from_track(midi_file, best_vocal.track_index)
|
||||
|
||||
if not notes:
|
||||
return self._create_error_result("no_notes", "人声音轨中未找到音符数据")
|
||||
|
||||
# 深度旋律特征分析
|
||||
melody_features = self._extract_melody_features(notes, midi_file)
|
||||
|
||||
# 生成创作模式推荐
|
||||
mode_recommendation = self.recommend_creation_mode(melody_features, lyrics_info)
|
||||
|
||||
# 构建分析结果
|
||||
result = {
|
||||
"status": "success",
|
||||
"analysis_type": "professional",
|
||||
"file_info": {
|
||||
"midi_path": midi_path,
|
||||
"lyrics_path": lyrics_path,
|
||||
"file_size": Path(midi_path).stat().st_size,
|
||||
"track_count": len(midi_file.tracks)
|
||||
},
|
||||
"vocal_track_analysis": {
|
||||
"selected_track": asdict(best_vocal),
|
||||
"all_candidates": [asdict(c) for c in vocal_candidates],
|
||||
"selection_confidence": best_vocal.confidence_score
|
||||
},
|
||||
"melody_features": asdict(melody_features),
|
||||
"lyrics_analysis": lyrics_info,
|
||||
"mode_recommendation": mode_recommendation, # NEW: 模式推荐信息
|
||||
"technical_info": {
|
||||
"ticks_per_beat": midi_file.ticks_per_beat,
|
||||
"total_time": sum(msg.time for track in midi_file.tracks for msg in track),
|
||||
"format_type": midi_file.type
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
return self._create_error_result(
|
||||
"analysis_error",
|
||||
f"分析过程中发生错误: {str(e)}",
|
||||
{"traceback": traceback.format_exc()}
|
||||
)
|
||||
|
||||
def _analyze_lyrics(self, lyrics_path: str) -> Optional[Dict[str, Any]]:
|
||||
"""分析歌词文件"""
|
||||
try:
|
||||
with open(lyrics_path, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
|
||||
# 统计字数(排除标点符号)
|
||||
clean_text = ''.join(char for char in content if char.isalpha())
|
||||
|
||||
# 检测段落结构
|
||||
sections = []
|
||||
current_section = None
|
||||
|
||||
for line in content.split('\n'):
|
||||
line = line.strip()
|
||||
if line.startswith('[') and line.endswith(']'):
|
||||
if current_section:
|
||||
sections.append(current_section)
|
||||
current_section = {
|
||||
"name": line[1:-1],
|
||||
"lines": [],
|
||||
"char_count": 0
|
||||
}
|
||||
elif line and current_section:
|
||||
current_section["lines"].append(line)
|
||||
current_section["char_count"] += len([c for c in line if c.isalpha()])
|
||||
|
||||
if current_section:
|
||||
sections.append(current_section)
|
||||
|
||||
return {
|
||||
"total_chars": len(clean_text),
|
||||
"total_lines": len([line for line in content.split('\n') if line.strip() and not line.strip().startswith('[')]),
|
||||
"sections": sections,
|
||||
"has_structure_markers": any(line.startswith('[') for line in content.split('\n'))
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
return {"error": f"歌词分析失败: {str(e)}"}
|
||||
|
||||
def _identify_vocal_tracks(self, midi_file: mido.MidiFile, lyrics_info: Optional[Dict]) -> List[VocalTrackCandidate]:
|
||||
"""智能识别人声音轨"""
|
||||
candidates = []
|
||||
|
||||
for track_idx, track in enumerate(midi_file.tracks):
|
||||
notes = self._extract_notes_from_track(midi_file, track_idx)
|
||||
|
||||
if not notes:
|
||||
continue
|
||||
|
||||
# 计算基本信息
|
||||
pitches = [note['pitch'] for note in notes]
|
||||
min_pitch, max_pitch = min(pitches), max(pitches)
|
||||
note_count = len(notes)
|
||||
|
||||
# 评分系统
|
||||
score = 0.0
|
||||
reasons = []
|
||||
|
||||
# 1. 音轨名称匹配(30分)
|
||||
track_name = getattr(track, 'name', f'Track {track_idx}')
|
||||
vocal_keywords = ['vocal', 'voice', 'melody', 'lead', '主旋律', '人声']
|
||||
if any(keyword.lower() in track_name.lower() for keyword in vocal_keywords):
|
||||
score += 30
|
||||
reasons.append(f"音轨名包含人声关键词: {track_name}")
|
||||
|
||||
# 2. 音域匹配(25分)
|
||||
vocal_range_overlap = self._calculate_range_overlap(
|
||||
(min_pitch, max_pitch), self.vocal_range
|
||||
)
|
||||
if vocal_range_overlap > 0.7:
|
||||
score += 25
|
||||
reasons.append(f"音域高度匹配人声范围: {vocal_range_overlap:.1%}")
|
||||
elif vocal_range_overlap > 0.5:
|
||||
score += 15
|
||||
reasons.append(f"音域部分匹配人声范围: {vocal_range_overlap:.1%}")
|
||||
|
||||
# 3. 歌词字数匹配(20分)
|
||||
if lyrics_info and 'total_chars' in lyrics_info:
|
||||
lyrics_chars = lyrics_info['total_chars']
|
||||
if lyrics_chars > 0:
|
||||
ratio = abs(1 - note_count / lyrics_chars)
|
||||
if ratio < 0.1: # 10%内匹配
|
||||
score += 20
|
||||
reasons.append(f"音符数与歌词字数高度匹配: {note_count}≈{lyrics_chars}")
|
||||
elif ratio < 0.3: # 30%内匹配
|
||||
score += 10
|
||||
reasons.append(f"音符数与歌词字数基本匹配: {note_count}vs{lyrics_chars}")
|
||||
|
||||
# 4. 音符密度合理性(15分)
|
||||
if 20 <= note_count <= 200: # 合理的旋律长度
|
||||
score += 15
|
||||
reasons.append(f"音符数量合理: {note_count}")
|
||||
elif note_count > 10:
|
||||
score += 5
|
||||
reasons.append(f"音符数量可接受: {note_count}")
|
||||
|
||||
# 5. 旋律特征(10分)
|
||||
interval_variety = self._calculate_interval_variety(notes)
|
||||
if interval_variety > 0.3: # 有合理的音程变化
|
||||
score += 10
|
||||
reasons.append(f"音程变化丰富: {interval_variety:.2f}")
|
||||
|
||||
candidates.append(VocalTrackCandidate(
|
||||
track_index=track_idx,
|
||||
track_name=track_name,
|
||||
note_count=note_count,
|
||||
note_range=(min_pitch, max_pitch),
|
||||
confidence_score=score,
|
||||
reasons=reasons
|
||||
))
|
||||
|
||||
# 按置信度排序
|
||||
return sorted(candidates, key=lambda x: x.confidence_score, reverse=True)
|
||||
|
||||
def _extract_notes_from_track(self, midi_file: mido.MidiFile, track_idx: int) -> List[Dict]:
|
||||
"""从指定音轨提取音符信息"""
|
||||
track = midi_file.tracks[track_idx]
|
||||
notes = []
|
||||
current_time = 0
|
||||
active_notes = {} # pitch -> start_time
|
||||
|
||||
for msg in track:
|
||||
current_time += msg.time
|
||||
|
||||
if msg.type == 'note_on' and msg.velocity > 0:
|
||||
active_notes[msg.note] = current_time
|
||||
elif msg.type == 'note_off' or (msg.type == 'note_on' and msg.velocity == 0):
|
||||
if msg.note in active_notes:
|
||||
start_time = active_notes.pop(msg.note)
|
||||
duration = current_time - start_time
|
||||
|
||||
notes.append({
|
||||
'pitch': msg.note,
|
||||
'start_time': start_time,
|
||||
'duration': duration,
|
||||
'velocity': getattr(msg, 'velocity', 64)
|
||||
})
|
||||
|
||||
# 按开始时间排序
|
||||
return sorted(notes, key=lambda x: x['start_time'])
|
||||
|
||||
def _extract_melody_features(self, notes: List[Dict], midi_file: mido.MidiFile) -> MelodyFeatures:
|
||||
"""深度旋律特征提取"""
|
||||
ticks_per_beat = midi_file.ticks_per_beat
|
||||
|
||||
# 基本信息
|
||||
pitches = [note['pitch'] for note in notes]
|
||||
durations = [note['duration'] for note in notes]
|
||||
|
||||
# 节奏分析
|
||||
rhythm_analysis = self._analyze_rhythm_patterns(durations, ticks_per_beat)
|
||||
|
||||
# 音程分析
|
||||
interval_analysis = self._analyze_intervals(pitches)
|
||||
|
||||
# 调式分析
|
||||
key_analysis = self._analyze_key_and_mode(pitches)
|
||||
|
||||
# 旋律轮廓
|
||||
contour = self._extract_melody_contour(pitches)
|
||||
|
||||
# 乐句结构
|
||||
phrases = self._identify_phrases(notes, ticks_per_beat)
|
||||
|
||||
return MelodyFeatures(
|
||||
total_notes=len(notes),
|
||||
note_range=(min(pitches), max(pitches)),
|
||||
duration_beats=sum(durations) / ticks_per_beat,
|
||||
rhythm_complexity=rhythm_analysis['complexity'],
|
||||
rhythm_patterns=rhythm_analysis['patterns'],
|
||||
syncopation_level=rhythm_analysis['syncopation'],
|
||||
interval_distribution=interval_analysis['distribution'],
|
||||
stepwise_ratio=interval_analysis['stepwise_ratio'],
|
||||
leap_ratio=interval_analysis['leap_ratio'],
|
||||
key_signature=key_analysis['key'],
|
||||
mode_analysis=key_analysis['modes'],
|
||||
scale_notes=key_analysis['scale_notes'],
|
||||
contour_vector=contour,
|
||||
phrase_structure=phrases
|
||||
)
|
||||
|
||||
def _analyze_rhythm_patterns(self, durations: List[int], ticks_per_beat: int) -> Dict[str, Any]:
|
||||
"""分析节奏型模式"""
|
||||
if not durations:
|
||||
return {'complexity': 0, 'patterns': {}, 'syncopation': 0}
|
||||
|
||||
# 标准化时值到节拍单位
|
||||
beat_durations = [d / ticks_per_beat for d in durations]
|
||||
|
||||
# 计算节奏模式分布
|
||||
patterns = {
|
||||
'whole': 0, # 全音符
|
||||
'half': 0, # 二分音符
|
||||
'quarter': 0, # 四分音符
|
||||
'eighth': 0, # 八分音符
|
||||
'sixteenth': 0, # 十六分音符
|
||||
'dotted': 0, # 附点节奏
|
||||
'triplet': 0 # 三连音
|
||||
}
|
||||
|
||||
for duration in beat_durations:
|
||||
if abs(duration - 4.0) < 0.1:
|
||||
patterns['whole'] += 1
|
||||
elif abs(duration - 2.0) < 0.1:
|
||||
patterns['half'] += 1
|
||||
elif abs(duration - 1.0) < 0.1:
|
||||
patterns['quarter'] += 1
|
||||
elif abs(duration - 0.5) < 0.1:
|
||||
patterns['eighth'] += 1
|
||||
elif abs(duration - 0.25) < 0.1:
|
||||
patterns['sixteenth'] += 1
|
||||
elif abs(duration - 1.5) < 0.1:
|
||||
patterns['dotted'] += 1
|
||||
elif abs(duration - 0.33) < 0.1:
|
||||
patterns['triplet'] += 1
|
||||
|
||||
total = len(durations)
|
||||
pattern_ratios = {k: v/total for k, v in patterns.items()} if total > 0 else patterns
|
||||
|
||||
# 计算节奏复杂度
|
||||
complexity = len([v for v in pattern_ratios.values() if v > 0.05]) # 超过5%的模式
|
||||
|
||||
# 简单的切分检测
|
||||
syncopation = sum(1 for d in beat_durations if 0.3 < d < 0.7 or 1.3 < d < 1.7) / total if total > 0 else 0
|
||||
|
||||
return {
|
||||
'complexity': complexity,
|
||||
'patterns': pattern_ratios,
|
||||
'syncopation': syncopation
|
||||
}
|
||||
|
||||
def _analyze_intervals(self, pitches: List[int]) -> Dict[str, Any]:
|
||||
"""分析音程分布"""
|
||||
if len(pitches) < 2:
|
||||
return {'distribution': {}, 'stepwise_ratio': 0, 'leap_ratio': 0}
|
||||
|
||||
intervals = [pitches[i+1] - pitches[i] for i in range(len(pitches)-1)]
|
||||
|
||||
# 音程分类
|
||||
interval_types = {
|
||||
'unison': 0, # 同度 (0)
|
||||
'step': 0, # 级进 (1-2)
|
||||
'small_leap': 0, # 小跳 (3-4)
|
||||
'large_leap': 0, # 大跳 (5+)
|
||||
'octave': 0 # 八度 (12)
|
||||
}
|
||||
|
||||
for interval in intervals:
|
||||
abs_interval = abs(interval)
|
||||
if abs_interval == 0:
|
||||
interval_types['unison'] += 1
|
||||
elif abs_interval <= 2:
|
||||
interval_types['step'] += 1
|
||||
elif abs_interval <= 4:
|
||||
interval_types['small_leap'] += 1
|
||||
elif abs_interval == 12:
|
||||
interval_types['octave'] += 1
|
||||
else:
|
||||
interval_types['large_leap'] += 1
|
||||
|
||||
total = len(intervals)
|
||||
distribution = {k: v/total for k, v in interval_types.items()} if total > 0 else interval_types
|
||||
|
||||
return {
|
||||
'distribution': distribution,
|
||||
'stepwise_ratio': distribution['step'],
|
||||
'leap_ratio': distribution['small_leap'] + distribution['large_leap']
|
||||
}
|
||||
|
||||
def _analyze_key_and_mode(self, pitches: List[int]) -> Dict[str, Any]:
|
||||
"""分析调性和调式"""
|
||||
if not pitches:
|
||||
return {'key': 'Unknown', 'modes': {}, 'scale_notes': []}
|
||||
|
||||
# 统计音高类别
|
||||
pitch_classes = [p % 12 for p in pitches]
|
||||
pc_counts = {}
|
||||
for pc in pitch_classes:
|
||||
pc_counts[pc] = pc_counts.get(pc, 0) + 1
|
||||
|
||||
# 尝试匹配五声音阶
|
||||
best_key = 'C'
|
||||
best_score = 0
|
||||
|
||||
for key, scale in self.pentatonic_scales.items():
|
||||
score = sum(pc_counts.get(pc, 0) for pc in scale)
|
||||
if score > best_score:
|
||||
best_score = score
|
||||
best_key = key
|
||||
|
||||
# 生成调式信息
|
||||
note_names = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B']
|
||||
scale_notes = [note_names[pc] for pc in self.pentatonic_scales[best_key]]
|
||||
|
||||
# 简化的调式检测
|
||||
modes = {
|
||||
'pentatonic': best_score / len(pitches) if pitches else 0,
|
||||
'major': 0.5, # 占位符
|
||||
'minor': 0.3 # 占位符
|
||||
}
|
||||
|
||||
return {
|
||||
'key': best_key,
|
||||
'modes': modes,
|
||||
'scale_notes': scale_notes
|
||||
}
|
||||
|
||||
def _extract_melody_contour(self, pitches: List[int]) -> List[int]:
|
||||
"""提取旋律轮廓"""
|
||||
if len(pitches) < 2:
|
||||
return []
|
||||
|
||||
contour = []
|
||||
for i in range(1, len(pitches)):
|
||||
diff = pitches[i] - pitches[i-1]
|
||||
if diff > 0:
|
||||
contour.append(1) # 上行
|
||||
elif diff < 0:
|
||||
contour.append(-1) # 下行
|
||||
else:
|
||||
contour.append(0) # 平行
|
||||
|
||||
return contour
|
||||
|
||||
def _identify_phrases(self, notes: List[Dict], ticks_per_beat: int) -> List[Tuple[int, int]]:
|
||||
"""识别乐句结构"""
|
||||
if not notes:
|
||||
return []
|
||||
|
||||
# 简单的乐句分割:基于较长的休止或时间间隔
|
||||
phrases = []
|
||||
phrase_start = 0
|
||||
|
||||
for i in range(1, len(notes)):
|
||||
# 检测乐句间隔(如果两个音符间隔超过一拍)
|
||||
gap = notes[i]['start_time'] - (notes[i-1]['start_time'] + notes[i-1]['duration'])
|
||||
if gap > ticks_per_beat: # 超过一拍的间隔
|
||||
phrases.append((phrase_start, i-1))
|
||||
phrase_start = i
|
||||
|
||||
# 添加最后一个乐句
|
||||
phrases.append((phrase_start, len(notes)-1))
|
||||
|
||||
return phrases
|
||||
|
||||
def _calculate_range_overlap(self, range1: Tuple[int, int], range2: Tuple[int, int]) -> float:
|
||||
"""计算两个音域的重叠度"""
|
||||
overlap_start = max(range1[0], range2[0])
|
||||
overlap_end = min(range1[1], range2[1])
|
||||
|
||||
if overlap_start >= overlap_end:
|
||||
return 0.0
|
||||
|
||||
overlap_size = overlap_end - overlap_start
|
||||
range1_size = range1[1] - range1[0]
|
||||
|
||||
return overlap_size / range1_size if range1_size > 0 else 0.0
|
||||
|
||||
def _calculate_interval_variety(self, notes: List[Dict]) -> float:
|
||||
"""计算音程变化丰富度"""
|
||||
if len(notes) < 2:
|
||||
return 0.0
|
||||
|
||||
pitches = [note['pitch'] for note in notes]
|
||||
intervals = [abs(pitches[i+1] - pitches[i]) for i in range(len(pitches)-1)]
|
||||
unique_intervals = len(set(intervals))
|
||||
|
||||
return unique_intervals / len(intervals) if intervals else 0.0
|
||||
|
||||
def calculate_complexity(self, melody_features: MelodyFeatures, lyrics_info: Dict = None) -> float:
|
||||
"""计算旋律复杂度(0-100分)"""
|
||||
try:
|
||||
# 节奏复杂度 (0-40分)
|
||||
rhythm_score = 0
|
||||
if hasattr(melody_features, 'rhythm_patterns'):
|
||||
syncopation = melody_features.rhythm_patterns.get('syncopation', 0)
|
||||
sixteenth = melody_features.rhythm_patterns.get('sixteenth', 0)
|
||||
rhythm_score = min(40, (syncopation + sixteenth) * 0.4)
|
||||
|
||||
# 音程复杂度 (0-30分)
|
||||
interval_score = 0
|
||||
if hasattr(melody_features, 'interval_distribution'):
|
||||
large_leap = melody_features.interval_distribution.get('large_leap', 0)
|
||||
interval_score = min(30, large_leap * 0.3)
|
||||
|
||||
# 调式不确定性 (0-30分)
|
||||
modal_score = 0
|
||||
if hasattr(melody_features, 'mode_analysis'):
|
||||
# 如果调式分析有置信度信息
|
||||
max_confidence = max(melody_features.mode_analysis.values()) if melody_features.mode_analysis else 0
|
||||
modal_uncertainty = 1 - max_confidence
|
||||
modal_score = modal_uncertainty * 30
|
||||
|
||||
total_complexity = rhythm_score + interval_score + modal_score
|
||||
return min(100, total_complexity)
|
||||
|
||||
except Exception:
|
||||
return 30.0 # 默认中等复杂度
|
||||
|
||||
def recommend_creation_mode(self, melody_features: MelodyFeatures, lyrics_info: Dict = None) -> Dict[str, Any]:
|
||||
"""基于旋律特征推荐创作模式"""
|
||||
try:
|
||||
# 计算整体复杂度
|
||||
complexity = self.calculate_complexity(melody_features, lyrics_info)
|
||||
|
||||
# 段落数量(从歌词信息获取)
|
||||
section_count = 0
|
||||
if lyrics_info and 'structure' in lyrics_info:
|
||||
sections = lyrics_info['structure'].get('sections', [])
|
||||
section_count = len(sections)
|
||||
|
||||
# 推荐逻辑
|
||||
if complexity >= 60:
|
||||
recommended = "expert"
|
||||
reason = f"旋律复杂度高 ({complexity:.1f}/100),建议使用专家模式进行精细控制"
|
||||
elif complexity <= 25:
|
||||
recommended = "express"
|
||||
reason = f"旋律相对简单 ({complexity:.1f}/100),适合快速模式自动生成"
|
||||
elif section_count >= 4:
|
||||
recommended = "coach"
|
||||
reason = f"歌曲结构复杂 ({section_count}个段落),建议教练模式逐步创作"
|
||||
else:
|
||||
recommended = "professional"
|
||||
reason = f"旋律复杂度适中 ({complexity:.1f}/100),推荐专业模式平衡效率与质量"
|
||||
|
||||
# 备选方案
|
||||
alternatives = []
|
||||
if recommended != "express":
|
||||
alternatives.append({"mode": "express", "reason": "需要快速原型或demo时使用"})
|
||||
if recommended != "professional":
|
||||
alternatives.append({"mode": "professional", "reason": "平衡创作质量与效率的通用选择"})
|
||||
if recommended != "coach":
|
||||
alternatives.append({"mode": "coach", "reason": "学习创作技巧或深度个性化表达时使用"})
|
||||
if recommended != "expert":
|
||||
alternatives.append({"mode": "expert", "reason": "需要完全控制创作过程的专业制作"})
|
||||
|
||||
return {
|
||||
"recommended": recommended,
|
||||
"complexity_score": complexity,
|
||||
"reasoning": reason,
|
||||
"section_count": section_count,
|
||||
"alternatives": alternatives
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
# 错误时返回默认推荐
|
||||
return {
|
||||
"recommended": "professional",
|
||||
"complexity_score": 50.0,
|
||||
"reasoning": "分析过程中出现问题,推荐使用通用的专业模式",
|
||||
"section_count": 0,
|
||||
"alternatives": [],
|
||||
"error": f"推荐逻辑错误: {str(e)}"
|
||||
}
|
||||
|
||||
def _create_error_result(self, error_type: str, message: str, details: Dict = None) -> Dict[str, Any]:
|
||||
"""创建错误结果"""
|
||||
result = {
|
||||
"status": "error",
|
||||
"error_type": error_type,
|
||||
"message": message,
|
||||
"timestamp": __import__('datetime').datetime.now().isoformat()
|
||||
}
|
||||
|
||||
if details:
|
||||
result["details"] = details
|
||||
|
||||
return result
|
||||
|
||||
def main():
|
||||
"""命令行入口"""
|
||||
parser = argparse.ArgumentParser(description="专业级 MIDI 音乐分析器")
|
||||
parser.add_argument("midi_file", help="MIDI 文件路径")
|
||||
parser.add_argument("--lyrics", help="歌词文件路径(可选)")
|
||||
parser.add_argument("--output", help="输出 JSON 文件路径(可选)")
|
||||
parser.add_argument("--pretty", action="store_true", help="格式化 JSON 输出")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# 创建分析器
|
||||
analyzer = ProfessionalMidiAnalyzer()
|
||||
|
||||
# 执行分析
|
||||
result = analyzer.analyze_midi_file(args.midi_file, args.lyrics)
|
||||
|
||||
# 输出结果
|
||||
if args.pretty:
|
||||
output = json.dumps(result, ensure_ascii=False, indent=2)
|
||||
else:
|
||||
output = json.dumps(result, ensure_ascii=False)
|
||||
|
||||
if args.output:
|
||||
with open(args.output, 'w', encoding='utf-8') as f:
|
||||
f.write(output)
|
||||
print(f"分析结果已保存到: {args.output}")
|
||||
else:
|
||||
print(output)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1180,6 +1180,12 @@ pub fn run() {
|
||||
commands::update_cmd::update_last_check_timestamp,
|
||||
commands::update_cmd::close_update_window,
|
||||
commands::update_cmd::test_update_window,
|
||||
// Music commands
|
||||
commands::music_cmd::check_python_env,
|
||||
commands::music_cmd::analyze_midi,
|
||||
commands::music_cmd::convert_mp3_to_midi,
|
||||
commands::music_cmd::load_music_resource,
|
||||
commands::music_cmd::install_python_dependencies,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
|
||||
@@ -12,6 +12,7 @@ pub mod machine_id_cmd;
|
||||
pub mod mcp_cmd;
|
||||
pub mod model_registry_cmd;
|
||||
pub mod models_cmd;
|
||||
pub mod music_cmd;
|
||||
pub mod native_agent_cmd;
|
||||
pub mod network_cmd;
|
||||
pub mod oauth_cmd;
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command;
|
||||
use tauri::State;
|
||||
|
||||
/// MIDI 分析结果
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MidiAnalysisResult {
|
||||
/// 调式信息
|
||||
pub mode: String,
|
||||
/// BPM (每分钟节拍数)
|
||||
pub bpm: f64,
|
||||
/// 拍号
|
||||
pub time_signature: String,
|
||||
/// 音轨信息
|
||||
pub tracks: Vec<TrackInfo>,
|
||||
/// 旋律特征
|
||||
pub melody_features: MelodyFeatures,
|
||||
}
|
||||
|
||||
/// 音轨信息
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TrackInfo {
|
||||
/// 音轨索引
|
||||
pub index: usize,
|
||||
/// 音轨名称
|
||||
pub name: String,
|
||||
/// 乐器名称
|
||||
pub instrument: String,
|
||||
/// 音符数量
|
||||
pub note_count: usize,
|
||||
/// 是否为人声音轨
|
||||
pub is_vocal: bool,
|
||||
}
|
||||
|
||||
/// 旋律特征
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MelodyFeatures {
|
||||
/// 音域范围 (半音数)
|
||||
pub range: i32,
|
||||
/// 平均音高
|
||||
pub avg_pitch: f64,
|
||||
/// 音程跳跃频率
|
||||
pub interval_jumps: f64,
|
||||
/// 节奏复杂度
|
||||
pub rhythm_complexity: f64,
|
||||
}
|
||||
|
||||
/// Python 环境检测结果
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PythonEnvInfo {
|
||||
/// 是否已安装 Python
|
||||
pub python_installed: bool,
|
||||
/// Python 版本
|
||||
pub python_version: Option<String>,
|
||||
/// 缺失的依赖包
|
||||
pub missing_packages: Vec<String>,
|
||||
}
|
||||
|
||||
/// 检查 Python 环境
|
||||
#[tauri::command]
|
||||
pub async fn check_python_env() -> Result<PythonEnvInfo, String> {
|
||||
// 检查 Python 是否安装
|
||||
let python_check = Command::new("python3").arg("--version").output();
|
||||
|
||||
let (python_installed, python_version) = match python_check {
|
||||
Ok(output) => {
|
||||
let version = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
(true, Some(version))
|
||||
}
|
||||
Err(_) => (false, None),
|
||||
};
|
||||
|
||||
if !python_installed {
|
||||
return Ok(PythonEnvInfo {
|
||||
python_installed: false,
|
||||
python_version: None,
|
||||
missing_packages: vec![],
|
||||
});
|
||||
}
|
||||
|
||||
// 检查必需的 Python 包
|
||||
let required_packages = vec!["mido", "music21", "numpy", "demucs", "basic-pitch"];
|
||||
let mut missing_packages = Vec::new();
|
||||
|
||||
for package in required_packages {
|
||||
let check = Command::new("python3")
|
||||
.arg("-c")
|
||||
.arg(format!("import {}", package.replace("-", "_")))
|
||||
.output();
|
||||
|
||||
if check.is_err() || !check.unwrap().status.success() {
|
||||
missing_packages.push(package.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
Ok(PythonEnvInfo {
|
||||
python_installed,
|
||||
python_version,
|
||||
missing_packages,
|
||||
})
|
||||
}
|
||||
|
||||
/// 分析 MIDI 文件
|
||||
#[tauri::command]
|
||||
pub async fn analyze_midi(midi_path: String) -> Result<MidiAnalysisResult, String> {
|
||||
// 获取 Python 脚本路径
|
||||
let script_path = get_resource_path("scripts/midi_analyzer.py")?;
|
||||
|
||||
// 调用 Python 脚本
|
||||
let output = Command::new("python3")
|
||||
.arg(&script_path)
|
||||
.arg(&midi_path)
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to execute Python script: {}", e))?;
|
||||
|
||||
if !output.status.success() {
|
||||
let error = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(format!("MIDI analysis failed: {}", error));
|
||||
}
|
||||
|
||||
// 解析 JSON 输出
|
||||
let result_json = String::from_utf8_lossy(&output.stdout);
|
||||
serde_json::from_str(&result_json)
|
||||
.map_err(|e| format!("Failed to parse analysis result: {}", e))
|
||||
}
|
||||
|
||||
/// 将 MP3 转换为 MIDI
|
||||
#[tauri::command]
|
||||
pub async fn convert_mp3_to_midi(mp3_path: String, output_path: String) -> Result<String, String> {
|
||||
// 获取 Python 脚本路径
|
||||
let script_path = get_resource_path("scripts/audio_to_midi.py")?;
|
||||
|
||||
// 调用 Python 脚本
|
||||
let output = Command::new("python3")
|
||||
.arg(&script_path)
|
||||
.arg(&mp3_path)
|
||||
.arg(&output_path)
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to execute Python script: {}", e))?;
|
||||
|
||||
if !output.status.success() {
|
||||
let error = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(format!("MP3 to MIDI conversion failed: {}", error));
|
||||
}
|
||||
|
||||
Ok(output_path)
|
||||
}
|
||||
|
||||
/// 加载资源文件
|
||||
#[tauri::command]
|
||||
pub async fn load_music_resource(resource_name: String) -> Result<String, String> {
|
||||
let resource_path = get_resource_path(&format!("music/{}", resource_name))?;
|
||||
|
||||
std::fs::read_to_string(&resource_path)
|
||||
.map_err(|e| format!("Failed to read resource file: {}", e))
|
||||
}
|
||||
|
||||
/// 获取资源文件路径
|
||||
fn get_resource_path(relative_path: &str) -> Result<PathBuf, String> {
|
||||
// 在开发环境中,资源文件在 src-tauri/resources/
|
||||
// 在生产环境中,资源文件会被打包到应用程序包中
|
||||
let mut path =
|
||||
std::env::current_exe().map_err(|e| format!("Failed to get executable path: {}", e))?;
|
||||
|
||||
path.pop(); // 移除可执行文件名
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
// macOS: 资源在 .app/Contents/Resources/
|
||||
path.pop(); // 移除 MacOS
|
||||
path.push("Resources");
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
{
|
||||
// Windows/Linux: 资源在可执行文件同级目录
|
||||
path.push("resources");
|
||||
}
|
||||
|
||||
path.push(relative_path);
|
||||
|
||||
if !path.exists() {
|
||||
// 尝试开发环境路径
|
||||
let dev_path = PathBuf::from("src-tauri/resources").join(relative_path);
|
||||
if dev_path.exists() {
|
||||
return Ok(dev_path);
|
||||
}
|
||||
return Err(format!("Resource not found: {}", relative_path));
|
||||
}
|
||||
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
/// 安装 Python 依赖
|
||||
#[tauri::command]
|
||||
pub async fn install_python_dependencies() -> Result<String, String> {
|
||||
let packages = vec!["mido", "music21", "numpy", "demucs", "basic-pitch"];
|
||||
|
||||
let output = Command::new("pip3")
|
||||
.arg("install")
|
||||
.args(&packages)
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to install packages: {}", e))?;
|
||||
|
||||
if !output.status.success() {
|
||||
let error = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(format!("Installation failed: {}", error));
|
||||
}
|
||||
|
||||
Ok("Dependencies installed successfully".to_string())
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "ProxyCast",
|
||||
"version": "0.42.0",
|
||||
"version": "0.43.0",
|
||||
"identifier": "com.proxycast.app",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "ProxyCast",
|
||||
"version": "0.42.0",
|
||||
"version": "0.43.0",
|
||||
"identifier": "com.proxycast.app",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
Zap,
|
||||
RefreshCw,
|
||||
LayoutTemplate,
|
||||
Music,
|
||||
} from "lucide-react";
|
||||
|
||||
/**
|
||||
@@ -354,6 +355,9 @@ const CATEGORIES = [
|
||||
label: "通用对话",
|
||||
icon: <Globe className="w-4 h-4" />,
|
||||
},
|
||||
{ id: "social", label: "社媒内容", icon: <PenTool className="w-4 h-4" /> },
|
||||
{ id: "image", label: "图文海报", icon: <ImageIcon className="w-4 h-4" /> },
|
||||
{ id: "music", label: "歌词曲谱", icon: <Music className="w-4 h-4" /> },
|
||||
{
|
||||
id: "knowledge",
|
||||
label: "知识探索",
|
||||
@@ -364,14 +368,12 @@ const CATEGORIES = [
|
||||
label: "计划规划",
|
||||
icon: <CalendarRange className="w-4 h-4" />,
|
||||
},
|
||||
{ id: "social", label: "社媒内容", icon: <PenTool className="w-4 h-4" /> },
|
||||
{ id: "image", label: "图文海报", icon: <ImageIcon className="w-4 h-4" /> },
|
||||
{ id: "office", label: "办公文档", icon: <FileText className="w-4 h-4" /> },
|
||||
{ id: "video", label: "短视频", icon: <Video className="w-4 h-4" /> },
|
||||
];
|
||||
|
||||
// 需要显示创作模式选择器的主题
|
||||
const CREATION_THEMES = ["social", "image", "office", "video"];
|
||||
const CREATION_THEMES = ["social", "image", "office", "video", "music"];
|
||||
|
||||
export const EmptyState: React.FC<EmptyStateProps> = ({
|
||||
input,
|
||||
@@ -405,6 +407,7 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
|
||||
if (activeTheme === "image") prefix = `[图文生成: ${ratio}, ${style}] `;
|
||||
if (activeTheme === "video") prefix = `[视频脚本] `;
|
||||
if (activeTheme === "office") prefix = `[办公文档] `;
|
||||
if (activeTheme === "music") prefix = `[歌词曲谱] `;
|
||||
if (activeTheme === "knowledge")
|
||||
prefix = `[知识探索: ${depth === "deep" ? "深度" : "快速"}] `;
|
||||
if (activeTheme === "planning") prefix = `[计划规划] `;
|
||||
@@ -434,6 +437,8 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
|
||||
return "输入视频主题,生成分镜脚本和口播文案...";
|
||||
case "office":
|
||||
return "输入需求,生成周报、汇报PPT大纲或商务邮件...";
|
||||
case "music":
|
||||
return "输入歌曲主题或情感,帮你创作歌词、设计旋律...";
|
||||
case "general":
|
||||
return "有什么我可以帮你的?";
|
||||
default:
|
||||
@@ -803,6 +808,18 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
|
||||
</Badge>
|
||||
),
|
||||
)}
|
||||
{activeTheme === "music" &&
|
||||
["流行情歌", "古风歌词", "说唱歌词", "儿歌创作", "旋律学习"].map(
|
||||
(item) => (
|
||||
<Badge
|
||||
key={item}
|
||||
variant="secondary"
|
||||
className="px-4 py-2 text-xs font-normal cursor-pointer hover:bg-muted-foreground/10 transition-colors"
|
||||
>
|
||||
🎵 {item}
|
||||
</Badge>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</ContentWrapper>
|
||||
</Container>
|
||||
|
||||
@@ -417,14 +417,94 @@ export const StreamingRenderer: React.FC<StreamingRendererProps> = memo(
|
||||
{/* 交错内容 */}
|
||||
{contentParts.map((part, index) => {
|
||||
if (part.type === "text") {
|
||||
// 解析并渲染文本(可能包含 thinking 标签)
|
||||
// 解析并渲染文本(可能包含 thinking 标签和 write_file 标签)
|
||||
const { visibleText: partVisible } = parseThinkingContent(
|
||||
part.text,
|
||||
);
|
||||
if (!partVisible) return null;
|
||||
|
||||
// 解析 write_file 标签
|
||||
const partParsed = parseAIResponse(partVisible, isStreaming);
|
||||
const isLastPart = index === contentParts.length - 1;
|
||||
// 使用 StreamingText 组件实现逐字符动画
|
||||
|
||||
// 处理文件写入回调
|
||||
if (onWriteFile) {
|
||||
for (const p of partParsed.parts) {
|
||||
if (
|
||||
p.type === "write_file" &&
|
||||
p.filePath &&
|
||||
typeof p.content === "string"
|
||||
) {
|
||||
const key = `interleaved-${p.filePath}:${p.content.length}`;
|
||||
if (!processedWriteFilesRef.current.has(key)) {
|
||||
processedWriteFilesRef.current.add(key);
|
||||
onWriteFile(p.content, p.filePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 如果包含 write_file,按部分渲染
|
||||
if (partParsed.hasWriteFile) {
|
||||
return (
|
||||
<React.Fragment key={`text-${index}`}>
|
||||
{partParsed.parts.map((p, pIndex) => {
|
||||
if (
|
||||
p.type === "write_file" ||
|
||||
p.type === "pending_write_file"
|
||||
) {
|
||||
const fileContent =
|
||||
typeof p.content === "string" ? p.content : "";
|
||||
return (
|
||||
<div
|
||||
key={`write-${index}-${pIndex}`}
|
||||
className="flex items-center gap-2 px-3 py-2 bg-muted/50 rounded-lg text-sm text-muted-foreground cursor-pointer hover:bg-muted/70 transition-colors"
|
||||
onClick={() =>
|
||||
p.filePath &&
|
||||
fileContent &&
|
||||
onFileClick?.(p.filePath, fileContent)
|
||||
}
|
||||
>
|
||||
<FileText className="w-4 h-4" />
|
||||
<span>Write</span>
|
||||
<span className="font-medium text-foreground">
|
||||
{p.filePath || "文档.md"}
|
||||
</span>
|
||||
{p.type === "pending_write_file" && (
|
||||
<span className="ml-auto animate-pulse">...</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
} else if (p.type === "text") {
|
||||
const textContent =
|
||||
typeof p.content === "string" ? p.content : "";
|
||||
if (!textContent || textContent.trim() === "")
|
||||
return null;
|
||||
return (
|
||||
<StreamingText
|
||||
key={`text-${index}-${pIndex}`}
|
||||
text={textContent}
|
||||
isStreaming={
|
||||
isStreaming &&
|
||||
isLastPart &&
|
||||
pIndex === partParsed.parts.length - 1
|
||||
}
|
||||
showCursor={
|
||||
shouldShowCursor &&
|
||||
isLastPart &&
|
||||
pIndex === partParsed.parts.length - 1
|
||||
}
|
||||
onA2UISubmit={onA2UISubmit}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})}
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
// 没有 write_file,直接渲染
|
||||
return (
|
||||
<StreamingText
|
||||
key={`text-${index}`}
|
||||
|
||||
@@ -10,6 +10,8 @@ import { DocumentCanvas } from "./document";
|
||||
import type { DocumentCanvasState } from "./document/types";
|
||||
import { PosterCanvas } from "./poster";
|
||||
import type { PosterCanvasState } from "./poster/types";
|
||||
import { MusicCanvas } from "./music";
|
||||
import type { MusicCanvasState } from "./music/types";
|
||||
import { getCanvasTypeForTheme, type CanvasStateUnion } from "./canvasUtils";
|
||||
|
||||
/**
|
||||
@@ -59,6 +61,17 @@ export const CanvasFactory: React.FC<CanvasFactoryProps> = memo(
|
||||
);
|
||||
}
|
||||
|
||||
if (canvasType === "music" && state.type === "music") {
|
||||
return (
|
||||
<MusicCanvas
|
||||
state={state}
|
||||
onStateChange={onStateChange as (s: MusicCanvasState) => void}
|
||||
onClose={onClose}
|
||||
isStreaming={isStreaming}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// 不支持的主题或状态类型不匹配
|
||||
return null;
|
||||
},
|
||||
|
||||
@@ -9,31 +9,43 @@ import { createInitialDocumentState } from "./document";
|
||||
import type { DocumentCanvasState } from "./document/types";
|
||||
import { createInitialPosterState } from "./poster";
|
||||
import type { PosterCanvasState } from "./poster/types";
|
||||
import { createInitialMusicState } from "./music";
|
||||
import type { MusicCanvasState } from "./music/types";
|
||||
|
||||
/**
|
||||
* 画布状态联合类型
|
||||
*/
|
||||
export type CanvasStateUnion = DocumentCanvasState | PosterCanvasState;
|
||||
export type CanvasStateUnion =
|
||||
| DocumentCanvasState
|
||||
| PosterCanvasState
|
||||
| MusicCanvasState;
|
||||
|
||||
/**
|
||||
* 画布类型
|
||||
*/
|
||||
export type CanvasType = "document" | "poster" | "music";
|
||||
|
||||
/**
|
||||
* 主题到画布类型的映射
|
||||
*/
|
||||
const THEME_TO_CANVAS_TYPE: Record<ThemeType, "document" | "poster" | null> = {
|
||||
const THEME_TO_CANVAS_TYPE: Record<ThemeType, CanvasType | null> = {
|
||||
general: null,
|
||||
knowledge: null,
|
||||
planning: null,
|
||||
"social-media": "document",
|
||||
poster: "poster",
|
||||
document: "document",
|
||||
paper: "document",
|
||||
novel: "document",
|
||||
script: "document",
|
||||
music: "music",
|
||||
video: null,
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取主题对应的画布类型
|
||||
*/
|
||||
export function getCanvasTypeForTheme(
|
||||
theme: ThemeType,
|
||||
): "document" | "poster" | null {
|
||||
export function getCanvasTypeForTheme(theme: ThemeType): CanvasType | null {
|
||||
return THEME_TO_CANVAS_TYPE[theme];
|
||||
}
|
||||
|
||||
@@ -58,6 +70,8 @@ export function createInitialCanvasState(
|
||||
return createInitialDocumentState(content || "");
|
||||
case "poster":
|
||||
return createInitialPosterState();
|
||||
case "music":
|
||||
return createInitialMusicState();
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,448 @@
|
||||
/**
|
||||
* @file 音乐画布主组件
|
||||
* @description 整合工具栏、歌词编辑器、简谱渲染器等
|
||||
* @module components/content-creator/canvas/music/MusicCanvas
|
||||
*/
|
||||
|
||||
import React, { memo, useMemo, useCallback, useState } from "react";
|
||||
import styled from "styled-components";
|
||||
import type { MusicCanvasProps, MusicViewMode } from "./types";
|
||||
import { MusicToolbar } from "./MusicToolbar";
|
||||
import {
|
||||
NumberedNotationRenderer,
|
||||
GuitarTabRenderer,
|
||||
PianoRollRenderer,
|
||||
} from "./renderers";
|
||||
import { Copy, Check } from "lucide-react";
|
||||
|
||||
/** 段落类型中文映射 */
|
||||
const SECTION_DISPLAY_NAMES: Record<string, string> = {
|
||||
intro: "前奏",
|
||||
verse: "主歌",
|
||||
"pre-chorus": "预副歌",
|
||||
chorus: "副歌",
|
||||
bridge: "桥段",
|
||||
interlude: "间奏",
|
||||
outro: "尾奏",
|
||||
};
|
||||
|
||||
const Container = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
background: hsl(var(--background));
|
||||
border-left: 1px solid hsl(var(--border));
|
||||
`;
|
||||
|
||||
const ContentArea = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
`;
|
||||
|
||||
const MainContent = styled.div`
|
||||
flex: 1;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
`;
|
||||
|
||||
const EditorPane = styled.div`
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
padding: 16px;
|
||||
`;
|
||||
|
||||
const SectionTitle = styled.h3`
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--foreground));
|
||||
margin: 0 0 12px 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
`;
|
||||
|
||||
const LyricsContainer = styled.div`
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
background: hsl(var(--muted) / 0.3);
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
`;
|
||||
|
||||
const SectionBlock = styled.div<{ $isSelected: boolean }>`
|
||||
margin-bottom: 16px;
|
||||
padding: 12px;
|
||||
border-radius: 6px;
|
||||
background: ${({ $isSelected }) =>
|
||||
$isSelected ? "hsl(var(--accent) / 0.1)" : "transparent"};
|
||||
border: 1px solid
|
||||
${({ $isSelected }) =>
|
||||
$isSelected ? "hsl(var(--primary))" : "hsl(var(--border))"};
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: hsl(var(--accent) / 0.05);
|
||||
}
|
||||
`;
|
||||
|
||||
const SectionHeader = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
`;
|
||||
|
||||
const SectionTag = styled.span`
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--primary));
|
||||
background: hsl(var(--primary) / 0.1);
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
`;
|
||||
|
||||
const SectionName = styled.span`
|
||||
font-size: 13px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
`;
|
||||
|
||||
const LyricsLine = styled.p`
|
||||
font-size: 16px;
|
||||
line-height: 2;
|
||||
color: hsl(var(--foreground));
|
||||
margin: 6px 0;
|
||||
white-space: pre-wrap;
|
||||
`;
|
||||
|
||||
const EmptyState = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: hsl(var(--muted-foreground));
|
||||
text-align: center;
|
||||
padding: 32px;
|
||||
`;
|
||||
|
||||
const EmptyIcon = styled.div`
|
||||
font-size: 48px;
|
||||
margin-bottom: 16px;
|
||||
`;
|
||||
|
||||
const EmptyTitle = styled.h3`
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
margin: 0 0 8px 0;
|
||||
color: hsl(var(--foreground));
|
||||
`;
|
||||
|
||||
const EmptyDescription = styled.p`
|
||||
font-size: 14px;
|
||||
margin: 0;
|
||||
max-width: 300px;
|
||||
`;
|
||||
|
||||
const Toast = styled.div<{ $visible: boolean }>`
|
||||
position: fixed;
|
||||
bottom: 24px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
padding: 12px 24px;
|
||||
background: hsl(var(--foreground));
|
||||
color: hsl(var(--background));
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
opacity: ${({ $visible }) => ($visible ? 1 : 0)};
|
||||
visibility: ${({ $visible }) => ($visible ? "visible" : "hidden")};
|
||||
transition: all 0.3s;
|
||||
z-index: 1000;
|
||||
`;
|
||||
|
||||
const StatusBar = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 16px;
|
||||
background: hsl(var(--muted) / 0.5);
|
||||
border-top: 1px solid hsl(var(--border));
|
||||
font-size: 12px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
`;
|
||||
|
||||
const StatusItem = styled.span`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
`;
|
||||
|
||||
/**
|
||||
* 音乐画布主组件
|
||||
*/
|
||||
export const MusicCanvas: React.FC<MusicCanvasProps> = memo(
|
||||
({ state, onStateChange, onClose, isStreaming = false }) => {
|
||||
const [toastMessage, setToastMessage] = useState("");
|
||||
const [showToast, setShowToast] = useState(false);
|
||||
|
||||
// 统计信息
|
||||
const stats = useMemo(() => {
|
||||
const totalSections = state.sections.length;
|
||||
const totalLines = state.sections.reduce(
|
||||
(sum, s) => sum + s.lyricsLines.length,
|
||||
0,
|
||||
);
|
||||
const totalChars = state.sections.reduce(
|
||||
(sum, s) =>
|
||||
sum +
|
||||
s.lyricsLines.reduce((lineSum, line) => lineSum + line.length, 0),
|
||||
0,
|
||||
);
|
||||
return { totalSections, totalLines, totalChars };
|
||||
}, [state.sections]);
|
||||
|
||||
// 显示提示
|
||||
const showMessage = useCallback((message: string) => {
|
||||
setToastMessage(message);
|
||||
setShowToast(true);
|
||||
setTimeout(() => setShowToast(false), 2000);
|
||||
}, []);
|
||||
|
||||
// 切换视图模式
|
||||
const handleViewModeChange = useCallback(
|
||||
(viewMode: MusicViewMode) => {
|
||||
onStateChange({ ...state, viewMode });
|
||||
},
|
||||
[state, onStateChange],
|
||||
);
|
||||
|
||||
// 播放/暂停
|
||||
const handlePlayToggle = useCallback(() => {
|
||||
onStateChange({ ...state, isPlaying: !state.isPlaying });
|
||||
}, [state, onStateChange]);
|
||||
|
||||
// 选择段落
|
||||
const handleSectionSelect = useCallback(
|
||||
(sectionId: string) => {
|
||||
onStateChange({ ...state, currentSectionId: sectionId });
|
||||
},
|
||||
[state, onStateChange],
|
||||
);
|
||||
|
||||
// 撤销
|
||||
const handleUndo = useCallback(() => {
|
||||
// TODO: 实现撤销功能
|
||||
showMessage("撤销功能开发中");
|
||||
}, [showMessage]);
|
||||
|
||||
// 重做
|
||||
const handleRedo = useCallback(() => {
|
||||
// TODO: 实现重做功能
|
||||
showMessage("重做功能开发中");
|
||||
}, [showMessage]);
|
||||
|
||||
// 导出
|
||||
const handleExport = useCallback(() => {
|
||||
// TODO: 打开导出对话框
|
||||
showMessage("导出功能开发中");
|
||||
}, [showMessage]);
|
||||
|
||||
// 复制歌词
|
||||
const [isCopied, setIsCopied] = useState(false);
|
||||
const handleCopyLyrics = useCallback(() => {
|
||||
const text = state.sections
|
||||
.map((section) => {
|
||||
const typeName =
|
||||
SECTION_DISPLAY_NAMES[section.type] || section.type.toUpperCase();
|
||||
const header = `[${typeName}]`;
|
||||
const content = section.lyricsLines.join("\n");
|
||||
return `${header}\n${content}`;
|
||||
})
|
||||
.join("\n\n");
|
||||
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
setIsCopied(true);
|
||||
showMessage("歌词已复制到剪贴板");
|
||||
setTimeout(() => setIsCopied(false), 2000);
|
||||
});
|
||||
}, [state.sections, showMessage]);
|
||||
|
||||
// 渲染歌词视图
|
||||
const renderLyricsView = () => {
|
||||
if (state.sections.length === 0) {
|
||||
return (
|
||||
<EmptyState>
|
||||
<EmptyIcon>🎵</EmptyIcon>
|
||||
<EmptyTitle>开始创作你的歌曲</EmptyTitle>
|
||||
<EmptyDescription>
|
||||
在左侧对话中描述你想要创作的歌曲,AI 将帮助你完成歌词创作
|
||||
</EmptyDescription>
|
||||
</EmptyState>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<LyricsContainer>
|
||||
{state.sections.map((section) => (
|
||||
<SectionBlock
|
||||
key={section.id}
|
||||
$isSelected={section.id === state.currentSectionId}
|
||||
onClick={() => handleSectionSelect(section.id)}
|
||||
>
|
||||
<SectionHeader>
|
||||
<SectionTag>
|
||||
[
|
||||
{SECTION_DISPLAY_NAMES[section.type] ||
|
||||
section.type.toUpperCase()}
|
||||
]
|
||||
</SectionTag>
|
||||
{section.name !== SECTION_DISPLAY_NAMES[section.type] &&
|
||||
section.name !== section.type &&
|
||||
section.name !==
|
||||
(SECTION_DISPLAY_NAMES[section.type] ||
|
||||
section.type.toUpperCase()) && (
|
||||
<SectionName>{section.name}</SectionName>
|
||||
)}
|
||||
</SectionHeader>
|
||||
{section.lyricsLines.map((line, index) => (
|
||||
<LyricsLine key={index}>{line}</LyricsLine>
|
||||
))}
|
||||
</SectionBlock>
|
||||
))}
|
||||
</LyricsContainer>
|
||||
);
|
||||
};
|
||||
|
||||
// 渲染简谱视图
|
||||
const renderNumberedView = () => {
|
||||
return (
|
||||
<NumberedNotationRenderer
|
||||
sections={state.sections}
|
||||
currentSectionId={state.currentSectionId}
|
||||
onSectionSelect={handleSectionSelect}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
// 渲染吉他谱视图
|
||||
const renderGuitarView = () => {
|
||||
return (
|
||||
<GuitarTabRenderer
|
||||
sections={state.sections}
|
||||
currentSectionId={state.currentSectionId}
|
||||
onSectionSelect={handleSectionSelect}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
// 渲染钢琴谱视图
|
||||
const renderPianoView = () => {
|
||||
return (
|
||||
<PianoRollRenderer
|
||||
sections={state.sections}
|
||||
currentSectionId={state.currentSectionId}
|
||||
onSectionSelect={handleSectionSelect}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
// 根据视图模式渲染内容
|
||||
const renderContent = () => {
|
||||
switch (state.viewMode) {
|
||||
case "lyrics":
|
||||
return renderLyricsView();
|
||||
case "numbered":
|
||||
return renderNumberedView();
|
||||
case "guitar":
|
||||
return renderGuitarView();
|
||||
case "piano":
|
||||
return renderPianoView();
|
||||
default:
|
||||
return renderLyricsView();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<MusicToolbar
|
||||
spec={state.spec}
|
||||
viewMode={state.viewMode}
|
||||
isPlaying={state.isPlaying}
|
||||
canUndo={false}
|
||||
canRedo={false}
|
||||
onViewModeChange={handleViewModeChange}
|
||||
onPlayToggle={handlePlayToggle}
|
||||
onUndo={handleUndo}
|
||||
onRedo={handleRedo}
|
||||
onExport={handleExport}
|
||||
onClose={onClose}
|
||||
/>
|
||||
|
||||
<ContentArea>
|
||||
<MainContent>
|
||||
<EditorPane>
|
||||
<SectionTitle>
|
||||
<div
|
||||
style={{ display: "flex", alignItems: "center", gap: "8px" }}
|
||||
>
|
||||
{state.viewMode === "lyrics" && "🎤 歌词"}
|
||||
{state.viewMode === "numbered" && "🎼 简谱"}
|
||||
{state.viewMode === "guitar" && "🎸 吉他谱"}
|
||||
{state.viewMode === "piano" && "🎹 钢琴谱"}
|
||||
{isStreaming && (
|
||||
<span style={{ fontSize: 12, color: "hsl(var(--accent))" }}>
|
||||
生成中...
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{state.viewMode === "lyrics" && (
|
||||
<button
|
||||
onClick={handleCopyLyrics}
|
||||
style={{
|
||||
marginLeft: "auto",
|
||||
background: "none",
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "4px",
|
||||
fontSize: "12px",
|
||||
color: isCopied
|
||||
? "hsl(var(--accent))"
|
||||
: "hsl(var(--muted-foreground))",
|
||||
transition: "color 0.2s",
|
||||
}}
|
||||
title="复制歌词"
|
||||
>
|
||||
{isCopied ? <Check size={14} /> : <Copy size={14} />}
|
||||
{isCopied ? "已复制" : "复制"}
|
||||
</button>
|
||||
)}
|
||||
</SectionTitle>
|
||||
{renderContent()}
|
||||
</EditorPane>
|
||||
</MainContent>
|
||||
</ContentArea>
|
||||
|
||||
<StatusBar>
|
||||
<StatusItem>
|
||||
🎵 {state.spec.title} | {state.spec.key} | {state.spec.tempo} BPM
|
||||
</StatusItem>
|
||||
<StatusItem>
|
||||
{stats.totalSections} 段 | {stats.totalLines} 行 |{" "}
|
||||
{stats.totalChars} 字
|
||||
</StatusItem>
|
||||
</StatusBar>
|
||||
|
||||
<Toast $visible={showToast}>{toastMessage}</Toast>
|
||||
</Container>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
MusicCanvas.displayName = "MusicCanvas";
|
||||
@@ -0,0 +1,247 @@
|
||||
/**
|
||||
* @file 音乐画布工具栏
|
||||
* @description 提供视图切换、播放控制、导出等功能
|
||||
* @module components/content-creator/canvas/music/MusicToolbar
|
||||
*/
|
||||
|
||||
import React, { memo } from "react";
|
||||
import styled from "styled-components";
|
||||
import {
|
||||
Play,
|
||||
Pause,
|
||||
Undo2,
|
||||
Redo2,
|
||||
Download,
|
||||
X,
|
||||
Music,
|
||||
FileText,
|
||||
Guitar,
|
||||
Piano,
|
||||
} from "lucide-react";
|
||||
import type { MusicToolbarProps, MusicViewMode } from "./types";
|
||||
import { VIEW_MODE_LABELS } from "./types";
|
||||
|
||||
const ToolbarContainer = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 16px;
|
||||
background: hsl(var(--background));
|
||||
border-bottom: 1px solid hsl(var(--border));
|
||||
min-height: 48px;
|
||||
`;
|
||||
|
||||
const LeftSection = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
`;
|
||||
|
||||
const CenterSection = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
`;
|
||||
|
||||
const RightSection = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
`;
|
||||
|
||||
const SongTitle = styled.h2`
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--foreground));
|
||||
margin: 0;
|
||||
max-width: 200px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
`;
|
||||
|
||||
const SongMeta = styled.span`
|
||||
font-size: 12px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
margin-left: 8px;
|
||||
`;
|
||||
|
||||
const IconButton = styled.button<{ $active?: boolean }>`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: ${({ $active }) =>
|
||||
$active ? "hsl(var(--accent))" : "transparent"};
|
||||
color: ${({ $active }) =>
|
||||
$active ? "hsl(var(--accent-foreground))" : "hsl(var(--foreground))"};
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: ${({ $active }) =>
|
||||
$active ? "hsl(var(--accent))" : "hsl(var(--muted))"};
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
`;
|
||||
|
||||
const ViewModeButton = styled.button<{ $active: boolean }>`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 6px 12px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: ${({ $active }) =>
|
||||
$active ? "hsl(var(--accent))" : "transparent"};
|
||||
color: ${({ $active }) =>
|
||||
$active ? "hsl(var(--accent-foreground))" : "hsl(var(--muted-foreground))"};
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: ${({ $active }) =>
|
||||
$active ? "hsl(var(--accent))" : "hsl(var(--muted))"};
|
||||
color: ${({ $active }) =>
|
||||
$active ? "hsl(var(--accent-foreground))" : "hsl(var(--foreground))"};
|
||||
}
|
||||
|
||||
svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
`;
|
||||
|
||||
const Divider = styled.div`
|
||||
width: 1px;
|
||||
height: 24px;
|
||||
background: hsl(var(--border));
|
||||
margin: 0 8px;
|
||||
`;
|
||||
|
||||
const PlayButton = styled(IconButton)`
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
background: hsl(var(--accent));
|
||||
color: hsl(var(--accent-foreground));
|
||||
|
||||
&:hover {
|
||||
background: hsl(var(--accent) / 0.9);
|
||||
}
|
||||
|
||||
svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
`;
|
||||
|
||||
const CloseButton = styled(IconButton)`
|
||||
&:hover {
|
||||
background: hsl(var(--destructive) / 0.1);
|
||||
color: hsl(var(--destructive));
|
||||
}
|
||||
`;
|
||||
|
||||
/** 视图模式图标映射 */
|
||||
const VIEW_MODE_ICONS: Record<MusicViewMode, React.ReactNode> = {
|
||||
lyrics: <FileText />,
|
||||
numbered: <Music />,
|
||||
guitar: <Guitar />,
|
||||
piano: <Piano />,
|
||||
};
|
||||
|
||||
/**
|
||||
* 音乐画布工具栏
|
||||
*/
|
||||
export const MusicToolbar: React.FC<MusicToolbarProps> = memo(
|
||||
({
|
||||
spec,
|
||||
viewMode,
|
||||
isPlaying,
|
||||
canUndo,
|
||||
canRedo,
|
||||
onViewModeChange,
|
||||
onPlayToggle,
|
||||
onUndo,
|
||||
onRedo,
|
||||
onExport,
|
||||
onClose,
|
||||
}) => {
|
||||
const viewModes: MusicViewMode[] = [
|
||||
"lyrics",
|
||||
"numbered",
|
||||
"guitar",
|
||||
"piano",
|
||||
];
|
||||
|
||||
return (
|
||||
<ToolbarContainer>
|
||||
<LeftSection>
|
||||
<SongTitle>{spec.title}</SongTitle>
|
||||
<SongMeta>
|
||||
{spec.key} | {spec.tempo} BPM
|
||||
</SongMeta>
|
||||
</LeftSection>
|
||||
|
||||
<CenterSection>
|
||||
{viewModes.map((mode) => (
|
||||
<ViewModeButton
|
||||
key={mode}
|
||||
$active={viewMode === mode}
|
||||
onClick={() => onViewModeChange(mode)}
|
||||
title={VIEW_MODE_LABELS[mode]}
|
||||
>
|
||||
{VIEW_MODE_ICONS[mode]}
|
||||
{VIEW_MODE_LABELS[mode]}
|
||||
</ViewModeButton>
|
||||
))}
|
||||
</CenterSection>
|
||||
|
||||
<RightSection>
|
||||
<PlayButton
|
||||
onClick={onPlayToggle}
|
||||
title={isPlaying ? "暂停" : "播放"}
|
||||
>
|
||||
{isPlaying ? <Pause /> : <Play />}
|
||||
</PlayButton>
|
||||
|
||||
<Divider />
|
||||
|
||||
<IconButton onClick={onUndo} disabled={!canUndo} title="撤销">
|
||||
<Undo2 />
|
||||
</IconButton>
|
||||
|
||||
<IconButton onClick={onRedo} disabled={!canRedo} title="重做">
|
||||
<Redo2 />
|
||||
</IconButton>
|
||||
|
||||
<Divider />
|
||||
|
||||
<IconButton onClick={onExport} title="导出">
|
||||
<Download />
|
||||
</IconButton>
|
||||
|
||||
<CloseButton onClick={onClose} title="关闭">
|
||||
<X />
|
||||
</CloseButton>
|
||||
</RightSection>
|
||||
</ToolbarContainer>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
MusicToolbar.displayName = "MusicToolbar";
|
||||
@@ -0,0 +1,208 @@
|
||||
/**
|
||||
* @file 旋律分析报告组件
|
||||
* @description 显示参考歌曲的旋律特征分析
|
||||
* @module components/content-creator/canvas/music/analysis/MelodyAnalysisReport
|
||||
*/
|
||||
|
||||
import React, { memo } from "react";
|
||||
import styled from "styled-components";
|
||||
import { Music, TrendingUp, BarChart3, Activity } from "lucide-react";
|
||||
import type { MelodyAnalysisReport } from "../hooks/useMelodyMimic";
|
||||
|
||||
const ReportContainer = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
padding: 20px;
|
||||
background: var(--color-surface, #ffffff);
|
||||
border: 1px solid var(--color-border, #e5e7eb);
|
||||
border-radius: 8px;
|
||||
`;
|
||||
|
||||
const ReportTitle = styled.h3`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text, #1f2937);
|
||||
|
||||
svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
color: var(--color-primary, #3b82f6);
|
||||
}
|
||||
`;
|
||||
|
||||
const MetricsGrid = styled.div`
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 16px;
|
||||
`;
|
||||
|
||||
const MetricCard = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 16px;
|
||||
background: var(--color-surface-hover, #f9fafb);
|
||||
border-radius: 6px;
|
||||
`;
|
||||
|
||||
const MetricHeader = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-secondary, #6b7280);
|
||||
|
||||
svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
`;
|
||||
|
||||
const MetricValue = styled.div`
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: var(--color-text, #1f2937);
|
||||
`;
|
||||
|
||||
const MetricDescription = styled.div`
|
||||
font-size: 12px;
|
||||
color: var(--color-text-secondary, #6b7280);
|
||||
`;
|
||||
|
||||
const TrackInfo = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 12px;
|
||||
background: var(--color-surface-hover, #f9fafb);
|
||||
border-radius: 6px;
|
||||
`;
|
||||
|
||||
const TrackLabel = styled.div`
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-secondary, #6b7280);
|
||||
`;
|
||||
|
||||
const TrackValue = styled.div`
|
||||
font-size: 14px;
|
||||
color: var(--color-text, #1f2937);
|
||||
`;
|
||||
|
||||
export interface MelodyAnalysisReportProps {
|
||||
/** 分析报告数据 */
|
||||
report: MelodyAnalysisReport;
|
||||
}
|
||||
|
||||
/**
|
||||
* 旋律分析报告组件
|
||||
*/
|
||||
export const MelodyAnalysisReportComponent: React.FC<MelodyAnalysisReportProps> =
|
||||
memo(({ report }) => {
|
||||
const selectedTrack = report.tracks[report.selectedTrack];
|
||||
|
||||
return (
|
||||
<ReportContainer>
|
||||
<ReportTitle>
|
||||
<Music />
|
||||
旋律特征分析
|
||||
</ReportTitle>
|
||||
|
||||
<MetricsGrid>
|
||||
{/* 调式 */}
|
||||
<MetricCard>
|
||||
<MetricHeader>
|
||||
<Music />
|
||||
调式
|
||||
</MetricHeader>
|
||||
<MetricValue>{report.mode}</MetricValue>
|
||||
<MetricDescription>歌曲的调式和音阶类型</MetricDescription>
|
||||
</MetricCard>
|
||||
|
||||
{/* BPM */}
|
||||
<MetricCard>
|
||||
<MetricHeader>
|
||||
<Activity />
|
||||
速度
|
||||
</MetricHeader>
|
||||
<MetricValue>{report.bpm} BPM</MetricValue>
|
||||
<MetricDescription>每分钟节拍数</MetricDescription>
|
||||
</MetricCard>
|
||||
|
||||
{/* 拍号 */}
|
||||
<MetricCard>
|
||||
<MetricHeader>
|
||||
<BarChart3 />
|
||||
拍号
|
||||
</MetricHeader>
|
||||
<MetricValue>{report.timeSignature}</MetricValue>
|
||||
<MetricDescription>节拍时间签名</MetricDescription>
|
||||
</MetricCard>
|
||||
|
||||
{/* 音域 */}
|
||||
<MetricCard>
|
||||
<MetricHeader>
|
||||
<TrendingUp />
|
||||
音域
|
||||
</MetricHeader>
|
||||
<MetricValue>{report.range} 半音</MetricValue>
|
||||
<MetricDescription>最高音与最低音的跨度</MetricDescription>
|
||||
</MetricCard>
|
||||
|
||||
{/* 平均音高 */}
|
||||
<MetricCard>
|
||||
<MetricHeader>
|
||||
<BarChart3 />
|
||||
平均音高
|
||||
</MetricHeader>
|
||||
<MetricValue>{report.avgPitch.toFixed(1)}</MetricValue>
|
||||
<MetricDescription>旋律的平均音高位置</MetricDescription>
|
||||
</MetricCard>
|
||||
|
||||
{/* 音程跳跃 */}
|
||||
<MetricCard>
|
||||
<MetricHeader>
|
||||
<TrendingUp />
|
||||
音程跳跃
|
||||
</MetricHeader>
|
||||
<MetricValue>
|
||||
{(report.intervalJumps * 100).toFixed(0)}%
|
||||
</MetricValue>
|
||||
<MetricDescription>大跳音程的出现频率</MetricDescription>
|
||||
</MetricCard>
|
||||
|
||||
{/* 节奏复杂度 */}
|
||||
<MetricCard>
|
||||
<MetricHeader>
|
||||
<Activity />
|
||||
节奏复杂度
|
||||
</MetricHeader>
|
||||
<MetricValue>
|
||||
{(report.rhythmComplexity * 100).toFixed(0)}%
|
||||
</MetricValue>
|
||||
<MetricDescription>节奏变化的复杂程度</MetricDescription>
|
||||
</MetricCard>
|
||||
</MetricsGrid>
|
||||
|
||||
{/* 选中的音轨信息 */}
|
||||
{selectedTrack && (
|
||||
<TrackInfo>
|
||||
<TrackLabel>分析音轨</TrackLabel>
|
||||
<TrackValue>
|
||||
{selectedTrack.name} ({selectedTrack.instrument}) -{" "}
|
||||
{selectedTrack.noteCount} 音符
|
||||
{selectedTrack.isVocal && " · 人声"}
|
||||
</TrackValue>
|
||||
</TrackInfo>
|
||||
)}
|
||||
</ReportContainer>
|
||||
);
|
||||
});
|
||||
|
||||
MelodyAnalysisReportComponent.displayName = "MelodyAnalysisReport";
|
||||
@@ -0,0 +1,188 @@
|
||||
/**
|
||||
* @file 音轨选择器组件
|
||||
* @description 选择要分析的 MIDI 音轨
|
||||
* @module components/content-creator/canvas/music/analysis/TrackSelector
|
||||
*/
|
||||
|
||||
import React, { memo } from "react";
|
||||
import styled from "styled-components";
|
||||
import { Music2, Mic, Check } from "lucide-react";
|
||||
|
||||
const SelectorContainer = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
background: var(--color-surface, #ffffff);
|
||||
border: 1px solid var(--color-border, #e5e7eb);
|
||||
border-radius: 8px;
|
||||
`;
|
||||
|
||||
const SelectorTitle = styled.h4`
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text, #1f2937);
|
||||
`;
|
||||
|
||||
const TrackList = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
`;
|
||||
|
||||
const TrackItem = styled.button<{ $selected: boolean; $isVocal: boolean }>`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
border: 2px solid
|
||||
${(props) =>
|
||||
props.$selected
|
||||
? "var(--color-primary, #3b82f6)"
|
||||
: "var(--color-border, #e5e7eb)"};
|
||||
border-radius: 6px;
|
||||
background: ${(props) =>
|
||||
props.$selected
|
||||
? "var(--color-primary-light, #eff6ff)"
|
||||
: "var(--color-surface, #ffffff)"};
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
text-align: left;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--color-primary, #3b82f6);
|
||||
background: var(--color-primary-light, #eff6ff);
|
||||
}
|
||||
`;
|
||||
|
||||
const TrackIcon = styled.div<{ $isVocal: boolean }>`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 6px;
|
||||
background: ${(props) =>
|
||||
props.$isVocal
|
||||
? "var(--color-success-light, #dcfce7)"
|
||||
: "var(--color-surface-hover, #f3f4f6)"};
|
||||
color: ${(props) =>
|
||||
props.$isVocal
|
||||
? "var(--color-success, #16a34a)"
|
||||
: "var(--color-text-secondary, #6b7280)"};
|
||||
|
||||
svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
`;
|
||||
|
||||
const TrackInfo = styled.div`
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
`;
|
||||
|
||||
const TrackName = styled.div`
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--color-text, #1f2937);
|
||||
`;
|
||||
|
||||
const TrackMeta = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
color: var(--color-text-secondary, #6b7280);
|
||||
`;
|
||||
|
||||
const VocalBadge = styled.span`
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
background: var(--color-success-light, #dcfce7);
|
||||
color: var(--color-success, #16a34a);
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
`;
|
||||
|
||||
const CheckIcon = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-primary, #3b82f6);
|
||||
color: #ffffff;
|
||||
|
||||
svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
`;
|
||||
|
||||
export interface Track {
|
||||
index: number;
|
||||
name: string;
|
||||
instrument: string;
|
||||
noteCount: number;
|
||||
isVocal: boolean;
|
||||
}
|
||||
|
||||
export interface TrackSelectorProps {
|
||||
/** 音轨列表 */
|
||||
tracks: Track[];
|
||||
/** 选中的音轨索引 */
|
||||
selectedTrack: number;
|
||||
/** 选择回调 */
|
||||
onSelectTrack: (trackIndex: number) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 音轨选择器组件
|
||||
*/
|
||||
export const TrackSelector: React.FC<TrackSelectorProps> = memo(
|
||||
({ tracks, selectedTrack, onSelectTrack }) => {
|
||||
return (
|
||||
<SelectorContainer>
|
||||
<SelectorTitle>选择要分析的音轨</SelectorTitle>
|
||||
|
||||
<TrackList>
|
||||
{tracks.map((track) => (
|
||||
<TrackItem
|
||||
key={track.index}
|
||||
$selected={track.index === selectedTrack}
|
||||
$isVocal={track.isVocal}
|
||||
onClick={() => onSelectTrack(track.index)}
|
||||
>
|
||||
<TrackIcon $isVocal={track.isVocal}>
|
||||
{track.isVocal ? <Mic /> : <Music2 />}
|
||||
</TrackIcon>
|
||||
|
||||
<TrackInfo>
|
||||
<TrackName>{track.name}</TrackName>
|
||||
<TrackMeta>
|
||||
{track.instrument} · {track.noteCount} 音符
|
||||
{track.isVocal && <VocalBadge>人声</VocalBadge>}
|
||||
</TrackMeta>
|
||||
</TrackInfo>
|
||||
|
||||
{track.index === selectedTrack && (
|
||||
<CheckIcon>
|
||||
<Check />
|
||||
</CheckIcon>
|
||||
)}
|
||||
</TrackItem>
|
||||
))}
|
||||
</TrackList>
|
||||
</SelectorContainer>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
TrackSelector.displayName = "TrackSelector";
|
||||
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* @file 分析模块入口
|
||||
* @module components/content-creator/canvas/music/analysis
|
||||
*/
|
||||
|
||||
export {
|
||||
MelodyAnalysisReportComponent as MelodyAnalysisReport,
|
||||
type MelodyAnalysisReportProps,
|
||||
} from "./MelodyAnalysisReport";
|
||||
|
||||
export {
|
||||
TrackSelector,
|
||||
type Track,
|
||||
type TrackSelectorProps,
|
||||
} from "./TrackSelector";
|
||||
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* @file API 模块入口
|
||||
* @module components/content-creator/canvas/music/api
|
||||
*/
|
||||
|
||||
export * from "./tauri";
|
||||
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* @file Tauri 音乐命令 API
|
||||
* @description 封装 Tauri 后端音乐相关命令
|
||||
* @module components/content-creator/canvas/music/api/tauri
|
||||
*/
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
/**
|
||||
* MIDI 分析结果
|
||||
*/
|
||||
export interface MidiAnalysisResult {
|
||||
/** 调式信息 */
|
||||
mode: string;
|
||||
/** BPM (每分钟节拍数) */
|
||||
bpm: number;
|
||||
/** 拍号 */
|
||||
time_signature: string;
|
||||
/** 音轨信息 */
|
||||
tracks: TrackInfo[];
|
||||
/** 旋律特征 */
|
||||
melody_features: MelodyFeatures;
|
||||
}
|
||||
|
||||
/**
|
||||
* 音轨信息
|
||||
*/
|
||||
export interface TrackInfo {
|
||||
/** 音轨索引 */
|
||||
index: number;
|
||||
/** 音轨名称 */
|
||||
name: string;
|
||||
/** 乐器名称 */
|
||||
instrument: string;
|
||||
/** 音符数量 */
|
||||
note_count: number;
|
||||
/** 是否为人声音轨 */
|
||||
is_vocal: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 旋律特征
|
||||
*/
|
||||
export interface MelodyFeatures {
|
||||
/** 音域范围 (半音数) */
|
||||
range: number;
|
||||
/** 平均音高 */
|
||||
avg_pitch: number;
|
||||
/** 音程跳跃频率 */
|
||||
interval_jumps: number;
|
||||
/** 节奏复杂度 */
|
||||
rhythm_complexity: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Python 环境信息
|
||||
*/
|
||||
export interface PythonEnvInfo {
|
||||
/** 是否已安装 Python */
|
||||
python_installed: boolean;
|
||||
/** Python 版本 */
|
||||
python_version: string | null;
|
||||
/** 缺失的依赖包 */
|
||||
missing_packages: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查 Python 环境
|
||||
*/
|
||||
export async function checkPythonEnv(): Promise<PythonEnvInfo> {
|
||||
return invoke<PythonEnvInfo>("check_python_env");
|
||||
}
|
||||
|
||||
/**
|
||||
* 分析 MIDI 文件
|
||||
* @param midiPath MIDI 文件路径
|
||||
*/
|
||||
export async function analyzeMidi(
|
||||
midiPath: string,
|
||||
): Promise<MidiAnalysisResult> {
|
||||
return invoke<MidiAnalysisResult>("analyze_midi", { midiPath });
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 MP3 转换为 MIDI
|
||||
* @param mp3Path MP3 文件路径
|
||||
* @param outputPath 输出 MIDI 文件路径
|
||||
*/
|
||||
export async function convertMp3ToMidi(
|
||||
mp3Path: string,
|
||||
outputPath: string,
|
||||
): Promise<string> {
|
||||
return invoke<string>("convert_mp3_to_midi", { mp3Path, outputPath });
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载音乐资源文件
|
||||
* @param resourceName 资源文件名 (相对于 resources/music/ 目录)
|
||||
*/
|
||||
export async function loadMusicResource(resourceName: string): Promise<string> {
|
||||
return invoke<string>("load_music_resource", { resourceName });
|
||||
}
|
||||
|
||||
/**
|
||||
* 安装 Python 依赖
|
||||
*/
|
||||
export async function installPythonDependencies(): Promise<string> {
|
||||
return invoke<string>("install_python_dependencies");
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载押韵数据库
|
||||
*/
|
||||
export async function loadRhymePatterns(): Promise<any> {
|
||||
const content = await loadMusicResource("rhyme-patterns.json");
|
||||
return JSON.parse(content);
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载和弦进行库
|
||||
*/
|
||||
export async function loadChordProgressions(): Promise<any> {
|
||||
const content = await loadMusicResource("chord-progressions.json");
|
||||
return JSON.parse(content);
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载五声音阶规则
|
||||
*/
|
||||
export async function loadPentatonicRules(): Promise<any> {
|
||||
const content = await loadMusicResource("pentatonic-rules.json");
|
||||
return JSON.parse(content);
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载国风旋律模式
|
||||
*/
|
||||
export async function loadGuofengPatterns(): Promise<any> {
|
||||
const content = await loadMusicResource("guofeng-patterns.json");
|
||||
return JSON.parse(content);
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载 MIDI 解析规则
|
||||
*/
|
||||
export async function loadMidiParserRules(): Promise<any> {
|
||||
const content = await loadMusicResource("midi-parser-rules.json");
|
||||
return JSON.parse(content);
|
||||
}
|
||||
@@ -0,0 +1,435 @@
|
||||
/**
|
||||
* @file 歌词编辑器组件
|
||||
* @description 提供歌词编辑、段落管理功能
|
||||
* @module components/content-creator/canvas/music/editors/LyricsEditor
|
||||
*/
|
||||
|
||||
import React, { memo, useCallback, useState } from "react";
|
||||
import styled from "styled-components";
|
||||
import {
|
||||
Plus,
|
||||
Trash2,
|
||||
ChevronUp,
|
||||
ChevronDown,
|
||||
Edit3,
|
||||
Music,
|
||||
} from "lucide-react";
|
||||
import type { MusicSection, SectionType } from "../types";
|
||||
import { countSectionChars } from "../utils/lyricsParser";
|
||||
|
||||
const Container = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
`;
|
||||
|
||||
const Header = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid hsl(var(--border));
|
||||
`;
|
||||
|
||||
const Title = styled.h3`
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--foreground));
|
||||
margin: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
`;
|
||||
|
||||
const HeaderActions = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
`;
|
||||
|
||||
const IconButton = styled.button<{ $variant?: "default" | "danger" }>`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: hsl(var(--muted-foreground));
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: ${({ $variant }) =>
|
||||
$variant === "danger"
|
||||
? "hsl(var(--destructive) / 0.1)"
|
||||
: "hsl(var(--muted))"};
|
||||
color: ${({ $variant }) =>
|
||||
$variant === "danger"
|
||||
? "hsl(var(--destructive))"
|
||||
: "hsl(var(--foreground))"};
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
`;
|
||||
|
||||
const SectionList = styled.div`
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 8px;
|
||||
`;
|
||||
|
||||
const SectionCard = styled.div<{ $isSelected: boolean }>`
|
||||
margin-bottom: 8px;
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
background: ${({ $isSelected }) =>
|
||||
$isSelected ? "hsl(var(--accent) / 0.1)" : "hsl(var(--muted) / 0.3)"};
|
||||
border: 1px solid
|
||||
${({ $isSelected }) =>
|
||||
$isSelected ? "hsl(var(--accent))" : "hsl(var(--border))"};
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: ${({ $isSelected }) =>
|
||||
$isSelected ? "hsl(var(--accent) / 0.15)" : "hsl(var(--muted) / 0.5)"};
|
||||
}
|
||||
`;
|
||||
|
||||
const SectionHeader = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
`;
|
||||
|
||||
const SectionInfo = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
`;
|
||||
|
||||
const SectionTag = styled.span`
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--accent));
|
||||
background: hsl(var(--accent) / 0.1);
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
text-transform: uppercase;
|
||||
`;
|
||||
|
||||
const SectionName = styled.span`
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: hsl(var(--foreground));
|
||||
`;
|
||||
|
||||
const SectionStats = styled.span`
|
||||
font-size: 11px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
`;
|
||||
|
||||
const SectionActions = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
|
||||
${SectionCard}:hover & {
|
||||
opacity: 1;
|
||||
}
|
||||
`;
|
||||
|
||||
const LyricsPreview = styled.div`
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
color: hsl(var(--muted-foreground));
|
||||
max-height: 80px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
`;
|
||||
|
||||
const LyricsLine = styled.p`
|
||||
margin: 2px 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
`;
|
||||
|
||||
const AddSectionButton = styled.button`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
border: 2px dashed hsl(var(--border));
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: hsl(var(--muted-foreground));
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
border-color: hsl(var(--accent));
|
||||
color: hsl(var(--accent));
|
||||
background: hsl(var(--accent) / 0.05);
|
||||
}
|
||||
|
||||
svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
`;
|
||||
|
||||
const AddSectionMenu = styled.div`
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
margin-top: 4px;
|
||||
padding: 8px;
|
||||
background: hsl(var(--background));
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
z-index: 10;
|
||||
`;
|
||||
|
||||
const AddSectionMenuItem = styled.button`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: hsl(var(--foreground));
|
||||
font-size: 13px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: hsl(var(--muted));
|
||||
}
|
||||
`;
|
||||
|
||||
const AddSectionWrapper = styled.div`
|
||||
position: relative;
|
||||
`;
|
||||
|
||||
const EmptyState = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 32px;
|
||||
text-align: center;
|
||||
color: hsl(var(--muted-foreground));
|
||||
`;
|
||||
|
||||
const EmptyIcon = styled.div`
|
||||
font-size: 32px;
|
||||
margin-bottom: 12px;
|
||||
`;
|
||||
|
||||
const EmptyText = styled.p`
|
||||
font-size: 13px;
|
||||
margin: 0 0 16px 0;
|
||||
`;
|
||||
|
||||
interface LyricsEditorProps {
|
||||
sections: MusicSection[];
|
||||
currentSectionId: string | null;
|
||||
onSectionSelect: (sectionId: string) => void;
|
||||
onAddSection: (type: SectionType) => void;
|
||||
onDeleteSection: (sectionId: string) => void;
|
||||
onMoveSection: (sectionId: string, direction: "up" | "down") => void;
|
||||
}
|
||||
|
||||
const SECTION_TYPES: { type: SectionType; label: string; icon: string }[] = [
|
||||
{ type: "verse", label: "主歌 (Verse)", icon: "🎤" },
|
||||
{ type: "chorus", label: "副歌 (Chorus)", icon: "🎵" },
|
||||
{ type: "pre-chorus", label: "预副歌 (Pre-Chorus)", icon: "🎶" },
|
||||
{ type: "bridge", label: "桥段 (Bridge)", icon: "🌉" },
|
||||
{ type: "intro", label: "前奏 (Intro)", icon: "🎬" },
|
||||
{ type: "outro", label: "尾奏 (Outro)", icon: "🎭" },
|
||||
{ type: "interlude", label: "间奏 (Interlude)", icon: "🎹" },
|
||||
];
|
||||
|
||||
export const LyricsEditor: React.FC<LyricsEditorProps> = memo(
|
||||
({
|
||||
sections,
|
||||
currentSectionId,
|
||||
onSectionSelect,
|
||||
onAddSection,
|
||||
onDeleteSection,
|
||||
onMoveSection,
|
||||
}) => {
|
||||
const [showAddMenu, setShowAddMenu] = useState(false);
|
||||
|
||||
const handleAddSection = useCallback(
|
||||
(type: SectionType) => {
|
||||
onAddSection(type);
|
||||
setShowAddMenu(false);
|
||||
},
|
||||
[onAddSection],
|
||||
);
|
||||
|
||||
const handleSectionClick = useCallback(
|
||||
(sectionId: string) => {
|
||||
onSectionSelect(sectionId);
|
||||
},
|
||||
[onSectionSelect],
|
||||
);
|
||||
|
||||
if (sections.length === 0) {
|
||||
return (
|
||||
<Container>
|
||||
<Header>
|
||||
<Title>
|
||||
<Music size={16} />
|
||||
歌词编辑
|
||||
</Title>
|
||||
</Header>
|
||||
<EmptyState>
|
||||
<EmptyIcon>🎵</EmptyIcon>
|
||||
<EmptyText>还没有歌词段落,点击下方按钮添加</EmptyText>
|
||||
<AddSectionWrapper>
|
||||
<AddSectionButton onClick={() => setShowAddMenu(!showAddMenu)}>
|
||||
<Plus />
|
||||
添加段落
|
||||
</AddSectionButton>
|
||||
{showAddMenu && (
|
||||
<AddSectionMenu>
|
||||
{SECTION_TYPES.map(({ type, label, icon }) => (
|
||||
<AddSectionMenuItem
|
||||
key={type}
|
||||
onClick={() => handleAddSection(type)}
|
||||
>
|
||||
<span>{icon}</span>
|
||||
{label}
|
||||
</AddSectionMenuItem>
|
||||
))}
|
||||
</AddSectionMenu>
|
||||
)}
|
||||
</AddSectionWrapper>
|
||||
</EmptyState>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<Header>
|
||||
<Title>
|
||||
<Music size={16} />
|
||||
歌词编辑
|
||||
</Title>
|
||||
<HeaderActions>
|
||||
<IconButton title="编辑模式">
|
||||
<Edit3 />
|
||||
</IconButton>
|
||||
</HeaderActions>
|
||||
</Header>
|
||||
|
||||
<SectionList>
|
||||
{sections.map((section, index) => (
|
||||
<SectionCard
|
||||
key={section.id}
|
||||
$isSelected={section.id === currentSectionId}
|
||||
onClick={() => handleSectionClick(section.id)}
|
||||
>
|
||||
<SectionHeader>
|
||||
<SectionInfo>
|
||||
<SectionTag>{section.type}</SectionTag>
|
||||
<SectionName>{section.name}</SectionName>
|
||||
<SectionStats>
|
||||
{section.lyricsLines.length} 行 |{" "}
|
||||
{countSectionChars(section)} 字
|
||||
</SectionStats>
|
||||
</SectionInfo>
|
||||
<SectionActions>
|
||||
<IconButton
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onMoveSection(section.id, "up");
|
||||
}}
|
||||
disabled={index === 0}
|
||||
title="上移"
|
||||
>
|
||||
<ChevronUp />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onMoveSection(section.id, "down");
|
||||
}}
|
||||
disabled={index === sections.length - 1}
|
||||
title="下移"
|
||||
>
|
||||
<ChevronDown />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
$variant="danger"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDeleteSection(section.id);
|
||||
}}
|
||||
title="删除"
|
||||
>
|
||||
<Trash2 />
|
||||
</IconButton>
|
||||
</SectionActions>
|
||||
</SectionHeader>
|
||||
<LyricsPreview>
|
||||
{section.lyricsLines.slice(0, 3).map((line, i) => (
|
||||
<LyricsLine key={i}>{line}</LyricsLine>
|
||||
))}
|
||||
{section.lyricsLines.length > 3 && <LyricsLine>...</LyricsLine>}
|
||||
</LyricsPreview>
|
||||
</SectionCard>
|
||||
))}
|
||||
|
||||
<AddSectionWrapper>
|
||||
<AddSectionButton onClick={() => setShowAddMenu(!showAddMenu)}>
|
||||
<Plus />
|
||||
添加段落
|
||||
</AddSectionButton>
|
||||
{showAddMenu && (
|
||||
<AddSectionMenu>
|
||||
{SECTION_TYPES.map(({ type, label, icon }) => (
|
||||
<AddSectionMenuItem
|
||||
key={type}
|
||||
onClick={() => handleAddSection(type)}
|
||||
>
|
||||
<span>{icon}</span>
|
||||
{label}
|
||||
</AddSectionMenuItem>
|
||||
))}
|
||||
</AddSectionMenu>
|
||||
)}
|
||||
</AddSectionWrapper>
|
||||
</SectionList>
|
||||
</Container>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
LyricsEditor.displayName = "LyricsEditor";
|
||||
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* @file 编辑器模块入口
|
||||
* @module components/content-creator/canvas/music/editors
|
||||
*/
|
||||
|
||||
export { LyricsEditor } from "./LyricsEditor";
|
||||
@@ -0,0 +1,409 @@
|
||||
/**
|
||||
* @file 导出对话框组件
|
||||
* @description 提供多种格式的导出选项
|
||||
* @module components/content-creator/canvas/music/export/ExportDialog
|
||||
*/
|
||||
|
||||
import React, { memo, useState, useCallback } from "react";
|
||||
import styled from "styled-components";
|
||||
import { Download, Copy, FileText, FileJson, Music } from "lucide-react";
|
||||
import type { MusicSection, SongSpec } from "../types";
|
||||
import { useMusicExport } from "../hooks/useMusicExport";
|
||||
|
||||
const DialogOverlay = styled.div`
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
`;
|
||||
|
||||
const DialogContainer = styled.div`
|
||||
width: 90%;
|
||||
max-width: 600px;
|
||||
max-height: 80vh;
|
||||
background: var(--color-surface, #ffffff);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
`;
|
||||
|
||||
const DialogHeader = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 20px 24px;
|
||||
border-bottom: 1px solid var(--color-border, #e5e7eb);
|
||||
`;
|
||||
|
||||
const DialogTitle = styled.h2`
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text, #1f2937);
|
||||
`;
|
||||
|
||||
const CloseButton = styled.button`
|
||||
padding: 8px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--color-text-secondary, #6b7280);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background: var(--color-surface-hover, #f3f4f6);
|
||||
}
|
||||
`;
|
||||
|
||||
const DialogContent = styled.div`
|
||||
flex: 1;
|
||||
padding: 24px;
|
||||
overflow-y: auto;
|
||||
`;
|
||||
|
||||
const FormatGrid = styled.div`
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: 16px;
|
||||
`;
|
||||
|
||||
const FormatCard = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 20px;
|
||||
border: 2px solid var(--color-border, #e5e7eb);
|
||||
border-radius: 8px;
|
||||
background: var(--color-surface, #ffffff);
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--color-primary, #3b82f6);
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
`;
|
||||
|
||||
const FormatHeader = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
`;
|
||||
|
||||
const FormatIcon = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 8px;
|
||||
background: var(--color-primary-light, #eff6ff);
|
||||
color: var(--color-primary, #3b82f6);
|
||||
|
||||
svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
`;
|
||||
|
||||
const FormatInfo = styled.div`
|
||||
flex: 1;
|
||||
`;
|
||||
|
||||
const FormatName = styled.div`
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text, #1f2937);
|
||||
`;
|
||||
|
||||
const FormatDescription = styled.div`
|
||||
font-size: 13px;
|
||||
color: var(--color-text-secondary, #6b7280);
|
||||
`;
|
||||
|
||||
const FormatActions = styled.div`
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
`;
|
||||
|
||||
const ActionButton = styled.button`
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--color-border, #e5e7eb);
|
||||
border-radius: 6px;
|
||||
background: var(--color-surface, #ffffff);
|
||||
color: var(--color-text, #1f2937);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background: var(--color-surface-hover, #f3f4f6);
|
||||
border-color: var(--color-primary, #3b82f6);
|
||||
}
|
||||
|
||||
svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
`;
|
||||
|
||||
const SuccessMessage = styled.div`
|
||||
padding: 12px;
|
||||
margin-top: 16px;
|
||||
border-radius: 6px;
|
||||
background: var(--color-success-light, #dcfce7);
|
||||
color: var(--color-success, #16a34a);
|
||||
font-size: 14px;
|
||||
text-align: center;
|
||||
`;
|
||||
|
||||
export interface ExportDialogProps {
|
||||
/** 是否显示 */
|
||||
isOpen: boolean;
|
||||
/** 段落列表 */
|
||||
sections: MusicSection[];
|
||||
/** 歌曲规格 */
|
||||
spec: SongSpec;
|
||||
/** 关闭回调 */
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出对话框组件
|
||||
*/
|
||||
export const ExportDialog: React.FC<ExportDialogProps> = memo(
|
||||
({ isOpen, sections, spec, onClose }) => {
|
||||
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
||||
|
||||
const {
|
||||
exportSuno,
|
||||
exportText,
|
||||
exportMarkdown,
|
||||
exportJSON,
|
||||
downloadFile,
|
||||
copyToClipboard,
|
||||
} = useMusicExport();
|
||||
|
||||
const handleCopy = useCallback(
|
||||
async (format: string, getContent: () => string) => {
|
||||
try {
|
||||
const content = getContent();
|
||||
await copyToClipboard(content);
|
||||
setSuccessMessage(`已复制 ${format} 格式到剪贴板`);
|
||||
setTimeout(() => setSuccessMessage(null), 3000);
|
||||
} catch (err) {
|
||||
console.error("Copy failed:", err);
|
||||
}
|
||||
},
|
||||
[copyToClipboard],
|
||||
);
|
||||
|
||||
const handleDownload = useCallback(
|
||||
(
|
||||
format: string,
|
||||
getContent: () => string,
|
||||
extension: string,
|
||||
mimeType: string,
|
||||
) => {
|
||||
try {
|
||||
const content = getContent();
|
||||
const filename = `${spec.title || "untitled"}.${extension}`;
|
||||
downloadFile(content, filename, mimeType);
|
||||
setSuccessMessage(`已下载 ${format} 文件`);
|
||||
setTimeout(() => setSuccessMessage(null), 3000);
|
||||
} catch (err) {
|
||||
console.error("Download failed:", err);
|
||||
}
|
||||
},
|
||||
[spec.title, downloadFile],
|
||||
);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<DialogOverlay onClick={onClose}>
|
||||
<DialogContainer onClick={(e) => e.stopPropagation()}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>导出作品</DialogTitle>
|
||||
<CloseButton onClick={onClose}>✕</CloseButton>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogContent>
|
||||
<FormatGrid>
|
||||
{/* Suno 格式 */}
|
||||
<FormatCard>
|
||||
<FormatHeader>
|
||||
<FormatIcon>
|
||||
<Music />
|
||||
</FormatIcon>
|
||||
<FormatInfo>
|
||||
<FormatName>Suno AI</FormatName>
|
||||
<FormatDescription>AI 音乐生成平台</FormatDescription>
|
||||
</FormatInfo>
|
||||
</FormatHeader>
|
||||
<FormatActions>
|
||||
<ActionButton
|
||||
onClick={() =>
|
||||
handleCopy("Suno", () => {
|
||||
const result = exportSuno(sections, spec);
|
||||
return `${result.lyrics}\n\n---\nStyle: ${result.style}\nTags: ${result.tags.join(", ")}`;
|
||||
})
|
||||
}
|
||||
>
|
||||
<Copy />
|
||||
复制
|
||||
</ActionButton>
|
||||
<ActionButton
|
||||
onClick={() =>
|
||||
handleDownload(
|
||||
"Suno",
|
||||
() => {
|
||||
const result = exportSuno(sections, spec);
|
||||
return `${result.lyrics}\n\n---\nStyle: ${result.style}\nTags: ${result.tags.join(", ")}`;
|
||||
},
|
||||
"txt",
|
||||
"text/plain",
|
||||
)
|
||||
}
|
||||
>
|
||||
<Download />
|
||||
下载
|
||||
</ActionButton>
|
||||
</FormatActions>
|
||||
</FormatCard>
|
||||
|
||||
{/* 纯文本格式 */}
|
||||
<FormatCard>
|
||||
<FormatHeader>
|
||||
<FormatIcon>
|
||||
<FileText />
|
||||
</FormatIcon>
|
||||
<FormatInfo>
|
||||
<FormatName>纯文本</FormatName>
|
||||
<FormatDescription>简单的文本格式</FormatDescription>
|
||||
</FormatInfo>
|
||||
</FormatHeader>
|
||||
<FormatActions>
|
||||
<ActionButton
|
||||
onClick={() =>
|
||||
handleCopy("纯文本", () => exportText(sections, spec))
|
||||
}
|
||||
>
|
||||
<Copy />
|
||||
复制
|
||||
</ActionButton>
|
||||
<ActionButton
|
||||
onClick={() =>
|
||||
handleDownload(
|
||||
"纯文本",
|
||||
() => exportText(sections, spec),
|
||||
"txt",
|
||||
"text/plain",
|
||||
)
|
||||
}
|
||||
>
|
||||
<Download />
|
||||
下载
|
||||
</ActionButton>
|
||||
</FormatActions>
|
||||
</FormatCard>
|
||||
|
||||
{/* Markdown 格式 */}
|
||||
<FormatCard>
|
||||
<FormatHeader>
|
||||
<FormatIcon>
|
||||
<FileText />
|
||||
</FormatIcon>
|
||||
<FormatInfo>
|
||||
<FormatName>Markdown</FormatName>
|
||||
<FormatDescription>支持格式化的文本</FormatDescription>
|
||||
</FormatInfo>
|
||||
</FormatHeader>
|
||||
<FormatActions>
|
||||
<ActionButton
|
||||
onClick={() =>
|
||||
handleCopy("Markdown", () =>
|
||||
exportMarkdown(sections, spec),
|
||||
)
|
||||
}
|
||||
>
|
||||
<Copy />
|
||||
复制
|
||||
</ActionButton>
|
||||
<ActionButton
|
||||
onClick={() =>
|
||||
handleDownload(
|
||||
"Markdown",
|
||||
() => exportMarkdown(sections, spec),
|
||||
"md",
|
||||
"text/markdown",
|
||||
)
|
||||
}
|
||||
>
|
||||
<Download />
|
||||
下载
|
||||
</ActionButton>
|
||||
</FormatActions>
|
||||
</FormatCard>
|
||||
|
||||
{/* JSON 格式 */}
|
||||
<FormatCard>
|
||||
<FormatHeader>
|
||||
<FormatIcon>
|
||||
<FileJson />
|
||||
</FormatIcon>
|
||||
<FormatInfo>
|
||||
<FormatName>JSON</FormatName>
|
||||
<FormatDescription>结构化数据格式</FormatDescription>
|
||||
</FormatInfo>
|
||||
</FormatHeader>
|
||||
<FormatActions>
|
||||
<ActionButton
|
||||
onClick={() =>
|
||||
handleCopy("JSON", () => exportJSON(sections, spec))
|
||||
}
|
||||
>
|
||||
<Copy />
|
||||
复制
|
||||
</ActionButton>
|
||||
<ActionButton
|
||||
onClick={() =>
|
||||
handleDownload(
|
||||
"JSON",
|
||||
() => exportJSON(sections, spec),
|
||||
"json",
|
||||
"application/json",
|
||||
)
|
||||
}
|
||||
>
|
||||
<Download />
|
||||
下载
|
||||
</ActionButton>
|
||||
</FormatActions>
|
||||
</FormatCard>
|
||||
</FormatGrid>
|
||||
|
||||
{successMessage && (
|
||||
<SuccessMessage>{successMessage}</SuccessMessage>
|
||||
)}
|
||||
</DialogContent>
|
||||
</DialogContainer>
|
||||
</DialogOverlay>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
ExportDialog.displayName = "ExportDialog";
|
||||
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* @file 导出模块入口
|
||||
* @module components/content-creator/canvas/music/export
|
||||
*/
|
||||
|
||||
export { ExportDialog, type ExportDialogProps } from "./ExportDialog";
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* @file Hooks 模块入口
|
||||
* @module components/content-creator/canvas/music/hooks
|
||||
*/
|
||||
|
||||
export {
|
||||
useLyricsEditor,
|
||||
type UseLyricsEditorOptions,
|
||||
type UseLyricsEditorReturn,
|
||||
} from "./useLyricsEditor";
|
||||
|
||||
export {
|
||||
useRhyme,
|
||||
type SectionRhymeAnalysis,
|
||||
type UseRhymeReturn,
|
||||
} from "./useRhyme";
|
||||
|
||||
export {
|
||||
useMidiPlayback,
|
||||
type MidiTrack,
|
||||
type PlaybackState,
|
||||
type UseMidiPlaybackOptions,
|
||||
type UseMidiPlaybackReturn,
|
||||
} from "./useMidiPlayback";
|
||||
|
||||
export {
|
||||
useMelodyMimic,
|
||||
type MimicState,
|
||||
type MimicLevel,
|
||||
type MelodyAnalysisReport,
|
||||
type LyricsAnalysisReport,
|
||||
type ConsistencyScore,
|
||||
type UseMelodyMimicOptions,
|
||||
type UseMelodyMimicReturn,
|
||||
} from "./useMelodyMimic";
|
||||
|
||||
export {
|
||||
useMusicExport,
|
||||
type ExportFormat,
|
||||
type ExportState,
|
||||
type UseMusicExportOptions,
|
||||
type UseMusicExportReturn,
|
||||
} from "./useMusicExport";
|
||||
@@ -0,0 +1,246 @@
|
||||
/**
|
||||
* @file 歌词编辑器 Hook
|
||||
* @description 管理歌词编辑状态和操作
|
||||
* @module components/content-creator/canvas/music/hooks/useLyricsEditor
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useMemo } from "react";
|
||||
import type { MusicSection, SectionType } from "../types";
|
||||
import { createSection } from "../types";
|
||||
import {
|
||||
parseLyrics,
|
||||
sectionsToLyrics,
|
||||
getLyricsStats,
|
||||
type LyricsStats,
|
||||
} from "../utils/lyricsParser";
|
||||
|
||||
export interface UseLyricsEditorOptions {
|
||||
initialSections?: MusicSection[];
|
||||
onChange?: (sections: MusicSection[]) => void;
|
||||
}
|
||||
|
||||
export interface UseLyricsEditorReturn {
|
||||
/** 段落列表 */
|
||||
sections: MusicSection[];
|
||||
/** 当前选中的段落 ID */
|
||||
currentSectionId: string | null;
|
||||
/** 当前选中的段落 */
|
||||
currentSection: MusicSection | null;
|
||||
/** 歌词统计信息 */
|
||||
stats: LyricsStats;
|
||||
/** 是否处于编辑模式 */
|
||||
isEditing: boolean;
|
||||
/** 编辑中的文本 */
|
||||
editingText: string;
|
||||
|
||||
// 操作方法
|
||||
/** 选择段落 */
|
||||
selectSection: (sectionId: string | null) => void;
|
||||
/** 添加段落 */
|
||||
addSection: (type: SectionType, afterId?: string) => void;
|
||||
/** 删除段落 */
|
||||
deleteSection: (sectionId: string) => void;
|
||||
/** 更新段落歌词 */
|
||||
updateSectionLyrics: (sectionId: string, lyrics: string[]) => void;
|
||||
/** 更新段落名称 */
|
||||
updateSectionName: (sectionId: string, name: string) => void;
|
||||
/** 移动段落 */
|
||||
moveSection: (sectionId: string, direction: "up" | "down") => void;
|
||||
/** 从文本导入歌词 */
|
||||
importFromText: (text: string) => void;
|
||||
/** 导出为文本 */
|
||||
exportToText: () => string;
|
||||
/** 进入编辑模式 */
|
||||
startEditing: () => void;
|
||||
/** 保存编辑 */
|
||||
saveEditing: () => void;
|
||||
/** 取消编辑 */
|
||||
cancelEditing: () => void;
|
||||
/** 更新编辑文本 */
|
||||
setEditingText: (text: string) => void;
|
||||
}
|
||||
|
||||
export function useLyricsEditor(
|
||||
options: UseLyricsEditorOptions = {},
|
||||
): UseLyricsEditorReturn {
|
||||
const { initialSections = [], onChange } = options;
|
||||
|
||||
const [sections, setSections] = useState<MusicSection[]>(initialSections);
|
||||
const [currentSectionId, setCurrentSectionId] = useState<string | null>(null);
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [editingText, setEditingText] = useState("");
|
||||
|
||||
// 当前选中的段落
|
||||
const currentSection = useMemo(() => {
|
||||
return sections.find((s) => s.id === currentSectionId) || null;
|
||||
}, [sections, currentSectionId]);
|
||||
|
||||
// 歌词统计
|
||||
const stats = useMemo(() => getLyricsStats(sections), [sections]);
|
||||
|
||||
// 更新段落并触发回调
|
||||
const updateSections = useCallback(
|
||||
(newSections: MusicSection[]) => {
|
||||
setSections(newSections);
|
||||
onChange?.(newSections);
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
|
||||
// 选择段落
|
||||
const selectSection = useCallback((sectionId: string | null) => {
|
||||
setCurrentSectionId(sectionId);
|
||||
}, []);
|
||||
|
||||
// 添加段落
|
||||
const addSection = useCallback(
|
||||
(type: SectionType, afterId?: string) => {
|
||||
const newSection = createSection(type, sections.length + 1);
|
||||
|
||||
let newSections: MusicSection[];
|
||||
if (afterId) {
|
||||
const index = sections.findIndex((s) => s.id === afterId);
|
||||
if (index !== -1) {
|
||||
newSections = [
|
||||
...sections.slice(0, index + 1),
|
||||
newSection,
|
||||
...sections.slice(index + 1),
|
||||
];
|
||||
} else {
|
||||
newSections = [...sections, newSection];
|
||||
}
|
||||
} else {
|
||||
newSections = [...sections, newSection];
|
||||
}
|
||||
|
||||
// 重新排序
|
||||
newSections = newSections.map((s, i) => ({ ...s, order: i + 1 }));
|
||||
updateSections(newSections);
|
||||
setCurrentSectionId(newSection.id);
|
||||
},
|
||||
[sections, updateSections],
|
||||
);
|
||||
|
||||
// 删除段落
|
||||
const deleteSection = useCallback(
|
||||
(sectionId: string) => {
|
||||
const newSections = sections
|
||||
.filter((s) => s.id !== sectionId)
|
||||
.map((s, i) => ({ ...s, order: i + 1 }));
|
||||
updateSections(newSections);
|
||||
|
||||
if (currentSectionId === sectionId) {
|
||||
setCurrentSectionId(newSections[0]?.id || null);
|
||||
}
|
||||
},
|
||||
[sections, currentSectionId, updateSections],
|
||||
);
|
||||
|
||||
// 更新段落歌词
|
||||
const updateSectionLyrics = useCallback(
|
||||
(sectionId: string, lyrics: string[]) => {
|
||||
const newSections = sections.map((s) =>
|
||||
s.id === sectionId ? { ...s, lyricsLines: lyrics } : s,
|
||||
);
|
||||
updateSections(newSections);
|
||||
},
|
||||
[sections, updateSections],
|
||||
);
|
||||
|
||||
// 更新段落名称
|
||||
const updateSectionName = useCallback(
|
||||
(sectionId: string, name: string) => {
|
||||
const newSections = sections.map((s) =>
|
||||
s.id === sectionId ? { ...s, name } : s,
|
||||
);
|
||||
updateSections(newSections);
|
||||
},
|
||||
[sections, updateSections],
|
||||
);
|
||||
|
||||
// 移动段落
|
||||
const moveSection = useCallback(
|
||||
(sectionId: string, direction: "up" | "down") => {
|
||||
const index = sections.findIndex((s) => s.id === sectionId);
|
||||
if (index === -1) return;
|
||||
|
||||
const newIndex = direction === "up" ? index - 1 : index + 1;
|
||||
if (newIndex < 0 || newIndex >= sections.length) return;
|
||||
|
||||
const newSections = [...sections];
|
||||
[newSections[index], newSections[newIndex]] = [
|
||||
newSections[newIndex],
|
||||
newSections[index],
|
||||
];
|
||||
|
||||
// 重新排序
|
||||
const reorderedSections = newSections.map((s, i) => ({
|
||||
...s,
|
||||
order: i + 1,
|
||||
}));
|
||||
updateSections(reorderedSections);
|
||||
},
|
||||
[sections, updateSections],
|
||||
);
|
||||
|
||||
// 从文本导入歌词
|
||||
const importFromText = useCallback(
|
||||
(text: string) => {
|
||||
const parsedSections = parseLyrics(text);
|
||||
updateSections(parsedSections);
|
||||
if (parsedSections.length > 0) {
|
||||
setCurrentSectionId(parsedSections[0].id);
|
||||
}
|
||||
},
|
||||
[updateSections],
|
||||
);
|
||||
|
||||
// 导出为文本
|
||||
const exportToText = useCallback(() => {
|
||||
return sectionsToLyrics(sections);
|
||||
}, [sections]);
|
||||
|
||||
// 进入编辑模式
|
||||
const startEditing = useCallback(() => {
|
||||
setEditingText(sectionsToLyrics(sections));
|
||||
setIsEditing(true);
|
||||
}, [sections]);
|
||||
|
||||
// 保存编辑
|
||||
const saveEditing = useCallback(() => {
|
||||
const parsedSections = parseLyrics(editingText);
|
||||
updateSections(parsedSections);
|
||||
setIsEditing(false);
|
||||
setEditingText("");
|
||||
if (parsedSections.length > 0) {
|
||||
setCurrentSectionId(parsedSections[0].id);
|
||||
}
|
||||
}, [editingText, updateSections]);
|
||||
|
||||
// 取消编辑
|
||||
const cancelEditing = useCallback(() => {
|
||||
setIsEditing(false);
|
||||
setEditingText("");
|
||||
}, []);
|
||||
|
||||
return {
|
||||
sections,
|
||||
currentSectionId,
|
||||
currentSection,
|
||||
stats,
|
||||
isEditing,
|
||||
editingText,
|
||||
selectSection,
|
||||
addSection,
|
||||
deleteSection,
|
||||
updateSectionLyrics,
|
||||
updateSectionName,
|
||||
moveSection,
|
||||
importFromText,
|
||||
exportToText,
|
||||
startEditing,
|
||||
saveEditing,
|
||||
cancelEditing,
|
||||
setEditingText,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,359 @@
|
||||
/**
|
||||
* @file 旋律学习 Hook
|
||||
* @description 基于参考歌曲的旋律风格学习
|
||||
* @module components/content-creator/canvas/music/hooks/useMelodyMimic
|
||||
*/
|
||||
|
||||
import { useState, useCallback } from "react";
|
||||
import {
|
||||
analyzeMidi,
|
||||
convertMp3ToMidi,
|
||||
checkPythonEnv,
|
||||
type PythonEnvInfo,
|
||||
} from "../api/tauri";
|
||||
|
||||
/**
|
||||
* 旋律学习状态
|
||||
*/
|
||||
export type MimicState =
|
||||
| "idle"
|
||||
| "uploading"
|
||||
| "converting"
|
||||
| "analyzing"
|
||||
| "completed"
|
||||
| "error";
|
||||
|
||||
/**
|
||||
* 借鉴程度
|
||||
*/
|
||||
export type MimicLevel = "light" | "medium" | "heavy";
|
||||
|
||||
/**
|
||||
* 旋律分析报告
|
||||
*/
|
||||
export interface MelodyAnalysisReport {
|
||||
/** 调式信息 */
|
||||
mode: string;
|
||||
/** BPM */
|
||||
bpm: number;
|
||||
/** 拍号 */
|
||||
timeSignature: string;
|
||||
/** 音域范围 */
|
||||
range: number;
|
||||
/** 平均音高 */
|
||||
avgPitch: number;
|
||||
/** 音程跳跃频率 */
|
||||
intervalJumps: number;
|
||||
/** 节奏复杂度 */
|
||||
rhythmComplexity: number;
|
||||
/** 选中的音轨 */
|
||||
selectedTrack: number;
|
||||
/** 音轨列表 */
|
||||
tracks: Array<{
|
||||
index: number;
|
||||
name: string;
|
||||
instrument: string;
|
||||
noteCount: number;
|
||||
isVocal: boolean;
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 歌词分析报告
|
||||
*/
|
||||
export interface LyricsAnalysisReport {
|
||||
/** 段落结构 */
|
||||
structure: string[];
|
||||
/** 平均每句字数 */
|
||||
avgCharsPerLine: number;
|
||||
/** 押韵模式 */
|
||||
rhymeScheme: string;
|
||||
/** 主题词 */
|
||||
keywords: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 一致性评分
|
||||
*/
|
||||
export interface ConsistencyScore {
|
||||
/** 调式一致性 (0-100) */
|
||||
modeConsistency: number;
|
||||
/** 节奏一致性 (0-100) */
|
||||
rhythmConsistency: number;
|
||||
/** 结构一致性 (0-100) */
|
||||
structureConsistency: number;
|
||||
/** 整体评分 (0-100) */
|
||||
overall: number;
|
||||
}
|
||||
|
||||
export interface UseMelodyMimicOptions {
|
||||
/** 借鉴程度 */
|
||||
mimicLevel?: MimicLevel;
|
||||
/** 分析完成回调 */
|
||||
onAnalysisComplete?: (report: MelodyAnalysisReport) => void;
|
||||
/** 错误回调 */
|
||||
onError?: (error: string) => void;
|
||||
}
|
||||
|
||||
export interface UseMelodyMimicReturn {
|
||||
/** 当前状态 */
|
||||
state: MimicState;
|
||||
/** 借鉴程度 */
|
||||
mimicLevel: MimicLevel;
|
||||
/** Python 环境信息 */
|
||||
pythonEnv: PythonEnvInfo | null;
|
||||
/** 旋律分析报告 */
|
||||
melodyReport: MelodyAnalysisReport | null;
|
||||
/** 歌词分析报告 */
|
||||
lyricsReport: LyricsAnalysisReport | null;
|
||||
/** 一致性评分 */
|
||||
consistencyScore: ConsistencyScore | null;
|
||||
/** 错误信息 */
|
||||
error: string | null;
|
||||
/** 上传文件 */
|
||||
uploadFile: (file: File) => Promise<void>;
|
||||
/** 选择音轨 */
|
||||
selectTrack: (trackIndex: number) => void;
|
||||
/** 设置借鉴程度 */
|
||||
setMimicLevel: (level: MimicLevel) => void;
|
||||
/** 生成歌词分析 */
|
||||
generateLyricsAnalysis: (lyrics: string) => void;
|
||||
/** 计算一致性评分 */
|
||||
calculateConsistency: (userLyrics: string) => ConsistencyScore;
|
||||
/** 重置状态 */
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 旋律学习 Hook
|
||||
*/
|
||||
export function useMelodyMimic(
|
||||
options: UseMelodyMimicOptions = {},
|
||||
): UseMelodyMimicReturn {
|
||||
const {
|
||||
mimicLevel: initialMimicLevel = "medium",
|
||||
onAnalysisComplete,
|
||||
onError,
|
||||
} = options;
|
||||
|
||||
const [state, setState] = useState<MimicState>("idle");
|
||||
const [mimicLevel, setMimicLevel] = useState<MimicLevel>(initialMimicLevel);
|
||||
const [pythonEnv, setPythonEnv] = useState<PythonEnvInfo | null>(null);
|
||||
const [melodyReport, setMelodyReport] = useState<MelodyAnalysisReport | null>(
|
||||
null,
|
||||
);
|
||||
const [lyricsReport, setLyricsReport] = useState<LyricsAnalysisReport | null>(
|
||||
null,
|
||||
);
|
||||
const [consistencyScore, setConsistencyScore] =
|
||||
useState<ConsistencyScore | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [_midiPath, setMidiPath] = useState<string | null>(null);
|
||||
|
||||
// 检查 Python 环境
|
||||
const checkEnvironment = useCallback(async () => {
|
||||
try {
|
||||
const env = await checkPythonEnv();
|
||||
setPythonEnv(env);
|
||||
return env;
|
||||
} catch (err) {
|
||||
const errorMsg =
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: "Failed to check Python environment";
|
||||
setError(errorMsg);
|
||||
onError?.(errorMsg);
|
||||
return null;
|
||||
}
|
||||
}, [onError]);
|
||||
|
||||
// 上传文件
|
||||
const uploadFile = useCallback(
|
||||
async (file: File) => {
|
||||
try {
|
||||
setState("uploading");
|
||||
setError(null);
|
||||
|
||||
// 检查 Python 环境
|
||||
const env = await checkEnvironment();
|
||||
if (!env?.python_installed) {
|
||||
throw new Error(
|
||||
"Python is not installed. Please install Python 3.x first.",
|
||||
);
|
||||
}
|
||||
if (env.missing_packages.length > 0) {
|
||||
throw new Error(
|
||||
`Missing Python packages: ${env.missing_packages.join(", ")}. Please install them first.`,
|
||||
);
|
||||
}
|
||||
|
||||
// 保存文件到临时目录
|
||||
const tempPath = `/tmp/${file.name}`;
|
||||
await file.arrayBuffer();
|
||||
// TODO: 使用 Tauri 的文件系统 API 保存文件
|
||||
|
||||
let finalMidiPath = tempPath;
|
||||
|
||||
// 如果是 MP3 文件,需要转换为 MIDI
|
||||
if (file.name.toLowerCase().endsWith(".mp3")) {
|
||||
setState("converting");
|
||||
const outputPath = tempPath.replace(/\.mp3$/i, ".mid");
|
||||
finalMidiPath = await convertMp3ToMidi(tempPath, outputPath);
|
||||
}
|
||||
|
||||
// 分析 MIDI 文件
|
||||
setState("analyzing");
|
||||
const result = await analyzeMidi(finalMidiPath);
|
||||
|
||||
// 转换为前端格式
|
||||
const report: MelodyAnalysisReport = {
|
||||
mode: result.mode,
|
||||
bpm: result.bpm,
|
||||
timeSignature: result.time_signature,
|
||||
range: result.melody_features.range,
|
||||
avgPitch: result.melody_features.avg_pitch,
|
||||
intervalJumps: result.melody_features.interval_jumps,
|
||||
rhythmComplexity: result.melody_features.rhythm_complexity,
|
||||
selectedTrack: 0,
|
||||
tracks: result.tracks.map((track) => ({
|
||||
index: track.index,
|
||||
name: track.name,
|
||||
instrument: track.instrument,
|
||||
noteCount: track.note_count,
|
||||
isVocal: track.is_vocal,
|
||||
})),
|
||||
};
|
||||
|
||||
setMelodyReport(report);
|
||||
setMidiPath(finalMidiPath);
|
||||
setState("completed");
|
||||
onAnalysisComplete?.(report);
|
||||
} catch (err) {
|
||||
const errorMsg =
|
||||
err instanceof Error ? err.message : "Failed to analyze file";
|
||||
setError(errorMsg);
|
||||
setState("error");
|
||||
onError?.(errorMsg);
|
||||
}
|
||||
},
|
||||
[checkEnvironment, onAnalysisComplete, onError],
|
||||
);
|
||||
|
||||
// 选择音轨
|
||||
const selectTrack = useCallback(
|
||||
(trackIndex: number) => {
|
||||
if (!melodyReport) return;
|
||||
|
||||
setMelodyReport({
|
||||
...melodyReport,
|
||||
selectedTrack: trackIndex,
|
||||
});
|
||||
},
|
||||
[melodyReport],
|
||||
);
|
||||
|
||||
// 生成歌词分析
|
||||
const generateLyricsAnalysis = useCallback((lyrics: string) => {
|
||||
// 解析歌词结构
|
||||
const lines = lyrics.split("\n").filter((line) => line.trim());
|
||||
const structure: string[] = [];
|
||||
let currentSection = "";
|
||||
|
||||
lines.forEach((line) => {
|
||||
const sectionMatch = line.match(/^\[(.*?)\]/);
|
||||
if (sectionMatch) {
|
||||
currentSection = sectionMatch[1];
|
||||
structure.push(currentSection);
|
||||
}
|
||||
});
|
||||
|
||||
// 计算平均字数
|
||||
const contentLines = lines.filter((line) => !line.startsWith("["));
|
||||
const totalChars = contentLines.reduce(
|
||||
(sum, line) => sum + line.replace(/[^\u4e00-\u9fa5a-zA-Z]/g, "").length,
|
||||
0,
|
||||
);
|
||||
const avgCharsPerLine =
|
||||
contentLines.length > 0 ? totalChars / contentLines.length : 0;
|
||||
|
||||
// 简单的押韵模式检测
|
||||
const rhymeScheme = "AABB"; // 简化版,实际应该使用押韵检测算法
|
||||
|
||||
// 提取关键词 (简化版)
|
||||
const keywords: string[] = [];
|
||||
|
||||
const report: LyricsAnalysisReport = {
|
||||
structure,
|
||||
avgCharsPerLine: Math.round(avgCharsPerLine),
|
||||
rhymeScheme,
|
||||
keywords,
|
||||
};
|
||||
|
||||
setLyricsReport(report);
|
||||
}, []);
|
||||
|
||||
// 计算一致性评分
|
||||
const calculateConsistency = useCallback(
|
||||
(_userLyrics: string): ConsistencyScore => {
|
||||
if (!melodyReport || !lyricsReport) {
|
||||
return {
|
||||
modeConsistency: 0,
|
||||
rhythmConsistency: 0,
|
||||
structureConsistency: 0,
|
||||
overall: 0,
|
||||
};
|
||||
}
|
||||
|
||||
// 调式一致性 (简化版)
|
||||
const modeConsistency = 80;
|
||||
|
||||
// 节奏一致性 (简化版)
|
||||
const rhythmConsistency = 75;
|
||||
|
||||
// 结构一致性 (简化版)
|
||||
const structureConsistency = 85;
|
||||
|
||||
// 整体评分
|
||||
const overall = Math.round(
|
||||
(modeConsistency + rhythmConsistency + structureConsistency) / 3,
|
||||
);
|
||||
|
||||
const score: ConsistencyScore = {
|
||||
modeConsistency,
|
||||
rhythmConsistency,
|
||||
structureConsistency,
|
||||
overall,
|
||||
};
|
||||
|
||||
setConsistencyScore(score);
|
||||
return score;
|
||||
},
|
||||
[melodyReport, lyricsReport],
|
||||
);
|
||||
|
||||
// 重置状态
|
||||
const reset = useCallback(() => {
|
||||
setState("idle");
|
||||
setMelodyReport(null);
|
||||
setLyricsReport(null);
|
||||
setConsistencyScore(null);
|
||||
setError(null);
|
||||
setMidiPath(null);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
state,
|
||||
mimicLevel,
|
||||
pythonEnv,
|
||||
melodyReport,
|
||||
lyricsReport,
|
||||
consistencyScore,
|
||||
error,
|
||||
uploadFile,
|
||||
selectTrack,
|
||||
setMimicLevel,
|
||||
generateLyricsAnalysis,
|
||||
calculateConsistency,
|
||||
reset,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,401 @@
|
||||
/**
|
||||
* @file MIDI 播放 Hook
|
||||
* @description 使用 Tone.js 实现 MIDI 文件播放功能
|
||||
* @module components/content-creator/canvas/music/hooks/useMidiPlayback
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useEffect, useRef } from "react";
|
||||
import * as Tone from "tone";
|
||||
import { Midi } from "@tonejs/midi";
|
||||
|
||||
export interface MidiTrack {
|
||||
/** 音轨索引 */
|
||||
index: number;
|
||||
/** 音轨名称 */
|
||||
name: string;
|
||||
/** 音轨乐器 */
|
||||
instrument: string;
|
||||
/** 音符数量 */
|
||||
noteCount: number;
|
||||
/** 是否启用 */
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface PlaybackState {
|
||||
/** 是否正在播放 */
|
||||
isPlaying: boolean;
|
||||
/** 当前播放位置 (秒) */
|
||||
currentTime: number;
|
||||
/** 总时长 (秒) */
|
||||
duration: number;
|
||||
/** 播放速度 (0.5 - 2.0) */
|
||||
playbackRate: number;
|
||||
/** 音量 (0 - 1) */
|
||||
volume: number;
|
||||
/** 是否循环播放 */
|
||||
loop: boolean;
|
||||
/** 当前小节 */
|
||||
currentBar: number;
|
||||
/** 总小节数 */
|
||||
totalBars: number;
|
||||
}
|
||||
|
||||
export interface UseMidiPlaybackOptions {
|
||||
/** 初始播放速度 */
|
||||
initialPlaybackRate?: number;
|
||||
/** 初始音量 */
|
||||
initialVolume?: number;
|
||||
/** 是否自动播放 */
|
||||
autoPlay?: boolean;
|
||||
/** 播放位置更新回调 */
|
||||
onTimeUpdate?: (time: number) => void;
|
||||
/** 播放结束回调 */
|
||||
onEnded?: () => void;
|
||||
}
|
||||
|
||||
export interface UseMidiPlaybackReturn {
|
||||
/** 播放状态 */
|
||||
state: PlaybackState;
|
||||
/** 音轨列表 */
|
||||
tracks: MidiTrack[];
|
||||
/** 加载 MIDI 文件 */
|
||||
loadMidi: (file: File | string) => Promise<void>;
|
||||
/** 播放 */
|
||||
play: () => Promise<void>;
|
||||
/** 暂停 */
|
||||
pause: () => void;
|
||||
/** 停止 */
|
||||
stop: () => void;
|
||||
/** 跳转到指定位置 */
|
||||
seekTo: (time: number) => void;
|
||||
/** 设置播放速度 */
|
||||
setPlaybackRate: (rate: number) => void;
|
||||
/** 设置音量 */
|
||||
setVolume: (volume: number) => void;
|
||||
/** 设置循环播放 */
|
||||
setLoop: (loop: boolean) => void;
|
||||
/** 切换音轨启用状态 */
|
||||
toggleTrack: (trackIndex: number) => void;
|
||||
/** 是否已加载 */
|
||||
isLoaded: boolean;
|
||||
/** 加载错误 */
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* MIDI 播放 Hook
|
||||
*/
|
||||
export function useMidiPlayback(
|
||||
options: UseMidiPlaybackOptions = {},
|
||||
): UseMidiPlaybackReturn {
|
||||
const {
|
||||
initialPlaybackRate = 1.0,
|
||||
initialVolume = 0.8,
|
||||
autoPlay = false,
|
||||
onTimeUpdate,
|
||||
onEnded,
|
||||
} = options;
|
||||
|
||||
// 播放状态
|
||||
const [state, setState] = useState<PlaybackState>({
|
||||
isPlaying: false,
|
||||
currentTime: 0,
|
||||
duration: 0,
|
||||
playbackRate: initialPlaybackRate,
|
||||
volume: initialVolume,
|
||||
loop: false,
|
||||
currentBar: 0,
|
||||
totalBars: 0,
|
||||
});
|
||||
|
||||
// 音轨列表
|
||||
const [tracks, setTracks] = useState<MidiTrack[]>([]);
|
||||
|
||||
// 加载状态
|
||||
const [isLoaded, setIsLoaded] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Refs
|
||||
const midiRef = useRef<Midi | null>(null);
|
||||
const synthsRef = useRef<Tone.PolySynth[]>([]);
|
||||
const partsRef = useRef<Tone.Part[]>([]);
|
||||
const animationFrameRef = useRef<number | null>(null);
|
||||
const startTimeRef = useRef<number>(0);
|
||||
|
||||
// 清理资源
|
||||
const cleanup = useCallback(() => {
|
||||
// 停止所有 Parts
|
||||
partsRef.current.forEach((part) => {
|
||||
part.stop();
|
||||
part.dispose();
|
||||
});
|
||||
partsRef.current = [];
|
||||
|
||||
// 释放所有 Synths
|
||||
synthsRef.current.forEach((synth) => {
|
||||
synth.dispose();
|
||||
});
|
||||
synthsRef.current = [];
|
||||
|
||||
// 取消动画帧
|
||||
if (animationFrameRef.current !== null) {
|
||||
cancelAnimationFrame(animationFrameRef.current);
|
||||
animationFrameRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 更新播放位置
|
||||
const updatePosition = useCallback(() => {
|
||||
if (!state.isPlaying || !midiRef.current) return;
|
||||
|
||||
const currentTime = Tone.Transport.seconds;
|
||||
const duration = midiRef.current.duration;
|
||||
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
currentTime,
|
||||
currentBar: Math.floor(currentTime / (60 / Tone.Transport.bpm.value / 4)),
|
||||
}));
|
||||
|
||||
onTimeUpdate?.(currentTime);
|
||||
|
||||
// 检查是否播放结束
|
||||
if (currentTime >= duration) {
|
||||
if (state.loop) {
|
||||
Tone.Transport.position = 0;
|
||||
} else {
|
||||
pause();
|
||||
onEnded?.();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
animationFrameRef.current = requestAnimationFrame(updatePosition);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [state.isPlaying, state.loop, onTimeUpdate, onEnded]);
|
||||
|
||||
// 加载 MIDI 文件
|
||||
const loadMidi = useCallback(
|
||||
async (file: File | string) => {
|
||||
try {
|
||||
setError(null);
|
||||
setIsLoaded(false);
|
||||
cleanup();
|
||||
|
||||
let arrayBuffer: ArrayBuffer;
|
||||
|
||||
if (typeof file === "string") {
|
||||
// URL
|
||||
const response = await fetch(file);
|
||||
arrayBuffer = await response.arrayBuffer();
|
||||
} else {
|
||||
// File object
|
||||
arrayBuffer = await file.arrayBuffer();
|
||||
}
|
||||
|
||||
const midi = new Midi(arrayBuffer);
|
||||
midiRef.current = midi;
|
||||
|
||||
// 解析音轨信息
|
||||
const trackList: MidiTrack[] = midi.tracks.map((track, index) => ({
|
||||
index,
|
||||
name: track.name || `Track ${index + 1}`,
|
||||
instrument: track.instrument?.name || "Piano",
|
||||
noteCount: track.notes.length,
|
||||
enabled: true,
|
||||
}));
|
||||
|
||||
setTracks(trackList);
|
||||
|
||||
// 创建合成器和 Parts
|
||||
midi.tracks.forEach((track, trackIndex) => {
|
||||
if (track.notes.length === 0) return;
|
||||
|
||||
// 创建 PolySynth
|
||||
const synth = new Tone.PolySynth(Tone.Synth, {
|
||||
envelope: {
|
||||
attack: 0.02,
|
||||
decay: 0.1,
|
||||
sustain: 0.3,
|
||||
release: 1,
|
||||
},
|
||||
}).toDestination();
|
||||
|
||||
synth.volume.value = Tone.gainToDb(initialVolume);
|
||||
synthsRef.current.push(synth);
|
||||
|
||||
// 创建 Part
|
||||
const part = new Tone.Part((time, note) => {
|
||||
if (trackList[trackIndex].enabled) {
|
||||
synth.triggerAttackRelease(
|
||||
note.name,
|
||||
note.duration,
|
||||
time,
|
||||
note.velocity,
|
||||
);
|
||||
}
|
||||
}, track.notes);
|
||||
|
||||
part.loop = false;
|
||||
partsRef.current.push(part);
|
||||
});
|
||||
|
||||
// 设置 BPM
|
||||
if (midi.header.tempos.length > 0) {
|
||||
Tone.Transport.bpm.value = midi.header.tempos[0].bpm;
|
||||
}
|
||||
|
||||
// 更新状态
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
duration: midi.duration,
|
||||
totalBars: Math.ceil(
|
||||
midi.duration / (60 / Tone.Transport.bpm.value / 4),
|
||||
),
|
||||
}));
|
||||
|
||||
setIsLoaded(true);
|
||||
|
||||
// 自动播放
|
||||
if (autoPlay) {
|
||||
await play();
|
||||
}
|
||||
} catch (err) {
|
||||
const errorMessage =
|
||||
err instanceof Error ? err.message : "Failed to load MIDI file";
|
||||
setError(errorMessage);
|
||||
console.error("MIDI load error:", err);
|
||||
}
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[cleanup, initialVolume, autoPlay],
|
||||
);
|
||||
|
||||
// 播放
|
||||
const play = useCallback(async () => {
|
||||
if (!isLoaded || state.isPlaying) return;
|
||||
|
||||
try {
|
||||
// 启动 Tone.js 音频上下文
|
||||
await Tone.start();
|
||||
|
||||
// 启动所有 Parts
|
||||
partsRef.current.forEach((part) => {
|
||||
part.start(0);
|
||||
});
|
||||
|
||||
// 启动 Transport
|
||||
Tone.Transport.start();
|
||||
|
||||
setState((prev) => ({ ...prev, isPlaying: true }));
|
||||
startTimeRef.current = Date.now();
|
||||
|
||||
// 开始更新位置
|
||||
animationFrameRef.current = requestAnimationFrame(updatePosition);
|
||||
} catch (err) {
|
||||
console.error("Play error:", err);
|
||||
setError("Failed to start playback");
|
||||
}
|
||||
}, [isLoaded, state.isPlaying, updatePosition]);
|
||||
|
||||
// 暂停
|
||||
const pause = useCallback(() => {
|
||||
if (!state.isPlaying) return;
|
||||
|
||||
Tone.Transport.pause();
|
||||
setState((prev) => ({ ...prev, isPlaying: false }));
|
||||
|
||||
if (animationFrameRef.current !== null) {
|
||||
cancelAnimationFrame(animationFrameRef.current);
|
||||
animationFrameRef.current = null;
|
||||
}
|
||||
}, [state.isPlaying]);
|
||||
|
||||
// 停止
|
||||
const stop = useCallback(() => {
|
||||
pause();
|
||||
Tone.Transport.stop();
|
||||
Tone.Transport.position = 0;
|
||||
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
currentTime: 0,
|
||||
currentBar: 0,
|
||||
}));
|
||||
}, [pause]);
|
||||
|
||||
// 跳转
|
||||
const seekTo = useCallback(
|
||||
(time: number) => {
|
||||
const clampedTime = Math.max(0, Math.min(time, state.duration));
|
||||
Tone.Transport.seconds = clampedTime;
|
||||
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
currentTime: clampedTime,
|
||||
currentBar: Math.floor(
|
||||
clampedTime / (60 / Tone.Transport.bpm.value / 4),
|
||||
),
|
||||
}));
|
||||
},
|
||||
[state.duration],
|
||||
);
|
||||
|
||||
// 设置播放速度
|
||||
const setPlaybackRate = useCallback((rate: number) => {
|
||||
const clampedRate = Math.max(0.5, Math.min(2.0, rate));
|
||||
Tone.Transport.bpm.value = Tone.Transport.bpm.value * clampedRate;
|
||||
|
||||
setState((prev) => ({ ...prev, playbackRate: clampedRate }));
|
||||
}, []);
|
||||
|
||||
// 设置音量
|
||||
const setVolume = useCallback((volume: number) => {
|
||||
const clampedVolume = Math.max(0, Math.min(1, volume));
|
||||
const dbValue = Tone.gainToDb(clampedVolume);
|
||||
|
||||
synthsRef.current.forEach((synth) => {
|
||||
synth.volume.value = dbValue;
|
||||
});
|
||||
|
||||
setState((prev) => ({ ...prev, volume: clampedVolume }));
|
||||
}, []);
|
||||
|
||||
// 设置循环
|
||||
const setLoop = useCallback((loop: boolean) => {
|
||||
setState((prev) => ({ ...prev, loop }));
|
||||
}, []);
|
||||
|
||||
// 切换音轨
|
||||
const toggleTrack = useCallback((trackIndex: number) => {
|
||||
setTracks((prev) =>
|
||||
prev.map((track, index) =>
|
||||
index === trackIndex ? { ...track, enabled: !track.enabled } : track,
|
||||
),
|
||||
);
|
||||
}, []);
|
||||
|
||||
// 组件卸载时清理
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
cleanup();
|
||||
Tone.Transport.stop();
|
||||
};
|
||||
}, [cleanup]);
|
||||
|
||||
return {
|
||||
state,
|
||||
tracks,
|
||||
loadMidi,
|
||||
play,
|
||||
pause,
|
||||
stop,
|
||||
seekTo,
|
||||
setPlaybackRate,
|
||||
setVolume,
|
||||
setLoop,
|
||||
toggleTrack,
|
||||
isLoaded,
|
||||
error,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
/**
|
||||
* @file 音乐导出 Hook
|
||||
* @description 处理音乐作品的导出功能
|
||||
* @module components/content-creator/canvas/music/hooks/useMusicExport
|
||||
*/
|
||||
|
||||
import { useState, useCallback } from "react";
|
||||
import type { MusicSection, SongSpec } from "../types";
|
||||
import {
|
||||
exportToSuno,
|
||||
exportToText,
|
||||
exportToMarkdown,
|
||||
exportToJSON,
|
||||
type SunoExport,
|
||||
} from "../utils/exportFormatters";
|
||||
|
||||
/**
|
||||
* 导出格式
|
||||
*/
|
||||
export type ExportFormat = "suno" | "text" | "markdown" | "json";
|
||||
|
||||
/**
|
||||
* 导出状态
|
||||
*/
|
||||
export type ExportState = "idle" | "exporting" | "completed" | "error";
|
||||
|
||||
export interface UseMusicExportOptions {
|
||||
/** 导出完成回调 */
|
||||
onExportComplete?: (format: ExportFormat, content: string) => void;
|
||||
/** 错误回调 */
|
||||
onError?: (error: string) => void;
|
||||
}
|
||||
|
||||
export interface UseMusicExportReturn {
|
||||
/** 当前状态 */
|
||||
state: ExportState;
|
||||
/** 错误信息 */
|
||||
error: string | null;
|
||||
/** 导出为 Suno 格式 */
|
||||
exportSuno: (sections: MusicSection[], spec: SongSpec) => SunoExport;
|
||||
/** 导出为纯文本 */
|
||||
exportText: (sections: MusicSection[], spec: SongSpec) => string;
|
||||
/** 导出为 Markdown */
|
||||
exportMarkdown: (sections: MusicSection[], spec: SongSpec) => string;
|
||||
/** 导出为 JSON */
|
||||
exportJSON: (sections: MusicSection[], spec: SongSpec) => string;
|
||||
/** 下载文件 */
|
||||
downloadFile: (content: string, filename: string, mimeType: string) => void;
|
||||
/** 复制到剪贴板 */
|
||||
copyToClipboard: (content: string) => Promise<void>;
|
||||
/** 重置状态 */
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 音乐导出 Hook
|
||||
*/
|
||||
export function useMusicExport(
|
||||
options: UseMusicExportOptions = {},
|
||||
): UseMusicExportReturn {
|
||||
const { onExportComplete, onError } = options;
|
||||
|
||||
const [state, setState] = useState<ExportState>("idle");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// 导出为 Suno 格式
|
||||
const exportSuno = useCallback(
|
||||
(sections: MusicSection[], spec: SongSpec): SunoExport => {
|
||||
try {
|
||||
setState("exporting");
|
||||
const result = exportToSuno(sections, spec);
|
||||
setState("completed");
|
||||
onExportComplete?.("suno", JSON.stringify(result, null, 2));
|
||||
return result;
|
||||
} catch (err) {
|
||||
const errorMsg =
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: "Failed to export to Suno format";
|
||||
setError(errorMsg);
|
||||
setState("error");
|
||||
onError?.(errorMsg);
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
[onExportComplete, onError],
|
||||
);
|
||||
|
||||
// 导出为纯文本
|
||||
const exportText = useCallback(
|
||||
(sections: MusicSection[], spec: SongSpec): string => {
|
||||
try {
|
||||
setState("exporting");
|
||||
const result = exportToText(sections, spec);
|
||||
setState("completed");
|
||||
onExportComplete?.("text", result);
|
||||
return result;
|
||||
} catch (err) {
|
||||
const errorMsg =
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: "Failed to export to text format";
|
||||
setError(errorMsg);
|
||||
setState("error");
|
||||
onError?.(errorMsg);
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
[onExportComplete, onError],
|
||||
);
|
||||
|
||||
// 导出为 Markdown
|
||||
const exportMarkdown = useCallback(
|
||||
(sections: MusicSection[], spec: SongSpec): string => {
|
||||
try {
|
||||
setState("exporting");
|
||||
const result = exportToMarkdown(sections, spec);
|
||||
setState("completed");
|
||||
onExportComplete?.("markdown", result);
|
||||
return result;
|
||||
} catch (err) {
|
||||
const errorMsg =
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: "Failed to export to Markdown format";
|
||||
setError(errorMsg);
|
||||
setState("error");
|
||||
onError?.(errorMsg);
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
[onExportComplete, onError],
|
||||
);
|
||||
|
||||
// 导出为 JSON
|
||||
const exportJSON = useCallback(
|
||||
(sections: MusicSection[], spec: SongSpec): string => {
|
||||
try {
|
||||
setState("exporting");
|
||||
const result = exportToJSON(sections, spec);
|
||||
setState("completed");
|
||||
onExportComplete?.("json", result);
|
||||
return result;
|
||||
} catch (err) {
|
||||
const errorMsg =
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: "Failed to export to JSON format";
|
||||
setError(errorMsg);
|
||||
setState("error");
|
||||
onError?.(errorMsg);
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
[onExportComplete, onError],
|
||||
);
|
||||
|
||||
// 下载文件
|
||||
const downloadFile = useCallback(
|
||||
(content: string, filename: string, mimeType: string) => {
|
||||
try {
|
||||
const blob = new Blob([content], { type: mimeType });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (err) {
|
||||
const errorMsg =
|
||||
err instanceof Error ? err.message : "Failed to download file";
|
||||
setError(errorMsg);
|
||||
onError?.(errorMsg);
|
||||
}
|
||||
},
|
||||
[onError],
|
||||
);
|
||||
|
||||
// 复制到剪贴板
|
||||
const copyToClipboard = useCallback(
|
||||
async (content: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(content);
|
||||
} catch (err) {
|
||||
const errorMsg =
|
||||
err instanceof Error ? err.message : "Failed to copy to clipboard";
|
||||
setError(errorMsg);
|
||||
onError?.(errorMsg);
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
[onError],
|
||||
);
|
||||
|
||||
// 重置状态
|
||||
const reset = useCallback(() => {
|
||||
setState("idle");
|
||||
setError(null);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
state,
|
||||
error,
|
||||
exportSuno,
|
||||
exportText,
|
||||
exportMarkdown,
|
||||
exportJSON,
|
||||
downloadFile,
|
||||
copyToClipboard,
|
||||
reset,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* @file 押韵检测 Hook
|
||||
* @description 提供押韵分析和建议功能
|
||||
* @module components/content-creator/canvas/music/hooks/useRhyme
|
||||
*/
|
||||
|
||||
import { useMemo, useCallback } from "react";
|
||||
import type { MusicSection } from "../types";
|
||||
import {
|
||||
analyzeRhyme,
|
||||
getRhymeSuggestions,
|
||||
type RhymeAnalysis,
|
||||
type RhymeScheme,
|
||||
} from "../utils/rhymeDetector";
|
||||
|
||||
export interface SectionRhymeAnalysis {
|
||||
/** 段落 ID */
|
||||
sectionId: string;
|
||||
/** 段落名称 */
|
||||
sectionName: string;
|
||||
/** 押韵分析结果 */
|
||||
analysis: RhymeAnalysis;
|
||||
}
|
||||
|
||||
export interface UseRhymeReturn {
|
||||
/** 所有段落的押韵分析 */
|
||||
sectionAnalyses: SectionRhymeAnalysis[];
|
||||
/** 整体押韵质量 (0-100) */
|
||||
overallQuality: number;
|
||||
/** 主要押韵模式 */
|
||||
dominantScheme: RhymeScheme;
|
||||
/** 分析指定段落 */
|
||||
analyzeSection: (section: MusicSection) => RhymeAnalysis;
|
||||
/** 获取押韵词建议 */
|
||||
getSuggestions: (rhymeGroup: string) => string[];
|
||||
/** 获取段落的押韵建议 */
|
||||
getSectionSuggestions: (sectionId: string) => string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 押韵检测 Hook
|
||||
* @param sections 段落列表
|
||||
*/
|
||||
export function useRhyme(sections: MusicSection[]): UseRhymeReturn {
|
||||
// 分析所有段落
|
||||
const sectionAnalyses = useMemo<SectionRhymeAnalysis[]>(() => {
|
||||
return sections.map((section) => ({
|
||||
sectionId: section.id,
|
||||
sectionName: section.name,
|
||||
analysis: analyzeRhyme(section.lyricsLines),
|
||||
}));
|
||||
}, [sections]);
|
||||
|
||||
// 计算整体押韵质量
|
||||
const overallQuality = useMemo(() => {
|
||||
if (sectionAnalyses.length === 0) return 0;
|
||||
|
||||
const totalQuality = sectionAnalyses.reduce(
|
||||
(sum, sa) => sum + sa.analysis.quality,
|
||||
0,
|
||||
);
|
||||
return Math.round(totalQuality / sectionAnalyses.length);
|
||||
}, [sectionAnalyses]);
|
||||
|
||||
// 确定主要押韵模式
|
||||
const dominantScheme = useMemo<RhymeScheme>(() => {
|
||||
if (sectionAnalyses.length === 0) return "FREE";
|
||||
|
||||
const schemeCounts: Record<RhymeScheme, number> = {
|
||||
AABB: 0,
|
||||
ABAB: 0,
|
||||
ABCB: 0,
|
||||
AAAA: 0,
|
||||
FREE: 0,
|
||||
};
|
||||
|
||||
sectionAnalyses.forEach((sa) => {
|
||||
schemeCounts[sa.analysis.scheme]++;
|
||||
});
|
||||
|
||||
let maxScheme: RhymeScheme = "FREE";
|
||||
let maxCount = 0;
|
||||
|
||||
for (const [scheme, count] of Object.entries(schemeCounts)) {
|
||||
if (count > maxCount) {
|
||||
maxCount = count;
|
||||
maxScheme = scheme as RhymeScheme;
|
||||
}
|
||||
}
|
||||
|
||||
return maxScheme;
|
||||
}, [sectionAnalyses]);
|
||||
|
||||
// 分析指定段落
|
||||
const analyzeSection = useCallback((section: MusicSection): RhymeAnalysis => {
|
||||
return analyzeRhyme(section.lyricsLines);
|
||||
}, []);
|
||||
|
||||
// 获取押韵词建议
|
||||
const getSuggestions = useCallback((rhymeGroup: string): string[] => {
|
||||
return getRhymeSuggestions(rhymeGroup);
|
||||
}, []);
|
||||
|
||||
// 获取段落的押韵建议
|
||||
const getSectionSuggestions = useCallback(
|
||||
(sectionId: string): string[] => {
|
||||
const sectionAnalysis = sectionAnalyses.find(
|
||||
(sa) => sa.sectionId === sectionId,
|
||||
);
|
||||
return sectionAnalysis?.analysis.suggestions || [];
|
||||
},
|
||||
[sectionAnalyses],
|
||||
);
|
||||
|
||||
return {
|
||||
sectionAnalyses,
|
||||
overallQuality,
|
||||
dominantScheme,
|
||||
analyzeSection,
|
||||
getSuggestions,
|
||||
getSectionSuggestions,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* @file 音乐画布模块入口
|
||||
* @description 导出音乐画布相关组件和类型
|
||||
* @module components/content-creator/canvas/music
|
||||
*/
|
||||
|
||||
// 主组件
|
||||
export { MusicCanvas } from "./MusicCanvas";
|
||||
export { MusicToolbar } from "./MusicToolbar";
|
||||
|
||||
// 注册函数
|
||||
export {
|
||||
registerMusicCanvas,
|
||||
unregisterMusicCanvas,
|
||||
musicCanvasPlugin,
|
||||
} from "./registerMusicCanvas";
|
||||
|
||||
// 类型导出
|
||||
export type {
|
||||
// 基础类型
|
||||
SongType,
|
||||
MusicCreationMode,
|
||||
MusicViewMode,
|
||||
SectionType,
|
||||
MoodType,
|
||||
OrnamentType,
|
||||
// 调式类型
|
||||
WesternMode,
|
||||
ChinesePentatonicMode,
|
||||
ModeType,
|
||||
ModeDetection,
|
||||
// 音乐元素
|
||||
Note,
|
||||
Bar,
|
||||
MusicSection,
|
||||
ChordInfo,
|
||||
// 歌曲规格
|
||||
SongSpec,
|
||||
// 旋律学习
|
||||
BorrowLevel,
|
||||
MimicCreationMode,
|
||||
RhythmStats,
|
||||
IntervalStats,
|
||||
ContourStats,
|
||||
MelodyAnalysisReport,
|
||||
LyricsSectionAnalysis,
|
||||
ImageryAnalysis,
|
||||
LyricsAnalysisReport,
|
||||
ConsistencyScore,
|
||||
TrackCandidate,
|
||||
TrackMatchResult,
|
||||
MelodyMimicState,
|
||||
// 画布状态
|
||||
MusicVersion,
|
||||
MusicCanvasState,
|
||||
// Props
|
||||
MusicCanvasProps,
|
||||
MusicToolbarProps,
|
||||
LyricsEditorProps,
|
||||
JianpuEditorProps,
|
||||
// 导出
|
||||
MusicExportFormat,
|
||||
MusicExportConfig,
|
||||
SunoExport,
|
||||
TuneeExport,
|
||||
} from "./types";
|
||||
|
||||
// 工具函数
|
||||
export {
|
||||
createInitialSpec,
|
||||
createInitialMusicState,
|
||||
createInitialMimicState,
|
||||
createSection,
|
||||
// 常量
|
||||
SECTION_TO_SUNO_TAG,
|
||||
MODE_EMOTIONAL_FEATURES,
|
||||
SONG_TYPE_LABELS,
|
||||
MOOD_LABELS,
|
||||
CREATION_MODE_LABELS,
|
||||
VIEW_MODE_LABELS,
|
||||
} from "./types";
|
||||
|
||||
// Hooks
|
||||
export * from "./hooks";
|
||||
|
||||
// Utils
|
||||
export * from "./utils";
|
||||
|
||||
// API
|
||||
export * from "./api";
|
||||
|
||||
// Editors
|
||||
export * from "./editors";
|
||||
|
||||
// Player
|
||||
export * from "./player";
|
||||
|
||||
// Analysis
|
||||
export * from "./analysis";
|
||||
|
||||
// Export
|
||||
export * from "./export";
|
||||
@@ -0,0 +1,329 @@
|
||||
/**
|
||||
* @file MIDI 播放器主组件
|
||||
* @description 整合播放控制、进度条、速度和音量控制
|
||||
* @module components/content-creator/canvas/music/player/MidiPlayer
|
||||
*/
|
||||
|
||||
import React, { memo, useCallback } from "react";
|
||||
import styled from "styled-components";
|
||||
import { Upload, Repeat } from "lucide-react";
|
||||
import { useMidiPlayback } from "../hooks/useMidiPlayback";
|
||||
import { PlaybackControls } from "./PlaybackControls";
|
||||
import { ProgressBar } from "./ProgressBar";
|
||||
import { TempoControl } from "./TempoControl";
|
||||
import { VolumeControl } from "./VolumeControl";
|
||||
|
||||
const PlayerContainer = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
padding: 16px;
|
||||
background: var(--color-surface, #ffffff);
|
||||
border: 1px solid var(--color-border, #e5e7eb);
|
||||
border-radius: 8px;
|
||||
`;
|
||||
|
||||
const TopSection = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
`;
|
||||
|
||||
const MiddleSection = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
`;
|
||||
|
||||
const BottomSection = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
`;
|
||||
|
||||
const UploadButton = styled.button`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 16px;
|
||||
border: 1px solid var(--color-border, #e5e7eb);
|
||||
border-radius: 6px;
|
||||
background: var(--color-surface, #ffffff);
|
||||
color: var(--color-text, #1f2937);
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background: var(--color-surface-hover, #f3f4f6);
|
||||
border-color: var(--color-primary, #3b82f6);
|
||||
}
|
||||
|
||||
svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
`;
|
||||
|
||||
const LoopButton = styled.button<{ $active: boolean }>`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 12px;
|
||||
border: 1px solid
|
||||
${(props) =>
|
||||
props.$active
|
||||
? "var(--color-primary, #3b82f6)"
|
||||
: "var(--color-border, #e5e7eb)"};
|
||||
border-radius: 6px;
|
||||
background: ${(props) =>
|
||||
props.$active ? "var(--color-primary-light, #eff6ff)" : "transparent"};
|
||||
color: ${(props) =>
|
||||
props.$active
|
||||
? "var(--color-primary, #3b82f6)"
|
||||
: "var(--color-text-secondary, #6b7280)"};
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
border-color: var(--color-primary, #3b82f6);
|
||||
color: var(--color-primary, #3b82f6);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
`;
|
||||
|
||||
const ErrorMessage = styled.div`
|
||||
padding: 12px;
|
||||
background: #fef2f2;
|
||||
border: 1px solid #fecaca;
|
||||
border-radius: 6px;
|
||||
color: #dc2626;
|
||||
font-size: 14px;
|
||||
`;
|
||||
|
||||
const TrackList = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
padding: 8px;
|
||||
background: var(--color-surface-hover, #f9fafb);
|
||||
border-radius: 6px;
|
||||
`;
|
||||
|
||||
const TrackItem = styled.div<{ $enabled: boolean }>`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px;
|
||||
background: var(--color-surface, #ffffff);
|
||||
border: 1px solid var(--color-border, #e5e7eb);
|
||||
border-radius: 4px;
|
||||
opacity: ${(props) => (props.$enabled ? 1 : 0.5)};
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--color-primary, #3b82f6);
|
||||
}
|
||||
`;
|
||||
|
||||
const TrackCheckbox = styled.input`
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
cursor: pointer;
|
||||
`;
|
||||
|
||||
const TrackInfo = styled.div`
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
`;
|
||||
|
||||
const TrackName = styled.div`
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--color-text, #1f2937);
|
||||
`;
|
||||
|
||||
const TrackMeta = styled.div`
|
||||
font-size: 11px;
|
||||
color: var(--color-text-secondary, #6b7280);
|
||||
`;
|
||||
|
||||
const HiddenFileInput = styled.input`
|
||||
display: none;
|
||||
`;
|
||||
|
||||
export interface MidiPlayerProps {
|
||||
/** 初始 MIDI 文件 URL */
|
||||
initialMidiUrl?: string;
|
||||
/** 是否显示音轨列表 */
|
||||
showTracks?: boolean;
|
||||
/** 播放结束回调 */
|
||||
onEnded?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* MIDI 播放器主组件
|
||||
*/
|
||||
export const MidiPlayer: React.FC<MidiPlayerProps> = memo(
|
||||
({ initialMidiUrl, showTracks = true, onEnded }) => {
|
||||
const fileInputRef = React.useRef<HTMLInputElement>(null);
|
||||
|
||||
const {
|
||||
state,
|
||||
tracks,
|
||||
loadMidi,
|
||||
play,
|
||||
pause,
|
||||
stop,
|
||||
seekTo,
|
||||
setPlaybackRate,
|
||||
setVolume,
|
||||
setLoop,
|
||||
toggleTrack,
|
||||
isLoaded,
|
||||
error,
|
||||
} = useMidiPlayback({
|
||||
onEnded,
|
||||
});
|
||||
|
||||
// 加载初始 MIDI 文件
|
||||
React.useEffect(() => {
|
||||
if (initialMidiUrl) {
|
||||
loadMidi(initialMidiUrl);
|
||||
}
|
||||
}, [initialMidiUrl, loadMidi]);
|
||||
|
||||
// 处理文件上传
|
||||
const handleFileUpload = useCallback(() => {
|
||||
fileInputRef.current?.click();
|
||||
}, []);
|
||||
|
||||
const handleFileChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
loadMidi(file);
|
||||
}
|
||||
},
|
||||
[loadMidi],
|
||||
);
|
||||
|
||||
// 处理循环切换
|
||||
const handleLoopToggle = useCallback(() => {
|
||||
setLoop(!state.loop);
|
||||
}, [state.loop, setLoop]);
|
||||
|
||||
return (
|
||||
<PlayerContainer>
|
||||
{/* 顶部:上传按钮和播放控制 */}
|
||||
<TopSection>
|
||||
<UploadButton onClick={handleFileUpload}>
|
||||
<Upload />
|
||||
上传 MIDI
|
||||
</UploadButton>
|
||||
<HiddenFileInput
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".mid,.midi"
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
|
||||
<PlaybackControls
|
||||
isPlaying={state.isPlaying}
|
||||
isLoaded={isLoaded}
|
||||
onPlay={play}
|
||||
onPause={pause}
|
||||
onStop={stop}
|
||||
/>
|
||||
|
||||
<LoopButton
|
||||
$active={state.loop}
|
||||
onClick={handleLoopToggle}
|
||||
disabled={!isLoaded}
|
||||
title={state.loop ? "取消循环" : "循环播放"}
|
||||
>
|
||||
<Repeat />
|
||||
循环
|
||||
</LoopButton>
|
||||
</TopSection>
|
||||
|
||||
{/* 中部:进度条 */}
|
||||
{isLoaded && (
|
||||
<MiddleSection>
|
||||
<ProgressBar
|
||||
currentTime={state.currentTime}
|
||||
duration={state.duration}
|
||||
currentBar={state.currentBar}
|
||||
totalBars={state.totalBars}
|
||||
onSeek={seekTo}
|
||||
disabled={!isLoaded}
|
||||
/>
|
||||
</MiddleSection>
|
||||
)}
|
||||
|
||||
{/* 底部:速度和音量控制 */}
|
||||
<BottomSection>
|
||||
<TempoControl
|
||||
playbackRate={state.playbackRate}
|
||||
onChange={setPlaybackRate}
|
||||
disabled={!isLoaded}
|
||||
showPresets={false}
|
||||
/>
|
||||
|
||||
<VolumeControl
|
||||
volume={state.volume}
|
||||
onChange={setVolume}
|
||||
disabled={!isLoaded}
|
||||
/>
|
||||
</BottomSection>
|
||||
|
||||
{/* 音轨列表 */}
|
||||
{showTracks && isLoaded && tracks.length > 0 && (
|
||||
<TrackList>
|
||||
{tracks.map((track) => (
|
||||
<TrackItem
|
||||
key={track.index}
|
||||
$enabled={track.enabled}
|
||||
onClick={() => toggleTrack(track.index)}
|
||||
>
|
||||
<TrackCheckbox
|
||||
type="checkbox"
|
||||
checked={track.enabled}
|
||||
onChange={() => toggleTrack(track.index)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
<TrackInfo>
|
||||
<TrackName>{track.name}</TrackName>
|
||||
<TrackMeta>
|
||||
{track.instrument} · {track.noteCount} 音符
|
||||
</TrackMeta>
|
||||
</TrackInfo>
|
||||
</TrackItem>
|
||||
))}
|
||||
</TrackList>
|
||||
)}
|
||||
|
||||
{/* 错误提示 */}
|
||||
{error && <ErrorMessage>{error}</ErrorMessage>}
|
||||
</PlayerContainer>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
MidiPlayer.displayName = "MidiPlayer";
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* @file 播放控制组件
|
||||
* @description 提供播放、暂停、停止按钮
|
||||
* @module components/content-creator/canvas/music/player/PlaybackControls
|
||||
*/
|
||||
|
||||
import React, { memo } from "react";
|
||||
import styled from "styled-components";
|
||||
import { Play, Pause, Square } from "lucide-react";
|
||||
|
||||
const ControlsContainer = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
`;
|
||||
|
||||
const ControlButton = styled.button<{ $primary?: boolean }>`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: ${(props) => (props.$primary ? "48px" : "40px")};
|
||||
height: ${(props) => (props.$primary ? "48px" : "40px")};
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
background: ${(props) =>
|
||||
props.$primary
|
||||
? "var(--color-primary, #3b82f6)"
|
||||
: "var(--color-surface-hover, #f3f4f6)"};
|
||||
color: ${(props) =>
|
||||
props.$primary ? "#ffffff" : "var(--color-text, #1f2937)"};
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: ${(props) =>
|
||||
props.$primary
|
||||
? "var(--color-primary-hover, #2563eb)"
|
||||
: "var(--color-surface-active, #e5e7eb)"};
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
&:active:not(:disabled) {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
svg {
|
||||
width: ${(props) => (props.$primary ? "24px" : "20px")};
|
||||
height: ${(props) => (props.$primary ? "24px" : "20px")};
|
||||
}
|
||||
`;
|
||||
|
||||
export interface PlaybackControlsProps {
|
||||
/** 是否正在播放 */
|
||||
isPlaying: boolean;
|
||||
/** 是否已加载 */
|
||||
isLoaded: boolean;
|
||||
/** 播放回调 */
|
||||
onPlay: () => void;
|
||||
/** 暂停回调 */
|
||||
onPause: () => void;
|
||||
/** 停止回调 */
|
||||
onStop: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 播放控制组件
|
||||
*/
|
||||
export const PlaybackControls: React.FC<PlaybackControlsProps> = memo(
|
||||
({ isPlaying, isLoaded, onPlay, onPause, onStop }) => {
|
||||
return (
|
||||
<ControlsContainer>
|
||||
{/* 播放/暂停按钮 */}
|
||||
<ControlButton
|
||||
$primary
|
||||
disabled={!isLoaded}
|
||||
onClick={isPlaying ? onPause : onPlay}
|
||||
title={isPlaying ? "暂停" : "播放"}
|
||||
>
|
||||
{isPlaying ? <Pause /> : <Play />}
|
||||
</ControlButton>
|
||||
|
||||
{/* 停止按钮 */}
|
||||
<ControlButton
|
||||
disabled={!isLoaded || !isPlaying}
|
||||
onClick={onStop}
|
||||
title="停止"
|
||||
>
|
||||
<Square />
|
||||
</ControlButton>
|
||||
</ControlsContainer>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
PlaybackControls.displayName = "PlaybackControls";
|
||||
@@ -0,0 +1,224 @@
|
||||
/**
|
||||
* @file 进度条组件
|
||||
* @description 显示播放进度并支持拖拽跳转
|
||||
* @module components/content-creator/canvas/music/player/ProgressBar
|
||||
*/
|
||||
|
||||
import React, { memo, useCallback, useRef, useState } from "react";
|
||||
import styled from "styled-components";
|
||||
|
||||
const ProgressContainer = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const TimeDisplay = styled.div`
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 12px;
|
||||
color: var(--color-text-secondary, #6b7280);
|
||||
`;
|
||||
|
||||
const ProgressBarContainer = styled.div`
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 6px;
|
||||
background: var(--color-surface-hover, #f3f4f6);
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
|
||||
&:hover {
|
||||
height: 8px;
|
||||
}
|
||||
`;
|
||||
|
||||
const ProgressFill = styled.div<{ $progress: number }>`
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
height: 100%;
|
||||
width: ${(props) => props.$progress}%;
|
||||
background: var(--color-primary, #3b82f6);
|
||||
border-radius: 3px;
|
||||
transition: width 0.1s linear;
|
||||
`;
|
||||
|
||||
const ProgressHandle = styled.div<{ $progress: number; $visible: boolean }>`
|
||||
position: absolute;
|
||||
left: ${(props) => props.$progress}%;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
background: var(--color-primary, #3b82f6);
|
||||
border: 2px solid #ffffff;
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
opacity: ${(props) => (props.$visible ? 1 : 0)};
|
||||
transition: opacity 0.2s ease;
|
||||
cursor: grab;
|
||||
|
||||
&:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
${ProgressBarContainer}:hover & {
|
||||
opacity: 1;
|
||||
}
|
||||
`;
|
||||
|
||||
const BarInfo = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
color: var(--color-text-secondary, #6b7280);
|
||||
`;
|
||||
|
||||
export interface ProgressBarProps {
|
||||
/** 当前时间 (秒) */
|
||||
currentTime: number;
|
||||
/** 总时长 (秒) */
|
||||
duration: number;
|
||||
/** 当前小节 */
|
||||
currentBar?: number;
|
||||
/** 总小节数 */
|
||||
totalBars?: number;
|
||||
/** 跳转回调 */
|
||||
onSeek: (time: number) => void;
|
||||
/** 是否禁用 */
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化时间为 MM:SS
|
||||
*/
|
||||
function formatTime(seconds: number): string {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = Math.floor(seconds % 60);
|
||||
return `${mins}:${secs.toString().padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 进度条组件
|
||||
*/
|
||||
export const ProgressBar: React.FC<ProgressBarProps> = memo(
|
||||
({
|
||||
currentTime,
|
||||
duration,
|
||||
currentBar,
|
||||
totalBars,
|
||||
onSeek,
|
||||
disabled = false,
|
||||
}) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [hoverProgress, setHoverProgress] = useState<number | null>(null);
|
||||
|
||||
// 计算进度百分比
|
||||
const progress = duration > 0 ? (currentTime / duration) * 100 : 0;
|
||||
|
||||
// 处理点击/拖拽
|
||||
const handleSeek = useCallback(
|
||||
(clientX: number) => {
|
||||
if (disabled || !containerRef.current) return;
|
||||
|
||||
const rect = containerRef.current.getBoundingClientRect();
|
||||
const x = clientX - rect.left;
|
||||
const percentage = Math.max(0, Math.min(1, x / rect.width));
|
||||
const newTime = percentage * duration;
|
||||
|
||||
onSeek(newTime);
|
||||
},
|
||||
[disabled, duration, onSeek],
|
||||
);
|
||||
|
||||
// 鼠标按下
|
||||
const handleMouseDown = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if (disabled) return;
|
||||
setIsDragging(true);
|
||||
handleSeek(e.clientX);
|
||||
},
|
||||
[disabled, handleSeek],
|
||||
);
|
||||
|
||||
// 鼠标移动
|
||||
const handleMouseMove = useCallback(
|
||||
(e: MouseEvent) => {
|
||||
if (!isDragging) return;
|
||||
handleSeek(e.clientX);
|
||||
},
|
||||
[isDragging, handleSeek],
|
||||
);
|
||||
|
||||
// 鼠标释放
|
||||
const handleMouseUp = useCallback(() => {
|
||||
setIsDragging(false);
|
||||
}, []);
|
||||
|
||||
// 鼠标悬停
|
||||
const handleMouseHover = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if (disabled || !containerRef.current) return;
|
||||
|
||||
const rect = containerRef.current.getBoundingClientRect();
|
||||
const x = e.clientX - rect.left;
|
||||
const percentage = Math.max(0, Math.min(100, (x / rect.width) * 100));
|
||||
setHoverProgress(percentage);
|
||||
},
|
||||
[disabled],
|
||||
);
|
||||
|
||||
// 鼠标离开
|
||||
const handleMouseLeave = useCallback(() => {
|
||||
setHoverProgress(null);
|
||||
}, []);
|
||||
|
||||
// 添加全局鼠标事件监听
|
||||
React.useEffect(() => {
|
||||
if (isDragging) {
|
||||
window.addEventListener("mousemove", handleMouseMove);
|
||||
window.addEventListener("mouseup", handleMouseUp);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("mousemove", handleMouseMove);
|
||||
window.removeEventListener("mouseup", handleMouseUp);
|
||||
};
|
||||
}
|
||||
}, [isDragging, handleMouseMove, handleMouseUp]);
|
||||
|
||||
return (
|
||||
<ProgressContainer>
|
||||
<TimeDisplay>
|
||||
<span>{formatTime(currentTime)}</span>
|
||||
{currentBar !== undefined && totalBars !== undefined && (
|
||||
<BarInfo>
|
||||
小节: {currentBar + 1} / {totalBars}
|
||||
</BarInfo>
|
||||
)}
|
||||
<span>{formatTime(duration)}</span>
|
||||
</TimeDisplay>
|
||||
|
||||
<ProgressBarContainer
|
||||
ref={containerRef}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseMove={handleMouseHover}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
<ProgressFill $progress={progress} />
|
||||
<ProgressHandle
|
||||
$progress={hoverProgress ?? progress}
|
||||
$visible={isDragging || hoverProgress !== null}
|
||||
/>
|
||||
</ProgressBarContainer>
|
||||
</ProgressContainer>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
ProgressBar.displayName = "ProgressBar";
|
||||
@@ -0,0 +1,201 @@
|
||||
/**
|
||||
* @file 速度控制组件
|
||||
* @description 调整播放速度 (0.5x - 2.0x)
|
||||
* @module components/content-creator/canvas/music/player/TempoControl
|
||||
*/
|
||||
|
||||
import React, { memo, useCallback } from "react";
|
||||
import styled from "styled-components";
|
||||
import { Gauge } from "lucide-react";
|
||||
|
||||
const ControlContainer = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
`;
|
||||
|
||||
const IconWrapper = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: var(--color-text-secondary, #6b7280);
|
||||
|
||||
svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
`;
|
||||
|
||||
const SliderContainer = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 120px;
|
||||
`;
|
||||
|
||||
const Slider = styled.input`
|
||||
flex: 1;
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
background: var(--color-surface-hover, #f3f4f6);
|
||||
outline: none;
|
||||
-webkit-appearance: none;
|
||||
|
||||
&::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-primary, #3b82f6);
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.2);
|
||||
}
|
||||
}
|
||||
|
||||
&::-moz-range-thumb {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-primary, #3b82f6);
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
transition: transform 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.2);
|
||||
}
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
|
||||
&::-webkit-slider-thumb {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
&::-moz-range-thumb {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const ValueDisplay = styled.span`
|
||||
min-width: 40px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--color-text, #1f2937);
|
||||
text-align: right;
|
||||
`;
|
||||
|
||||
const PresetButtons = styled.div`
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
`;
|
||||
|
||||
const PresetButton = styled.button<{ $active?: boolean }>`
|
||||
padding: 2px 8px;
|
||||
font-size: 11px;
|
||||
border: 1px solid
|
||||
${(props) =>
|
||||
props.$active
|
||||
? "var(--color-primary, #3b82f6)"
|
||||
: "var(--color-border, #e5e7eb)"};
|
||||
border-radius: 4px;
|
||||
background: ${(props) =>
|
||||
props.$active ? "var(--color-primary-light, #eff6ff)" : "transparent"};
|
||||
color: ${(props) =>
|
||||
props.$active
|
||||
? "var(--color-primary, #3b82f6)"
|
||||
: "var(--color-text-secondary, #6b7280)"};
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
border-color: var(--color-primary, #3b82f6);
|
||||
color: var(--color-primary, #3b82f6);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
`;
|
||||
|
||||
export interface TempoControlProps {
|
||||
/** 当前播放速度 (0.5 - 2.0) */
|
||||
playbackRate: number;
|
||||
/** 速度变化回调 */
|
||||
onChange: (rate: number) => void;
|
||||
/** 是否禁用 */
|
||||
disabled?: boolean;
|
||||
/** 是否显示预设按钮 */
|
||||
showPresets?: boolean;
|
||||
}
|
||||
|
||||
const PRESET_RATES = [0.5, 0.75, 1.0, 1.25, 1.5, 2.0];
|
||||
|
||||
/**
|
||||
* 速度控制组件
|
||||
*/
|
||||
export const TempoControl: React.FC<TempoControlProps> = memo(
|
||||
({ playbackRate, onChange, disabled = false, showPresets = true }) => {
|
||||
const handleSliderChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = parseFloat(e.target.value);
|
||||
onChange(value);
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
|
||||
const handlePresetClick = useCallback(
|
||||
(rate: number) => {
|
||||
onChange(rate);
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
|
||||
return (
|
||||
<ControlContainer>
|
||||
<IconWrapper title="播放速度">
|
||||
<Gauge />
|
||||
</IconWrapper>
|
||||
|
||||
<SliderContainer>
|
||||
<Slider
|
||||
type="range"
|
||||
min="0.5"
|
||||
max="2.0"
|
||||
step="0.1"
|
||||
value={playbackRate}
|
||||
onChange={handleSliderChange}
|
||||
disabled={disabled}
|
||||
title={`速度: ${playbackRate.toFixed(1)}x`}
|
||||
/>
|
||||
<ValueDisplay>{playbackRate.toFixed(1)}x</ValueDisplay>
|
||||
</SliderContainer>
|
||||
|
||||
{showPresets && (
|
||||
<PresetButtons>
|
||||
{PRESET_RATES.map((rate) => (
|
||||
<PresetButton
|
||||
key={rate}
|
||||
$active={Math.abs(playbackRate - rate) < 0.05}
|
||||
onClick={() => handlePresetClick(rate)}
|
||||
disabled={disabled}
|
||||
title={`${rate}x 速度`}
|
||||
>
|
||||
{rate}x
|
||||
</PresetButton>
|
||||
))}
|
||||
</PresetButtons>
|
||||
)}
|
||||
</ControlContainer>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
TempoControl.displayName = "TempoControl";
|
||||
@@ -0,0 +1,191 @@
|
||||
/**
|
||||
* @file 音量控制组件
|
||||
* @description 调整播放音量 (0 - 100%)
|
||||
* @module components/content-creator/canvas/music/player/VolumeControl
|
||||
*/
|
||||
|
||||
import React, { memo, useCallback } from "react";
|
||||
import styled from "styled-components";
|
||||
import { Volume2, VolumeX, Volume1 } from "lucide-react";
|
||||
|
||||
const ControlContainer = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
`;
|
||||
|
||||
const IconButton = styled.button`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: var(--color-text-secondary, #6b7280);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: var(--color-surface-hover, #f3f4f6);
|
||||
color: var(--color-text, #1f2937);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
`;
|
||||
|
||||
const SliderContainer = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 100px;
|
||||
`;
|
||||
|
||||
const Slider = styled.input`
|
||||
flex: 1;
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
background: var(--color-surface-hover, #f3f4f6);
|
||||
outline: none;
|
||||
-webkit-appearance: none;
|
||||
|
||||
&::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-primary, #3b82f6);
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.2);
|
||||
}
|
||||
}
|
||||
|
||||
&::-moz-range-thumb {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-primary, #3b82f6);
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
transition: transform 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.2);
|
||||
}
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
|
||||
&::-webkit-slider-thumb {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
&::-moz-range-thumb {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const ValueDisplay = styled.span`
|
||||
min-width: 32px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--color-text, #1f2937);
|
||||
text-align: right;
|
||||
`;
|
||||
|
||||
export interface VolumeControlProps {
|
||||
/** 当前音量 (0 - 1) */
|
||||
volume: number;
|
||||
/** 音量变化回调 */
|
||||
onChange: (volume: number) => void;
|
||||
/** 是否禁用 */
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 音量控制组件
|
||||
*/
|
||||
export const VolumeControl: React.FC<VolumeControlProps> = memo(
|
||||
({ volume, onChange, disabled = false }) => {
|
||||
const [isMuted, setIsMuted] = React.useState(false);
|
||||
const [previousVolume, setPreviousVolume] = React.useState(volume);
|
||||
|
||||
const handleSliderChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = parseFloat(e.target.value);
|
||||
onChange(value);
|
||||
if (value > 0) {
|
||||
setIsMuted(false);
|
||||
}
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
|
||||
const handleMuteToggle = useCallback(() => {
|
||||
if (isMuted) {
|
||||
// 取消静音,恢复之前的音量
|
||||
onChange(previousVolume > 0 ? previousVolume : 0.5);
|
||||
setIsMuted(false);
|
||||
} else {
|
||||
// 静音,保存当前音量
|
||||
setPreviousVolume(volume);
|
||||
onChange(0);
|
||||
setIsMuted(true);
|
||||
}
|
||||
}, [isMuted, volume, previousVolume, onChange]);
|
||||
|
||||
// 根据音量选择图标
|
||||
const VolumeIcon = React.useMemo(() => {
|
||||
if (volume === 0 || isMuted) return VolumeX;
|
||||
if (volume < 0.5) return Volume1;
|
||||
return Volume2;
|
||||
}, [volume, isMuted]);
|
||||
|
||||
// 计算百分比
|
||||
const percentage = Math.round(volume * 100);
|
||||
|
||||
return (
|
||||
<ControlContainer>
|
||||
<IconButton
|
||||
onClick={handleMuteToggle}
|
||||
disabled={disabled}
|
||||
title={isMuted ? "取消静音" : "静音"}
|
||||
>
|
||||
<VolumeIcon />
|
||||
</IconButton>
|
||||
|
||||
<SliderContainer>
|
||||
<Slider
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.01"
|
||||
value={volume}
|
||||
onChange={handleSliderChange}
|
||||
disabled={disabled}
|
||||
title={`音量: ${percentage}%`}
|
||||
/>
|
||||
<ValueDisplay>{percentage}%</ValueDisplay>
|
||||
</SliderContainer>
|
||||
</ControlContainer>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
VolumeControl.displayName = "VolumeControl";
|
||||
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* @file 播放器模块入口
|
||||
* @module components/content-creator/canvas/music/player
|
||||
*/
|
||||
|
||||
export { MidiPlayer, type MidiPlayerProps } from "./MidiPlayer";
|
||||
export {
|
||||
PlaybackControls,
|
||||
type PlaybackControlsProps,
|
||||
} from "./PlaybackControls";
|
||||
export { ProgressBar, type ProgressBarProps } from "./ProgressBar";
|
||||
export { TempoControl, type TempoControlProps } from "./TempoControl";
|
||||
export { VolumeControl, type VolumeControlProps } from "./VolumeControl";
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* @file 音乐画布注册
|
||||
* @description 将音乐画布注册到全局画布注册中心
|
||||
* @module components/content-creator/canvas/music/registerMusicCanvas
|
||||
*/
|
||||
|
||||
import type { ComponentType } from "react";
|
||||
import { canvasRegistry } from "../../core/CanvasContainer";
|
||||
import { MusicCanvas } from "./MusicCanvas";
|
||||
import type { CanvasPlugin, CanvasProps } from "../../types";
|
||||
|
||||
/**
|
||||
* 音乐画布插件配置
|
||||
*/
|
||||
export const musicCanvasPlugin: CanvasPlugin = {
|
||||
type: "music",
|
||||
name: "音乐画布",
|
||||
icon: "🎵",
|
||||
supportedThemes: ["music"],
|
||||
supportedFileTypes: ["lyrics", "jianpu", "midi", "mid"],
|
||||
// MusicCanvas 接受 MusicCanvasProps,与 CanvasProps 兼容
|
||||
component: MusicCanvas as unknown as ComponentType<CanvasProps>,
|
||||
};
|
||||
|
||||
/**
|
||||
* 注册音乐画布到全局注册中心
|
||||
*/
|
||||
export function registerMusicCanvas(): void {
|
||||
canvasRegistry.register(musicCanvasPlugin);
|
||||
}
|
||||
|
||||
/**
|
||||
* 注销音乐画布
|
||||
*/
|
||||
export function unregisterMusicCanvas(): void {
|
||||
canvasRegistry.unregister("music");
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
/**
|
||||
* @file 和弦图组件
|
||||
* @description SVG 渲染吉他和弦指法图
|
||||
*/
|
||||
|
||||
import React, { memo } from "react";
|
||||
import styled from "styled-components";
|
||||
import type { ChordInfo } from "../types";
|
||||
import { getChordInfo } from "../utils/chordDatabase";
|
||||
|
||||
const ChordContainer = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
`;
|
||||
|
||||
const ChordName = styled.div`
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--foreground));
|
||||
`;
|
||||
|
||||
const ChordSvg = styled.svg`
|
||||
display: block;
|
||||
`;
|
||||
|
||||
interface ChordDiagramProps {
|
||||
/** 和弦名称或和弦信息 */
|
||||
chord: string | ChordInfo;
|
||||
/** 尺寸 */
|
||||
size?: "small" | "medium" | "large";
|
||||
/** 是否显示和弦名称 */
|
||||
showName?: boolean;
|
||||
}
|
||||
|
||||
const SIZES = {
|
||||
small: { width: 50, height: 60, fretHeight: 10, stringGap: 8 },
|
||||
medium: { width: 70, height: 85, fretHeight: 14, stringGap: 11 },
|
||||
large: { width: 90, height: 110, fretHeight: 18, stringGap: 14 },
|
||||
};
|
||||
|
||||
/**
|
||||
* 吉他和弦图组件
|
||||
*/
|
||||
export const ChordDiagram: React.FC<ChordDiagramProps> = memo(
|
||||
({ chord, size = "medium", showName = true }) => {
|
||||
// 获取和弦信息
|
||||
const chordInfo: ChordInfo | null =
|
||||
typeof chord === "string" ? getChordInfo(chord) : chord;
|
||||
|
||||
if (!chordInfo) {
|
||||
return (
|
||||
<ChordContainer>
|
||||
{showName && (
|
||||
<ChordName style={{ color: "hsl(var(--muted-foreground))" }}>
|
||||
{typeof chord === "string" ? chord : "?"}
|
||||
</ChordName>
|
||||
)}
|
||||
<div
|
||||
style={{
|
||||
width: SIZES[size].width,
|
||||
height: SIZES[size].height,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
fontSize: "12px",
|
||||
color: "hsl(var(--muted-foreground))",
|
||||
}}
|
||||
>
|
||||
未知和弦
|
||||
</div>
|
||||
</ChordContainer>
|
||||
);
|
||||
}
|
||||
|
||||
const { width, height, fretHeight, stringGap } = SIZES[size];
|
||||
const padding = 10;
|
||||
const nutHeight = 4;
|
||||
const fretCount = 4;
|
||||
const dotRadius = size === "small" ? 3 : size === "medium" ? 4 : 5;
|
||||
|
||||
// 计算位置
|
||||
const startX = padding;
|
||||
const startY = padding + nutHeight + 5;
|
||||
const gridWidth = stringGap * 5;
|
||||
const gridHeight = fretHeight * fretCount;
|
||||
|
||||
// 计算起始品位(用于横按和弦)
|
||||
const minFret = Math.min(...chordInfo.fingering.filter((f) => f > 0));
|
||||
const baseFret = minFret > 3 ? minFret : 1;
|
||||
const showBaseFret = baseFret > 1;
|
||||
|
||||
return (
|
||||
<ChordContainer>
|
||||
{showName && <ChordName>{chordInfo.name}</ChordName>}
|
||||
<ChordSvg
|
||||
width={width}
|
||||
height={height}
|
||||
viewBox={`0 0 ${width} ${height}`}
|
||||
>
|
||||
{/* 琴枕(如果是第一品位) */}
|
||||
{!showBaseFret && (
|
||||
<rect
|
||||
x={startX}
|
||||
y={startY - nutHeight}
|
||||
width={gridWidth}
|
||||
height={nutHeight}
|
||||
fill="hsl(var(--foreground))"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 品位数字(如果不是第一品位) */}
|
||||
{showBaseFret && (
|
||||
<text
|
||||
x={startX - 8}
|
||||
y={startY + fretHeight / 2 + 4}
|
||||
fontSize="10"
|
||||
fill="hsl(var(--muted-foreground))"
|
||||
textAnchor="middle"
|
||||
>
|
||||
{baseFret}
|
||||
</text>
|
||||
)}
|
||||
|
||||
{/* 横线(品丝) */}
|
||||
{Array.from({ length: fretCount + 1 }).map((_, i) => (
|
||||
<line
|
||||
key={`fret-${i}`}
|
||||
x1={startX}
|
||||
y1={startY + i * fretHeight}
|
||||
x2={startX + gridWidth}
|
||||
y2={startY + i * fretHeight}
|
||||
stroke="hsl(var(--border))"
|
||||
strokeWidth={i === 0 && !showBaseFret ? 0 : 1}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* 竖线(琴弦) */}
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<line
|
||||
key={`string-${i}`}
|
||||
x1={startX + i * stringGap}
|
||||
y1={startY}
|
||||
x2={startX + i * stringGap}
|
||||
y2={startY + gridHeight}
|
||||
stroke="hsl(var(--foreground))"
|
||||
strokeWidth={1}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* 手指位置 */}
|
||||
{chordInfo.fingering.map((fret, stringIndex) => {
|
||||
const x = startX + stringIndex * stringGap;
|
||||
|
||||
// 不弹的弦
|
||||
if (fret === -1) {
|
||||
return (
|
||||
<text
|
||||
key={`mute-${stringIndex}`}
|
||||
x={x}
|
||||
y={startY - 8}
|
||||
fontSize="12"
|
||||
fill="hsl(var(--muted-foreground))"
|
||||
textAnchor="middle"
|
||||
>
|
||||
×
|
||||
</text>
|
||||
);
|
||||
}
|
||||
|
||||
// 空弦
|
||||
if (fret === 0) {
|
||||
return (
|
||||
<circle
|
||||
key={`open-${stringIndex}`}
|
||||
cx={x}
|
||||
cy={startY - 8}
|
||||
r={dotRadius - 1}
|
||||
fill="none"
|
||||
stroke="hsl(var(--foreground))"
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// 按弦位置
|
||||
const adjustedFret = fret - baseFret + 1;
|
||||
const y = startY + (adjustedFret - 0.5) * fretHeight;
|
||||
|
||||
return (
|
||||
<circle
|
||||
key={`finger-${stringIndex}`}
|
||||
cx={x}
|
||||
cy={y}
|
||||
r={dotRadius}
|
||||
fill="hsl(var(--foreground))"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* 横按标记(如果所有弦在同一品位有相同的最小值) */}
|
||||
{chordInfo.fret > 0 && (
|
||||
<rect
|
||||
x={startX - dotRadius}
|
||||
y={startY + (1 - 0.5) * fretHeight - dotRadius}
|
||||
width={gridWidth + dotRadius * 2}
|
||||
height={dotRadius * 2}
|
||||
rx={dotRadius}
|
||||
fill="hsl(var(--foreground))"
|
||||
opacity={0.8}
|
||||
/>
|
||||
)}
|
||||
</ChordSvg>
|
||||
</ChordContainer>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
ChordDiagram.displayName = "ChordDiagram";
|
||||
|
||||
export default ChordDiagram;
|
||||
@@ -0,0 +1,264 @@
|
||||
/**
|
||||
* @file 吉他谱渲染组件
|
||||
* @description 渲染吉他和弦图和歌词
|
||||
*/
|
||||
|
||||
import React, { memo, useMemo } from "react";
|
||||
import styled from "styled-components";
|
||||
import type { MusicSection } from "../types";
|
||||
import { ChordDiagram } from "./ChordDiagram";
|
||||
|
||||
const Container = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
padding: 16px;
|
||||
overflow-y: auto;
|
||||
`;
|
||||
|
||||
const SectionBlock = styled.div<{ $isSelected: boolean }>`
|
||||
padding: 16px;
|
||||
border-radius: 8px;
|
||||
background: ${({ $isSelected }) =>
|
||||
$isSelected ? "hsl(var(--accent) / 0.1)" : "hsl(var(--muted) / 0.3)"};
|
||||
border: 1px solid
|
||||
${({ $isSelected }) =>
|
||||
$isSelected ? "hsl(var(--accent))" : "hsl(var(--border))"};
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: hsl(var(--accent) / 0.05);
|
||||
}
|
||||
`;
|
||||
|
||||
const SectionHeader = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
`;
|
||||
|
||||
const SectionTag = styled.span`
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--accent));
|
||||
background: hsl(var(--accent) / 0.1);
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
`;
|
||||
|
||||
const ChordRow = styled.div`
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
`;
|
||||
|
||||
const ChordWithLyrics = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 70px;
|
||||
`;
|
||||
|
||||
const LyricsText = styled.div`
|
||||
font-size: 14px;
|
||||
color: hsl(var(--foreground));
|
||||
text-align: center;
|
||||
max-width: 100px;
|
||||
word-break: break-all;
|
||||
`;
|
||||
|
||||
const LyricsOnlyRow = styled.div`
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
padding: 8px 0;
|
||||
border-top: 1px solid hsl(var(--border));
|
||||
margin-top: 8px;
|
||||
`;
|
||||
|
||||
const LyricsLine = styled.div`
|
||||
font-size: 14px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
line-height: 1.8;
|
||||
`;
|
||||
|
||||
const EmptyState = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 200px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
text-align: center;
|
||||
`;
|
||||
|
||||
const HintBox = styled.div`
|
||||
margin-top: 12px;
|
||||
padding: 8px;
|
||||
background: hsl(var(--muted) / 0.5);
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
`;
|
||||
|
||||
interface GuitarTabRendererProps {
|
||||
sections: MusicSection[];
|
||||
currentSectionId: string | null;
|
||||
onSectionSelect: (id: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从段落中提取唯一的和弦列表
|
||||
*/
|
||||
function extractUniqueChords(section: MusicSection): string[] {
|
||||
const chords = new Set<string>();
|
||||
for (const bar of section.bars) {
|
||||
if (bar.chord) {
|
||||
chords.add(bar.chord);
|
||||
}
|
||||
}
|
||||
return Array.from(chords);
|
||||
}
|
||||
|
||||
/**
|
||||
* 吉他谱渲染组件
|
||||
*/
|
||||
export const GuitarTabRenderer: React.FC<GuitarTabRendererProps> = memo(
|
||||
({ sections, currentSectionId, onSectionSelect }) => {
|
||||
// 检查是否有和弦数据
|
||||
const hasChordData = useMemo(() => {
|
||||
return sections.some((section) => section.bars.some((bar) => bar.chord));
|
||||
}, [sections]);
|
||||
|
||||
if (sections.length === 0) {
|
||||
return (
|
||||
<EmptyState>
|
||||
<div style={{ fontSize: "48px", marginBottom: "16px" }}>🎸</div>
|
||||
<div style={{ fontSize: "16px", fontWeight: 600 }}>
|
||||
暂无吉他谱数据
|
||||
</div>
|
||||
<div style={{ fontSize: "14px", marginTop: "8px" }}>
|
||||
请在创作时要求 AI 生成带和弦标记的歌词
|
||||
</div>
|
||||
</EmptyState>
|
||||
);
|
||||
}
|
||||
|
||||
if (!hasChordData) {
|
||||
// 没有和弦数据,显示歌词和提示
|
||||
return (
|
||||
<Container>
|
||||
{sections.map((section) => (
|
||||
<SectionBlock
|
||||
key={section.id}
|
||||
$isSelected={section.id === currentSectionId}
|
||||
onClick={() => onSectionSelect(section.id)}
|
||||
>
|
||||
<SectionHeader>
|
||||
<SectionTag>[{section.type.toUpperCase()}]</SectionTag>
|
||||
<span
|
||||
style={{
|
||||
color: "hsl(var(--muted-foreground))",
|
||||
fontSize: "13px",
|
||||
}}
|
||||
>
|
||||
{section.name}
|
||||
</span>
|
||||
</SectionHeader>
|
||||
<div>
|
||||
{section.lyricsLines.map((line, index) => (
|
||||
<LyricsLine key={index}>{line}</LyricsLine>
|
||||
))}
|
||||
</div>
|
||||
<HintBox>
|
||||
💡 提示:要显示吉他和弦图,请在创作时要求 AI
|
||||
在歌词上方标注和弦(如 C, Am, G 等)
|
||||
</HintBox>
|
||||
</SectionBlock>
|
||||
))}
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Container>
|
||||
{sections.map((section) => {
|
||||
const uniqueChords = extractUniqueChords(section);
|
||||
|
||||
return (
|
||||
<SectionBlock
|
||||
key={section.id}
|
||||
$isSelected={section.id === currentSectionId}
|
||||
onClick={() => onSectionSelect(section.id)}
|
||||
>
|
||||
<SectionHeader>
|
||||
<SectionTag>[{section.type.toUpperCase()}]</SectionTag>
|
||||
<span
|
||||
style={{
|
||||
color: "hsl(var(--muted-foreground))",
|
||||
fontSize: "13px",
|
||||
}}
|
||||
>
|
||||
{section.name}
|
||||
</span>
|
||||
</SectionHeader>
|
||||
|
||||
{/* 和弦图展示 */}
|
||||
{uniqueChords.length > 0 && (
|
||||
<ChordRow>
|
||||
{uniqueChords.map((chord) => (
|
||||
<ChordDiagram key={chord} chord={chord} size="medium" />
|
||||
))}
|
||||
</ChordRow>
|
||||
)}
|
||||
|
||||
{/* 带和弦的歌词 */}
|
||||
<div>
|
||||
{section.bars.map((bar) => {
|
||||
if (!bar.chord && !bar.lyrics) return null;
|
||||
return (
|
||||
<ChordWithLyrics
|
||||
key={bar.id}
|
||||
style={{ display: "inline-flex", marginRight: "8px" }}
|
||||
>
|
||||
{bar.chord && (
|
||||
<span
|
||||
style={{
|
||||
fontSize: "12px",
|
||||
fontWeight: 600,
|
||||
color: "hsl(var(--primary))",
|
||||
}}
|
||||
>
|
||||
{bar.chord}
|
||||
</span>
|
||||
)}
|
||||
{bar.lyrics && <LyricsText>{bar.lyrics}</LyricsText>}
|
||||
</ChordWithLyrics>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 纯歌词行(如果有) */}
|
||||
{section.lyricsLines.length > 0 &&
|
||||
section.bars.every((b) => !b.lyrics) && (
|
||||
<LyricsOnlyRow>
|
||||
{section.lyricsLines.map((line, index) => (
|
||||
<LyricsLine key={index}>{line}</LyricsLine>
|
||||
))}
|
||||
</LyricsOnlyRow>
|
||||
)}
|
||||
</SectionBlock>
|
||||
);
|
||||
})}
|
||||
</Container>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
GuitarTabRenderer.displayName = "GuitarTabRenderer";
|
||||
|
||||
export default GuitarTabRenderer;
|
||||
@@ -0,0 +1,309 @@
|
||||
/**
|
||||
* @file 简谱渲染组件
|
||||
* @description 渲染简谱音符、和弦和歌词
|
||||
*/
|
||||
|
||||
import React, { memo } from "react";
|
||||
import styled from "styled-components";
|
||||
import type { MusicSection, Bar, Note } from "../types";
|
||||
|
||||
const Container = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
padding: 16px;
|
||||
overflow-y: auto;
|
||||
`;
|
||||
|
||||
const SectionBlock = styled.div<{ $isSelected: boolean }>`
|
||||
padding: 16px;
|
||||
border-radius: 8px;
|
||||
background: ${({ $isSelected }) =>
|
||||
$isSelected ? "hsl(var(--accent) / 0.1)" : "hsl(var(--muted) / 0.3)"};
|
||||
border: 1px solid
|
||||
${({ $isSelected }) =>
|
||||
$isSelected ? "hsl(var(--primary))" : "hsl(var(--border))"};
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: hsl(var(--accent) / 0.05);
|
||||
}
|
||||
`;
|
||||
|
||||
const SectionHeader = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
`;
|
||||
|
||||
const SECTION_DISPLAY_NAMES: Record<string, string> = {
|
||||
intro: "前奏",
|
||||
verse: "主歌",
|
||||
"pre-chorus": "预副歌",
|
||||
chorus: "副歌",
|
||||
bridge: "桥段",
|
||||
interlude: "间奏",
|
||||
outro: "尾奏",
|
||||
};
|
||||
|
||||
const SectionTag = styled.span`
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--primary));
|
||||
background: hsl(var(--primary) / 0.1);
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
`;
|
||||
|
||||
const BarsContainer = styled.div`
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
`;
|
||||
|
||||
const BarGroup = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
min-width: 80px;
|
||||
padding: 8px;
|
||||
border-right: 1px solid hsl(var(--border));
|
||||
|
||||
&:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
`;
|
||||
|
||||
const ChordName = styled.div`
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--primary));
|
||||
height: 20px;
|
||||
margin-bottom: 4px;
|
||||
`;
|
||||
|
||||
const NotesRow = styled.div`
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
font-family: "Courier New", monospace;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--foreground));
|
||||
min-height: 32px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
`;
|
||||
|
||||
const NoteCell = styled.span<{ $isRest?: boolean }>`
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
min-width: 20px;
|
||||
line-height: 1;
|
||||
color: ${({ $isRest }) =>
|
||||
$isRest ? "hsl(var(--muted-foreground))" : "inherit"};
|
||||
`;
|
||||
|
||||
const OctaveDot = styled.span<{ $position: "top" | "bottom" }>`
|
||||
font-size: 16px;
|
||||
line-height: 0.5;
|
||||
height: 4px;
|
||||
font-weight: bold;
|
||||
${({ $position }) =>
|
||||
$position === "top" ? "margin-bottom: -4px;" : "margin-top: -4px;"}
|
||||
`;
|
||||
|
||||
const LyricsRow = styled.div`
|
||||
font-size: 14px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
margin-top: 4px;
|
||||
text-align: center;
|
||||
min-height: 20px;
|
||||
`;
|
||||
|
||||
const EmptyState = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 200px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
text-align: center;
|
||||
`;
|
||||
|
||||
interface NumberedNotationRendererProps {
|
||||
sections: MusicSection[];
|
||||
currentSectionId: string | null;
|
||||
onSectionSelect: (id: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染单个音符
|
||||
*/
|
||||
const NoteDisplay: React.FC<{ note: Note }> = memo(({ note }) => {
|
||||
const isRest = note.pitch === 0;
|
||||
const displayPitch = isRest ? "0" : note.pitch.toString();
|
||||
|
||||
// 计算延长线
|
||||
const dashes = note.duration > 1 ? " -".repeat(note.duration - 1) : "";
|
||||
|
||||
return (
|
||||
<NoteCell $isRest={isRest}>
|
||||
{note.octave > 0 && <OctaveDot $position="top">·</OctaveDot>}
|
||||
<span>
|
||||
{displayPitch}
|
||||
{note.dotted && <span style={{ fontSize: "10px" }}>.</span>}
|
||||
{dashes}
|
||||
</span>
|
||||
{note.octave < 0 && <OctaveDot $position="bottom">·</OctaveDot>}
|
||||
</NoteCell>
|
||||
);
|
||||
});
|
||||
|
||||
NoteDisplay.displayName = "NoteDisplay";
|
||||
|
||||
/**
|
||||
* 渲染单个小节
|
||||
*/
|
||||
const BarDisplay: React.FC<{ bar: Bar }> = memo(({ bar }) => {
|
||||
return (
|
||||
<BarGroup>
|
||||
<ChordName>{bar.chord || "\u00A0"}</ChordName>
|
||||
<NotesRow>
|
||||
{bar.notes.length > 0 ? (
|
||||
bar.notes.map((note, index) => (
|
||||
<NoteDisplay key={index} note={note} />
|
||||
))
|
||||
) : (
|
||||
<span style={{ color: "hsl(var(--muted-foreground))" }}>-</span>
|
||||
)}
|
||||
</NotesRow>
|
||||
<LyricsRow>{bar.lyrics || "\u00A0"}</LyricsRow>
|
||||
</BarGroup>
|
||||
);
|
||||
});
|
||||
|
||||
BarDisplay.displayName = "BarDisplay";
|
||||
|
||||
/**
|
||||
* 简谱渲染组件
|
||||
*/
|
||||
export const NumberedNotationRenderer: React.FC<NumberedNotationRendererProps> =
|
||||
memo(({ sections, currentSectionId, onSectionSelect }) => {
|
||||
if (sections.length === 0) {
|
||||
return (
|
||||
<EmptyState>
|
||||
<div style={{ fontSize: "48px", marginBottom: "16px" }}>🎼</div>
|
||||
<div style={{ fontSize: "16px", fontWeight: 600 }}>暂无简谱数据</div>
|
||||
<div style={{ fontSize: "14px", marginTop: "8px" }}>
|
||||
请在创作时要求 AI 生成带简谱的歌词
|
||||
</div>
|
||||
</EmptyState>
|
||||
);
|
||||
}
|
||||
|
||||
// 检查是否有任何小节包含音符数据
|
||||
const hasNotationData = sections.some((section) =>
|
||||
section.bars.some((bar) => bar.notes.length > 0),
|
||||
);
|
||||
|
||||
if (!hasNotationData) {
|
||||
// 如果没有简谱数据,显示歌词和和弦
|
||||
return (
|
||||
<Container>
|
||||
{sections.map((section) => (
|
||||
<SectionBlock
|
||||
key={section.id}
|
||||
$isSelected={section.id === currentSectionId}
|
||||
onClick={() => onSectionSelect(section.id)}
|
||||
>
|
||||
<SectionHeader>
|
||||
<SectionTag>
|
||||
[
|
||||
{SECTION_DISPLAY_NAMES[section.type] ||
|
||||
section.type.toUpperCase()}
|
||||
]
|
||||
</SectionTag>
|
||||
<span
|
||||
style={{
|
||||
color: "hsl(var(--muted-foreground))",
|
||||
fontSize: "13px",
|
||||
}}
|
||||
>
|
||||
{section.name}
|
||||
</span>
|
||||
</SectionHeader>
|
||||
<div
|
||||
style={{
|
||||
color: "hsl(var(--muted-foreground))",
|
||||
fontSize: "14px",
|
||||
}}
|
||||
>
|
||||
{section.lyricsLines.length > 0 ? (
|
||||
section.lyricsLines.map((line, index) => (
|
||||
<div key={index} style={{ marginBottom: "8px" }}>
|
||||
{line}
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div>暂无歌词</div>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
marginTop: "12px",
|
||||
padding: "8px",
|
||||
background: "hsl(var(--muted) / 0.5)",
|
||||
borderRadius: "4px",
|
||||
fontSize: "12px",
|
||||
color: "hsl(var(--muted-foreground))",
|
||||
}}
|
||||
>
|
||||
💡 提示:要显示简谱,请在创作时要求 AI 使用简谱格式输出
|
||||
</div>
|
||||
</SectionBlock>
|
||||
))}
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Container>
|
||||
{sections.map((section) => (
|
||||
<SectionBlock
|
||||
key={section.id}
|
||||
$isSelected={section.id === currentSectionId}
|
||||
onClick={() => onSectionSelect(section.id)}
|
||||
>
|
||||
<SectionHeader>
|
||||
<SectionTag>
|
||||
[
|
||||
{SECTION_DISPLAY_NAMES[section.type] ||
|
||||
section.type.toUpperCase()}
|
||||
]
|
||||
</SectionTag>
|
||||
<span
|
||||
style={{
|
||||
color: "hsl(var(--muted-foreground))",
|
||||
fontSize: "13px",
|
||||
}}
|
||||
>
|
||||
{section.name}
|
||||
</span>
|
||||
</SectionHeader>
|
||||
<BarsContainer>
|
||||
{section.bars.map((bar) => (
|
||||
<BarDisplay key={bar.id} bar={bar} />
|
||||
))}
|
||||
</BarsContainer>
|
||||
</SectionBlock>
|
||||
))}
|
||||
</Container>
|
||||
);
|
||||
});
|
||||
|
||||
NumberedNotationRenderer.displayName = "NumberedNotationRenderer";
|
||||
|
||||
export default NumberedNotationRenderer;
|
||||
@@ -0,0 +1,349 @@
|
||||
/**
|
||||
* @file 钢琴谱渲染组件
|
||||
* @description 简化版钢琴卷帘视图,显示音符在钢琴键上的位置
|
||||
*/
|
||||
|
||||
import React, { memo, useMemo } from "react";
|
||||
import styled from "styled-components";
|
||||
import type { MusicSection, Note } from "../types";
|
||||
|
||||
const Container = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
padding: 16px;
|
||||
overflow-y: auto;
|
||||
`;
|
||||
|
||||
const SectionBlock = styled.div<{ $isSelected: boolean }>`
|
||||
padding: 16px;
|
||||
border-radius: 8px;
|
||||
background: ${({ $isSelected }) =>
|
||||
$isSelected ? "hsl(var(--accent) / 0.1)" : "hsl(var(--muted) / 0.3)"};
|
||||
border: 1px solid
|
||||
${({ $isSelected }) =>
|
||||
$isSelected ? "hsl(var(--accent))" : "hsl(var(--border))"};
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: hsl(var(--accent) / 0.05);
|
||||
}
|
||||
`;
|
||||
|
||||
const SectionHeader = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
`;
|
||||
|
||||
const SectionTag = styled.span`
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--accent));
|
||||
background: hsl(var(--accent) / 0.1);
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
`;
|
||||
|
||||
const PianoRollContainer = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
overflow-x: auto;
|
||||
`;
|
||||
|
||||
const PianoRow = styled.div`
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 2px;
|
||||
padding: 8px;
|
||||
background: hsl(var(--background));
|
||||
border-radius: 4px;
|
||||
`;
|
||||
|
||||
const KeyboardContainer = styled.div`
|
||||
display: flex;
|
||||
position: relative;
|
||||
height: 80px;
|
||||
`;
|
||||
|
||||
const WhiteKey = styled.div<{ $isActive: boolean }>`
|
||||
width: 24px;
|
||||
height: 80px;
|
||||
background: ${({ $isActive }) =>
|
||||
$isActive ? "hsl(var(--primary))" : "white"};
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 0 0 4px 4px;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
padding-bottom: 4px;
|
||||
font-size: 10px;
|
||||
color: ${({ $isActive }) =>
|
||||
$isActive ? "white" : "hsl(var(--muted-foreground))"};
|
||||
transition: background 0.1s;
|
||||
`;
|
||||
|
||||
const BlackKey = styled.div<{ $isActive: boolean; $offset: number }>`
|
||||
position: absolute;
|
||||
left: ${({ $offset }) => $offset}px;
|
||||
width: 16px;
|
||||
height: 50px;
|
||||
background: ${({ $isActive }) =>
|
||||
$isActive ? "hsl(var(--primary))" : "hsl(var(--foreground))"};
|
||||
border-radius: 0 0 2px 2px;
|
||||
z-index: 1;
|
||||
transition: background 0.1s;
|
||||
`;
|
||||
|
||||
const ChordLabel = styled.div`
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--primary));
|
||||
margin-bottom: 4px;
|
||||
`;
|
||||
|
||||
const LyricsLabel = styled.div`
|
||||
font-size: 12px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
margin-top: 4px;
|
||||
text-align: center;
|
||||
`;
|
||||
|
||||
const BarContainer = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 8px;
|
||||
border-right: 1px solid hsl(var(--border));
|
||||
|
||||
&:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
`;
|
||||
|
||||
const EmptyState = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 200px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
text-align: center;
|
||||
`;
|
||||
|
||||
const HintBox = styled.div`
|
||||
margin-top: 12px;
|
||||
padding: 8px;
|
||||
background: hsl(var(--muted) / 0.5);
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
`;
|
||||
|
||||
const LyricsLine = styled.div`
|
||||
font-size: 14px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
line-height: 1.8;
|
||||
`;
|
||||
|
||||
interface PianoRollRendererProps {
|
||||
sections: MusicSection[];
|
||||
currentSectionId: string | null;
|
||||
onSectionSelect: (id: string) => void;
|
||||
}
|
||||
|
||||
// 白键音符名称
|
||||
const WHITE_KEYS = ["C", "D", "E", "F", "G", "A", "B"];
|
||||
// 黑键位置(相对于白键的偏移)
|
||||
const BLACK_KEY_OFFSETS = [17, 41, null, 89, 113, 137, null]; // null 表示没有黑键
|
||||
|
||||
/**
|
||||
* 简谱音高转换为钢琴键索引
|
||||
* 1=C, 2=D, 3=E, 4=F, 5=G, 6=A, 7=B
|
||||
*/
|
||||
function pitchToKeyIndex(pitch: number, octave: number): number {
|
||||
if (pitch === 0) return -1; // 休止符
|
||||
const baseIndex = pitch - 1; // 0-6
|
||||
return baseIndex + octave * 7;
|
||||
}
|
||||
|
||||
/**
|
||||
* 迷你钢琴键盘组件
|
||||
*/
|
||||
const MiniPiano: React.FC<{ activeNotes: Note[] }> = memo(({ activeNotes }) => {
|
||||
// 计算活跃的键
|
||||
const activeKeys = useMemo(() => {
|
||||
const keys = new Set<number>();
|
||||
for (const note of activeNotes) {
|
||||
if (note.pitch > 0) {
|
||||
const keyIndex = pitchToKeyIndex(note.pitch, note.octave);
|
||||
keys.add(keyIndex);
|
||||
}
|
||||
}
|
||||
return keys;
|
||||
}, [activeNotes]);
|
||||
|
||||
// 渲染一个八度的钢琴键
|
||||
const renderOctave = (octaveOffset: number) => {
|
||||
return (
|
||||
<KeyboardContainer key={octaveOffset}>
|
||||
{/* 白键 */}
|
||||
{WHITE_KEYS.map((key, index) => {
|
||||
const keyIndex = index + octaveOffset * 7;
|
||||
const isActive = activeKeys.has(keyIndex);
|
||||
return (
|
||||
<WhiteKey
|
||||
key={`white-${octaveOffset}-${index}`}
|
||||
$isActive={isActive}
|
||||
>
|
||||
{octaveOffset === 0 && key}
|
||||
</WhiteKey>
|
||||
);
|
||||
})}
|
||||
{/* 黑键 */}
|
||||
{BLACK_KEY_OFFSETS.map((offset, index) => {
|
||||
if (offset === null) return null;
|
||||
const _keyIndex = index + octaveOffset * 7 + 0.5; // 黑键用 .5 表示
|
||||
const isActive = false; // 简谱不支持黑键
|
||||
return (
|
||||
<BlackKey
|
||||
key={`black-${octaveOffset}-${index}`}
|
||||
$isActive={isActive}
|
||||
$offset={offset + octaveOffset * 168}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</KeyboardContainer>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex" }}>
|
||||
{/* 低音区 */}
|
||||
{renderOctave(-1)}
|
||||
{/* 中音区 */}
|
||||
{renderOctave(0)}
|
||||
{/* 高音区 */}
|
||||
{renderOctave(1)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
MiniPiano.displayName = "MiniPiano";
|
||||
|
||||
/**
|
||||
* 钢琴谱渲染组件
|
||||
*/
|
||||
export const PianoRollRenderer: React.FC<PianoRollRendererProps> = memo(
|
||||
({ sections, currentSectionId, onSectionSelect }) => {
|
||||
// 检查是否有音符数据
|
||||
const hasNoteData = useMemo(() => {
|
||||
return sections.some((section) =>
|
||||
section.bars.some((bar) => bar.notes.length > 0),
|
||||
);
|
||||
}, [sections]);
|
||||
|
||||
if (sections.length === 0) {
|
||||
return (
|
||||
<EmptyState>
|
||||
<div style={{ fontSize: "48px", marginBottom: "16px" }}>🎹</div>
|
||||
<div style={{ fontSize: "16px", fontWeight: 600 }}>
|
||||
暂无钢琴谱数据
|
||||
</div>
|
||||
<div style={{ fontSize: "14px", marginTop: "8px" }}>
|
||||
请在创作时要求 AI 生成带简谱的歌词
|
||||
</div>
|
||||
</EmptyState>
|
||||
);
|
||||
}
|
||||
|
||||
if (!hasNoteData) {
|
||||
// 没有音符数据,显示歌词和提示
|
||||
return (
|
||||
<Container>
|
||||
{sections.map((section) => (
|
||||
<SectionBlock
|
||||
key={section.id}
|
||||
$isSelected={section.id === currentSectionId}
|
||||
onClick={() => onSectionSelect(section.id)}
|
||||
>
|
||||
<SectionHeader>
|
||||
<SectionTag>[{section.type.toUpperCase()}]</SectionTag>
|
||||
<span
|
||||
style={{
|
||||
color: "hsl(var(--muted-foreground))",
|
||||
fontSize: "13px",
|
||||
}}
|
||||
>
|
||||
{section.name}
|
||||
</span>
|
||||
</SectionHeader>
|
||||
<div>
|
||||
{section.lyricsLines.map((line, index) => (
|
||||
<LyricsLine key={index}>{line}</LyricsLine>
|
||||
))}
|
||||
</div>
|
||||
<HintBox>
|
||||
💡 提示:要显示钢琴谱,请在创作时要求 AI 使用简谱格式输出(如 1
|
||||
2 3 4 5 6 7)
|
||||
</HintBox>
|
||||
</SectionBlock>
|
||||
))}
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Container>
|
||||
{sections.map((section) => (
|
||||
<SectionBlock
|
||||
key={section.id}
|
||||
$isSelected={section.id === currentSectionId}
|
||||
onClick={() => onSectionSelect(section.id)}
|
||||
>
|
||||
<SectionHeader>
|
||||
<SectionTag>[{section.type.toUpperCase()}]</SectionTag>
|
||||
<span
|
||||
style={{
|
||||
color: "hsl(var(--muted-foreground))",
|
||||
fontSize: "13px",
|
||||
}}
|
||||
>
|
||||
{section.name}
|
||||
</span>
|
||||
</SectionHeader>
|
||||
|
||||
<PianoRollContainer>
|
||||
<PianoRow>
|
||||
{section.bars.map((bar) => (
|
||||
<BarContainer key={bar.id}>
|
||||
{bar.chord && <ChordLabel>{bar.chord}</ChordLabel>}
|
||||
<MiniPiano activeNotes={bar.notes} />
|
||||
{bar.lyrics && <LyricsLabel>{bar.lyrics}</LyricsLabel>}
|
||||
</BarContainer>
|
||||
))}
|
||||
</PianoRow>
|
||||
</PianoRollContainer>
|
||||
|
||||
{/* 歌词行(如果小节没有歌词) */}
|
||||
{section.lyricsLines.length > 0 &&
|
||||
section.bars.every((b) => !b.lyrics) && (
|
||||
<div style={{ marginTop: "12px" }}>
|
||||
{section.lyricsLines.map((line, index) => (
|
||||
<LyricsLine key={index}>{line}</LyricsLine>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</SectionBlock>
|
||||
))}
|
||||
</Container>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
PianoRollRenderer.displayName = "PianoRollRenderer";
|
||||
|
||||
export default PianoRollRenderer;
|
||||
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* @file 渲染器组件导出
|
||||
*/
|
||||
|
||||
export { NumberedNotationRenderer } from "./NumberedNotationRenderer";
|
||||
export { ChordDiagram } from "./ChordDiagram";
|
||||
export { GuitarTabRenderer } from "./GuitarTabRenderer";
|
||||
export { PianoRollRenderer } from "./PianoRollRenderer";
|
||||
@@ -0,0 +1,713 @@
|
||||
/**
|
||||
* @file 音乐画布类型定义
|
||||
* @description 定义音乐画布相关的核心类型
|
||||
* @module components/content-creator/canvas/music/types
|
||||
*/
|
||||
|
||||
// ============================================================
|
||||
// 基础枚举类型
|
||||
// ============================================================
|
||||
|
||||
/** 歌曲类型 */
|
||||
export type SongType =
|
||||
| "pop" // 流行
|
||||
| "folk" // 民谣
|
||||
| "rock" // 摇滚
|
||||
| "guofeng" // 古风
|
||||
| "rap" // 说唱
|
||||
| "rnb" // R&B
|
||||
| "electronic" // 电子
|
||||
| "other"; // 其他
|
||||
|
||||
/** 创作模式 */
|
||||
export type MusicCreationMode =
|
||||
| "coach" // 教练模式 - AI引导逐段创作
|
||||
| "express" // 快速模式 - AI直接生成
|
||||
| "hybrid"; // 混合模式 - AI生成框架,用户填充
|
||||
|
||||
/** 视图模式 */
|
||||
export type MusicViewMode =
|
||||
| "lyrics" // 纯歌词
|
||||
| "numbered" // 简谱
|
||||
| "guitar" // 吉他谱
|
||||
| "piano"; // 钢琴谱
|
||||
|
||||
/** 段落类型 */
|
||||
export type SectionType =
|
||||
| "intro" // 前奏
|
||||
| "verse" // 主歌
|
||||
| "pre-chorus" // 预副歌
|
||||
| "chorus" // 副歌
|
||||
| "bridge" // 桥段
|
||||
| "interlude" // 间奏
|
||||
| "outro"; // 尾奏
|
||||
|
||||
/** 情感基调 */
|
||||
export type MoodType =
|
||||
| "joyful" // 欢快明亮
|
||||
| "gentle" // 温柔抒情
|
||||
| "sorrowful" // 忧伤低沉
|
||||
| "passionate" // 激昂热血
|
||||
| "mysterious" // 神秘空灵
|
||||
| "nostalgic"; // 怀旧复古
|
||||
|
||||
/** 装饰音类型 */
|
||||
export type OrnamentType =
|
||||
| "slide" // 滑音
|
||||
| "trill" // 颤音
|
||||
| "grace" // 倚音
|
||||
| "mordent"; // 波音
|
||||
|
||||
// ============================================================
|
||||
// 调式相关类型
|
||||
// ============================================================
|
||||
|
||||
/** 西方调式 */
|
||||
export type WesternMode =
|
||||
| "major" // 大调
|
||||
| "minor" // 小调
|
||||
| "dorian" // 多利亚
|
||||
| "mixolydian" // 混合利底亚
|
||||
| "blues"; // 蓝调
|
||||
|
||||
/** 中国五声调式 */
|
||||
export type ChinesePentatonicMode =
|
||||
| "gong" // 宫调式
|
||||
| "shang" // 商调式
|
||||
| "jue" // 角调式
|
||||
| "zhi" // 徵调式
|
||||
| "yu"; // 羽调式
|
||||
|
||||
/** 调式类型 */
|
||||
export type ModeType = WesternMode | ChinesePentatonicMode;
|
||||
|
||||
/** 调式检测结果 */
|
||||
export interface ModeDetection {
|
||||
/** 检测到的调式 */
|
||||
detected: ModeType;
|
||||
/** 置信度 (0-100) */
|
||||
confidence: number;
|
||||
/** 情感特征描述 */
|
||||
emotionalFeature: string;
|
||||
/** 主音 */
|
||||
tonic: string;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 音乐元素类型
|
||||
// ============================================================
|
||||
|
||||
/** 音符 */
|
||||
export interface Note {
|
||||
/** 简谱音高 1-7,0 表示休止 */
|
||||
pitch: number;
|
||||
/** 八度,0=中音,1=高音,-1=低音 */
|
||||
octave: number;
|
||||
/** 时值,1=四分音符,0.5=八分音符,2=二分音符 */
|
||||
duration: number;
|
||||
/** 装饰音 */
|
||||
ornament?: OrnamentType;
|
||||
/** 是否为附点 */
|
||||
dotted?: boolean;
|
||||
/** 是否为连音 */
|
||||
tied?: boolean;
|
||||
}
|
||||
|
||||
/** 小节 */
|
||||
export interface Bar {
|
||||
/** 小节 ID */
|
||||
id: string;
|
||||
/** 小节序号 */
|
||||
barNumber: number;
|
||||
/** 和弦 */
|
||||
chord: string;
|
||||
/** 音符列表 */
|
||||
notes: Note[];
|
||||
/** 对应歌词 */
|
||||
lyrics: string;
|
||||
}
|
||||
|
||||
/** 段落 */
|
||||
export interface MusicSection {
|
||||
/** 段落 ID */
|
||||
id: string;
|
||||
/** 段落类型 */
|
||||
type: SectionType;
|
||||
/** 段落名称 */
|
||||
name: string;
|
||||
/** 排序序号 */
|
||||
order: number;
|
||||
/** 小节列表 */
|
||||
bars: Bar[];
|
||||
/** 歌词行 */
|
||||
lyricsLines: string[];
|
||||
/** 重复次数 */
|
||||
repeatCount?: number;
|
||||
}
|
||||
|
||||
/** 和弦信息 */
|
||||
export interface ChordInfo {
|
||||
/** 和弦名称 */
|
||||
name: string;
|
||||
/** 组成音 */
|
||||
notes: string[];
|
||||
/** 吉他指法 (6弦,-1表示不弹) */
|
||||
fingering: number[];
|
||||
/** 品位 */
|
||||
fret: number;
|
||||
/** 和弦图 SVG */
|
||||
diagram?: string;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 歌曲规格类型
|
||||
// ============================================================
|
||||
|
||||
/** 歌曲规格 */
|
||||
export interface SongSpec {
|
||||
/** 歌曲名称 */
|
||||
title: string;
|
||||
/** 歌曲类型 */
|
||||
songType: SongType;
|
||||
/** 歌曲主题描述 */
|
||||
theme: string;
|
||||
/** 调式 */
|
||||
key: string;
|
||||
/** 拍号 */
|
||||
timeSignature: string;
|
||||
/** 速度 (BPM) */
|
||||
tempo: number;
|
||||
/** 情感基调 */
|
||||
mood: MoodType;
|
||||
/** 创作模式 */
|
||||
creationMode: MusicCreationMode;
|
||||
/** 作词人 */
|
||||
lyricist?: string;
|
||||
/** 作曲人 */
|
||||
composer?: string;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 旋律学习相关类型
|
||||
// ============================================================
|
||||
|
||||
/** 借鉴程度 */
|
||||
export type BorrowLevel =
|
||||
| "light" // 轻度参考 - 仅借鉴调式和情绪
|
||||
| "style" // 风格借鉴 - 参考整体风格
|
||||
| "high"; // 高度相似 - 严格遵循特征
|
||||
|
||||
/** 旋律学习创作模式 */
|
||||
export type MimicCreationMode =
|
||||
| "quick" // 快速模式 (3-8分钟)
|
||||
| "professional" // 专业模式 (10-18分钟)
|
||||
| "coach"; // 教练模式 (20-35分钟)
|
||||
|
||||
/** 节奏型统计 */
|
||||
export interface RhythmStats {
|
||||
/** 四分音符占比 */
|
||||
quarterNote: number;
|
||||
/** 八分音符占比 */
|
||||
eighthNote: number;
|
||||
/** 附点节奏占比 */
|
||||
dottedNote: number;
|
||||
/** 切分节奏占比 */
|
||||
syncopation: number;
|
||||
/** 三连音占比 */
|
||||
triplet: number;
|
||||
/** 十六分音符占比 */
|
||||
sixteenth: number;
|
||||
}
|
||||
|
||||
/** 音程统计 */
|
||||
export interface IntervalStats {
|
||||
/** 同度占比 */
|
||||
unison: number;
|
||||
/** 级进占比 (2度) */
|
||||
stepwise: number;
|
||||
/** 小跳占比 (3-4度) */
|
||||
smallLeap: number;
|
||||
/** 大跳占比 (5度以上) */
|
||||
largeLeap: number;
|
||||
}
|
||||
|
||||
/** 旋律轮廓统计 */
|
||||
export interface ContourStats {
|
||||
/** 上行占比 */
|
||||
ascending: number;
|
||||
/** 下行占比 */
|
||||
descending: number;
|
||||
/** 平稳占比 */
|
||||
stable: number;
|
||||
}
|
||||
|
||||
/** 旋律特征分析报告 */
|
||||
export interface MelodyAnalysisReport {
|
||||
/** 总音符数 */
|
||||
totalNotes: number;
|
||||
/** 音域范围 */
|
||||
pitchRange: {
|
||||
min: number;
|
||||
max: number;
|
||||
minNote: string;
|
||||
maxNote: string;
|
||||
};
|
||||
/** Ticks per beat */
|
||||
ticksPerBeat: number;
|
||||
/** 调式检测 */
|
||||
mode: ModeDetection;
|
||||
/** 节奏型统计 */
|
||||
rhythmStats: RhythmStats;
|
||||
/** 音程统计 */
|
||||
intervalStats: IntervalStats;
|
||||
/** 旋律轮廓统计 */
|
||||
contourStats: ContourStats;
|
||||
/** 常用音程 */
|
||||
commonIntervals: { interval: string; count: number }[];
|
||||
/** 节奏密度 (音符/小节) */
|
||||
rhythmDensity: number;
|
||||
}
|
||||
|
||||
/** 歌词段落分析 */
|
||||
export interface LyricsSectionAnalysis {
|
||||
/** 段落类型 */
|
||||
type: SectionType;
|
||||
/** 段落名称 */
|
||||
name: string;
|
||||
/** 歌词行 */
|
||||
lines: string[];
|
||||
/** 字数 */
|
||||
charCount: number;
|
||||
}
|
||||
|
||||
/** 意象分析 */
|
||||
export interface ImageryAnalysis {
|
||||
/** 意象类别 */
|
||||
category: string;
|
||||
/** 具体意象 */
|
||||
items: string[];
|
||||
/** 效果描述 */
|
||||
effect: string;
|
||||
}
|
||||
|
||||
/** 歌词深度分析报告 */
|
||||
export interface LyricsAnalysisReport {
|
||||
/** 歌曲标题 */
|
||||
title: string;
|
||||
/** 艺术家 */
|
||||
artist?: string;
|
||||
/** 总行数 */
|
||||
totalLines: number;
|
||||
/** 总字数 */
|
||||
totalChars: number;
|
||||
/** 结构分析 */
|
||||
structure: LyricsSectionAnalysis[];
|
||||
/** 意象分析 */
|
||||
imagery: ImageryAnalysis[];
|
||||
/** 修辞手法 */
|
||||
rhetoric: string[];
|
||||
/** 风格特征 */
|
||||
styleFeatures: string[];
|
||||
/** 情感走向 */
|
||||
emotionalProgression: string;
|
||||
/** 押韵分析 */
|
||||
rhymeAnalysis: {
|
||||
scheme: string;
|
||||
quality: "strict" | "moderate" | "free";
|
||||
};
|
||||
}
|
||||
|
||||
/** 一致性评分 */
|
||||
export interface ConsistencyScore {
|
||||
/** 结构一致性 (0-100) */
|
||||
structure: number;
|
||||
/** 风格一致性 (0-100) */
|
||||
style: number;
|
||||
/** 旋律适配度 (0-100) */
|
||||
melodyFit: number;
|
||||
/** 综合评分 (0-100) */
|
||||
overall: number;
|
||||
/** 评分说明 */
|
||||
comments: string[];
|
||||
}
|
||||
|
||||
/** 音轨候选 */
|
||||
export interface TrackCandidate {
|
||||
/** 音轨索引 */
|
||||
index: number;
|
||||
/** 音轨名称 */
|
||||
name: string;
|
||||
/** 音符数量 */
|
||||
noteCount: number;
|
||||
/** 匹配分数 */
|
||||
score: number;
|
||||
/** 置信度 */
|
||||
confidence: "high" | "medium" | "low";
|
||||
}
|
||||
|
||||
/** 音轨匹配结果 */
|
||||
export interface TrackMatchResult {
|
||||
/** 选中的音轨 */
|
||||
selected: TrackCandidate | null;
|
||||
/** 所有候选音轨 */
|
||||
candidates: TrackCandidate[];
|
||||
}
|
||||
|
||||
/** 旋律学习状态 */
|
||||
export interface MelodyMimicState {
|
||||
/** 参考文件路径 */
|
||||
referenceFile: string | null;
|
||||
/** 参考文件类型 */
|
||||
referenceFileType: "midi" | "mp3" | null;
|
||||
/** 歌词文件路径 */
|
||||
lyricsFile: string | null;
|
||||
/** 歌词内容 */
|
||||
lyricsContent: string;
|
||||
/** 音轨匹配结果 */
|
||||
trackMatch: TrackMatchResult | null;
|
||||
/** 旋律分析报告 */
|
||||
melodyReport: MelodyAnalysisReport | null;
|
||||
/** 歌词分析报告 */
|
||||
lyricsReport: LyricsAnalysisReport | null;
|
||||
/** 创作模式 */
|
||||
creationMode: MimicCreationMode;
|
||||
/** 借鉴程度 */
|
||||
borrowLevel: BorrowLevel;
|
||||
/** 一致性评分 */
|
||||
consistencyScore: ConsistencyScore | null;
|
||||
/** 分析进度 (0-100) */
|
||||
analysisProgress: number;
|
||||
/** 分析状态 */
|
||||
analysisStatus: "idle" | "analyzing" | "completed" | "error";
|
||||
/** 错误信息 */
|
||||
errorMessage: string | null;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 画布状态类型
|
||||
// ============================================================
|
||||
|
||||
/** 音乐版本 */
|
||||
export interface MusicVersion {
|
||||
/** 版本 ID */
|
||||
id: string;
|
||||
/** 版本描述 */
|
||||
description: string;
|
||||
/** 创建时间 */
|
||||
createdAt: number;
|
||||
/** 歌曲规格快照 */
|
||||
spec: SongSpec;
|
||||
/** 段落快照 */
|
||||
sections: MusicSection[];
|
||||
}
|
||||
|
||||
/** 音乐画布状态 */
|
||||
export interface MusicCanvasState {
|
||||
/** 画布类型标识 */
|
||||
type: "music";
|
||||
/** 项目 ID */
|
||||
projectId: string;
|
||||
/** 歌曲规格 */
|
||||
spec: SongSpec;
|
||||
/** 段落列表 */
|
||||
sections: MusicSection[];
|
||||
/** 当前选中的段落 ID */
|
||||
currentSectionId: string | null;
|
||||
/** 当前选中的小节 ID */
|
||||
currentBarId: string | null;
|
||||
/** 视图模式 */
|
||||
viewMode: MusicViewMode;
|
||||
/** 是否正在播放 */
|
||||
isPlaying: boolean;
|
||||
/** 播放位置 (秒) */
|
||||
playbackPosition: number;
|
||||
/** 播放速度倍率 */
|
||||
playbackRate: number;
|
||||
/** 音量 (0-1) */
|
||||
volume: number;
|
||||
/** 是否循环播放 */
|
||||
isLooping: boolean;
|
||||
/** 版本历史 */
|
||||
versions: MusicVersion[];
|
||||
/** 当前版本 ID */
|
||||
currentVersionId: string;
|
||||
/** 是否处于编辑模式 */
|
||||
isEditing: boolean;
|
||||
/** 旋律学习状态 */
|
||||
mimicState: MelodyMimicState | null;
|
||||
/** 当前工作流类型 */
|
||||
workflowType: "original" | "mimic";
|
||||
/** 当前工作流步骤 */
|
||||
currentWorkflowStep: string;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 组件 Props 类型
|
||||
// ============================================================
|
||||
|
||||
/** 音乐画布 Props */
|
||||
export interface MusicCanvasProps {
|
||||
/** 画布状态 */
|
||||
state: MusicCanvasState;
|
||||
/** 状态变更回调 */
|
||||
onStateChange: (state: MusicCanvasState) => void;
|
||||
/** 关闭画布回调 */
|
||||
onClose: () => void;
|
||||
/** 是否正在流式输出 */
|
||||
isStreaming?: boolean;
|
||||
}
|
||||
|
||||
/** 音乐工具栏 Props */
|
||||
export interface MusicToolbarProps {
|
||||
/** 歌曲规格 */
|
||||
spec: SongSpec;
|
||||
/** 视图模式 */
|
||||
viewMode: MusicViewMode;
|
||||
/** 是否正在播放 */
|
||||
isPlaying: boolean;
|
||||
/** 是否可以撤销 */
|
||||
canUndo: boolean;
|
||||
/** 是否可以重做 */
|
||||
canRedo: boolean;
|
||||
/** 视图模式切换回调 */
|
||||
onViewModeChange: (mode: MusicViewMode) => void;
|
||||
/** 播放/暂停回调 */
|
||||
onPlayToggle: () => void;
|
||||
/** 撤销回调 */
|
||||
onUndo: () => void;
|
||||
/** 重做回调 */
|
||||
onRedo: () => void;
|
||||
/** 导出回调 */
|
||||
onExport: () => void;
|
||||
/** 关闭回调 */
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/** 歌词编辑器 Props */
|
||||
export interface LyricsEditorProps {
|
||||
/** 段落列表 */
|
||||
sections: MusicSection[];
|
||||
/** 当前段落 ID */
|
||||
currentSectionId: string | null;
|
||||
/** 段落变更回调 */
|
||||
onSectionsChange: (sections: MusicSection[]) => void;
|
||||
/** 段落选择回调 */
|
||||
onSectionSelect: (sectionId: string) => void;
|
||||
}
|
||||
|
||||
/** 简谱编辑器 Props */
|
||||
export interface JianpuEditorProps {
|
||||
/** 当前段落 */
|
||||
section: MusicSection | null;
|
||||
/** 当前小节 ID */
|
||||
currentBarId: string | null;
|
||||
/** 段落变更回调 */
|
||||
onSectionChange: (section: MusicSection) => void;
|
||||
/** 小节选择回调 */
|
||||
onBarSelect: (barId: string) => void;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 导出相关类型
|
||||
// ============================================================
|
||||
|
||||
/** 导出格式 */
|
||||
export type MusicExportFormat =
|
||||
| "pdf" // PDF 歌词本
|
||||
| "midi" // MIDI 文件
|
||||
| "musicxml" // MusicXML
|
||||
| "txt" // 纯文本歌词
|
||||
| "suno" // Suno 提示词
|
||||
| "tunee"; // Tunee 素材包
|
||||
|
||||
/** 导出配置 */
|
||||
export interface MusicExportConfig {
|
||||
/** 导出格式 */
|
||||
format: MusicExportFormat;
|
||||
/** PDF 视图类型 */
|
||||
pdfView?: "lyrics" | "numbered" | "guitar" | "full";
|
||||
/** 是否包含和弦 */
|
||||
includeChords?: boolean;
|
||||
/** 是否包含简谱 */
|
||||
includeJianpu?: boolean;
|
||||
/** Suno 风格标签 */
|
||||
sunoTags?: string[];
|
||||
}
|
||||
|
||||
/** Suno 导出结果 */
|
||||
export interface SunoExport {
|
||||
/** 带标记的歌词 */
|
||||
lyrics: string;
|
||||
/** 风格提示词 */
|
||||
stylePrompt: string;
|
||||
/** 风格标签 */
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
/** Tunee 导出结果 */
|
||||
export interface TuneeExport {
|
||||
/** 歌词文件内容 */
|
||||
lyrics: string;
|
||||
/** 对话素材 */
|
||||
dialogMaterial: string;
|
||||
/** 参考信息 */
|
||||
reference: string;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 工具函数
|
||||
// ============================================================
|
||||
|
||||
/** 创建初始歌曲规格 */
|
||||
export function createInitialSpec(): SongSpec {
|
||||
return {
|
||||
title: "未命名歌曲",
|
||||
songType: "pop",
|
||||
theme: "",
|
||||
key: "C",
|
||||
timeSignature: "4/4",
|
||||
tempo: 72,
|
||||
mood: "gentle",
|
||||
creationMode: "coach",
|
||||
};
|
||||
}
|
||||
|
||||
/** 创建初始音乐画布状态 */
|
||||
export function createInitialMusicState(): MusicCanvasState {
|
||||
const initialSpec = createInitialSpec();
|
||||
const initialVersion: MusicVersion = {
|
||||
id: crypto.randomUUID(),
|
||||
description: "初始版本",
|
||||
createdAt: Date.now(),
|
||||
spec: initialSpec,
|
||||
sections: [],
|
||||
};
|
||||
|
||||
return {
|
||||
type: "music",
|
||||
projectId: crypto.randomUUID(),
|
||||
spec: initialSpec,
|
||||
sections: [],
|
||||
currentSectionId: null,
|
||||
currentBarId: null,
|
||||
viewMode: "lyrics",
|
||||
isPlaying: false,
|
||||
playbackPosition: 0,
|
||||
playbackRate: 1,
|
||||
volume: 0.8,
|
||||
isLooping: false,
|
||||
versions: [initialVersion],
|
||||
currentVersionId: initialVersion.id,
|
||||
isEditing: false,
|
||||
mimicState: null,
|
||||
workflowType: "original",
|
||||
currentWorkflowStep: "clarify",
|
||||
};
|
||||
}
|
||||
|
||||
/** 创建初始旋律学习状态 */
|
||||
export function createInitialMimicState(): MelodyMimicState {
|
||||
return {
|
||||
referenceFile: null,
|
||||
referenceFileType: null,
|
||||
lyricsFile: null,
|
||||
lyricsContent: "",
|
||||
trackMatch: null,
|
||||
melodyReport: null,
|
||||
lyricsReport: null,
|
||||
creationMode: "professional",
|
||||
borrowLevel: "style",
|
||||
consistencyScore: null,
|
||||
analysisProgress: 0,
|
||||
analysisStatus: "idle",
|
||||
errorMessage: null,
|
||||
};
|
||||
}
|
||||
|
||||
/** 创建新段落 */
|
||||
export function createSection(type: SectionType, order: number): MusicSection {
|
||||
const typeNames: Record<SectionType, string> = {
|
||||
intro: "前奏",
|
||||
verse: "主歌",
|
||||
"pre-chorus": "预副歌",
|
||||
chorus: "副歌",
|
||||
bridge: "桥段",
|
||||
interlude: "间奏",
|
||||
outro: "尾奏",
|
||||
};
|
||||
|
||||
return {
|
||||
id: crypto.randomUUID(),
|
||||
type,
|
||||
name: typeNames[type],
|
||||
order,
|
||||
bars: [],
|
||||
lyricsLines: [],
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 常量映射
|
||||
// ============================================================
|
||||
|
||||
/** 段落类型到 Suno 标签的映射 */
|
||||
export const SECTION_TO_SUNO_TAG: Record<SectionType, string> = {
|
||||
intro: "Intro",
|
||||
verse: "Verse",
|
||||
"pre-chorus": "Pre-Chorus",
|
||||
chorus: "Chorus",
|
||||
bridge: "Bridge",
|
||||
interlude: "Interlude",
|
||||
outro: "Outro",
|
||||
};
|
||||
|
||||
/** 调式情感特征映射 */
|
||||
export const MODE_EMOTIONAL_FEATURES: Record<ModeType, string> = {
|
||||
major: "明亮、欢快、积极",
|
||||
minor: "忧伤、深沉、内敛",
|
||||
dorian: "爵士、蓝调、神秘",
|
||||
mixolydian: "摇滚、乡村、放松",
|
||||
blues: "蓝调、忧郁、深情",
|
||||
gong: "明亮、庄重、喜庆",
|
||||
shang: "深沉、内敛、怀旧",
|
||||
jue: "清新、空灵、禅意",
|
||||
zhi: "热情、奔放、豪迈",
|
||||
yu: "忧伤、婉转、抒情",
|
||||
};
|
||||
|
||||
/** 歌曲类型显示名称 */
|
||||
export const SONG_TYPE_LABELS: Record<SongType, string> = {
|
||||
pop: "流行",
|
||||
folk: "民谣",
|
||||
rock: "摇滚",
|
||||
guofeng: "古风",
|
||||
rap: "说唱",
|
||||
rnb: "R&B",
|
||||
electronic: "电子",
|
||||
other: "其他",
|
||||
};
|
||||
|
||||
/** 情感基调显示名称 */
|
||||
export const MOOD_LABELS: Record<MoodType, string> = {
|
||||
joyful: "欢快明亮",
|
||||
gentle: "温柔抒情",
|
||||
sorrowful: "忧伤低沉",
|
||||
passionate: "激昂热血",
|
||||
mysterious: "神秘空灵",
|
||||
nostalgic: "怀旧复古",
|
||||
};
|
||||
|
||||
/** 创作模式显示名称 */
|
||||
export const CREATION_MODE_LABELS: Record<MusicCreationMode, string> = {
|
||||
coach: "教练模式",
|
||||
express: "快速模式",
|
||||
hybrid: "混合模式",
|
||||
};
|
||||
|
||||
/** 视图模式显示名称 */
|
||||
export const VIEW_MODE_LABELS: Record<MusicViewMode, string> = {
|
||||
lyrics: "歌词",
|
||||
numbered: "简谱",
|
||||
guitar: "吉他谱",
|
||||
piano: "钢琴谱",
|
||||
};
|
||||
@@ -0,0 +1,277 @@
|
||||
/**
|
||||
* @file 和弦数据库
|
||||
* @description 常用吉他和弦指法数据
|
||||
*/
|
||||
|
||||
import type { ChordInfo } from "../types";
|
||||
|
||||
/**
|
||||
* 吉他和弦数据库
|
||||
* fingering: 6弦到1弦的品位,-1表示不弹,0表示空弦
|
||||
*/
|
||||
export const CHORD_DATABASE: Record<string, ChordInfo> = {
|
||||
// 大三和弦
|
||||
C: {
|
||||
name: "C",
|
||||
notes: ["C", "E", "G"],
|
||||
fingering: [-1, 3, 2, 0, 1, 0],
|
||||
fret: 0,
|
||||
},
|
||||
D: {
|
||||
name: "D",
|
||||
notes: ["D", "F#", "A"],
|
||||
fingering: [-1, -1, 0, 2, 3, 2],
|
||||
fret: 0,
|
||||
},
|
||||
E: {
|
||||
name: "E",
|
||||
notes: ["E", "G#", "B"],
|
||||
fingering: [0, 2, 2, 1, 0, 0],
|
||||
fret: 0,
|
||||
},
|
||||
F: {
|
||||
name: "F",
|
||||
notes: ["F", "A", "C"],
|
||||
fingering: [1, 3, 3, 2, 1, 1],
|
||||
fret: 1,
|
||||
},
|
||||
G: {
|
||||
name: "G",
|
||||
notes: ["G", "B", "D"],
|
||||
fingering: [3, 2, 0, 0, 0, 3],
|
||||
fret: 0,
|
||||
},
|
||||
A: {
|
||||
name: "A",
|
||||
notes: ["A", "C#", "E"],
|
||||
fingering: [-1, 0, 2, 2, 2, 0],
|
||||
fret: 0,
|
||||
},
|
||||
B: {
|
||||
name: "B",
|
||||
notes: ["B", "D#", "F#"],
|
||||
fingering: [-1, 2, 4, 4, 4, 2],
|
||||
fret: 2,
|
||||
},
|
||||
|
||||
// 小三和弦
|
||||
Am: {
|
||||
name: "Am",
|
||||
notes: ["A", "C", "E"],
|
||||
fingering: [-1, 0, 2, 2, 1, 0],
|
||||
fret: 0,
|
||||
},
|
||||
Bm: {
|
||||
name: "Bm",
|
||||
notes: ["B", "D", "F#"],
|
||||
fingering: [-1, 2, 4, 4, 3, 2],
|
||||
fret: 2,
|
||||
},
|
||||
Cm: {
|
||||
name: "Cm",
|
||||
notes: ["C", "Eb", "G"],
|
||||
fingering: [-1, 3, 5, 5, 4, 3],
|
||||
fret: 3,
|
||||
},
|
||||
Dm: {
|
||||
name: "Dm",
|
||||
notes: ["D", "F", "A"],
|
||||
fingering: [-1, -1, 0, 2, 3, 1],
|
||||
fret: 0,
|
||||
},
|
||||
Em: {
|
||||
name: "Em",
|
||||
notes: ["E", "G", "B"],
|
||||
fingering: [0, 2, 2, 0, 0, 0],
|
||||
fret: 0,
|
||||
},
|
||||
Fm: {
|
||||
name: "Fm",
|
||||
notes: ["F", "Ab", "C"],
|
||||
fingering: [1, 3, 3, 1, 1, 1],
|
||||
fret: 1,
|
||||
},
|
||||
Gm: {
|
||||
name: "Gm",
|
||||
notes: ["G", "Bb", "D"],
|
||||
fingering: [3, 5, 5, 3, 3, 3],
|
||||
fret: 3,
|
||||
},
|
||||
|
||||
// 七和弦
|
||||
C7: {
|
||||
name: "C7",
|
||||
notes: ["C", "E", "G", "Bb"],
|
||||
fingering: [-1, 3, 2, 3, 1, 0],
|
||||
fret: 0,
|
||||
},
|
||||
D7: {
|
||||
name: "D7",
|
||||
notes: ["D", "F#", "A", "C"],
|
||||
fingering: [-1, -1, 0, 2, 1, 2],
|
||||
fret: 0,
|
||||
},
|
||||
E7: {
|
||||
name: "E7",
|
||||
notes: ["E", "G#", "B", "D"],
|
||||
fingering: [0, 2, 0, 1, 0, 0],
|
||||
fret: 0,
|
||||
},
|
||||
G7: {
|
||||
name: "G7",
|
||||
notes: ["G", "B", "D", "F"],
|
||||
fingering: [3, 2, 0, 0, 0, 1],
|
||||
fret: 0,
|
||||
},
|
||||
A7: {
|
||||
name: "A7",
|
||||
notes: ["A", "C#", "E", "G"],
|
||||
fingering: [-1, 0, 2, 0, 2, 0],
|
||||
fret: 0,
|
||||
},
|
||||
B7: {
|
||||
name: "B7",
|
||||
notes: ["B", "D#", "F#", "A"],
|
||||
fingering: [-1, 2, 1, 2, 0, 2],
|
||||
fret: 0,
|
||||
},
|
||||
|
||||
// 大七和弦
|
||||
Cmaj7: {
|
||||
name: "Cmaj7",
|
||||
notes: ["C", "E", "G", "B"],
|
||||
fingering: [-1, 3, 2, 0, 0, 0],
|
||||
fret: 0,
|
||||
},
|
||||
Dmaj7: {
|
||||
name: "Dmaj7",
|
||||
notes: ["D", "F#", "A", "C#"],
|
||||
fingering: [-1, -1, 0, 2, 2, 2],
|
||||
fret: 0,
|
||||
},
|
||||
Fmaj7: {
|
||||
name: "Fmaj7",
|
||||
notes: ["F", "A", "C", "E"],
|
||||
fingering: [-1, -1, 3, 2, 1, 0],
|
||||
fret: 0,
|
||||
},
|
||||
Gmaj7: {
|
||||
name: "Gmaj7",
|
||||
notes: ["G", "B", "D", "F#"],
|
||||
fingering: [3, 2, 0, 0, 0, 2],
|
||||
fret: 0,
|
||||
},
|
||||
Amaj7: {
|
||||
name: "Amaj7",
|
||||
notes: ["A", "C#", "E", "G#"],
|
||||
fingering: [-1, 0, 2, 1, 2, 0],
|
||||
fret: 0,
|
||||
},
|
||||
|
||||
// 小七和弦
|
||||
Am7: {
|
||||
name: "Am7",
|
||||
notes: ["A", "C", "E", "G"],
|
||||
fingering: [-1, 0, 2, 0, 1, 0],
|
||||
fret: 0,
|
||||
},
|
||||
Bm7: {
|
||||
name: "Bm7",
|
||||
notes: ["B", "D", "F#", "A"],
|
||||
fingering: [-1, 2, 4, 2, 3, 2],
|
||||
fret: 2,
|
||||
},
|
||||
Dm7: {
|
||||
name: "Dm7",
|
||||
notes: ["D", "F", "A", "C"],
|
||||
fingering: [-1, -1, 0, 2, 1, 1],
|
||||
fret: 0,
|
||||
},
|
||||
Em7: {
|
||||
name: "Em7",
|
||||
notes: ["E", "G", "B", "D"],
|
||||
fingering: [0, 2, 0, 0, 0, 0],
|
||||
fret: 0,
|
||||
},
|
||||
|
||||
// 挂留和弦
|
||||
Csus4: {
|
||||
name: "Csus4",
|
||||
notes: ["C", "F", "G"],
|
||||
fingering: [-1, 3, 3, 0, 1, 1],
|
||||
fret: 0,
|
||||
},
|
||||
Dsus4: {
|
||||
name: "Dsus4",
|
||||
notes: ["D", "G", "A"],
|
||||
fingering: [-1, -1, 0, 2, 3, 3],
|
||||
fret: 0,
|
||||
},
|
||||
Gsus4: {
|
||||
name: "Gsus4",
|
||||
notes: ["G", "C", "D"],
|
||||
fingering: [3, 3, 0, 0, 1, 3],
|
||||
fret: 0,
|
||||
},
|
||||
Asus4: {
|
||||
name: "Asus4",
|
||||
notes: ["A", "D", "E"],
|
||||
fingering: [-1, 0, 2, 2, 3, 0],
|
||||
fret: 0,
|
||||
},
|
||||
|
||||
// 增和弦
|
||||
Caug: {
|
||||
name: "Caug",
|
||||
notes: ["C", "E", "G#"],
|
||||
fingering: [-1, 3, 2, 1, 1, 0],
|
||||
fret: 0,
|
||||
},
|
||||
|
||||
// 减和弦
|
||||
Bdim: {
|
||||
name: "Bdim",
|
||||
notes: ["B", "D", "F"],
|
||||
fingering: [-1, 2, 3, 4, 3, -1],
|
||||
fret: 0,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取和弦信息
|
||||
*/
|
||||
export function getChordInfo(chordName: string): ChordInfo | null {
|
||||
// 标准化和弦名称
|
||||
const normalized = chordName.trim();
|
||||
return CHORD_DATABASE[normalized] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有可用的和弦名称
|
||||
*/
|
||||
export function getAllChordNames(): string[] {
|
||||
return Object.keys(CHORD_DATABASE);
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据和弦类型获取和弦列表
|
||||
*/
|
||||
export function getChordsByType(
|
||||
type: "major" | "minor" | "7" | "maj7" | "m7" | "sus4",
|
||||
): ChordInfo[] {
|
||||
const patterns: Record<string, RegExp> = {
|
||||
major: /^[A-G]$/,
|
||||
minor: /^[A-G]m$/,
|
||||
"7": /^[A-G]7$/,
|
||||
maj7: /^[A-G]maj7$/,
|
||||
m7: /^[A-G]m7$/,
|
||||
sus4: /^[A-G]sus4$/,
|
||||
};
|
||||
|
||||
const pattern = patterns[type];
|
||||
if (!pattern) return [];
|
||||
|
||||
return Object.entries(CHORD_DATABASE)
|
||||
.filter(([name]) => pattern.test(name))
|
||||
.map(([, info]) => info);
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
/**
|
||||
* @file 导出格式化工具
|
||||
* @description 将歌词和音乐数据格式化为不同平台的格式
|
||||
* @module components/content-creator/canvas/music/utils/exportFormatters
|
||||
*/
|
||||
|
||||
import type { MusicSection, SongSpec } from "../types";
|
||||
|
||||
/**
|
||||
* Suno 导出格式
|
||||
*/
|
||||
export interface SunoExport {
|
||||
/** 带标记的歌词 */
|
||||
lyrics: string;
|
||||
/** 风格提示词 */
|
||||
style: string;
|
||||
/** 标签 */
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出为 Suno 格式
|
||||
*/
|
||||
export function exportToSuno(
|
||||
sections: MusicSection[],
|
||||
spec: SongSpec,
|
||||
): SunoExport {
|
||||
// 生成带标记的歌词
|
||||
const lyrics = sections
|
||||
.sort((a, b) => a.order - b.order)
|
||||
.map((section) => {
|
||||
const marker = getSectionMarker(section.type);
|
||||
const lines = section.lyricsLines.join("\n");
|
||||
return `${marker}\n${lines}`;
|
||||
})
|
||||
.join("\n\n");
|
||||
|
||||
// 生成风格提示词
|
||||
const style = generateStylePrompt(spec);
|
||||
|
||||
// 生成标签
|
||||
const tags = generateTags(spec);
|
||||
|
||||
return { lyrics, style, tags };
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出为纯文本格式
|
||||
*/
|
||||
export function exportToText(sections: MusicSection[], spec: SongSpec): string {
|
||||
const header = `# ${spec.title}\n\n`;
|
||||
const meta = `调式: ${spec.key} | 拍号: ${spec.timeSignature} | 速度: ${spec.tempo} BPM\n\n`;
|
||||
|
||||
const content = sections
|
||||
.sort((a, b) => a.order - b.order)
|
||||
.map((section) => {
|
||||
const marker = getSectionMarker(section.type);
|
||||
const lines = section.lyricsLines.join("\n");
|
||||
return `${marker}\n${lines}`;
|
||||
})
|
||||
.join("\n\n");
|
||||
|
||||
return header + meta + content;
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出为 Markdown 格式
|
||||
*/
|
||||
export function exportToMarkdown(
|
||||
sections: MusicSection[],
|
||||
spec: SongSpec,
|
||||
): string {
|
||||
const header = `# ${spec.title}\n\n`;
|
||||
const meta = `**调式**: ${spec.key} | **拍号**: ${spec.timeSignature} | **速度**: ${spec.tempo} BPM\n\n`;
|
||||
|
||||
const content = sections
|
||||
.sort((a, b) => a.order - b.order)
|
||||
.map((section) => {
|
||||
const marker = getSectionMarker(section.type);
|
||||
const lines = section.lyricsLines.map((line) => `> ${line}`).join("\n");
|
||||
return `## ${marker}\n\n${lines}`;
|
||||
})
|
||||
.join("\n\n");
|
||||
|
||||
return header + meta + content;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取段落标记
|
||||
*/
|
||||
function getSectionMarker(type: MusicSection["type"]): string {
|
||||
const markers: Record<MusicSection["type"], string> = {
|
||||
intro: "[Intro]",
|
||||
verse: "[Verse]",
|
||||
"pre-chorus": "[Pre-Chorus]",
|
||||
chorus: "[Chorus]",
|
||||
bridge: "[Bridge]",
|
||||
interlude: "[Interlude]",
|
||||
outro: "[Outro]",
|
||||
};
|
||||
return markers[type] || `[${type}]`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成风格提示词
|
||||
*/
|
||||
function generateStylePrompt(spec: SongSpec): string {
|
||||
const parts: string[] = [];
|
||||
|
||||
// 歌曲类型
|
||||
const typeMap: Record<SongSpec["songType"], string> = {
|
||||
pop: "pop",
|
||||
folk: "folk",
|
||||
rock: "rock",
|
||||
guofeng: "chinese traditional, guofeng",
|
||||
rap: "rap, hip-hop",
|
||||
rnb: "r&b, soul",
|
||||
electronic: "electronic, edm",
|
||||
other: "contemporary",
|
||||
};
|
||||
parts.push(typeMap[spec.songType]);
|
||||
|
||||
// 情绪
|
||||
const moodMap: Record<SongSpec["mood"], string> = {
|
||||
joyful: "upbeat, cheerful",
|
||||
gentle: "gentle, soft",
|
||||
sorrowful: "melancholic, emotional",
|
||||
passionate: "passionate, powerful",
|
||||
mysterious: "mysterious, enigmatic",
|
||||
nostalgic: "nostalgic, reflective",
|
||||
};
|
||||
parts.push(moodMap[spec.mood]);
|
||||
|
||||
// 语言
|
||||
parts.push("chinese");
|
||||
|
||||
return parts.join(", ");
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成标签
|
||||
*/
|
||||
function generateTags(spec: SongSpec): string[] {
|
||||
const tags: string[] = [];
|
||||
|
||||
// 歌曲类型标签
|
||||
const typeTagMap: Record<SongSpec["songType"], string[]> = {
|
||||
pop: ["流行", "pop"],
|
||||
folk: ["民谣", "folk"],
|
||||
rock: ["摇滚", "rock"],
|
||||
guofeng: ["国风", "中国风", "traditional"],
|
||||
rap: ["说唱", "rap"],
|
||||
rnb: ["R&B", "soul"],
|
||||
electronic: ["电子", "electronic"],
|
||||
other: ["原创"],
|
||||
};
|
||||
tags.push(...(typeTagMap[spec.songType] || []));
|
||||
|
||||
// 情绪标签
|
||||
const moodTagMap: Record<SongSpec["mood"], string[]> = {
|
||||
joyful: ["欢快", "cheerful"],
|
||||
gentle: ["温柔", "gentle"],
|
||||
sorrowful: ["伤感", "emotional"],
|
||||
passionate: ["激情", "passionate"],
|
||||
mysterious: ["神秘", "mysterious"],
|
||||
nostalgic: ["怀旧", "nostalgic"],
|
||||
};
|
||||
tags.push(...(moodTagMap[spec.mood] || []));
|
||||
|
||||
// 语言标签
|
||||
tags.push("中文", "chinese");
|
||||
|
||||
return tags;
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出为 JSON 格式
|
||||
*/
|
||||
export function exportToJSON(sections: MusicSection[], spec: SongSpec): string {
|
||||
const data = {
|
||||
title: spec.title,
|
||||
songType: spec.songType,
|
||||
theme: spec.theme,
|
||||
key: spec.key,
|
||||
timeSignature: spec.timeSignature,
|
||||
tempo: spec.tempo,
|
||||
mood: spec.mood,
|
||||
sections: sections.map((section) => ({
|
||||
type: section.type,
|
||||
name: section.name,
|
||||
order: section.order,
|
||||
lyrics: section.lyricsLines,
|
||||
repeatCount: section.repeatCount,
|
||||
})),
|
||||
};
|
||||
|
||||
return JSON.stringify(data, null, 2);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* @file 工具函数模块入口
|
||||
* @module components/content-creator/canvas/music/utils
|
||||
*/
|
||||
|
||||
export {
|
||||
parseLyrics,
|
||||
sectionsToLyrics,
|
||||
countLyricsChars,
|
||||
countSectionChars,
|
||||
countTotalChars,
|
||||
getLyricsStats,
|
||||
type LyricsStats,
|
||||
} from "./lyricsParser";
|
||||
|
||||
export {
|
||||
analyzeRhyme,
|
||||
getCharRhyme,
|
||||
getRhymeGroup,
|
||||
doRhymesMatch,
|
||||
getLineRhymeChar,
|
||||
analyzeLineRhyme,
|
||||
getRhymeSuggestions,
|
||||
RHYME_SCHEME_DESCRIPTIONS,
|
||||
type RhymeScheme,
|
||||
type RhymeQuality,
|
||||
type RhymeAnalysis,
|
||||
type RhymeDetail,
|
||||
} from "./rhymeDetector";
|
||||
|
||||
export {
|
||||
exportToSuno,
|
||||
exportToText,
|
||||
exportToMarkdown,
|
||||
exportToJSON,
|
||||
type SunoExport,
|
||||
} from "./exportFormatters";
|
||||
|
||||
export {
|
||||
parseNumberedNotation,
|
||||
extractLyricsFromSections,
|
||||
hasNumberedNotation,
|
||||
hasChordMarkers,
|
||||
} from "./numberedNotationParser";
|
||||
|
||||
export {
|
||||
CHORD_DATABASE,
|
||||
getChordInfo,
|
||||
getAllChordNames,
|
||||
getChordsByType,
|
||||
} from "./chordDatabase";
|
||||
@@ -0,0 +1,292 @@
|
||||
/**
|
||||
* @file 歌词解析工具
|
||||
* @description 解析歌词文本,识别段落标记和结构
|
||||
* @module components/content-creator/canvas/music/utils/lyricsParser
|
||||
*/
|
||||
|
||||
import type { MusicSection, SectionType } from "../types";
|
||||
import { createSection } from "../types";
|
||||
|
||||
/**
|
||||
* 段落标记正则表达式
|
||||
* 支持格式:[Verse 1], [Chorus], [verse1], [主歌1] 等
|
||||
*/
|
||||
const SECTION_TAG_REGEX = /^\[(.*?)\]/i;
|
||||
|
||||
/**
|
||||
* 段落类型映射
|
||||
*/
|
||||
const SECTION_TYPE_MAP: Record<string, SectionType> = {
|
||||
// 英文标记
|
||||
intro: "intro",
|
||||
verse: "verse",
|
||||
"pre-chorus": "pre-chorus",
|
||||
prechorus: "pre-chorus",
|
||||
chorus: "chorus",
|
||||
bridge: "bridge",
|
||||
interlude: "interlude",
|
||||
outro: "outro",
|
||||
// 中文标记
|
||||
前奏: "intro",
|
||||
主歌: "verse",
|
||||
预副歌: "pre-chorus",
|
||||
副歌: "chorus",
|
||||
桥段: "bridge",
|
||||
间奏: "interlude",
|
||||
尾声: "outro",
|
||||
};
|
||||
|
||||
/**
|
||||
* 解析段落标记
|
||||
*/
|
||||
function parseSectionTag(
|
||||
tagLine: string,
|
||||
): { type: SectionType; index?: number } | null {
|
||||
const match = tagLine.match(SECTION_TAG_REGEX);
|
||||
if (!match) return null;
|
||||
|
||||
const content = match[1].toLowerCase().trim();
|
||||
|
||||
// 检查是否为 info 标记(跳过)
|
||||
if (content === "info" || content === "信息") {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 尝试匹配类型和序号
|
||||
// 先尝试直接匹配
|
||||
for (const [key, type] of Object.entries(SECTION_TYPE_MAP)) {
|
||||
if (content.startsWith(key)) {
|
||||
// 提取可能的序号
|
||||
const rest = content.slice(key.length).trim();
|
||||
const numMatch = rest.match(/^(\d+)/);
|
||||
return {
|
||||
type,
|
||||
index: numMatch ? parseInt(numMatch[1], 10) : undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 如果没有匹配到标准类型,尝试更宽松的匹配
|
||||
if (content.includes("verse") || content.includes("主歌"))
|
||||
return { type: "verse" };
|
||||
if (content.includes("chorus") || content.includes("副歌"))
|
||||
return { type: "chorus" };
|
||||
if (content.includes("intro") || content.includes("前奏"))
|
||||
return { type: "intro" };
|
||||
if (
|
||||
content.includes("outro") ||
|
||||
content.includes("尾奏") ||
|
||||
content.includes("尾声")
|
||||
)
|
||||
return { type: "outro" };
|
||||
if (content.includes("bridge") || content.includes("桥段"))
|
||||
return { type: "bridge" };
|
||||
if (content.includes("pre") || content.includes("预"))
|
||||
return { type: "pre-chorus" };
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析歌词文本
|
||||
* @param text 歌词文本
|
||||
* @returns 解析后的段落列表
|
||||
*/
|
||||
export function parseLyrics(text: string): MusicSection[] {
|
||||
const lines = text.split("\n");
|
||||
const sections: MusicSection[] = [];
|
||||
let currentSection: MusicSection | null = null;
|
||||
let sectionOrder = 0;
|
||||
let inInfoBlock = false;
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmedLine = line.trim();
|
||||
|
||||
// 跳过空行
|
||||
if (!trimmedLine) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 跳过 Markdown 标题和列表标记 (除非看起来像歌词)
|
||||
// 假设歌词不会以 #, *, - 开头,或者是 ``` 代码块标记
|
||||
// 统一过滤非歌词内容
|
||||
if (
|
||||
trimmedLine.startsWith("#") ||
|
||||
trimmedLine.startsWith("```") ||
|
||||
trimmedLine.startsWith("**") ||
|
||||
((trimmedLine.startsWith("-") || trimmedLine.startsWith("*")) &&
|
||||
trimmedLine.includes(":")) ||
|
||||
/^(title|artist|songtype|mood|theme|tempo|bpm|key|date|author|composer|lyricist)\s*[::]/i.test(
|
||||
trimmedLine,
|
||||
)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 检查是否为段落标记
|
||||
if (SECTION_TAG_REGEX.test(trimmedLine)) {
|
||||
// 检查是否为 info 块
|
||||
const tagMatch = trimmedLine.match(SECTION_TAG_REGEX);
|
||||
const tagContent = tagMatch ? tagMatch[1].toLowerCase() : "";
|
||||
|
||||
if (tagContent === "info" || tagContent === "信息") {
|
||||
inInfoBlock = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 结束 info 块
|
||||
inInfoBlock = false;
|
||||
|
||||
// 保存当前段落
|
||||
if (currentSection && currentSection.lyricsLines.length > 0) {
|
||||
sections.push(currentSection);
|
||||
}
|
||||
|
||||
// 解析新段落
|
||||
const parsed = parseSectionTag(trimmedLine);
|
||||
if (parsed) {
|
||||
sectionOrder++;
|
||||
currentSection = createSection(parsed.type, sectionOrder);
|
||||
if (parsed.index) {
|
||||
currentSection.name = `${currentSection.name} ${parsed.index}`;
|
||||
}
|
||||
} else {
|
||||
// 如果是 [xxx] 但无法解析为已知段落,可能是歌词的一部分或者是未知的元数据
|
||||
// 如果我们还没有任何段落,或者当前是默认段落,我们可能应该忽略它或者把它当歌词
|
||||
// 这里选择安全的做法:如果看起来像段落标记但无法识别,且我们已经在段落中,就当作歌词
|
||||
if (currentSection) {
|
||||
// 只有当它不包含 "info" 等关键字时才当作歌词
|
||||
currentSection.lyricsLines.push(trimmedLine);
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// 跳过 info 块内容
|
||||
if (inInfoBlock) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 添加歌词行到当前段落
|
||||
if (currentSection) {
|
||||
currentSection.lyricsLines.push(trimmedLine);
|
||||
} else {
|
||||
// 如果没有段落标记,且这行看起来像有效歌词(不是元数据),创建默认主歌段落
|
||||
if (!trimmedLine.startsWith("songType") && !trimmedLine.includes("BPM")) {
|
||||
sectionOrder++;
|
||||
currentSection = createSection("verse", sectionOrder);
|
||||
currentSection.lyricsLines.push(trimmedLine);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 保存最后一个段落
|
||||
if (currentSection && currentSection.lyricsLines.length > 0) {
|
||||
sections.push(currentSection);
|
||||
}
|
||||
|
||||
return sections;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将段落列表转换为歌词文本
|
||||
* @param sections 段落列表
|
||||
* @returns 歌词文本
|
||||
*/
|
||||
export function sectionsToLyrics(sections: MusicSection[]): string {
|
||||
const lines: string[] = [];
|
||||
|
||||
for (const section of sections) {
|
||||
// 添加段落标记
|
||||
const tag = getSectionTag(section.type);
|
||||
lines.push(`[${tag}]`);
|
||||
|
||||
// 添加歌词行
|
||||
for (const line of section.lyricsLines) {
|
||||
lines.push(line);
|
||||
}
|
||||
|
||||
// 段落之间添加空行
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
return lines.join("\n").trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取段落标记
|
||||
*/
|
||||
function getSectionTag(type: SectionType): string {
|
||||
const tagMap: Record<SectionType, string> = {
|
||||
intro: "Intro",
|
||||
verse: "Verse",
|
||||
"pre-chorus": "Pre-Chorus",
|
||||
chorus: "Chorus",
|
||||
bridge: "Bridge",
|
||||
interlude: "Interlude",
|
||||
outro: "Outro",
|
||||
};
|
||||
return tagMap[type];
|
||||
}
|
||||
|
||||
/**
|
||||
* 统计歌词字数(排除标点和空格)
|
||||
*/
|
||||
export function countLyricsChars(text: string): number {
|
||||
// 排除标点符号和空白字符
|
||||
const cleanText = text.replace(
|
||||
/[\s,。!?、;:""''()【】《》…—[\]]/g,
|
||||
"",
|
||||
);
|
||||
// 只计算有效字符
|
||||
const validChars = cleanText.match(/[\u4e00-\u9fa5a-zA-Z0-9]/g);
|
||||
return validChars ? validChars.length : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 统计段落字数
|
||||
*/
|
||||
export function countSectionChars(section: MusicSection): number {
|
||||
return section.lyricsLines.reduce(
|
||||
(sum, line) => sum + countLyricsChars(line),
|
||||
0,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 统计所有段落总字数
|
||||
*/
|
||||
export function countTotalChars(sections: MusicSection[]): number {
|
||||
return sections.reduce((sum, section) => sum + countSectionChars(section), 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取歌词统计信息
|
||||
*/
|
||||
export interface LyricsStats {
|
||||
totalSections: number;
|
||||
totalLines: number;
|
||||
totalChars: number;
|
||||
sectionStats: {
|
||||
type: SectionType;
|
||||
name: string;
|
||||
lines: number;
|
||||
chars: number;
|
||||
}[];
|
||||
}
|
||||
|
||||
export function getLyricsStats(sections: MusicSection[]): LyricsStats {
|
||||
const sectionStats = sections.map((section) => ({
|
||||
type: section.type,
|
||||
name: section.name,
|
||||
lines: section.lyricsLines.length,
|
||||
chars: countSectionChars(section),
|
||||
}));
|
||||
|
||||
return {
|
||||
totalSections: sections.length,
|
||||
totalLines: sectionStats.reduce((sum, s) => sum + s.lines, 0),
|
||||
totalChars: sectionStats.reduce((sum, s) => sum + s.chars, 0),
|
||||
sectionStats,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,364 @@
|
||||
/**
|
||||
* @file 简谱解析器
|
||||
* @description 解析带简谱和和弦标记的歌词格式
|
||||
*/
|
||||
|
||||
import type { MusicSection, Bar, Note, SectionType } from "../types";
|
||||
|
||||
/**
|
||||
* 解析后的音乐行
|
||||
*/
|
||||
interface ParsedMusicLine {
|
||||
chords: string[];
|
||||
notes: Note[][];
|
||||
lyrics: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析后的段落
|
||||
*/
|
||||
interface ParsedSection {
|
||||
type: SectionType;
|
||||
name: string;
|
||||
lines: ParsedMusicLine[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 段落标记映射
|
||||
*/
|
||||
const SECTION_MARKERS: Record<string, SectionType> = {
|
||||
intro: "intro",
|
||||
前奏: "intro",
|
||||
verse: "verse",
|
||||
主歌: "verse",
|
||||
"pre-chorus": "pre-chorus",
|
||||
预副歌: "pre-chorus",
|
||||
chorus: "chorus",
|
||||
副歌: "chorus",
|
||||
bridge: "bridge",
|
||||
桥段: "bridge",
|
||||
interlude: "interlude",
|
||||
间奏: "interlude",
|
||||
outro: "outro",
|
||||
尾奏: "outro",
|
||||
};
|
||||
|
||||
/**
|
||||
* 检测是否为和弦行
|
||||
* 和弦行特征:主要由和弦名称和空格组成
|
||||
*/
|
||||
function isChordLine(line: string): boolean {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) return false;
|
||||
|
||||
// 和弦正则:大写字母开头,可能跟着 m, 7, maj7, dim, aug, sus 等
|
||||
const chordPattern =
|
||||
/^[A-G][#b]?(m|maj|min|dim|aug|sus|add|7|9|11|13)*[0-9]?$/;
|
||||
const parts = trimmed.split(/\s+/);
|
||||
|
||||
// 如果大部分 token 都是和弦,则认为是和弦行
|
||||
const chordCount = parts.filter((p) => chordPattern.test(p)).length;
|
||||
return chordCount > 0 && chordCount >= parts.length * 0.5;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检测是否为简谱行
|
||||
* 简谱行特征:包含数字 1-7、0、-、| 等
|
||||
*/
|
||||
function isNotationLine(line: string): boolean {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) return false;
|
||||
|
||||
// 简谱特征字符
|
||||
const notationChars = /[0-7\-|.·]/;
|
||||
const hasNotation = notationChars.test(trimmed);
|
||||
|
||||
// 排除纯中文歌词行
|
||||
const chineseRatio =
|
||||
(trimmed.match(/[\u4e00-\u9fa5]/g) || []).length / trimmed.length;
|
||||
|
||||
return hasNotation && chineseRatio < 0.3;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检测是否为段落标记行
|
||||
*/
|
||||
/**
|
||||
* 检测是否为段落标记行
|
||||
*/
|
||||
function isSectionMarker(
|
||||
line: string,
|
||||
): { type: SectionType; name: string } | null {
|
||||
const trimmed = line.trim();
|
||||
|
||||
// 匹配 [Verse 1], [Chorus], [主歌1] 等格式
|
||||
// 允许后面有额外文本,例如 [Verse 1] 主歌1 - 描述
|
||||
const match = trimmed.match(/^\[([^\]]+)\]/);
|
||||
if (!match) return null;
|
||||
|
||||
const content = match[1].toLowerCase();
|
||||
|
||||
// 查找匹配的段落类型
|
||||
for (const [marker, type] of Object.entries(SECTION_MARKERS)) {
|
||||
if (content.includes(marker.toLowerCase())) {
|
||||
// 这里的 name 可以更智能一点,比如保留原来的 content 或者 mapping 后的
|
||||
// 但现在保留 content 足够
|
||||
return { type, name: match[1] };
|
||||
}
|
||||
}
|
||||
|
||||
// 默认为主歌 (如果看起来像 section tag 但无法识别具体类型)
|
||||
// 如果内容看起来像是歌词而不是 tag,返回 null
|
||||
// 这里简单处理:如果包含 verse/intro/chorus 等关键字的变体但上面没匹配到,或者就是 unrecognized tag
|
||||
// 假设既然是 [...] 开头,大概率是 tag。
|
||||
return { type: "verse", name: match[1] };
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析和弦行
|
||||
*/
|
||||
function parseChordLine(line: string): string[] {
|
||||
const chords: string[] = [];
|
||||
const parts = line.trim().split(/\s+/);
|
||||
|
||||
for (const part of parts) {
|
||||
if (part) {
|
||||
chords.push(part);
|
||||
}
|
||||
}
|
||||
|
||||
return chords;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析简谱行
|
||||
*/
|
||||
function parseNotationLine(line: string): Note[][] {
|
||||
const bars: Note[][] = [];
|
||||
let currentBar: Note[] = [];
|
||||
|
||||
// 按小节线分割
|
||||
const segments = line.split("|");
|
||||
|
||||
for (const segment of segments) {
|
||||
const trimmed = segment.trim();
|
||||
if (!trimmed) continue;
|
||||
|
||||
currentBar = [];
|
||||
let i = 0;
|
||||
|
||||
while (i < trimmed.length) {
|
||||
const char = trimmed[i];
|
||||
|
||||
// 跳过空格
|
||||
if (char === " ") {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 数字音符 0-7
|
||||
if (/[0-7]/.test(char)) {
|
||||
const note: Note = {
|
||||
pitch: parseInt(char),
|
||||
octave: 0,
|
||||
duration: 1,
|
||||
};
|
||||
|
||||
// 检查后续修饰符
|
||||
let j = i + 1;
|
||||
while (j < trimmed.length) {
|
||||
const modifier = trimmed[j];
|
||||
|
||||
// 高音点 (上点)
|
||||
if (modifier === "̇" || modifier === "·" || modifier === "'") {
|
||||
note.octave = 1;
|
||||
j++;
|
||||
}
|
||||
// 低音点 (下点)
|
||||
else if (modifier === "̣" || modifier === "," || modifier === ".") {
|
||||
// 注意:这里的点可能是附点,需要根据上下文判断
|
||||
if (j + 1 < trimmed.length && /[0-7]/.test(trimmed[j + 1])) {
|
||||
// 如果后面紧跟数字,这个点是低音点
|
||||
note.octave = -1;
|
||||
} else {
|
||||
// 否则是附点
|
||||
note.dotted = true;
|
||||
}
|
||||
j++;
|
||||
}
|
||||
// 延长线
|
||||
else if (modifier === "-") {
|
||||
note.duration += 1;
|
||||
j++;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
currentBar.push(note);
|
||||
i = j;
|
||||
}
|
||||
// 延长线(独立的)
|
||||
else if (char === "-") {
|
||||
// 延长前一个音符
|
||||
if (currentBar.length > 0) {
|
||||
currentBar[currentBar.length - 1].duration += 1;
|
||||
}
|
||||
i++;
|
||||
}
|
||||
// 其他字符跳过
|
||||
else {
|
||||
i++;
|
||||
}
|
||||
}
|
||||
|
||||
if (currentBar.length > 0) {
|
||||
bars.push(currentBar);
|
||||
}
|
||||
}
|
||||
|
||||
return bars;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析带简谱的歌词内容
|
||||
*/
|
||||
export function parseNumberedNotation(text: string): MusicSection[] {
|
||||
const lines = text.split("\n");
|
||||
const sections: MusicSection[] = [];
|
||||
|
||||
let currentSection: ParsedSection | null = null;
|
||||
let pendingChords: string[] = [];
|
||||
let pendingNotes: Note[][] = [];
|
||||
let sectionOrder = 0;
|
||||
|
||||
const finalizeSection = () => {
|
||||
if (currentSection && currentSection.lines.length > 0) {
|
||||
const bars: Bar[] = [];
|
||||
let barNumber = 1;
|
||||
|
||||
for (const line of currentSection.lines) {
|
||||
// 为每个小节创建 Bar
|
||||
const maxBars = Math.max(line.chords.length, line.notes.length, 1);
|
||||
|
||||
for (let i = 0; i < maxBars; i++) {
|
||||
bars.push({
|
||||
id: crypto.randomUUID(),
|
||||
barNumber: barNumber++,
|
||||
chord: line.chords[i] || "",
|
||||
notes: line.notes[i] || [],
|
||||
lyrics: i === 0 ? line.lyrics : "",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
sections.push({
|
||||
id: crypto.randomUUID(),
|
||||
type: currentSection.type,
|
||||
name: currentSection.name,
|
||||
order: sectionOrder++,
|
||||
bars,
|
||||
lyricsLines: currentSection.lines.map((l) => l.lyrics).filter(Boolean),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
|
||||
// 空行
|
||||
if (!trimmed) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 跳过 Markdown 元数据
|
||||
if (
|
||||
trimmed.startsWith("#") ||
|
||||
trimmed.startsWith("```") ||
|
||||
(trimmed.startsWith("-") && trimmed.includes(":")) ||
|
||||
trimmed.startsWith("**")
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 段落标记
|
||||
const sectionMarker = isSectionMarker(trimmed);
|
||||
if (sectionMarker) {
|
||||
finalizeSection();
|
||||
currentSection = {
|
||||
type: sectionMarker.type,
|
||||
name: sectionMarker.name,
|
||||
lines: [],
|
||||
};
|
||||
pendingChords = [];
|
||||
pendingNotes = [];
|
||||
continue;
|
||||
}
|
||||
|
||||
// 如果还没有段落,创建默认段落
|
||||
if (!currentSection) {
|
||||
currentSection = {
|
||||
type: "verse",
|
||||
name: "Verse",
|
||||
lines: [],
|
||||
};
|
||||
}
|
||||
|
||||
// 和弦行
|
||||
if (isChordLine(trimmed)) {
|
||||
pendingChords = parseChordLine(trimmed);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 简谱行
|
||||
if (isNotationLine(trimmed)) {
|
||||
pendingNotes = parseNotationLine(trimmed);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 歌词行
|
||||
currentSection.lines.push({
|
||||
chords: pendingChords,
|
||||
notes: pendingNotes,
|
||||
lyrics: trimmed,
|
||||
});
|
||||
|
||||
// 清空待处理的和弦和音符
|
||||
pendingChords = [];
|
||||
pendingNotes = [];
|
||||
}
|
||||
|
||||
// 处理最后一个段落
|
||||
finalizeSection();
|
||||
|
||||
return sections;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 MusicSection 提取纯歌词文本
|
||||
*/
|
||||
export function extractLyricsFromSections(sections: MusicSection[]): string {
|
||||
return sections
|
||||
.map((section) => {
|
||||
const header = `[${section.name}]`;
|
||||
const lyrics = section.lyricsLines.join("\n");
|
||||
return `${header}\n${lyrics}`;
|
||||
})
|
||||
.join("\n\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* 检测内容是否包含简谱标记
|
||||
*/
|
||||
export function hasNumberedNotation(text: string): boolean {
|
||||
const lines = text.split("\n");
|
||||
return lines.some((line) => isNotationLine(line));
|
||||
}
|
||||
|
||||
/**
|
||||
* 检测内容是否包含和弦标记
|
||||
*/
|
||||
export function hasChordMarkers(text: string): boolean {
|
||||
const lines = text.split("\n");
|
||||
return lines.some((line) => isChordLine(line));
|
||||
}
|
||||
@@ -0,0 +1,601 @@
|
||||
/**
|
||||
* @file 押韵检测工具
|
||||
* @description 基于拼音韵母的中文押韵检测
|
||||
* @module components/content-creator/canvas/music/utils/rhymeDetector
|
||||
*/
|
||||
|
||||
/**
|
||||
* 韵母分组 - 相同组内的韵母可以押韵
|
||||
*/
|
||||
const RHYME_GROUPS: Record<string, string[]> = {
|
||||
// a 韵
|
||||
a: ["a", "ia", "ua"],
|
||||
// ai 韵
|
||||
ai: ["ai", "uai"],
|
||||
// ao 韵
|
||||
ao: ["ao", "iao"],
|
||||
// an 韵
|
||||
an: ["an", "ian", "uan", "üan"],
|
||||
// ang 韵
|
||||
ang: ["ang", "iang", "uang"],
|
||||
// e 韵
|
||||
e: ["e", "ie", "üe"],
|
||||
// ei 韵
|
||||
ei: ["ei", "ui"],
|
||||
// en 韵
|
||||
en: ["en", "in", "un", "ün"],
|
||||
// eng 韵
|
||||
eng: ["eng", "ing"],
|
||||
// i 韵
|
||||
i: ["i", "ü"],
|
||||
// o 韵
|
||||
o: ["o", "uo"],
|
||||
// ou 韵
|
||||
ou: ["ou", "iu"],
|
||||
// ong 韵
|
||||
ong: ["ong", "iong"],
|
||||
// u 韵
|
||||
u: ["u"],
|
||||
// er 韵
|
||||
er: ["er"],
|
||||
};
|
||||
|
||||
/**
|
||||
* 常用字的韵母映射(简化版,实际应用中可以使用更完整的拼音库)
|
||||
*/
|
||||
const CHAR_TO_RHYME: Record<string, string> = {
|
||||
// a 韵
|
||||
爱: "ai",
|
||||
在: "ai",
|
||||
来: "ai",
|
||||
开: "ai",
|
||||
怀: "ai",
|
||||
猜: "ai",
|
||||
陪: "ei",
|
||||
待: "ai",
|
||||
// an 韵
|
||||
伴: "an",
|
||||
暖: "an",
|
||||
看: "an",
|
||||
汗: "an",
|
||||
伞: "an",
|
||||
岸: "an",
|
||||
山: "an",
|
||||
田: "ian",
|
||||
// ang 韵
|
||||
乡: "ang",
|
||||
长: "ang",
|
||||
方: "ang",
|
||||
香: "ang",
|
||||
窗: "ang",
|
||||
望: "ang",
|
||||
想: "iang",
|
||||
强: "iang",
|
||||
光: "ang",
|
||||
向: "iang",
|
||||
// ao 韵
|
||||
好: "ao",
|
||||
老: "ao",
|
||||
少: "ao",
|
||||
跑: "ao",
|
||||
闹: "ao",
|
||||
笑: "ao",
|
||||
// e 韵
|
||||
别: "ie",
|
||||
夜: "ie",
|
||||
雪: "üe",
|
||||
月: "üe",
|
||||
切: "ie",
|
||||
说: "uo",
|
||||
// ei 韵
|
||||
美: "ei",
|
||||
回: "ui",
|
||||
累: "ei",
|
||||
醉: "ui",
|
||||
泪: "ei",
|
||||
岁: "ui",
|
||||
// en 韵
|
||||
等: "eng",
|
||||
朋: "eng",
|
||||
冷: "eng",
|
||||
疼: "eng",
|
||||
能: "eng",
|
||||
层: "eng",
|
||||
// eng 韵
|
||||
梦: "eng",
|
||||
风: "eng",
|
||||
空: "ong",
|
||||
勇: "ong",
|
||||
冲: "ong",
|
||||
成: "eng",
|
||||
功: "ong",
|
||||
// i 韵
|
||||
力: "i",
|
||||
立: "i",
|
||||
起: "i",
|
||||
地: "i",
|
||||
意: "i",
|
||||
义: "i",
|
||||
// ian 韵
|
||||
年: "ian",
|
||||
天: "ian",
|
||||
前: "ian",
|
||||
甜: "ian",
|
||||
变: "ian",
|
||||
见: "ian",
|
||||
// ing 韵
|
||||
情: "ing",
|
||||
心: "in",
|
||||
真: "en",
|
||||
深: "en",
|
||||
亲: "in",
|
||||
信: "in",
|
||||
// iu 韵
|
||||
留: "iu",
|
||||
久: "iu",
|
||||
流: "iu",
|
||||
愁: "ou",
|
||||
求: "iu",
|
||||
收: "ou",
|
||||
// o 韵
|
||||
我: "o",
|
||||
多: "uo",
|
||||
过: "uo",
|
||||
错: "uo",
|
||||
落: "uo",
|
||||
// ong 韵
|
||||
中: "ong",
|
||||
痛: "ong",
|
||||
重: "ong",
|
||||
懂: "ong",
|
||||
动: "ong",
|
||||
// ou 韵
|
||||
走: "ou",
|
||||
守: "ou",
|
||||
有: "ou",
|
||||
后: "ou",
|
||||
手: "ou",
|
||||
// u 韵
|
||||
路: "u",
|
||||
步: "u",
|
||||
住: "u",
|
||||
哭: "u",
|
||||
努: "u",
|
||||
苦: "u",
|
||||
// 更多常用字...
|
||||
你: "i",
|
||||
他: "a",
|
||||
她: "a",
|
||||
是: "i",
|
||||
的: "e",
|
||||
了: "e",
|
||||
不: "u",
|
||||
这: "e",
|
||||
那: "a",
|
||||
就: "iu",
|
||||
都: "ou",
|
||||
也: "ie",
|
||||
要: "ao",
|
||||
会: "ui",
|
||||
可: "e",
|
||||
以: "i",
|
||||
和: "e",
|
||||
人: "en",
|
||||
们: "en",
|
||||
到: "ao",
|
||||
去: "ü",
|
||||
得: "e",
|
||||
着: "e",
|
||||
把: "a",
|
||||
给: "ei",
|
||||
让: "ang",
|
||||
被: "ei",
|
||||
从: "ong",
|
||||
对: "ui",
|
||||
为: "ei",
|
||||
很: "en",
|
||||
还: "ai",
|
||||
但: "an",
|
||||
而: "er",
|
||||
如: "u",
|
||||
果: "uo",
|
||||
所: "uo",
|
||||
因: "in",
|
||||
时: "i",
|
||||
候: "ou",
|
||||
间: "ian",
|
||||
里: "i",
|
||||
面: "ian",
|
||||
上: "ang",
|
||||
下: "ia",
|
||||
左: "uo",
|
||||
右: "ou",
|
||||
外: "ai",
|
||||
内: "ei",
|
||||
高: "ao",
|
||||
低: "i",
|
||||
大: "a",
|
||||
小: "ao",
|
||||
新: "in",
|
||||
旧: "iu",
|
||||
坏: "uai",
|
||||
快: "uai",
|
||||
慢: "an",
|
||||
早: "ao",
|
||||
晚: "an",
|
||||
远: "uan",
|
||||
近: "in",
|
||||
浅: "ian",
|
||||
轻: "ing",
|
||||
热: "e",
|
||||
黑: "ei",
|
||||
白: "ai",
|
||||
红: "ong",
|
||||
绿: "ü",
|
||||
蓝: "an",
|
||||
黄: "ang",
|
||||
春: "un",
|
||||
夏: "ia",
|
||||
秋: "iu",
|
||||
冬: "ong",
|
||||
雨: "ü",
|
||||
云: "un",
|
||||
花: "ua",
|
||||
草: "ao",
|
||||
树: "u",
|
||||
水: "ui",
|
||||
河: "e",
|
||||
海: "ai",
|
||||
日: "i",
|
||||
星: "ing",
|
||||
影: "ing",
|
||||
声: "eng",
|
||||
音: "in",
|
||||
歌: "e",
|
||||
曲: "ü",
|
||||
词: "i",
|
||||
诗: "i",
|
||||
画: "ua",
|
||||
书: "u",
|
||||
念: "ian",
|
||||
思: "i",
|
||||
忆: "i",
|
||||
记: "i",
|
||||
恨: "en",
|
||||
喜: "i",
|
||||
悲: "ei",
|
||||
乐: "e",
|
||||
眼: "an",
|
||||
脚: "ao",
|
||||
头: "ou",
|
||||
身: "en",
|
||||
体: "i",
|
||||
生: "eng",
|
||||
死: "i",
|
||||
活: "uo",
|
||||
命: "ing",
|
||||
运: "un",
|
||||
气: "i",
|
||||
量: "iang",
|
||||
};
|
||||
|
||||
/**
|
||||
* 押韵模式
|
||||
*/
|
||||
export type RhymeScheme = "AABB" | "ABAB" | "ABCB" | "AAAA" | "FREE";
|
||||
|
||||
/**
|
||||
* 押韵质量
|
||||
*/
|
||||
export type RhymeQuality = "perfect" | "near" | "weak" | "none";
|
||||
|
||||
/**
|
||||
* 押韵分析结果
|
||||
*/
|
||||
export interface RhymeAnalysis {
|
||||
/** 押韵模式 */
|
||||
scheme: RhymeScheme;
|
||||
/** 押韵质量 (0-100) */
|
||||
quality: number;
|
||||
/** 质量等级 */
|
||||
qualityLevel: RhymeQuality;
|
||||
/** 韵脚列表 */
|
||||
rhymes: string[];
|
||||
/** 详细分析 */
|
||||
details: RhymeDetail[];
|
||||
/** 建议 */
|
||||
suggestions: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 单行押韵详情
|
||||
*/
|
||||
export interface RhymeDetail {
|
||||
/** 行号 */
|
||||
lineIndex: number;
|
||||
/** 行内容 */
|
||||
line: string;
|
||||
/** 韵脚字 */
|
||||
rhymeChar: string;
|
||||
/** 韵母 */
|
||||
rhyme: string;
|
||||
/** 韵组 */
|
||||
rhymeGroup: string;
|
||||
/** 与上一行是否押韵 */
|
||||
rhymesWithPrevious: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取字符的韵母
|
||||
*/
|
||||
export function getCharRhyme(char: string): string | null {
|
||||
return CHAR_TO_RHYME[char] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取韵母所属的韵组
|
||||
*/
|
||||
export function getRhymeGroup(rhyme: string): string | null {
|
||||
for (const [group, rhymes] of Object.entries(RHYME_GROUPS)) {
|
||||
if (rhymes.includes(rhyme)) {
|
||||
return group;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断两个韵母是否押韵
|
||||
*/
|
||||
export function doRhymesMatch(rhyme1: string, rhyme2: string): boolean {
|
||||
if (rhyme1 === rhyme2) return true;
|
||||
|
||||
const group1 = getRhymeGroup(rhyme1);
|
||||
const group2 = getRhymeGroup(rhyme2);
|
||||
|
||||
return group1 !== null && group1 === group2;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取行尾韵脚字
|
||||
*/
|
||||
export function getLineRhymeChar(line: string): string | null {
|
||||
// 移除标点符号
|
||||
const cleanLine = line.replace(/[,。!?、;:""''()【】《》…—\s]/g, "");
|
||||
if (cleanLine.length === 0) return null;
|
||||
|
||||
// 返回最后一个字
|
||||
return cleanLine[cleanLine.length - 1];
|
||||
}
|
||||
|
||||
/**
|
||||
* 分析单行的韵脚
|
||||
*/
|
||||
export function analyzeLineRhyme(
|
||||
line: string,
|
||||
lineIndex: number,
|
||||
previousRhyme?: string,
|
||||
): RhymeDetail {
|
||||
const rhymeChar = getLineRhymeChar(line);
|
||||
const rhyme = rhymeChar ? getCharRhyme(rhymeChar) || "" : "";
|
||||
const rhymeGroup = rhyme ? getRhymeGroup(rhyme) || "" : "";
|
||||
|
||||
return {
|
||||
lineIndex,
|
||||
line,
|
||||
rhymeChar: rhymeChar || "",
|
||||
rhyme,
|
||||
rhymeGroup,
|
||||
rhymesWithPrevious: previousRhyme
|
||||
? doRhymesMatch(rhyme, previousRhyme)
|
||||
: false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 检测押韵模式
|
||||
*/
|
||||
function detectRhymeScheme(details: RhymeDetail[]): RhymeScheme {
|
||||
if (details.length < 2) return "FREE";
|
||||
|
||||
const groups = details.map((d) => d.rhymeGroup);
|
||||
|
||||
// 检测 AAAA (全部相同)
|
||||
if (groups.every((g) => g === groups[0] && g !== "")) {
|
||||
return "AAAA";
|
||||
}
|
||||
|
||||
// 检测 AABB (两两相同)
|
||||
if (details.length >= 4) {
|
||||
let isAABB = true;
|
||||
for (let i = 0; i < details.length - 1; i += 2) {
|
||||
if (groups[i] !== groups[i + 1] || groups[i] === "") {
|
||||
isAABB = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (isAABB) return "AABB";
|
||||
}
|
||||
|
||||
// 检测 ABAB (交错押韵)
|
||||
if (details.length >= 4) {
|
||||
const oddGroups = groups.filter((_, i) => i % 2 === 0);
|
||||
const evenGroups = groups.filter((_, i) => i % 2 === 1);
|
||||
|
||||
const oddSame = oddGroups.every((g) => g === oddGroups[0] && g !== "");
|
||||
const evenSame = evenGroups.every((g) => g === evenGroups[0] && g !== "");
|
||||
|
||||
if (oddSame && evenSame && oddGroups[0] !== evenGroups[0]) {
|
||||
return "ABAB";
|
||||
}
|
||||
}
|
||||
|
||||
// 检测 ABCB (隔行押韵)
|
||||
if (details.length >= 4) {
|
||||
const evenGroups = groups.filter((_, i) => i % 2 === 1);
|
||||
const evenSame = evenGroups.every((g) => g === evenGroups[0] && g !== "");
|
||||
|
||||
if (evenSame) {
|
||||
return "ABCB";
|
||||
}
|
||||
}
|
||||
|
||||
return "FREE";
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算押韵质量分数
|
||||
*/
|
||||
function calculateRhymeQuality(details: RhymeDetail[]): number {
|
||||
if (details.length < 2) return 0;
|
||||
|
||||
let matchCount = 0;
|
||||
let totalPairs = 0;
|
||||
|
||||
for (let i = 1; i < details.length; i++) {
|
||||
if (details[i].rhyme && details[i - 1].rhyme) {
|
||||
totalPairs++;
|
||||
if (details[i].rhymesWithPrevious) {
|
||||
matchCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (totalPairs === 0) return 0;
|
||||
|
||||
return Math.round((matchCount / totalPairs) * 100);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取质量等级
|
||||
*/
|
||||
function getQualityLevel(quality: number): RhymeQuality {
|
||||
if (quality >= 80) return "perfect";
|
||||
if (quality >= 60) return "near";
|
||||
if (quality >= 30) return "weak";
|
||||
return "none";
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成押韵建议
|
||||
*/
|
||||
function generateSuggestions(
|
||||
details: RhymeDetail[],
|
||||
scheme: RhymeScheme,
|
||||
quality: number,
|
||||
): string[] {
|
||||
const suggestions: string[] = [];
|
||||
|
||||
if (quality < 50) {
|
||||
suggestions.push("押韵质量较低,建议调整部分行尾用词");
|
||||
}
|
||||
|
||||
// 找出不押韵的行
|
||||
const nonRhymingLines = details.filter(
|
||||
(d, i) => i > 0 && !d.rhymesWithPrevious && d.rhyme,
|
||||
);
|
||||
|
||||
if (nonRhymingLines.length > 0) {
|
||||
const lineNumbers = nonRhymingLines.map((d) => d.lineIndex + 1).join("、");
|
||||
suggestions.push(`第 ${lineNumbers} 行与前一行不押韵,可考虑调整`);
|
||||
}
|
||||
|
||||
// 根据模式给出建议
|
||||
if (scheme === "FREE" && details.length >= 4) {
|
||||
suggestions.push("当前为自由韵,可尝试 AABB 或 ABAB 模式增强节奏感");
|
||||
}
|
||||
|
||||
return suggestions;
|
||||
}
|
||||
|
||||
/**
|
||||
* 分析歌词押韵
|
||||
* @param lines 歌词行数组
|
||||
* @returns 押韵分析结果
|
||||
*/
|
||||
export function analyzeRhyme(lines: string[]): RhymeAnalysis {
|
||||
// 过滤空行
|
||||
const validLines = lines.filter((line) => line.trim().length > 0);
|
||||
|
||||
if (validLines.length === 0) {
|
||||
return {
|
||||
scheme: "FREE",
|
||||
quality: 0,
|
||||
qualityLevel: "none",
|
||||
rhymes: [],
|
||||
details: [],
|
||||
suggestions: ["请添加歌词内容"],
|
||||
};
|
||||
}
|
||||
|
||||
// 分析每行
|
||||
const details: RhymeDetail[] = [];
|
||||
for (let i = 0; i < validLines.length; i++) {
|
||||
const previousRhyme = i > 0 ? details[i - 1].rhyme : undefined;
|
||||
details.push(analyzeLineRhyme(validLines[i], i, previousRhyme));
|
||||
}
|
||||
|
||||
// 检测押韵模式
|
||||
const scheme = detectRhymeScheme(details);
|
||||
|
||||
// 计算质量分数
|
||||
const quality = calculateRhymeQuality(details);
|
||||
const qualityLevel = getQualityLevel(quality);
|
||||
|
||||
// 提取韵脚
|
||||
const rhymes = details.map((d) => d.rhymeChar).filter(Boolean);
|
||||
|
||||
// 生成建议
|
||||
const suggestions = generateSuggestions(details, scheme, quality);
|
||||
|
||||
return {
|
||||
scheme,
|
||||
quality,
|
||||
qualityLevel,
|
||||
rhymes,
|
||||
details,
|
||||
suggestions,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取押韵词建议
|
||||
* @param rhymeGroup 韵组
|
||||
* @param theme 主题(可选)
|
||||
* @returns 押韵词列表
|
||||
*/
|
||||
export function getRhymeSuggestions(
|
||||
rhymeGroup: string,
|
||||
_theme?: string,
|
||||
): string[] {
|
||||
// 基于韵组返回常用押韵词
|
||||
const suggestions: Record<string, string[]> = {
|
||||
ai: ["爱", "在", "来", "开", "怀", "猜", "等待", "期待"],
|
||||
an: ["伴", "暖", "看", "岸", "山", "田", "温暖", "陪伴"],
|
||||
ang: ["乡", "长", "方", "香", "望", "想", "希望", "远方"],
|
||||
ao: ["好", "老", "少", "跑", "笑", "闹", "美好", "年少"],
|
||||
ei: ["美", "累", "泪", "岁", "醉", "回", "珍贵", "无悔"],
|
||||
en: ["等", "冷", "疼", "能", "深", "真", "永恒", "认真"],
|
||||
eng: ["梦", "风", "成", "冲", "勇", "空", "成功", "感动"],
|
||||
i: ["力", "立", "起", "地", "意", "义", "坚毅", "奇迹"],
|
||||
ian: ["年", "天", "前", "甜", "变", "见", "遇见", "青春"],
|
||||
ing: ["情", "心", "亲", "信", "星", "影", "心情", "感情"],
|
||||
iu: ["留", "久", "流", "求", "收", "秋", "停留", "不朽"],
|
||||
ong: ["中", "痛", "重", "懂", "动", "梦", "感动", "心痛"],
|
||||
ou: ["走", "守", "有", "后", "手", "头", "拥有", "温柔"],
|
||||
u: ["路", "步", "住", "哭", "努", "苦", "付出", "坚持"],
|
||||
};
|
||||
|
||||
return suggestions[rhymeGroup] || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 押韵模式描述
|
||||
*/
|
||||
export const RHYME_SCHEME_DESCRIPTIONS: Record<RhymeScheme, string> = {
|
||||
AABB: "两行一韵,连续押韵 - 适合流行歌曲,容易上口",
|
||||
ABAB: "交错押韵 - 增加节奏变化,适合抒情歌曲",
|
||||
ABCB: "隔行押韵 - 常用于民谣和说唱",
|
||||
AAAA: "通韵到底 - 适合短小精悍的段落",
|
||||
FREE: "自由韵 - 不拘泥于固定模式",
|
||||
};
|
||||
@@ -14,6 +14,181 @@ import {
|
||||
StepStatus,
|
||||
} from "../types";
|
||||
|
||||
/**
|
||||
* 获取音乐创作工作流步骤
|
||||
* 参考 Musicify MVP 的步骤设计
|
||||
*/
|
||||
function getMusicWorkflowSteps(mode: CreationMode): StepDefinition[] {
|
||||
// 快速模式:3 步骤(明确需求 → 生成歌词 → 导出)
|
||||
if (mode === "fast") {
|
||||
return [
|
||||
{
|
||||
id: "spec",
|
||||
type: "clarify",
|
||||
title: "明确需求",
|
||||
description: "定义歌曲主题、风格和情感",
|
||||
aiTask: { taskType: "spec", streaming: true },
|
||||
behavior: { skippable: false, redoable: true, autoAdvance: true },
|
||||
},
|
||||
{
|
||||
id: "lyrics",
|
||||
type: "write",
|
||||
title: "生成歌词",
|
||||
description: "AI 生成完整歌词",
|
||||
aiTask: { taskType: "lyrics", streaming: true },
|
||||
behavior: { skippable: false, redoable: true, autoAdvance: false },
|
||||
},
|
||||
{
|
||||
id: "export",
|
||||
type: "adapt",
|
||||
title: "导出",
|
||||
description: "导出到 Suno/Udio 等平台",
|
||||
aiTask: { taskType: "export", streaming: true },
|
||||
behavior: { skippable: true, redoable: true, autoAdvance: false },
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// 引导模式:完整 7 步骤
|
||||
return [
|
||||
{
|
||||
id: "spec",
|
||||
type: "clarify",
|
||||
title: "歌曲规格",
|
||||
description: "定义歌曲类型、时长、风格",
|
||||
aiTask: { taskType: "spec", streaming: true },
|
||||
behavior: { skippable: false, redoable: true, autoAdvance: true },
|
||||
},
|
||||
{
|
||||
id: "theme",
|
||||
type: "research",
|
||||
title: "主题构思",
|
||||
description: "引导思考核心主题和故事",
|
||||
aiTask: { taskType: "theme", streaming: true },
|
||||
behavior: { skippable: false, redoable: true, autoAdvance: false },
|
||||
},
|
||||
{
|
||||
id: "mood",
|
||||
type: "research",
|
||||
title: "情绪定位",
|
||||
description: "确定情绪氛围(温暖/激昂/治愈等)",
|
||||
aiTask: { taskType: "mood", streaming: true },
|
||||
behavior: { skippable: true, redoable: true, autoAdvance: false },
|
||||
},
|
||||
{
|
||||
id: "structure",
|
||||
type: "outline",
|
||||
title: "结构设计",
|
||||
description: "设计歌曲结构(主歌/副歌/桥段)",
|
||||
aiTask: { taskType: "structure", streaming: true },
|
||||
behavior: { skippable: false, redoable: true, autoAdvance: false },
|
||||
},
|
||||
{
|
||||
id: "lyrics",
|
||||
type: "write",
|
||||
title: "歌词创作",
|
||||
description: "创作完整歌词",
|
||||
aiTask: { taskType: "lyrics", streaming: true },
|
||||
behavior: { skippable: false, redoable: true, autoAdvance: false },
|
||||
},
|
||||
{
|
||||
id: "polish",
|
||||
type: "polish",
|
||||
title: "润色优化",
|
||||
description: "押韵检查和歌词润色",
|
||||
aiTask: { taskType: "polish", streaming: true },
|
||||
behavior: { skippable: true, redoable: true, autoAdvance: false },
|
||||
},
|
||||
{
|
||||
id: "export",
|
||||
type: "adapt",
|
||||
title: "导出",
|
||||
description: "导出到 Suno/Udio 等平台",
|
||||
aiTask: { taskType: "export", streaming: true },
|
||||
behavior: { skippable: true, redoable: true, autoAdvance: false },
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取海报创作工作流步骤
|
||||
*/
|
||||
function getPosterWorkflowSteps(mode: CreationMode): StepDefinition[] {
|
||||
// 快速模式:3 步骤(明确需求 → 生成设计 → 导出)
|
||||
if (mode === "fast") {
|
||||
return [
|
||||
{
|
||||
id: "brief",
|
||||
type: "clarify",
|
||||
title: "明确需求",
|
||||
description: "定义海报主题、尺寸和风格",
|
||||
aiTask: { taskType: "brief", streaming: true },
|
||||
behavior: { skippable: false, redoable: true, autoAdvance: true },
|
||||
},
|
||||
{
|
||||
id: "design",
|
||||
type: "write",
|
||||
title: "生成设计",
|
||||
description: "AI 生成海报设计方案",
|
||||
aiTask: { taskType: "design", streaming: true },
|
||||
behavior: { skippable: false, redoable: true, autoAdvance: false },
|
||||
},
|
||||
{
|
||||
id: "export",
|
||||
type: "adapt",
|
||||
title: "导出",
|
||||
description: "导出为图片或 PDF",
|
||||
aiTask: { taskType: "export", streaming: true },
|
||||
behavior: { skippable: true, redoable: true, autoAdvance: false },
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// 引导模式:5 步骤
|
||||
return [
|
||||
{
|
||||
id: "brief",
|
||||
type: "clarify",
|
||||
title: "需求分析",
|
||||
description: "明确海报目的、受众和场景",
|
||||
aiTask: { taskType: "brief", streaming: true },
|
||||
behavior: { skippable: false, redoable: true, autoAdvance: true },
|
||||
},
|
||||
{
|
||||
id: "copywriting",
|
||||
type: "research",
|
||||
title: "文案策划",
|
||||
description: "撰写海报标题和文案",
|
||||
aiTask: { taskType: "copywriting", streaming: true },
|
||||
behavior: { skippable: false, redoable: true, autoAdvance: false },
|
||||
},
|
||||
{
|
||||
id: "layout",
|
||||
type: "outline",
|
||||
title: "布局设计",
|
||||
description: "规划视觉层次和元素布局",
|
||||
aiTask: { taskType: "layout", streaming: true },
|
||||
behavior: { skippable: false, redoable: true, autoAdvance: false },
|
||||
},
|
||||
{
|
||||
id: "design",
|
||||
type: "write",
|
||||
title: "视觉设计",
|
||||
description: "生成完整海报设计",
|
||||
aiTask: { taskType: "design", streaming: true },
|
||||
behavior: { skippable: false, redoable: true, autoAdvance: false },
|
||||
},
|
||||
{
|
||||
id: "export",
|
||||
type: "adapt",
|
||||
title: "导出",
|
||||
description: "导出为图片或 PDF",
|
||||
aiTask: { taskType: "export", streaming: true },
|
||||
behavior: { skippable: true, redoable: true, autoAdvance: false },
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取主题对应的工作流步骤
|
||||
*/
|
||||
@@ -26,6 +201,16 @@ function getWorkflowSteps(
|
||||
return [];
|
||||
}
|
||||
|
||||
// 音乐主题:使用专门的音乐创作步骤
|
||||
if (theme === "music") {
|
||||
return getMusicWorkflowSteps(mode);
|
||||
}
|
||||
|
||||
// 海报主题:使用专门的海报创作步骤
|
||||
if (theme === "poster") {
|
||||
return getPosterWorkflowSteps(mode);
|
||||
}
|
||||
|
||||
// 快速模式:简化步骤(收集需求 → 生成初稿 → 迭代修改)
|
||||
if (mode === "fast") {
|
||||
return [
|
||||
@@ -259,28 +444,35 @@ export function useWorkflow(theme: ThemeType, mode: CreationMode) {
|
||||
*/
|
||||
const completeStep = useCallback(
|
||||
(result: StepResult) => {
|
||||
setSteps((prev) =>
|
||||
prev.map((step, i) =>
|
||||
i === currentStepIndex
|
||||
? { ...step, status: "completed" as StepStatus, result }
|
||||
: step,
|
||||
),
|
||||
);
|
||||
|
||||
// 自动进入下一步
|
||||
const nextIndex = currentStepIndex + 1;
|
||||
if (nextIndex < steps.length) {
|
||||
setCurrentStepIndex(nextIndex);
|
||||
// 使用函数式更新来获取最新的状态
|
||||
// 这样即使 AI 快速连续生成多个文件,也能正确推进步骤
|
||||
setCurrentStepIndex((prevIndex) => {
|
||||
// 标记当前步骤为完成
|
||||
setSteps((prev) =>
|
||||
prev.map((step, i) =>
|
||||
i === nextIndex
|
||||
? { ...step, status: "active" as StepStatus }
|
||||
i === prevIndex
|
||||
? { ...step, status: "completed" as StepStatus, result }
|
||||
: step,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 计算下一步索引
|
||||
const nextIndex = prevIndex + 1;
|
||||
if (nextIndex < steps.length) {
|
||||
// 激活下一步
|
||||
setSteps((prev) =>
|
||||
prev.map((step, i) =>
|
||||
i === nextIndex
|
||||
? { ...step, status: "active" as StepStatus }
|
||||
: step,
|
||||
),
|
||||
);
|
||||
return nextIndex;
|
||||
}
|
||||
return prevIndex;
|
||||
});
|
||||
},
|
||||
[currentStepIndex, steps.length],
|
||||
[steps.length],
|
||||
);
|
||||
|
||||
/**
|
||||
|
||||
@@ -14,6 +14,10 @@ import type React from "react";
|
||||
* - social-media: 社媒内容
|
||||
* - poster: 图文海报
|
||||
* - document: 办公文档
|
||||
* - paper: 学术论文
|
||||
* - novel: 小说创作
|
||||
* - script: 短剧脚本
|
||||
* - music: 音乐创作
|
||||
* - video: 短视频
|
||||
*/
|
||||
export type ThemeType =
|
||||
@@ -23,6 +27,10 @@ export type ThemeType =
|
||||
| "social-media"
|
||||
| "poster"
|
||||
| "document"
|
||||
| "paper"
|
||||
| "novel"
|
||||
| "script"
|
||||
| "music"
|
||||
| "video";
|
||||
|
||||
/**
|
||||
|
||||
@@ -16,6 +16,10 @@ const THEME_NAMES: Record<ThemeType, string> = {
|
||||
"social-media": "社媒内容",
|
||||
poster: "图文海报",
|
||||
document: "办公文档",
|
||||
paper: "学术论文",
|
||||
novel: "小说创作",
|
||||
script: "短剧脚本",
|
||||
music: "音乐创作",
|
||||
video: "短视频",
|
||||
};
|
||||
|
||||
@@ -52,10 +56,35 @@ const THEME_GUIDANCE: Record<ThemeType, string> = {
|
||||
- 结构清晰,逻辑严谨
|
||||
- 使用专业术语但保持可读性`,
|
||||
|
||||
paper: `
|
||||
【学术论文特点】
|
||||
- 严格遵循学术规范和引用格式
|
||||
- 论证逻辑严密,数据支撑充分
|
||||
- 使用专业术语,保持客观中立`,
|
||||
|
||||
novel: `
|
||||
【小说创作特点】
|
||||
- 注重人物塑造和情节发展
|
||||
- 场景描写生动,对话自然
|
||||
- 保持叙事节奏和悬念设置`,
|
||||
|
||||
script: `
|
||||
【短剧脚本特点】
|
||||
- 场景描述简洁明了
|
||||
- 对白自然,符合人物性格
|
||||
- 注重镜头语言和节奏把控`,
|
||||
|
||||
video: `
|
||||
【短视频特点】
|
||||
- 开头3秒抓注意力
|
||||
- 1分钟≈150-180字`,
|
||||
|
||||
music: `
|
||||
【音乐创作特点】
|
||||
- 歌词创作:注重押韵、意象、情感表达
|
||||
- 段落结构:主歌、副歌、桥段等标准结构
|
||||
- 平台适配:支持 Suno、Tunee 等 AI 音乐平台格式
|
||||
- 三种创作模式:教练模式(引导创作)、快速模式(AI生成)、混合模式(协作创作)`,
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -961,6 +990,624 @@ ${themeGuidance}
|
||||
记住:无论用户说什么,你的第一条回复必须是表单。`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成音乐创作模式的系统提示词
|
||||
* 基于 Musicify 项目的 lyrics.md, compose.md, melody-mimic.md
|
||||
*/
|
||||
function generateMusicCreationPrompt(mode: CreationMode): string {
|
||||
// 文件写入指令
|
||||
const fileWritingInstructions = `
|
||||
## 📁 文件写入格式
|
||||
|
||||
当需要输出歌曲内容时,使用以下标签格式:
|
||||
|
||||
<write_file path="文件名.md">
|
||||
内容...
|
||||
</write_file>
|
||||
|
||||
**重要**:
|
||||
- 这是标签格式,不是工具调用!直接写在回复文本中
|
||||
- <write_file> 标签内的内容会实时流式显示在右侧画布
|
||||
- 写入完成后,在对话框中简短说明即可
|
||||
|
||||
## 🎵 音乐创作文件体系
|
||||
|
||||
**每个步骤生成独立文件:**
|
||||
|
||||
| 步骤 | 文件名 | 内容说明 |
|
||||
|------|--------|----------|
|
||||
| 1. 歌曲规格 | song-spec.md | 歌曲主题、风格、情感、结构设定 |
|
||||
| 2. 歌词初稿 | lyrics-draft.md | 带段落标记的完整歌词 |
|
||||
| 3. 歌词终稿 | lyrics-final.txt | 优化后的最终歌词(Suno/Udio 格式) |
|
||||
| 4. 平台导出 | lyrics-suno.txt | Suno AI 专用格式(含风格提示词) |
|
||||
`;
|
||||
|
||||
// 基础音乐创作知识
|
||||
const musicTheoryBase = `
|
||||
## 🎼 音乐理论基础
|
||||
|
||||
### 歌曲结构标记(Suno/Tunee 标准格式)
|
||||
- \`[Intro]\` 前奏
|
||||
- \`[Verse]\` 或 \`[Verse 1]\` 主歌
|
||||
- \`[Pre-Chorus]\` 预副歌
|
||||
- \`[Chorus]\` 副歌
|
||||
- \`[Bridge]\` 桥段
|
||||
- \`[Interlude]\` 间奏
|
||||
- \`[Outro]\` 尾奏
|
||||
|
||||
### 简谱格式规范 ⭐ 重要
|
||||
|
||||
当用户要求生成简谱或完整乐谱时,使用以下三行格式:
|
||||
|
||||
\`\`\`
|
||||
[Verse 1]
|
||||
C Am F G
|
||||
1 2 3 5 | 6 5 3 2 | 1 - - - | 5 - - - |
|
||||
阳光洒落 在窗台上 温暖了 这一天
|
||||
\`\`\`
|
||||
|
||||
**格式说明**:
|
||||
- **第一行**:和弦名称(对齐到小节,如 C, Am, F, G, Dm, Em, G7 等)
|
||||
- **第二行**:简谱音符
|
||||
- 数字 1-7 表示 do-si
|
||||
- 0 表示休止符
|
||||
- \`-\` 表示延长(每个 - 延长一拍)
|
||||
- \`|\` 表示小节线
|
||||
- 高音在数字上方加点:1̇ 2̇ 3̇(或用 1' 2' 3' 表示)
|
||||
- 低音在数字下方加点:1̣ 2̣ 3̣(或用 1, 2, 3, 表示)
|
||||
- **第三行**:对应歌词(与音符对齐)
|
||||
|
||||
**示例**:
|
||||
\`\`\`
|
||||
[Chorus]
|
||||
F G C Am
|
||||
5 5 6 5 | 1' - 7 6 | 5 - - - | 6 5 3 2 |
|
||||
我想要 带你去看 海 蓝色的
|
||||
\`\`\`
|
||||
|
||||
### 风格化和弦进行参考
|
||||
|
||||
**流行音乐**:
|
||||
- 经典四和弦 (vi-IV-I-V): Am - F - C - G
|
||||
- 卡农进行 (I-V-vi-IV): C - G - Am - F
|
||||
|
||||
**中国风**:
|
||||
- 五声音阶进行: C - G - Am - Em
|
||||
- 特色乐器: 古筝、二胡、笛子
|
||||
|
||||
**民谣**:
|
||||
- 指弹分解和弦
|
||||
- 温柔、内省的情感
|
||||
|
||||
### 押韵技巧
|
||||
- **AABB**: 相邻两句押韵
|
||||
- **ABAB**: 交错押韵
|
||||
- **ABCB**: 隔行押韵
|
||||
- **内部押韵**: 句子内部创造韵律
|
||||
|
||||
### 情感词汇库
|
||||
- **欢快**: 跳跃、绚烂、飞扬、明媚、轻盈
|
||||
- **忧伤**: 凋零、黯然、飘零、孤寂、沉默
|
||||
- **温暖**: 拥抱、守候、暖阳、依偎、陪伴
|
||||
- **励志**: 追逐、突破、闪耀、坚持、梦想
|
||||
- **浪漫**: 邂逅、心动、缘分、温柔、永恒
|
||||
`;
|
||||
|
||||
if (mode === "fast") {
|
||||
return `# 🎵 音乐创作助手 - 快速模式
|
||||
|
||||
你是一位专业的音乐创作助手,帮助用户快速创作歌词。
|
||||
|
||||
${fileWritingInstructions}
|
||||
|
||||
${musicTheoryBase}
|
||||
|
||||
## 🚀 快速模式流程
|
||||
|
||||
**你的第一条回复必须是这个表单:**
|
||||
|
||||
\`\`\`a2ui
|
||||
{
|
||||
"type": "form",
|
||||
"title": "⚡ 快速创作 - 歌曲设定",
|
||||
"description": "请填写以下信息,我将为你生成完整歌词:",
|
||||
"fields": [
|
||||
{
|
||||
"id": "theme",
|
||||
"type": "text",
|
||||
"label": "歌曲主题/故事",
|
||||
"placeholder": "例如:冬天的思念、青春的回忆、离别的车站...",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"id": "songType",
|
||||
"type": "choice",
|
||||
"label": "歌曲风格",
|
||||
"options": [
|
||||
{"value": "pop", "label": "流行"},
|
||||
{"value": "folk", "label": "民谣"},
|
||||
{"value": "rock", "label": "摇滚"},
|
||||
{"value": "guofeng", "label": "古风/国风"},
|
||||
{"value": "rap", "label": "说唱"},
|
||||
{"value": "rnb", "label": "R&B"},
|
||||
{"value": "electronic", "label": "电子"}
|
||||
],
|
||||
"default": "pop"
|
||||
},
|
||||
{
|
||||
"id": "mood",
|
||||
"type": "choice",
|
||||
"label": "情感基调",
|
||||
"options": [
|
||||
{"value": "joyful", "label": "欢快明亮"},
|
||||
{"value": "gentle", "label": "温柔抒情"},
|
||||
{"value": "sorrowful", "label": "忧伤低沉"},
|
||||
{"value": "passionate", "label": "激昂热血"},
|
||||
{"value": "mysterious", "label": "神秘空灵"},
|
||||
{"value": "nostalgic", "label": "怀旧复古"}
|
||||
],
|
||||
"default": "gentle"
|
||||
},
|
||||
{
|
||||
"id": "structure",
|
||||
"type": "choice",
|
||||
"label": "歌曲结构",
|
||||
"options": [
|
||||
{"value": "simple", "label": "简单 (主歌+副歌)"},
|
||||
{"value": "standard", "label": "标准 (主歌+预副歌+副歌+桥段)"},
|
||||
{"value": "complex", "label": "完整 (含前奏/间奏/尾奏)"}
|
||||
],
|
||||
"default": "standard"
|
||||
},
|
||||
{
|
||||
"id": "platform",
|
||||
"type": "choice",
|
||||
"label": "目标平台",
|
||||
"options": [
|
||||
{"value": "suno", "label": "Suno AI"},
|
||||
{"value": "udio", "label": "Udio"},
|
||||
{"value": "general", "label": "通用格式"}
|
||||
],
|
||||
"default": "suno"
|
||||
}
|
||||
],
|
||||
"submitLabel": "开始创作 →"
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
**🛑 停止并等待用户填写表单**
|
||||
|
||||
---
|
||||
|
||||
### 收到表单后的创作流程
|
||||
|
||||
#### 步骤 1️⃣:生成歌曲规格(song-spec.md)
|
||||
|
||||
<write_file path="song-spec.md">
|
||||
# 🎵 歌曲规格
|
||||
|
||||
## 基本信息
|
||||
- **歌曲名称**: [根据主题生成]
|
||||
- **风格**: [用户选择]
|
||||
- **情感基调**: [用户选择]
|
||||
- **目标平台**: [用户选择]
|
||||
|
||||
## 歌曲结构
|
||||
[根据用户选择的结构类型]
|
||||
|
||||
## 创作方向
|
||||
- **核心意象**: [3-5个关键意象]
|
||||
- **押韵方案**: [建议的押韵模式]
|
||||
- **参考和弦**: [推荐的和弦进行]
|
||||
</write_file>
|
||||
|
||||
#### 步骤 2️⃣:生成歌词初稿(lyrics-draft.md)
|
||||
|
||||
<write_file path="lyrics-draft.md">
|
||||
# [歌曲名称]
|
||||
|
||||
[Verse 1]
|
||||
第一句歌词
|
||||
第二句歌词
|
||||
第三句歌词
|
||||
第四句歌词
|
||||
|
||||
[Pre-Chorus]
|
||||
预副歌歌词...
|
||||
|
||||
[Chorus]
|
||||
副歌歌词...
|
||||
|
||||
[Verse 2]
|
||||
...
|
||||
|
||||
[Bridge]
|
||||
...
|
||||
|
||||
[Outro]
|
||||
...
|
||||
</write_file>
|
||||
|
||||
#### 步骤 3️⃣:生成平台导出版本(lyrics-suno.txt)
|
||||
|
||||
<write_file path="lyrics-suno.txt">
|
||||
[Verse 1]
|
||||
歌词内容...
|
||||
|
||||
[Chorus]
|
||||
歌词内容...
|
||||
|
||||
---
|
||||
Style: [风格描述,如 pop, emotional, chinese]
|
||||
Tags: [标签,如 流行, 抒情, 中文]
|
||||
BPM: [建议BPM]
|
||||
Key: [建议调式]
|
||||
</write_file>
|
||||
|
||||
### 创作原则
|
||||
- 每句歌词 5-10 字为宜
|
||||
- 副歌要朗朗上口,易于记忆
|
||||
- 注意情感的起承转合
|
||||
- 避免过于抽象的表达
|
||||
- 段落标记使用标准格式:首字母大写 + 空格(如 [Verse 1])`;
|
||||
}
|
||||
|
||||
if (mode === "guided") {
|
||||
return `# 🎵 音乐创作助手 - 教练模式
|
||||
|
||||
你是一位专业的歌词创作教练,通过提问引导用户自己创作歌词。
|
||||
|
||||
${fileWritingInstructions}
|
||||
|
||||
${musicTheoryBase}
|
||||
|
||||
## 🎓 教练模式原则
|
||||
|
||||
### 你的角色
|
||||
- 🤝 **提问式引导**: 通过问题激发用户创意
|
||||
- 💡 **灵感启发**: 提供意象和场景建议
|
||||
- 📝 **实时反馈**: 对用户创作给予建设性意见
|
||||
- 🎯 **目标导向**: 确保每段都服务于整体主题
|
||||
|
||||
### 可以做
|
||||
- ✅ 提供结构框架(只有标题和段落划分)
|
||||
- ✅ 提出引导问题,帮用户回忆真实经历
|
||||
- ✅ 检查押韵质量,提供替换词建议
|
||||
- ✅ 给出修改建议
|
||||
|
||||
### 绝对不能做
|
||||
- ❌ 直接写完整的歌词段落
|
||||
- ❌ 替用户决定核心意象
|
||||
- ❌ 跳过引导直接生成
|
||||
|
||||
---
|
||||
|
||||
## 🔄 工作流程
|
||||
|
||||
**你的第一条回复必须是这个表单:**
|
||||
|
||||
\`\`\`a2ui
|
||||
{
|
||||
"type": "form",
|
||||
"title": "🎵 歌曲创作 - 基础设定",
|
||||
"description": "让我们一步步创作你的歌曲。首先,告诉我一些基本信息:",
|
||||
"fields": [
|
||||
{
|
||||
"id": "theme",
|
||||
"type": "text",
|
||||
"label": "歌曲主题/故事",
|
||||
"placeholder": "想表达什么情感或讲述什么故事?",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"id": "songType",
|
||||
"type": "choice",
|
||||
"label": "歌曲风格",
|
||||
"options": [
|
||||
{"value": "pop", "label": "流行"},
|
||||
{"value": "folk", "label": "民谣"},
|
||||
{"value": "rock", "label": "摇滚"},
|
||||
{"value": "guofeng", "label": "古风/国风"},
|
||||
{"value": "rap", "label": "说唱"},
|
||||
{"value": "rnb", "label": "R&B"}
|
||||
],
|
||||
"default": "pop"
|
||||
},
|
||||
{
|
||||
"id": "mood",
|
||||
"type": "choice",
|
||||
"label": "情感基调",
|
||||
"options": [
|
||||
{"value": "joyful", "label": "欢快明亮"},
|
||||
{"value": "gentle", "label": "温柔抒情"},
|
||||
{"value": "sorrowful", "label": "忧伤低沉"},
|
||||
{"value": "passionate", "label": "激昂热血"}
|
||||
],
|
||||
"default": "gentle"
|
||||
},
|
||||
{
|
||||
"id": "experience",
|
||||
"type": "text",
|
||||
"label": "真实经历(可选)",
|
||||
"placeholder": "有没有相关的真实经历或感受?好歌词来自真实情感"
|
||||
}
|
||||
],
|
||||
"submitLabel": "开始创作 →"
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
**🛑 停止并等待用户填写表单**
|
||||
|
||||
---
|
||||
|
||||
### 收到表单后的引导流程
|
||||
|
||||
#### 步骤 1️⃣:生成歌曲规格(song-spec.md)
|
||||
|
||||
收到表单后,先生成歌曲规格文件:
|
||||
|
||||
<write_file path="song-spec.md">
|
||||
# 🎵 歌曲规格
|
||||
|
||||
## 基本信息
|
||||
- **歌曲主题**: [用户填写的主题]
|
||||
- **风格**: [用户选择]
|
||||
- **情感基调**: [用户选择]
|
||||
- **创作模式**: 教练引导
|
||||
|
||||
## 歌曲结构(建议)
|
||||
根据风格推荐:
|
||||
- 流行: Verse 1 → Pre-Chorus → Chorus → Verse 2 → Chorus → Bridge → Chorus
|
||||
- 民谣: Verse 1 → Verse 2 → Chorus → Verse 3 → Chorus
|
||||
|
||||
## 真实素材
|
||||
[用户提供的真实经历]
|
||||
|
||||
## 创作方向
|
||||
- **核心意象**: [待用户确定]
|
||||
- **押韵方案**: [待确定]
|
||||
</write_file>
|
||||
|
||||
然后开始逐段引导。
|
||||
|
||||
#### 步骤 2️⃣:逐段引导创作
|
||||
|
||||
对每个段落,使用引导问题:
|
||||
|
||||
**主歌引导示例**:
|
||||
> 主歌通常是故事的开始,设定场景和情绪。
|
||||
>
|
||||
> 让我问你几个问题:
|
||||
> 1. 这个故事发生在什么时间、什么地点?
|
||||
> 2. 主人公在做什么?有什么感受?
|
||||
> 3. 用三个关键词描述这个场景的氛围?
|
||||
>
|
||||
> 请根据这些思考,尝试写出主歌的前两句(每句 5-8 字)
|
||||
|
||||
**副歌引导示例**:
|
||||
> 副歌是情感的爆发点,要朗朗上口。
|
||||
>
|
||||
> 思考一下:
|
||||
> 1. 这首歌最想传达的一句话是什么?
|
||||
> 2. 如果用一个画面来表达这种情感,会是什么?
|
||||
>
|
||||
> 请尝试写出副歌的核心句
|
||||
|
||||
#### 步骤 3️⃣:实时保存进度
|
||||
|
||||
每当用户完成一个段落,更新歌词文件:
|
||||
|
||||
<write_file path="lyrics-draft.md">
|
||||
# [歌曲名称]
|
||||
|
||||
[Verse 1]
|
||||
用户创作的歌词...
|
||||
|
||||
[Chorus]
|
||||
用户创作的副歌...
|
||||
|
||||
<!-- 待完成段落 -->
|
||||
</write_file>
|
||||
|
||||
#### 步骤 4️⃣:押韵检查与优化
|
||||
- 分析用户写的歌词的押韵质量
|
||||
- 提供押韵词替换建议
|
||||
- 检查节奏和可唱性
|
||||
|
||||
#### 步骤 5️⃣:整合与导出
|
||||
|
||||
完成所有段落后,生成最终版本:
|
||||
|
||||
<write_file path="lyrics-final.txt">
|
||||
[完整歌词,带标准段落标记]
|
||||
</write_file>
|
||||
|
||||
<write_file path="lyrics-suno.txt">
|
||||
[Suno 格式歌词]
|
||||
|
||||
---
|
||||
Style: [风格]
|
||||
Tags: [标签]
|
||||
</write_file>`;
|
||||
}
|
||||
|
||||
// hybrid 和 framework 模式 - 混合协作
|
||||
return `# 🎵 音乐创作助手 - 协作模式
|
||||
|
||||
你是一位专业的音乐创作伙伴,AI 负责框架和技术建议,用户负责核心创意。
|
||||
|
||||
${fileWritingInstructions}
|
||||
|
||||
${musicTheoryBase}
|
||||
|
||||
## 🤝 协作模式分工
|
||||
|
||||
| AI 负责 (40%) | 用户负责 (60%) |
|
||||
|--------------|---------------|
|
||||
| 歌曲结构设计 | 核心主题和故事 |
|
||||
| 押韵建议 | 关键意象选择 |
|
||||
| 每段开头示例 | 完整歌词内容 |
|
||||
| 技术优化建议 | 情感表达方式 |
|
||||
|
||||
---
|
||||
|
||||
## 🔄 工作流程
|
||||
|
||||
**你的第一条回复必须是这个表单:**
|
||||
|
||||
\`\`\`a2ui
|
||||
{
|
||||
"type": "form",
|
||||
"title": "🎵 歌曲创作 - 协作模式",
|
||||
"description": "AI 负责框架和押韵建议,你负责核心歌词内容:",
|
||||
"fields": [
|
||||
{
|
||||
"id": "theme",
|
||||
"type": "text",
|
||||
"label": "歌曲主题",
|
||||
"placeholder": "想表达什么情感或讲述什么故事?",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"id": "songType",
|
||||
"type": "choice",
|
||||
"label": "歌曲风格",
|
||||
"options": [
|
||||
{"value": "pop", "label": "流行"},
|
||||
{"value": "folk", "label": "民谣"},
|
||||
{"value": "rock", "label": "摇滚"},
|
||||
{"value": "guofeng", "label": "古风/国风"},
|
||||
{"value": "rap", "label": "说唱"},
|
||||
{"value": "rnb", "label": "R&B"},
|
||||
{"value": "electronic", "label": "电子"}
|
||||
],
|
||||
"default": "pop"
|
||||
},
|
||||
{
|
||||
"id": "mood",
|
||||
"type": "choice",
|
||||
"label": "情感基调",
|
||||
"options": [
|
||||
{"value": "joyful", "label": "欢快明亮"},
|
||||
{"value": "gentle", "label": "温柔抒情"},
|
||||
{"value": "sorrowful", "label": "忧伤低沉"},
|
||||
{"value": "passionate", "label": "激昂热血"},
|
||||
{"value": "mysterious", "label": "神秘空灵"},
|
||||
{"value": "nostalgic", "label": "怀旧复古"}
|
||||
],
|
||||
"default": "gentle"
|
||||
},
|
||||
{
|
||||
"id": "keywords",
|
||||
"type": "text",
|
||||
"label": "关键意象/词汇",
|
||||
"placeholder": "想在歌词中出现的词汇,用逗号分隔(如:月光,车站,背影)"
|
||||
},
|
||||
{
|
||||
"id": "reference",
|
||||
"type": "text",
|
||||
"label": "参考歌曲(可选)",
|
||||
"placeholder": "有没有想要参考的歌曲风格?"
|
||||
}
|
||||
],
|
||||
"submitLabel": "开始协作 →"
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
**🛑 停止并等待用户填写表单**
|
||||
|
||||
---
|
||||
|
||||
### 收到表单后的协作流程
|
||||
|
||||
#### 步骤 1️⃣:生成歌曲规格(song-spec.md)
|
||||
|
||||
<write_file path="song-spec.md">
|
||||
# 🎵 歌曲规格
|
||||
|
||||
## 基本信息
|
||||
- **歌曲主题**: [用户填写]
|
||||
- **风格**: [用户选择]
|
||||
- **情感基调**: [用户选择]
|
||||
- **创作模式**: 协作模式
|
||||
|
||||
## 歌曲结构
|
||||
[根据风格推荐的结构]
|
||||
|
||||
## 关键意象
|
||||
[用户提供的关键词]
|
||||
|
||||
## 参考歌曲
|
||||
[用户提供的参考]
|
||||
|
||||
## 创作方向
|
||||
- **押韵方案**: [建议的押韵模式]
|
||||
- **参考和弦**: [推荐的和弦进行]
|
||||
</write_file>
|
||||
|
||||
#### 步骤 2️⃣:生成歌词框架(lyrics-draft.md)
|
||||
|
||||
为每个段落提供开头示例和押韵建议:
|
||||
|
||||
<write_file path="lyrics-draft.md">
|
||||
# [歌曲名称]
|
||||
|
||||
## 创作框架
|
||||
|
||||
[Verse 1] - 场景铺垫
|
||||
**AI 示例**: 第一句示例...
|
||||
**押韵建议**: -ang 韵(光、芳、香)
|
||||
**💡 用户补充**: [请在此写出完整主歌]
|
||||
|
||||
[Pre-Chorus] - 情感过渡
|
||||
**AI 示例**: ...
|
||||
**💡 用户补充**: [请在此写出预副歌]
|
||||
|
||||
[Chorus] - 情感爆发
|
||||
**AI 示例**: ...
|
||||
**押韵建议**: ...
|
||||
**💡 用户补充**: [请在此写出副歌]
|
||||
|
||||
[Bridge] - 转折升华
|
||||
**💡 用户补充**: [请在此写出桥段]
|
||||
</write_file>
|
||||
|
||||
#### 步骤 3️⃣:逐段协作
|
||||
|
||||
为每个段落:
|
||||
1. 提供开头示例(1-2句)
|
||||
2. 提供押韵词库
|
||||
3. 提供意象建议
|
||||
4. 等待用户完成该段落
|
||||
|
||||
#### 步骤 4️⃣:优化与整合
|
||||
|
||||
用户完成所有段落后:
|
||||
- 检查押韵质量
|
||||
- 优化节奏和可唱性
|
||||
- 确保整体连贯
|
||||
|
||||
#### 步骤 5️⃣:导出最终版本
|
||||
|
||||
<write_file path="lyrics-final.txt">
|
||||
[完整歌词,带标准段落标记]
|
||||
</write_file>
|
||||
|
||||
<write_file path="lyrics-suno.txt">
|
||||
[Verse 1]
|
||||
歌词内容...
|
||||
|
||||
[Chorus]
|
||||
歌词内容...
|
||||
|
||||
---
|
||||
Style: [风格描述]
|
||||
Tags: [标签]
|
||||
BPM: [建议BPM]
|
||||
Key: [建议调式]
|
||||
</write_file>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成内容创作模式的系统提示词
|
||||
* @param theme 主题类型
|
||||
@@ -986,6 +1633,11 @@ ${themeGuidance}
|
||||
现在,请先询问用户想要${theme === "knowledge" ? "探索什么主题" : "规划什么内容"}。`;
|
||||
}
|
||||
|
||||
// 音乐创作使用专门的提示词
|
||||
if (theme === "music") {
|
||||
return generateMusicCreationPrompt(mode);
|
||||
}
|
||||
|
||||
// 根据创作模式生成不同的提示词
|
||||
switch (mode) {
|
||||
case "guided":
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
/**
|
||||
* @file 导出格式化工具
|
||||
* @description 将歌词和音乐数据格式化为不同平台的格式
|
||||
* @module components/content-creator/canvas/music/utils/exportFormatters
|
||||
*/
|
||||
|
||||
import type { MusicSection, SongSpec } from "../types";
|
||||
|
||||
/**
|
||||
* Suno 导出格式
|
||||
*/
|
||||
export interface SunoExport {
|
||||
/** 带标记的歌词 */
|
||||
lyrics: string;
|
||||
/** 风格提示词 */
|
||||
style: string;
|
||||
/** 标签 */
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出为 Suno 格式
|
||||
*/
|
||||
export function exportToSuno(
|
||||
sections: MusicSection[],
|
||||
spec: SongSpec,
|
||||
): SunoExport {
|
||||
// 生成带标记的歌词
|
||||
const lyrics = sections
|
||||
.sort((a, b) => a.order - b.order)
|
||||
.map((section) => {
|
||||
const marker = getSectionMarker(section.type);
|
||||
const lines = section.lyricsLines.join("\n");
|
||||
return `${marker}\n${lines}`;
|
||||
})
|
||||
.join("\n\n");
|
||||
|
||||
// 生成风格提示词
|
||||
const style = generateStylePrompt(spec);
|
||||
|
||||
// 生成标签
|
||||
const tags = generateTags(spec);
|
||||
|
||||
return { lyrics, style, tags };
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出为纯文本格式
|
||||
*/
|
||||
export function exportToText(
|
||||
sections: MusicSection[],
|
||||
spec: SongSpec,
|
||||
): string {
|
||||
const header = `# ${spec.title}\n\n`;
|
||||
const meta = `调式: ${spec.key} | 拍号: ${spec.timeSignature} | 速度: ${spec.tempo} BPM\n\n`;
|
||||
|
||||
const content = sections
|
||||
.sort((a, b) => a.order - b.order)
|
||||
.map((section) => {
|
||||
const marker = getSectionMarker(section.type);
|
||||
const lines = section.lyricsLines.join("\n");
|
||||
return `${marker}\n${lines}`;
|
||||
})
|
||||
.join("\n\n");
|
||||
|
||||
return header + meta + content;
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出为 Markdown 格式
|
||||
*/
|
||||
export function exportToMarkdown(
|
||||
sections: MusicSection[],
|
||||
spec: SongSpec,
|
||||
): string {
|
||||
const header = `# ${spec.title}\n\n`;
|
||||
const meta = `**调式**: ${spec.key} | **拍号**: ${spec.timeSignature} | **速度**: ${spec.tempo} BPM\n\n`;
|
||||
|
||||
const content = sections
|
||||
.sort((a, b) => a.order - b.order)
|
||||
.map((section) => {
|
||||
const marker = getSectionMarker(section.type);
|
||||
const lines = section.lyricsLines.map((line) => `> ${line}`).join("\n");
|
||||
return `## ${marker}\n\n${lines}`;
|
||||
})
|
||||
.join("\n\n");
|
||||
|
||||
return header + meta + content;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取段落标记
|
||||
*/
|
||||
function getSectionMarker(type: MusicSection["type"]): string {
|
||||
const markers: Record<MusicSection["type"], string> = {
|
||||
intro: "[Intro]",
|
||||
verse: "[Verse]",
|
||||
"pre-chorus": "[Pre-Chorus]",
|
||||
chorus: "[Chorus]",
|
||||
bridge: "[Bridge]",
|
||||
outro: "[Outro]",
|
||||
};
|
||||
return markers[type] || `[${type}]`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成风格提示词
|
||||
*/
|
||||
function generateStylePrompt(spec: SongSpec): string {
|
||||
const parts: string[] = [];
|
||||
|
||||
// 歌曲类型
|
||||
const typeMap: Record<SongSpec["songType"], string> = {
|
||||
pop: "pop",
|
||||
folk: "folk",
|
||||
rock: "rock",
|
||||
guofeng: "chinese traditional, guofeng",
|
||||
rap: "rap, hip-hop",
|
||||
other: "contemporary",
|
||||
};
|
||||
parts.push(typeMap[spec.songType]);
|
||||
|
||||
// 情绪
|
||||
const moodMap: Record<SongSpec["mood"], string> = {
|
||||
joyful: "upbeat, cheerful",
|
||||
gentle: "gentle, soft",
|
||||
sorrowful: "melancholic, emotional",
|
||||
passionate: "passionate, powerful",
|
||||
};
|
||||
parts.push(moodMap[spec.mood]);
|
||||
|
||||
// 语言
|
||||
parts.push("chinese");
|
||||
|
||||
return parts.join(", ");
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成标签
|
||||
*/
|
||||
function generateTags(spec: SongSpec): string[] {
|
||||
const tags: string[] = [];
|
||||
|
||||
// 歌曲类型标签
|
||||
const typeTagMap: Record<SongSpec["songType"], string[]> = {
|
||||
pop: ["流行", "pop"],
|
||||
folk: ["民谣", "folk"],
|
||||
rock: ["摇滚", "rock"],
|
||||
guofeng: ["国风", "中国风", "traditional"],
|
||||
rap: ["说唱", "rap"],
|
||||
other: ["原创"],
|
||||
};
|
||||
tags.push(...(typeTagMap[spec.songType] || []));
|
||||
|
||||
// 情绪标签
|
||||
const moodTagMap: Record<SongSpec["mood"], string[]> = {
|
||||
joyful: ["欢快", "cheerful"],
|
||||
gentle: ["温柔", "gentle"],
|
||||
sorrowful: ["伤感", "emotional"],
|
||||
passionate: ["激情", "passionate"],
|
||||
};
|
||||
tags.push(...(moodTagMap[spec.mood] || []));
|
||||
|
||||
// 语言标签
|
||||
tags.push("中文", "chinese");
|
||||
|
||||
return tags;
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出为 JSON 格式
|
||||
*/
|
||||
export function exportToJSON(
|
||||
sections: MusicSection[],
|
||||
spec: SongSpec,
|
||||
): string {
|
||||
const data = {
|
||||
title: spec.title,
|
||||
songType: spec.songType,
|
||||
theme: spec.theme,
|
||||
key: spec.key,
|
||||
timeSignature: spec.timeSignature,
|
||||
tempo: spec.tempo,
|
||||
mood: spec.mood,
|
||||
sections: sections.map((section) => ({
|
||||
type: section.type,
|
||||
name: section.name,
|
||||
order: section.order,
|
||||
lyrics: section.lyricsLines,
|
||||
repeatCount: section.repeatCount,
|
||||
})),
|
||||
};
|
||||
|
||||
return JSON.stringify(data, null, 2);
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
import http.client
|
||||
import json
|
||||
|
||||
conn = http.client.HTTPConnection("127.0.0.1", 8999)
|
||||
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": "Bearer Proxycast-key11"
|
||||
}
|
||||
|
||||
data = {
|
||||
"model": "claude-opus-4-5-20251101",
|
||||
"messages": [{"role": "user", "content": "你好"}],
|
||||
"stream": True
|
||||
}
|
||||
|
||||
print("发送流式请求...")
|
||||
print(f"请求数据: {json.dumps(data, ensure_ascii=False)}\n")
|
||||
conn.request("POST", "/v1/chat/completions", json.dumps(data), headers)
|
||||
|
||||
response = conn.getresponse()
|
||||
print(f"HTTP 状态码: {response.status}")
|
||||
print(f"响应头: {dict(response.getheaders())}\n")
|
||||
|
||||
print("开始接收流式数据:\n")
|
||||
chunk_count = 0
|
||||
content_buffer = ""
|
||||
|
||||
while True:
|
||||
line = response.readline()
|
||||
if not line:
|
||||
break
|
||||
|
||||
chunk_count += 1
|
||||
decoded = line.decode('utf-8').strip()
|
||||
|
||||
if not decoded:
|
||||
continue
|
||||
|
||||
print(f"Chunk {chunk_count}: {decoded[:150]}...")
|
||||
|
||||
if decoded.startswith('data: '):
|
||||
json_str = decoded[6:]
|
||||
if json_str == '[DONE]':
|
||||
print(" → 流结束")
|
||||
break
|
||||
|
||||
try:
|
||||
data_obj = json.loads(json_str)
|
||||
if 'choices' in data_obj and data_obj['choices']:
|
||||
delta = data_obj['choices'][0].get('delta', {})
|
||||
content = delta.get('content', '')
|
||||
if content:
|
||||
content_buffer += content
|
||||
print(f" ✓ 内容: {content}")
|
||||
except json.JSONDecodeError as e:
|
||||
print(f" ✗ JSON 解析失败: {e}")
|
||||
|
||||
print(f"\n总共接收 {chunk_count} 个数据块")
|
||||
print(f"累积内容: {content_buffer}")
|
||||
|
||||
conn.close()
|
||||
Reference in New Issue
Block a user