chore: bump version to 0.43.0

This commit is contained in:
coso
2026-01-12 00:00:11 +08:00
parent 8bc8e56c98
commit 7144cf771a
63 changed files with 11869 additions and 311 deletions
-218
View File
@@ -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
View File
@@ -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()
+6
View File
@@ -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");
+1
View File
@@ -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;
+212
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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],
);
/**
+8
View File
@@ -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);
}
-63
View File
@@ -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()