From 7144cf771a6cda0f17ec266668bdf0cfad1c5812 Mon Sep 17 00:00:00 2001 From: coso Date: Mon, 12 Jan 2026 00:00:11 +0800 Subject: [PATCH] chore: bump version to 0.43.0 --- TERM_SCROLLBACK_TOOL.md | 218 ------ artifacts/ref_model_selector.png | Bin 102202 -> 0 bytes .../crates/aster-cli/static/img/logo_dark.png | Bin 0 -> 67 bytes .../aster-cli/static/img/logo_light.png | Bin 0 -> 68 bytes package.json | 6 +- .../resources/music/chord-progressions.json | 251 ++++++ .../resources/music/guofeng-patterns.json | 266 +++++++ .../resources/music/midi-parser-rules.json | 251 ++++++ .../resources/music/pentatonic-rules.json | 104 +++ src-tauri/resources/music/rhyme-patterns.json | 183 +++++ src-tauri/resources/scripts/audio_to_midi.py | 367 +++++++++ src-tauri/resources/scripts/midi_analyzer.py | 677 +++++++++++++++++ src-tauri/src/app/runner.rs | 6 + src-tauri/src/commands/mod.rs | 1 + src-tauri/src/commands/music_cmd.rs | 212 ++++++ src-tauri/tauri.conf.headless.json | 2 +- src-tauri/tauri.conf.json | 2 +- .../agent/chat/components/EmptyState.tsx | 23 +- .../chat/components/StreamingRenderer.tsx | 84 ++- .../content-creator/canvas/CanvasFactory.tsx | 13 + .../content-creator/canvas/canvasUtils.ts | 24 +- .../canvas/music/MusicCanvas.tsx | 448 +++++++++++ .../canvas/music/MusicToolbar.tsx | 247 ++++++ .../music/analysis/MelodyAnalysisReport.tsx | 208 +++++ .../canvas/music/analysis/TrackSelector.tsx | 188 +++++ .../canvas/music/analysis/index.ts | 15 + .../content-creator/canvas/music/api/index.ts | 6 + .../content-creator/canvas/music/api/tauri.ts | 149 ++++ .../canvas/music/editors/LyricsEditor.tsx | 435 +++++++++++ .../canvas/music/editors/index.ts | 6 + .../canvas/music/export/ExportDialog.tsx | 409 ++++++++++ .../canvas/music/export/index.ts | 6 + .../canvas/music/hooks/index.ts | 43 ++ .../canvas/music/hooks/useLyricsEditor.ts | 246 ++++++ .../canvas/music/hooks/useMelodyMimic.ts | 359 +++++++++ .../canvas/music/hooks/useMidiPlayback.ts | 401 ++++++++++ .../canvas/music/hooks/useMusicExport.ts | 214 ++++++ .../canvas/music/hooks/useRhyme.ts | 123 +++ .../content-creator/canvas/music/index.ts | 102 +++ .../canvas/music/player/MidiPlayer.tsx | 329 ++++++++ .../canvas/music/player/PlaybackControls.tsx | 100 +++ .../canvas/music/player/ProgressBar.tsx | 224 ++++++ .../canvas/music/player/TempoControl.tsx | 201 +++++ .../canvas/music/player/VolumeControl.tsx | 191 +++++ .../canvas/music/player/index.ts | 13 + .../canvas/music/registerMusicCanvas.ts | 37 + .../canvas/music/renderers/ChordDiagram.tsx | 222 ++++++ .../music/renderers/GuitarTabRenderer.tsx | 264 +++++++ .../renderers/NumberedNotationRenderer.tsx | 309 ++++++++ .../music/renderers/PianoRollRenderer.tsx | 349 +++++++++ .../canvas/music/renderers/index.ts | 8 + .../content-creator/canvas/music/types.ts | 713 ++++++++++++++++++ .../canvas/music/utils/chordDatabase.ts | 277 +++++++ .../canvas/music/utils/exportFormatters.ts | 198 +++++ .../canvas/music/utils/index.ts | 51 ++ .../canvas/music/utils/lyricsParser.ts | 292 +++++++ .../music/utils/numberedNotationParser.ts | 364 +++++++++ .../canvas/music/utils/rhymeDetector.ts | 601 +++++++++++++++ .../content-creator/hooks/useWorkflow.ts | 224 +++++- src/components/content-creator/types.ts | 8 + .../content-creator/utils/systemPrompt.ts | 652 ++++++++++++++++ .../canvas/music/utils/exportFormatters.ts | 195 +++++ test_stream.py | 63 -- 63 files changed, 11869 insertions(+), 311 deletions(-) delete mode 100644 TERM_SCROLLBACK_TOOL.md delete mode 100644 artifacts/ref_model_selector.png create mode 100644 aster-rust/crates/aster-cli/static/img/logo_dark.png create mode 100644 aster-rust/crates/aster-cli/static/img/logo_light.png create mode 100644 src-tauri/resources/music/chord-progressions.json create mode 100644 src-tauri/resources/music/guofeng-patterns.json create mode 100644 src-tauri/resources/music/midi-parser-rules.json create mode 100644 src-tauri/resources/music/pentatonic-rules.json create mode 100644 src-tauri/resources/music/rhyme-patterns.json create mode 100644 src-tauri/resources/scripts/audio_to_midi.py create mode 100644 src-tauri/resources/scripts/midi_analyzer.py create mode 100644 src-tauri/src/commands/music_cmd.rs create mode 100644 src/components/content-creator/canvas/music/MusicCanvas.tsx create mode 100644 src/components/content-creator/canvas/music/MusicToolbar.tsx create mode 100644 src/components/content-creator/canvas/music/analysis/MelodyAnalysisReport.tsx create mode 100644 src/components/content-creator/canvas/music/analysis/TrackSelector.tsx create mode 100644 src/components/content-creator/canvas/music/analysis/index.ts create mode 100644 src/components/content-creator/canvas/music/api/index.ts create mode 100644 src/components/content-creator/canvas/music/api/tauri.ts create mode 100644 src/components/content-creator/canvas/music/editors/LyricsEditor.tsx create mode 100644 src/components/content-creator/canvas/music/editors/index.ts create mode 100644 src/components/content-creator/canvas/music/export/ExportDialog.tsx create mode 100644 src/components/content-creator/canvas/music/export/index.ts create mode 100644 src/components/content-creator/canvas/music/hooks/index.ts create mode 100644 src/components/content-creator/canvas/music/hooks/useLyricsEditor.ts create mode 100644 src/components/content-creator/canvas/music/hooks/useMelodyMimic.ts create mode 100644 src/components/content-creator/canvas/music/hooks/useMidiPlayback.ts create mode 100644 src/components/content-creator/canvas/music/hooks/useMusicExport.ts create mode 100644 src/components/content-creator/canvas/music/hooks/useRhyme.ts create mode 100644 src/components/content-creator/canvas/music/index.ts create mode 100644 src/components/content-creator/canvas/music/player/MidiPlayer.tsx create mode 100644 src/components/content-creator/canvas/music/player/PlaybackControls.tsx create mode 100644 src/components/content-creator/canvas/music/player/ProgressBar.tsx create mode 100644 src/components/content-creator/canvas/music/player/TempoControl.tsx create mode 100644 src/components/content-creator/canvas/music/player/VolumeControl.tsx create mode 100644 src/components/content-creator/canvas/music/player/index.ts create mode 100644 src/components/content-creator/canvas/music/registerMusicCanvas.ts create mode 100644 src/components/content-creator/canvas/music/renderers/ChordDiagram.tsx create mode 100644 src/components/content-creator/canvas/music/renderers/GuitarTabRenderer.tsx create mode 100644 src/components/content-creator/canvas/music/renderers/NumberedNotationRenderer.tsx create mode 100644 src/components/content-creator/canvas/music/renderers/PianoRollRenderer.tsx create mode 100644 src/components/content-creator/canvas/music/renderers/index.ts create mode 100644 src/components/content-creator/canvas/music/types.ts create mode 100644 src/components/content-creator/canvas/music/utils/chordDatabase.ts create mode 100644 src/components/content-creator/canvas/music/utils/exportFormatters.ts create mode 100644 src/components/content-creator/canvas/music/utils/index.ts create mode 100644 src/components/content-creator/canvas/music/utils/lyricsParser.ts create mode 100644 src/components/content-creator/canvas/music/utils/numberedNotationParser.ts create mode 100644 src/components/content-creator/canvas/music/utils/rhymeDetector.ts create mode 100644 srccontent-creator/canvas/music/utils/exportFormatters.ts delete mode 100644 test_stream.py diff --git a/TERM_SCROLLBACK_TOOL.md b/TERM_SCROLLBACK_TOOL.md deleted file mode 100644 index b6b1c8ace..000000000 --- a/TERM_SCROLLBACK_TOOL.md +++ /dev/null @@ -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/) diff --git a/artifacts/ref_model_selector.png b/artifacts/ref_model_selector.png deleted file mode 100644 index 593e216d297550298ab988e0ba9a3aa6d17fba13..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 102202 zcmeFZXFyY1^Dn#;A_PGopaRkYL5fs~^cISMf}&!f6O^I{q$(W}ih@c#AVsQz*Z~2h zLnsQ;qJVTEbfg!7Q1kA9oaZ^`{O|jIx?kRV{UWf}-h0ignOVQz%&h(3!g<|82ag>D z0N~IWJ?%>X00V!7F2ZTS|7vJD4uJnb+%M^B0y(YRKLG#}5HVO6Sh48}Gz6 z&}Qb1e)8kNHicq?8fd<0H+uYG8ggj%keY5dMdmO+gx*{oYQ!V;r(s&;wdlDs7-)jP*A@GP=LfsqCvUH1-8k}0nKc@dKySba zOuDU1Cc`8#*nXu{kABGuUj_sq^x=mBU44b34s~C8NC^lxy8ZJFOz*5IC+rk<`x+4s@Nr-21e^ zS|0h5 zEIke?%@dR`$M=v~<@7o1O`cC0hb*k4W)BvW4n)+ofl9n_2K%*sN#RKA#D>E6_{y^w zXjdE_f_E@|f7AgAXMFDNbrE&#>!Y$DaiNUI#@e8CN-V{bnWg%+Ye@sod%v>N>w$7{ z0Ewi=qRATuq)VyQ`ajGy%7O;^kJz02{0`G~92s~Drj+7y<-(?a1fT)LWJ&q$mNfN<%F=Q_5 z(EA~w>{Dcm$!zz-E_D^zGgv} zGvHm5;9dF(O3wxc!PEl{r0H{dTNi70^Kc@v9(I#o8mNERe zbLIqUiaxSo<~YOjENe6#R(hKBnr~Arx$FwX2L-;FbQn~#BQGn5Fmz(BBX#pW|2Gb6 zXP1!d(92#eyHAu1wB9KoEf<%qytSu3Aq9fviYCY+U7kkv-E%qyeT!IO)V1+|ALtB$ z34-EMZvuxYog;7psX4r0*LZvTG`HJ1Yj+q4izUFysB-9(0%!F~GWgrbGvG+$tI6jLxdWe0tv)@* zm2pBRs}mfFhbC$NFGv2dKa%>)|JRWYt53O9o}blu*`a&WXSg@U9sDqaQc&3yC3 zM|@Wh;p@uA*F%>60A>*<@#N#Z{}Q}d32GD?8tRdm8(E|XddGh&1stXZ4}6uGiJ}4c zU=>NAtNi0~P9$idZ*JT^|Bt_eYs0Jq3Qwoy{Q2LP-k`7mY05Y`_AmACPlyx@hKGgf zugUNKeQ65L0Te5Bjtl;m1?>Gu#)6{yN&m7F|JNl@JTMqUe)yk6;{g4HkFkOi3~B$E zV@@RD0u;~jgw&ls1j-HKh(leG5-_ib)pXz=x93`~+2P*6HSmptD%(AO0) z-R{0THi@m`=HZdOo}L6e=a%iVJDSkc|IYWhfg$r{14l<^=Y&t6KFP?+%6?V7D=;_M z(<8dPylm@3P$8zC(RJ$n@#A))lsKb(_NSpC>uRA@$(qWD1HN>0bhb`TIjFijT7Q?C zV8dGM0^nmP74lNYzF9S1PBN>w64_|2*xl<0qf~e5Bv~4t2vs(_cN|(ECf_vEqD*sf zps!D?in8OoKA9QSI5RVYS?hSPy0+HT{=%@SHk4Tzr6MmUcTA(Ayu80ag}gQV>5m%LMX!gx=CeMCc* z_p%-8SrTW=$XHWC%Lh7U-f~ak__*DTSYKXMpN?yOv1^9v+Z|cel0m5$Sb(`8x$V(i zwAd&Vt!P;l@PkZXga&ccE=_0^(Jd$Z$C0U0E$~yNBAi-GFl-vyaYD`5#XHBnDk+gs zRkmvUW{uK&VQ1I4Qzk#=Vn1OcO6ZSJZV3QpgMYzIbiN>FJm>mF!z`gdm@Z6oSRr%J z;G*!heY*cujtWaFE9W*D)9{;h!a+bfZpRzt=dKRTR}}-QA4%wt5KZ7Jsl5+Q4{va1 z;^O?ID}sL3q6#}E;Di_oUqG8i@sV4%w^#szp-w(M)Fv+dl=DFOtq}RuhBrI_G784r zo4Hg01-iG!60Dy1<~=A56$^-|d{vN?By^LoK3}<+b4I^p;W3G!pZrvEA(WGotfypb zq~_%woh^x4yLf)a=voJ&AF<>@;`T<^OtMQO@iCliaS)HZ#eXb2GVl?i_yT|z-Bu`Nbgqf!vx{=(2AX&m+H7q+0{LIs}q-Xub z01!eq^jz+3c+wI+RgMmT!07k>x*L7~C_4kd%>t00^M@^@U(c*~bt#+uYGd=#S@bZj zu*xcO)uU->z)7*&R7y8A)Yk6S^X4Br%cd*g1oYXIZ=2!}3|3|%92m!x3-I4xWK45dUsKq-77G?r-2~p}oTcjar zP{S*5AGR$8g_|J?T`YO@+Q|EgEaTmFajEV}Q)$O(;Py~v^1bNK&QLx@p96qGXm`CF z>gSdVGiq@w;+!M@>!BWJdQ6aHcn3NYiQ0}ku$dmPam&c68Hqf!if)2Ztb>^8feTU6 zb&;X}+)>OW0v{a!n3x;qmW5kh2C$t@Ou8+%lWbyq;@-u+pFi!;7&}OwE}?o=r@(|X z)A&xtcbV8;jED@+us^J3G~EMZseTwOs1SW_mZc5`XW4{b4`9g%3uxolL?owZT}GvG z-fivhtPALTzO3BDn0O1X@XnskkFvcc+{8dj4@5I(j>R2s-&#z-KFKHdv_c^NQlQTHg_He+RMSxYbp@6O}<|HYhku!ur0UhjP!(%QnHroz+F!AI;CZ zFrU@7*BL_iAtFq)qVP6_yhC4D0WjQ@z#9z4qJZNZgi?>}Cu~S$V)VB=b)p}q&;8

g0Tq~(&n+~YmNX9bWVgnjz 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°xGEROoTKtL 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

4qy5bs^>jlHvM9I}wiCh?ii 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

f;XwfDP(hDG=Wi0@C*~=q>`kzW>{En7qNFu9 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 Qy+C0GPgg&ebxsLQ0NrH~s{jB1 literal 0 HcmV?d00001 diff --git a/package.json b/package.json index 0921af82c..91d627af1 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src-tauri/resources/music/chord-progressions.json b/src-tauri/resources/music/chord-progressions.json new file mode 100644 index 000000000..a6d98cbc6 --- /dev/null +++ b/src-tauri/resources/music/chord-progressions.json @@ -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": [ + "共同和弦转调", + "属和弦转调", + "半音阶下行转调" + ] + } + } +} \ No newline at end of file diff --git a/src-tauri/resources/music/guofeng-patterns.json b/src-tauri/resources/music/guofeng-patterns.json new file mode 100644 index 000000000..6cce684b8 --- /dev/null +++ b/src-tauri/resources/music/guofeng-patterns.json @@ -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": "宫调式,清新优美,级进为主" + } + ] + } +} diff --git a/src-tauri/resources/music/midi-parser-rules.json b/src-tauri/resources/music/midi-parser-rules.json new file mode 100644 index 000000000..d7d9a5fa2 --- /dev/null +++ b/src-tauri/resources/music/midi-parser-rules.json @@ -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": "平静、叙述、稳定" + } + } +} diff --git a/src-tauri/resources/music/pentatonic-rules.json b/src-tauri/resources/music/pentatonic-rules.json new file mode 100644 index 000000000..26dc9c61f --- /dev/null +++ b/src-tauri/resources/music/pentatonic-rules.json @@ -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": [ + "优先使用级进(二度)保持旋律流畅", + "跳进后宜用级进反向进行", + "避免连续大跳进", + "句尾常用下行级进解决到主音" + ] + } +} diff --git a/src-tauri/resources/music/rhyme-patterns.json b/src-tauri/resources/music/rhyme-patterns.json new file mode 100644 index 000000000..ccaefe07a --- /dev/null +++ b/src-tauri/resources/music/rhyme-patterns.json @@ -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": "用于特殊效果"} + } + } +} \ No newline at end of file diff --git a/src-tauri/resources/scripts/audio_to_midi.py b/src-tauri/resources/scripts/audio_to_midi.py new file mode 100644 index 000000000..28bbf3e73 --- /dev/null +++ b/src-tauri/resources/scripts/audio_to_midi.py @@ -0,0 +1,367 @@ +#!/usr/bin/env python3 +""" +MP3 转 MIDI 工具 +使用 Demucs 分离人声 + Basic Pitch 转换 MIDI + +用法: + python audio_to_midi.py [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 [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() diff --git a/src-tauri/resources/scripts/midi_analyzer.py b/src-tauri/resources/scripts/midi_analyzer.py new file mode 100644 index 000000000..f5ac878b3 --- /dev/null +++ b/src-tauri/resources/scripts/midi_analyzer.py @@ -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() \ No newline at end of file diff --git a/src-tauri/src/app/runner.rs b/src-tauri/src/app/runner.rs index 9a9d00edd..743f99344 100644 --- a/src-tauri/src/app/runner.rs +++ b/src-tauri/src/app/runner.rs @@ -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"); diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 4fcd1b055..d979ecccf 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -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; diff --git a/src-tauri/src/commands/music_cmd.rs b/src-tauri/src/commands/music_cmd.rs new file mode 100644 index 000000000..7b25b8833 --- /dev/null +++ b/src-tauri/src/commands/music_cmd.rs @@ -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, + /// 旋律特征 + 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, + /// 缺失的依赖包 + pub missing_packages: Vec, +} + +/// 检查 Python 环境 +#[tauri::command] +pub async fn check_python_env() -> Result { + // 检查 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 { + // 获取 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 { + // 获取 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 { + 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 { + // 在开发环境中,资源文件在 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 { + 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()) +} diff --git a/src-tauri/tauri.conf.headless.json b/src-tauri/tauri.conf.headless.json index af878070e..2b57b8cf4 100644 --- a/src-tauri/tauri.conf.headless.json +++ b/src-tauri/tauri.conf.headless.json @@ -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", diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 355829b02..4280d91df 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -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", diff --git a/src/components/agent/chat/components/EmptyState.tsx b/src/components/agent/chat/components/EmptyState.tsx index 35f2e0016..45c73e463 100644 --- a/src/components/agent/chat/components/EmptyState.tsx +++ b/src/components/agent/chat/components/EmptyState.tsx @@ -16,6 +16,7 @@ import { Zap, RefreshCw, LayoutTemplate, + Music, } from "lucide-react"; /** @@ -354,6 +355,9 @@ const CATEGORIES = [ label: "通用对话", icon: , }, + { id: "social", label: "社媒内容", icon: }, + { id: "image", label: "图文海报", icon: }, + { id: "music", label: "歌词曲谱", icon: }, { id: "knowledge", label: "知识探索", @@ -364,14 +368,12 @@ const CATEGORIES = [ label: "计划规划", icon: , }, - { id: "social", label: "社媒内容", icon: }, - { id: "image", label: "图文海报", icon: }, { id: "office", label: "办公文档", icon: }, { id: "video", label: "短视频", icon:

+ p.filePath && + fileContent && + onFileClick?.(p.filePath, fileContent) + } + > + + Write + + {p.filePath || "文档.md"} + + {p.type === "pending_write_file" && ( + ... + )} +
+ ); + } else if (p.type === "text") { + const textContent = + typeof p.content === "string" ? p.content : ""; + if (!textContent || textContent.trim() === "") + return null; + return ( + + ); + } + return null; + })} + + ); + } + + // 没有 write_file,直接渲染 return ( = memo( ); } + if (canvasType === "music" && state.type === "music") { + return ( + void} + onClose={onClose} + isStreaming={isStreaming} + /> + ); + } + // 不支持的主题或状态类型不匹配 return null; }, diff --git a/src/components/content-creator/canvas/canvasUtils.ts b/src/components/content-creator/canvas/canvasUtils.ts index e5e1b855d..04e848ae8 100644 --- a/src/components/content-creator/canvas/canvasUtils.ts +++ b/src/components/content-creator/canvas/canvasUtils.ts @@ -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 = { +const THEME_TO_CANVAS_TYPE: Record = { 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; } diff --git a/src/components/content-creator/canvas/music/MusicCanvas.tsx b/src/components/content-creator/canvas/music/MusicCanvas.tsx new file mode 100644 index 000000000..9ea5e0f91 --- /dev/null +++ b/src/components/content-creator/canvas/music/MusicCanvas.tsx @@ -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 = { + 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 = 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 ( + + 🎵 + 开始创作你的歌曲 + + 在左侧对话中描述你想要创作的歌曲,AI 将帮助你完成歌词创作 + + + ); + } + + return ( + + {state.sections.map((section) => ( + handleSectionSelect(section.id)} + > + + + [ + {SECTION_DISPLAY_NAMES[section.type] || + section.type.toUpperCase()} + ] + + {section.name !== SECTION_DISPLAY_NAMES[section.type] && + section.name !== section.type && + section.name !== + (SECTION_DISPLAY_NAMES[section.type] || + section.type.toUpperCase()) && ( + {section.name} + )} + + {section.lyricsLines.map((line, index) => ( + {line} + ))} + + ))} + + ); + }; + + // 渲染简谱视图 + const renderNumberedView = () => { + return ( + + ); + }; + + // 渲染吉他谱视图 + const renderGuitarView = () => { + return ( + + ); + }; + + // 渲染钢琴谱视图 + const renderPianoView = () => { + return ( + + ); + }; + + // 根据视图模式渲染内容 + 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 ( + + + + + + + +
+ {state.viewMode === "lyrics" && "🎤 歌词"} + {state.viewMode === "numbered" && "🎼 简谱"} + {state.viewMode === "guitar" && "🎸 吉他谱"} + {state.viewMode === "piano" && "🎹 钢琴谱"} + {isStreaming && ( + + 生成中... + + )} +
+ {state.viewMode === "lyrics" && ( + + )} +
+ {renderContent()} +
+
+
+ + + + 🎵 {state.spec.title} | {state.spec.key} | {state.spec.tempo} BPM + + + {stats.totalSections} 段 | {stats.totalLines} 行 |{" "} + {stats.totalChars} 字 + + + + {toastMessage} +
+ ); + }, +); + +MusicCanvas.displayName = "MusicCanvas"; diff --git a/src/components/content-creator/canvas/music/MusicToolbar.tsx b/src/components/content-creator/canvas/music/MusicToolbar.tsx new file mode 100644 index 000000000..22ae51900 --- /dev/null +++ b/src/components/content-creator/canvas/music/MusicToolbar.tsx @@ -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 = { + lyrics: , + numbered: , + guitar: , + piano: , +}; + +/** + * 音乐画布工具栏 + */ +export const MusicToolbar: React.FC = memo( + ({ + spec, + viewMode, + isPlaying, + canUndo, + canRedo, + onViewModeChange, + onPlayToggle, + onUndo, + onRedo, + onExport, + onClose, + }) => { + const viewModes: MusicViewMode[] = [ + "lyrics", + "numbered", + "guitar", + "piano", + ]; + + return ( + + + {spec.title} + + {spec.key} | {spec.tempo} BPM + + + + + {viewModes.map((mode) => ( + onViewModeChange(mode)} + title={VIEW_MODE_LABELS[mode]} + > + {VIEW_MODE_ICONS[mode]} + {VIEW_MODE_LABELS[mode]} + + ))} + + + + + {isPlaying ? : } + + + + + + + + + + + + + + + + + + + + + + + + ); + }, +); + +MusicToolbar.displayName = "MusicToolbar"; diff --git a/src/components/content-creator/canvas/music/analysis/MelodyAnalysisReport.tsx b/src/components/content-creator/canvas/music/analysis/MelodyAnalysisReport.tsx new file mode 100644 index 000000000..fd1c4baaf --- /dev/null +++ b/src/components/content-creator/canvas/music/analysis/MelodyAnalysisReport.tsx @@ -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 = + memo(({ report }) => { + const selectedTrack = report.tracks[report.selectedTrack]; + + return ( + + + + 旋律特征分析 + + + + {/* 调式 */} + + + + 调式 + + {report.mode} + 歌曲的调式和音阶类型 + + + {/* BPM */} + + + + 速度 + + {report.bpm} BPM + 每分钟节拍数 + + + {/* 拍号 */} + + + + 拍号 + + {report.timeSignature} + 节拍时间签名 + + + {/* 音域 */} + + + + 音域 + + {report.range} 半音 + 最高音与最低音的跨度 + + + {/* 平均音高 */} + + + + 平均音高 + + {report.avgPitch.toFixed(1)} + 旋律的平均音高位置 + + + {/* 音程跳跃 */} + + + + 音程跳跃 + + + {(report.intervalJumps * 100).toFixed(0)}% + + 大跳音程的出现频率 + + + {/* 节奏复杂度 */} + + + + 节奏复杂度 + + + {(report.rhythmComplexity * 100).toFixed(0)}% + + 节奏变化的复杂程度 + + + + {/* 选中的音轨信息 */} + {selectedTrack && ( + + 分析音轨 + + {selectedTrack.name} ({selectedTrack.instrument}) -{" "} + {selectedTrack.noteCount} 音符 + {selectedTrack.isVocal && " · 人声"} + + + )} + + ); + }); + +MelodyAnalysisReportComponent.displayName = "MelodyAnalysisReport"; diff --git a/src/components/content-creator/canvas/music/analysis/TrackSelector.tsx b/src/components/content-creator/canvas/music/analysis/TrackSelector.tsx new file mode 100644 index 000000000..2d797a4c0 --- /dev/null +++ b/src/components/content-creator/canvas/music/analysis/TrackSelector.tsx @@ -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 = memo( + ({ tracks, selectedTrack, onSelectTrack }) => { + return ( + + 选择要分析的音轨 + + + {tracks.map((track) => ( + onSelectTrack(track.index)} + > + + {track.isVocal ? : } + + + + {track.name} + + {track.instrument} · {track.noteCount} 音符 + {track.isVocal && 人声} + + + + {track.index === selectedTrack && ( + + + + )} + + ))} + + + ); + }, +); + +TrackSelector.displayName = "TrackSelector"; diff --git a/src/components/content-creator/canvas/music/analysis/index.ts b/src/components/content-creator/canvas/music/analysis/index.ts new file mode 100644 index 000000000..72223f876 --- /dev/null +++ b/src/components/content-creator/canvas/music/analysis/index.ts @@ -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"; diff --git a/src/components/content-creator/canvas/music/api/index.ts b/src/components/content-creator/canvas/music/api/index.ts new file mode 100644 index 000000000..d271964e7 --- /dev/null +++ b/src/components/content-creator/canvas/music/api/index.ts @@ -0,0 +1,6 @@ +/** + * @file API 模块入口 + * @module components/content-creator/canvas/music/api + */ + +export * from "./tauri"; diff --git a/src/components/content-creator/canvas/music/api/tauri.ts b/src/components/content-creator/canvas/music/api/tauri.ts new file mode 100644 index 000000000..1443f5b4a --- /dev/null +++ b/src/components/content-creator/canvas/music/api/tauri.ts @@ -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 { + return invoke("check_python_env"); +} + +/** + * 分析 MIDI 文件 + * @param midiPath MIDI 文件路径 + */ +export async function analyzeMidi( + midiPath: string, +): Promise { + return invoke("analyze_midi", { midiPath }); +} + +/** + * 将 MP3 转换为 MIDI + * @param mp3Path MP3 文件路径 + * @param outputPath 输出 MIDI 文件路径 + */ +export async function convertMp3ToMidi( + mp3Path: string, + outputPath: string, +): Promise { + return invoke("convert_mp3_to_midi", { mp3Path, outputPath }); +} + +/** + * 加载音乐资源文件 + * @param resourceName 资源文件名 (相对于 resources/music/ 目录) + */ +export async function loadMusicResource(resourceName: string): Promise { + return invoke("load_music_resource", { resourceName }); +} + +/** + * 安装 Python 依赖 + */ +export async function installPythonDependencies(): Promise { + return invoke("install_python_dependencies"); +} + +/** + * 加载押韵数据库 + */ +export async function loadRhymePatterns(): Promise { + const content = await loadMusicResource("rhyme-patterns.json"); + return JSON.parse(content); +} + +/** + * 加载和弦进行库 + */ +export async function loadChordProgressions(): Promise { + const content = await loadMusicResource("chord-progressions.json"); + return JSON.parse(content); +} + +/** + * 加载五声音阶规则 + */ +export async function loadPentatonicRules(): Promise { + const content = await loadMusicResource("pentatonic-rules.json"); + return JSON.parse(content); +} + +/** + * 加载国风旋律模式 + */ +export async function loadGuofengPatterns(): Promise { + const content = await loadMusicResource("guofeng-patterns.json"); + return JSON.parse(content); +} + +/** + * 加载 MIDI 解析规则 + */ +export async function loadMidiParserRules(): Promise { + const content = await loadMusicResource("midi-parser-rules.json"); + return JSON.parse(content); +} diff --git a/src/components/content-creator/canvas/music/editors/LyricsEditor.tsx b/src/components/content-creator/canvas/music/editors/LyricsEditor.tsx new file mode 100644 index 000000000..27cb09d81 --- /dev/null +++ b/src/components/content-creator/canvas/music/editors/LyricsEditor.tsx @@ -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 = 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 ( + +
+ + <Music size={16} /> + 歌词编辑 + +
+ + 🎵 + 还没有歌词段落,点击下方按钮添加 + + setShowAddMenu(!showAddMenu)}> + + 添加段落 + + {showAddMenu && ( + + {SECTION_TYPES.map(({ type, label, icon }) => ( + handleAddSection(type)} + > + {icon} + {label} + + ))} + + )} + + +
+ ); + } + + return ( + +
+ + <Music size={16} /> + 歌词编辑 + + + + + + +
+ + + {sections.map((section, index) => ( + handleSectionClick(section.id)} + > + + + {section.type} + {section.name} + + {section.lyricsLines.length} 行 |{" "} + {countSectionChars(section)} 字 + + + + { + e.stopPropagation(); + onMoveSection(section.id, "up"); + }} + disabled={index === 0} + title="上移" + > + + + { + e.stopPropagation(); + onMoveSection(section.id, "down"); + }} + disabled={index === sections.length - 1} + title="下移" + > + + + { + e.stopPropagation(); + onDeleteSection(section.id); + }} + title="删除" + > + + + + + + {section.lyricsLines.slice(0, 3).map((line, i) => ( + {line} + ))} + {section.lyricsLines.length > 3 && ...} + + + ))} + + + setShowAddMenu(!showAddMenu)}> + + 添加段落 + + {showAddMenu && ( + + {SECTION_TYPES.map(({ type, label, icon }) => ( + handleAddSection(type)} + > + {icon} + {label} + + ))} + + )} + + +
+ ); + }, +); + +LyricsEditor.displayName = "LyricsEditor"; diff --git a/src/components/content-creator/canvas/music/editors/index.ts b/src/components/content-creator/canvas/music/editors/index.ts new file mode 100644 index 000000000..4786102d2 --- /dev/null +++ b/src/components/content-creator/canvas/music/editors/index.ts @@ -0,0 +1,6 @@ +/** + * @file 编辑器模块入口 + * @module components/content-creator/canvas/music/editors + */ + +export { LyricsEditor } from "./LyricsEditor"; diff --git a/src/components/content-creator/canvas/music/export/ExportDialog.tsx b/src/components/content-creator/canvas/music/export/ExportDialog.tsx new file mode 100644 index 000000000..1d1229da8 --- /dev/null +++ b/src/components/content-creator/canvas/music/export/ExportDialog.tsx @@ -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 = memo( + ({ isOpen, sections, spec, onClose }) => { + const [successMessage, setSuccessMessage] = useState(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 ( + + e.stopPropagation()}> + + 导出作品 + ✕ + + + + + {/* Suno 格式 */} + + + + + + + Suno AI + AI 音乐生成平台 + + + + + handleCopy("Suno", () => { + const result = exportSuno(sections, spec); + return `${result.lyrics}\n\n---\nStyle: ${result.style}\nTags: ${result.tags.join(", ")}`; + }) + } + > + + 复制 + + + handleDownload( + "Suno", + () => { + const result = exportSuno(sections, spec); + return `${result.lyrics}\n\n---\nStyle: ${result.style}\nTags: ${result.tags.join(", ")}`; + }, + "txt", + "text/plain", + ) + } + > + + 下载 + + + + + {/* 纯文本格式 */} + + + + + + + 纯文本 + 简单的文本格式 + + + + + handleCopy("纯文本", () => exportText(sections, spec)) + } + > + + 复制 + + + handleDownload( + "纯文本", + () => exportText(sections, spec), + "txt", + "text/plain", + ) + } + > + + 下载 + + + + + {/* Markdown 格式 */} + + + + + + + Markdown + 支持格式化的文本 + + + + + handleCopy("Markdown", () => + exportMarkdown(sections, spec), + ) + } + > + + 复制 + + + handleDownload( + "Markdown", + () => exportMarkdown(sections, spec), + "md", + "text/markdown", + ) + } + > + + 下载 + + + + + {/* JSON 格式 */} + + + + + + + JSON + 结构化数据格式 + + + + + handleCopy("JSON", () => exportJSON(sections, spec)) + } + > + + 复制 + + + handleDownload( + "JSON", + () => exportJSON(sections, spec), + "json", + "application/json", + ) + } + > + + 下载 + + + + + + {successMessage && ( + {successMessage} + )} + + + + ); + }, +); + +ExportDialog.displayName = "ExportDialog"; diff --git a/src/components/content-creator/canvas/music/export/index.ts b/src/components/content-creator/canvas/music/export/index.ts new file mode 100644 index 000000000..79c4e9f4c --- /dev/null +++ b/src/components/content-creator/canvas/music/export/index.ts @@ -0,0 +1,6 @@ +/** + * @file 导出模块入口 + * @module components/content-creator/canvas/music/export + */ + +export { ExportDialog, type ExportDialogProps } from "./ExportDialog"; diff --git a/src/components/content-creator/canvas/music/hooks/index.ts b/src/components/content-creator/canvas/music/hooks/index.ts new file mode 100644 index 000000000..8f56cfb73 --- /dev/null +++ b/src/components/content-creator/canvas/music/hooks/index.ts @@ -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"; diff --git a/src/components/content-creator/canvas/music/hooks/useLyricsEditor.ts b/src/components/content-creator/canvas/music/hooks/useLyricsEditor.ts new file mode 100644 index 000000000..777a36380 --- /dev/null +++ b/src/components/content-creator/canvas/music/hooks/useLyricsEditor.ts @@ -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(initialSections); + const [currentSectionId, setCurrentSectionId] = useState(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, + }; +} diff --git a/src/components/content-creator/canvas/music/hooks/useMelodyMimic.ts b/src/components/content-creator/canvas/music/hooks/useMelodyMimic.ts new file mode 100644 index 000000000..6c47b1a6d --- /dev/null +++ b/src/components/content-creator/canvas/music/hooks/useMelodyMimic.ts @@ -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; + /** 选择音轨 */ + 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("idle"); + const [mimicLevel, setMimicLevel] = useState(initialMimicLevel); + const [pythonEnv, setPythonEnv] = useState(null); + const [melodyReport, setMelodyReport] = useState( + null, + ); + const [lyricsReport, setLyricsReport] = useState( + null, + ); + const [consistencyScore, setConsistencyScore] = + useState(null); + const [error, setError] = useState(null); + const [_midiPath, setMidiPath] = useState(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, + }; +} diff --git a/src/components/content-creator/canvas/music/hooks/useMidiPlayback.ts b/src/components/content-creator/canvas/music/hooks/useMidiPlayback.ts new file mode 100644 index 000000000..48200345e --- /dev/null +++ b/src/components/content-creator/canvas/music/hooks/useMidiPlayback.ts @@ -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; + /** 播放 */ + play: () => Promise; + /** 暂停 */ + 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({ + isPlaying: false, + currentTime: 0, + duration: 0, + playbackRate: initialPlaybackRate, + volume: initialVolume, + loop: false, + currentBar: 0, + totalBars: 0, + }); + + // 音轨列表 + const [tracks, setTracks] = useState([]); + + // 加载状态 + const [isLoaded, setIsLoaded] = useState(false); + const [error, setError] = useState(null); + + // Refs + const midiRef = useRef(null); + const synthsRef = useRef([]); + const partsRef = useRef([]); + const animationFrameRef = useRef(null); + const startTimeRef = useRef(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, + }; +} diff --git a/src/components/content-creator/canvas/music/hooks/useMusicExport.ts b/src/components/content-creator/canvas/music/hooks/useMusicExport.ts new file mode 100644 index 000000000..fcad371e6 --- /dev/null +++ b/src/components/content-creator/canvas/music/hooks/useMusicExport.ts @@ -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; + /** 重置状态 */ + reset: () => void; +} + +/** + * 音乐导出 Hook + */ +export function useMusicExport( + options: UseMusicExportOptions = {}, +): UseMusicExportReturn { + const { onExportComplete, onError } = options; + + const [state, setState] = useState("idle"); + const [error, setError] = useState(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, + }; +} diff --git a/src/components/content-creator/canvas/music/hooks/useRhyme.ts b/src/components/content-creator/canvas/music/hooks/useRhyme.ts new file mode 100644 index 000000000..30de4b0f2 --- /dev/null +++ b/src/components/content-creator/canvas/music/hooks/useRhyme.ts @@ -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(() => { + 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(() => { + if (sectionAnalyses.length === 0) return "FREE"; + + const schemeCounts: Record = { + 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, + }; +} diff --git a/src/components/content-creator/canvas/music/index.ts b/src/components/content-creator/canvas/music/index.ts new file mode 100644 index 000000000..daa857cf7 --- /dev/null +++ b/src/components/content-creator/canvas/music/index.ts @@ -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"; diff --git a/src/components/content-creator/canvas/music/player/MidiPlayer.tsx b/src/components/content-creator/canvas/music/player/MidiPlayer.tsx new file mode 100644 index 000000000..f775dcbcf --- /dev/null +++ b/src/components/content-creator/canvas/music/player/MidiPlayer.tsx @@ -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 = memo( + ({ initialMidiUrl, showTracks = true, onEnded }) => { + const fileInputRef = React.useRef(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) => { + const file = e.target.files?.[0]; + if (file) { + loadMidi(file); + } + }, + [loadMidi], + ); + + // 处理循环切换 + const handleLoopToggle = useCallback(() => { + setLoop(!state.loop); + }, [state.loop, setLoop]); + + return ( + + {/* 顶部:上传按钮和播放控制 */} + + + + 上传 MIDI + + + + + + + + 循环 + + + + {/* 中部:进度条 */} + {isLoaded && ( + + + + )} + + {/* 底部:速度和音量控制 */} + + + + + + + {/* 音轨列表 */} + {showTracks && isLoaded && tracks.length > 0 && ( + + {tracks.map((track) => ( + toggleTrack(track.index)} + > + toggleTrack(track.index)} + onClick={(e) => e.stopPropagation()} + /> + + {track.name} + + {track.instrument} · {track.noteCount} 音符 + + + + ))} + + )} + + {/* 错误提示 */} + {error && {error}} + + ); + }, +); + +MidiPlayer.displayName = "MidiPlayer"; diff --git a/src/components/content-creator/canvas/music/player/PlaybackControls.tsx b/src/components/content-creator/canvas/music/player/PlaybackControls.tsx new file mode 100644 index 000000000..7afc79ab6 --- /dev/null +++ b/src/components/content-creator/canvas/music/player/PlaybackControls.tsx @@ -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 = memo( + ({ isPlaying, isLoaded, onPlay, onPause, onStop }) => { + return ( + + {/* 播放/暂停按钮 */} + + {isPlaying ? : } + + + {/* 停止按钮 */} + + + + + ); + }, +); + +PlaybackControls.displayName = "PlaybackControls"; diff --git a/src/components/content-creator/canvas/music/player/ProgressBar.tsx b/src/components/content-creator/canvas/music/player/ProgressBar.tsx new file mode 100644 index 000000000..3c29e3104 --- /dev/null +++ b/src/components/content-creator/canvas/music/player/ProgressBar.tsx @@ -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 = memo( + ({ + currentTime, + duration, + currentBar, + totalBars, + onSeek, + disabled = false, + }) => { + const containerRef = useRef(null); + const [isDragging, setIsDragging] = useState(false); + const [hoverProgress, setHoverProgress] = useState(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 ( + + + {formatTime(currentTime)} + {currentBar !== undefined && totalBars !== undefined && ( + + 小节: {currentBar + 1} / {totalBars} + + )} + {formatTime(duration)} + + + + + + + + ); + }, +); + +ProgressBar.displayName = "ProgressBar"; diff --git a/src/components/content-creator/canvas/music/player/TempoControl.tsx b/src/components/content-creator/canvas/music/player/TempoControl.tsx new file mode 100644 index 000000000..0e77884a8 --- /dev/null +++ b/src/components/content-creator/canvas/music/player/TempoControl.tsx @@ -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 = memo( + ({ playbackRate, onChange, disabled = false, showPresets = true }) => { + const handleSliderChange = useCallback( + (e: React.ChangeEvent) => { + const value = parseFloat(e.target.value); + onChange(value); + }, + [onChange], + ); + + const handlePresetClick = useCallback( + (rate: number) => { + onChange(rate); + }, + [onChange], + ); + + return ( + + + + + + + + {playbackRate.toFixed(1)}x + + + {showPresets && ( + + {PRESET_RATES.map((rate) => ( + handlePresetClick(rate)} + disabled={disabled} + title={`${rate}x 速度`} + > + {rate}x + + ))} + + )} + + ); + }, +); + +TempoControl.displayName = "TempoControl"; diff --git a/src/components/content-creator/canvas/music/player/VolumeControl.tsx b/src/components/content-creator/canvas/music/player/VolumeControl.tsx new file mode 100644 index 000000000..23721622a --- /dev/null +++ b/src/components/content-creator/canvas/music/player/VolumeControl.tsx @@ -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 = memo( + ({ volume, onChange, disabled = false }) => { + const [isMuted, setIsMuted] = React.useState(false); + const [previousVolume, setPreviousVolume] = React.useState(volume); + + const handleSliderChange = useCallback( + (e: React.ChangeEvent) => { + 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 ( + + + + + + + + {percentage}% + + + ); + }, +); + +VolumeControl.displayName = "VolumeControl"; diff --git a/src/components/content-creator/canvas/music/player/index.ts b/src/components/content-creator/canvas/music/player/index.ts new file mode 100644 index 000000000..eac6d3f10 --- /dev/null +++ b/src/components/content-creator/canvas/music/player/index.ts @@ -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"; diff --git a/src/components/content-creator/canvas/music/registerMusicCanvas.ts b/src/components/content-creator/canvas/music/registerMusicCanvas.ts new file mode 100644 index 000000000..5b40df44c --- /dev/null +++ b/src/components/content-creator/canvas/music/registerMusicCanvas.ts @@ -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, +}; + +/** + * 注册音乐画布到全局注册中心 + */ +export function registerMusicCanvas(): void { + canvasRegistry.register(musicCanvasPlugin); +} + +/** + * 注销音乐画布 + */ +export function unregisterMusicCanvas(): void { + canvasRegistry.unregister("music"); +} diff --git a/src/components/content-creator/canvas/music/renderers/ChordDiagram.tsx b/src/components/content-creator/canvas/music/renderers/ChordDiagram.tsx new file mode 100644 index 000000000..b0c7386bb --- /dev/null +++ b/src/components/content-creator/canvas/music/renderers/ChordDiagram.tsx @@ -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 = memo( + ({ chord, size = "medium", showName = true }) => { + // 获取和弦信息 + const chordInfo: ChordInfo | null = + typeof chord === "string" ? getChordInfo(chord) : chord; + + if (!chordInfo) { + return ( + + {showName && ( + + {typeof chord === "string" ? chord : "?"} + + )} +
+ 未知和弦 +
+
+ ); + } + + 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 ( + + {showName && {chordInfo.name}} + + {/* 琴枕(如果是第一品位) */} + {!showBaseFret && ( + + )} + + {/* 品位数字(如果不是第一品位) */} + {showBaseFret && ( + + {baseFret} + + )} + + {/* 横线(品丝) */} + {Array.from({ length: fretCount + 1 }).map((_, i) => ( + + ))} + + {/* 竖线(琴弦) */} + {Array.from({ length: 6 }).map((_, i) => ( + + ))} + + {/* 手指位置 */} + {chordInfo.fingering.map((fret, stringIndex) => { + const x = startX + stringIndex * stringGap; + + // 不弹的弦 + if (fret === -1) { + return ( + + × + + ); + } + + // 空弦 + if (fret === 0) { + return ( + + ); + } + + // 按弦位置 + const adjustedFret = fret - baseFret + 1; + const y = startY + (adjustedFret - 0.5) * fretHeight; + + return ( + + ); + })} + + {/* 横按标记(如果所有弦在同一品位有相同的最小值) */} + {chordInfo.fret > 0 && ( + + )} + + + ); + }, +); + +ChordDiagram.displayName = "ChordDiagram"; + +export default ChordDiagram; diff --git a/src/components/content-creator/canvas/music/renderers/GuitarTabRenderer.tsx b/src/components/content-creator/canvas/music/renderers/GuitarTabRenderer.tsx new file mode 100644 index 000000000..9a2e47bc7 --- /dev/null +++ b/src/components/content-creator/canvas/music/renderers/GuitarTabRenderer.tsx @@ -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(); + for (const bar of section.bars) { + if (bar.chord) { + chords.add(bar.chord); + } + } + return Array.from(chords); +} + +/** + * 吉他谱渲染组件 + */ +export const GuitarTabRenderer: React.FC = memo( + ({ sections, currentSectionId, onSectionSelect }) => { + // 检查是否有和弦数据 + const hasChordData = useMemo(() => { + return sections.some((section) => section.bars.some((bar) => bar.chord)); + }, [sections]); + + if (sections.length === 0) { + return ( + +
🎸
+
+ 暂无吉他谱数据 +
+
+ 请在创作时要求 AI 生成带和弦标记的歌词 +
+
+ ); + } + + if (!hasChordData) { + // 没有和弦数据,显示歌词和提示 + return ( + + {sections.map((section) => ( + onSectionSelect(section.id)} + > + + [{section.type.toUpperCase()}] + + {section.name} + + +
+ {section.lyricsLines.map((line, index) => ( + {line} + ))} +
+ + 💡 提示:要显示吉他和弦图,请在创作时要求 AI + 在歌词上方标注和弦(如 C, Am, G 等) + +
+ ))} +
+ ); + } + + return ( + + {sections.map((section) => { + const uniqueChords = extractUniqueChords(section); + + return ( + onSectionSelect(section.id)} + > + + [{section.type.toUpperCase()}] + + {section.name} + + + + {/* 和弦图展示 */} + {uniqueChords.length > 0 && ( + + {uniqueChords.map((chord) => ( + + ))} + + )} + + {/* 带和弦的歌词 */} +
+ {section.bars.map((bar) => { + if (!bar.chord && !bar.lyrics) return null; + return ( + + {bar.chord && ( + + {bar.chord} + + )} + {bar.lyrics && {bar.lyrics}} + + ); + })} +
+ + {/* 纯歌词行(如果有) */} + {section.lyricsLines.length > 0 && + section.bars.every((b) => !b.lyrics) && ( + + {section.lyricsLines.map((line, index) => ( + {line} + ))} + + )} +
+ ); + })} +
+ ); + }, +); + +GuitarTabRenderer.displayName = "GuitarTabRenderer"; + +export default GuitarTabRenderer; diff --git a/src/components/content-creator/canvas/music/renderers/NumberedNotationRenderer.tsx b/src/components/content-creator/canvas/music/renderers/NumberedNotationRenderer.tsx new file mode 100644 index 000000000..19b0fcaa3 --- /dev/null +++ b/src/components/content-creator/canvas/music/renderers/NumberedNotationRenderer.tsx @@ -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 = { + 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 ( + + {note.octave > 0 && ·} + + {displayPitch} + {note.dotted && .} + {dashes} + + {note.octave < 0 && ·} + + ); +}); + +NoteDisplay.displayName = "NoteDisplay"; + +/** + * 渲染单个小节 + */ +const BarDisplay: React.FC<{ bar: Bar }> = memo(({ bar }) => { + return ( + + {bar.chord || "\u00A0"} + + {bar.notes.length > 0 ? ( + bar.notes.map((note, index) => ( + + )) + ) : ( + - + )} + + {bar.lyrics || "\u00A0"} + + ); +}); + +BarDisplay.displayName = "BarDisplay"; + +/** + * 简谱渲染组件 + */ +export const NumberedNotationRenderer: React.FC = + memo(({ sections, currentSectionId, onSectionSelect }) => { + if (sections.length === 0) { + return ( + +
🎼
+
暂无简谱数据
+
+ 请在创作时要求 AI 生成带简谱的歌词 +
+
+ ); + } + + // 检查是否有任何小节包含音符数据 + const hasNotationData = sections.some((section) => + section.bars.some((bar) => bar.notes.length > 0), + ); + + if (!hasNotationData) { + // 如果没有简谱数据,显示歌词和和弦 + return ( + + {sections.map((section) => ( + onSectionSelect(section.id)} + > + + + [ + {SECTION_DISPLAY_NAMES[section.type] || + section.type.toUpperCase()} + ] + + + {section.name} + + +
+ {section.lyricsLines.length > 0 ? ( + section.lyricsLines.map((line, index) => ( +
+ {line} +
+ )) + ) : ( +
暂无歌词
+ )} +
+
+ 💡 提示:要显示简谱,请在创作时要求 AI 使用简谱格式输出 +
+
+ ))} +
+ ); + } + + return ( + + {sections.map((section) => ( + onSectionSelect(section.id)} + > + + + [ + {SECTION_DISPLAY_NAMES[section.type] || + section.type.toUpperCase()} + ] + + + {section.name} + + + + {section.bars.map((bar) => ( + + ))} + + + ))} + + ); + }); + +NumberedNotationRenderer.displayName = "NumberedNotationRenderer"; + +export default NumberedNotationRenderer; diff --git a/src/components/content-creator/canvas/music/renderers/PianoRollRenderer.tsx b/src/components/content-creator/canvas/music/renderers/PianoRollRenderer.tsx new file mode 100644 index 000000000..7a265481c --- /dev/null +++ b/src/components/content-creator/canvas/music/renderers/PianoRollRenderer.tsx @@ -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(); + 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 ( + + {/* 白键 */} + {WHITE_KEYS.map((key, index) => { + const keyIndex = index + octaveOffset * 7; + const isActive = activeKeys.has(keyIndex); + return ( + + {octaveOffset === 0 && key} + + ); + })} + {/* 黑键 */} + {BLACK_KEY_OFFSETS.map((offset, index) => { + if (offset === null) return null; + const _keyIndex = index + octaveOffset * 7 + 0.5; // 黑键用 .5 表示 + const isActive = false; // 简谱不支持黑键 + return ( + + ); + })} + + ); + }; + + return ( +
+ {/* 低音区 */} + {renderOctave(-1)} + {/* 中音区 */} + {renderOctave(0)} + {/* 高音区 */} + {renderOctave(1)} +
+ ); +}); + +MiniPiano.displayName = "MiniPiano"; + +/** + * 钢琴谱渲染组件 + */ +export const PianoRollRenderer: React.FC = 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 ( + +
🎹
+
+ 暂无钢琴谱数据 +
+
+ 请在创作时要求 AI 生成带简谱的歌词 +
+
+ ); + } + + if (!hasNoteData) { + // 没有音符数据,显示歌词和提示 + return ( + + {sections.map((section) => ( + onSectionSelect(section.id)} + > + + [{section.type.toUpperCase()}] + + {section.name} + + +
+ {section.lyricsLines.map((line, index) => ( + {line} + ))} +
+ + 💡 提示:要显示钢琴谱,请在创作时要求 AI 使用简谱格式输出(如 1 + 2 3 4 5 6 7) + +
+ ))} +
+ ); + } + + return ( + + {sections.map((section) => ( + onSectionSelect(section.id)} + > + + [{section.type.toUpperCase()}] + + {section.name} + + + + + + {section.bars.map((bar) => ( + + {bar.chord && {bar.chord}} + + {bar.lyrics && {bar.lyrics}} + + ))} + + + + {/* 歌词行(如果小节没有歌词) */} + {section.lyricsLines.length > 0 && + section.bars.every((b) => !b.lyrics) && ( +
+ {section.lyricsLines.map((line, index) => ( + {line} + ))} +
+ )} +
+ ))} +
+ ); + }, +); + +PianoRollRenderer.displayName = "PianoRollRenderer"; + +export default PianoRollRenderer; diff --git a/src/components/content-creator/canvas/music/renderers/index.ts b/src/components/content-creator/canvas/music/renderers/index.ts new file mode 100644 index 000000000..e5db408c6 --- /dev/null +++ b/src/components/content-creator/canvas/music/renderers/index.ts @@ -0,0 +1,8 @@ +/** + * @file 渲染器组件导出 + */ + +export { NumberedNotationRenderer } from "./NumberedNotationRenderer"; +export { ChordDiagram } from "./ChordDiagram"; +export { GuitarTabRenderer } from "./GuitarTabRenderer"; +export { PianoRollRenderer } from "./PianoRollRenderer"; diff --git a/src/components/content-creator/canvas/music/types.ts b/src/components/content-creator/canvas/music/types.ts new file mode 100644 index 000000000..556e32e10 --- /dev/null +++ b/src/components/content-creator/canvas/music/types.ts @@ -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 = { + 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 = { + intro: "Intro", + verse: "Verse", + "pre-chorus": "Pre-Chorus", + chorus: "Chorus", + bridge: "Bridge", + interlude: "Interlude", + outro: "Outro", +}; + +/** 调式情感特征映射 */ +export const MODE_EMOTIONAL_FEATURES: Record = { + major: "明亮、欢快、积极", + minor: "忧伤、深沉、内敛", + dorian: "爵士、蓝调、神秘", + mixolydian: "摇滚、乡村、放松", + blues: "蓝调、忧郁、深情", + gong: "明亮、庄重、喜庆", + shang: "深沉、内敛、怀旧", + jue: "清新、空灵、禅意", + zhi: "热情、奔放、豪迈", + yu: "忧伤、婉转、抒情", +}; + +/** 歌曲类型显示名称 */ +export const SONG_TYPE_LABELS: Record = { + pop: "流行", + folk: "民谣", + rock: "摇滚", + guofeng: "古风", + rap: "说唱", + rnb: "R&B", + electronic: "电子", + other: "其他", +}; + +/** 情感基调显示名称 */ +export const MOOD_LABELS: Record = { + joyful: "欢快明亮", + gentle: "温柔抒情", + sorrowful: "忧伤低沉", + passionate: "激昂热血", + mysterious: "神秘空灵", + nostalgic: "怀旧复古", +}; + +/** 创作模式显示名称 */ +export const CREATION_MODE_LABELS: Record = { + coach: "教练模式", + express: "快速模式", + hybrid: "混合模式", +}; + +/** 视图模式显示名称 */ +export const VIEW_MODE_LABELS: Record = { + lyrics: "歌词", + numbered: "简谱", + guitar: "吉他谱", + piano: "钢琴谱", +}; diff --git a/src/components/content-creator/canvas/music/utils/chordDatabase.ts b/src/components/content-creator/canvas/music/utils/chordDatabase.ts new file mode 100644 index 000000000..de6ef5acd --- /dev/null +++ b/src/components/content-creator/canvas/music/utils/chordDatabase.ts @@ -0,0 +1,277 @@ +/** + * @file 和弦数据库 + * @description 常用吉他和弦指法数据 + */ + +import type { ChordInfo } from "../types"; + +/** + * 吉他和弦数据库 + * fingering: 6弦到1弦的品位,-1表示不弹,0表示空弦 + */ +export const CHORD_DATABASE: Record = { + // 大三和弦 + 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 = { + 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); +} diff --git a/src/components/content-creator/canvas/music/utils/exportFormatters.ts b/src/components/content-creator/canvas/music/utils/exportFormatters.ts new file mode 100644 index 000000000..cbe4c8c75 --- /dev/null +++ b/src/components/content-creator/canvas/music/utils/exportFormatters.ts @@ -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 = { + 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 = { + 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 = { + 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 = { + 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 = { + 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); +} diff --git a/src/components/content-creator/canvas/music/utils/index.ts b/src/components/content-creator/canvas/music/utils/index.ts new file mode 100644 index 000000000..462bb6b36 --- /dev/null +++ b/src/components/content-creator/canvas/music/utils/index.ts @@ -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"; diff --git a/src/components/content-creator/canvas/music/utils/lyricsParser.ts b/src/components/content-creator/canvas/music/utils/lyricsParser.ts new file mode 100644 index 000000000..b2803b58e --- /dev/null +++ b/src/components/content-creator/canvas/music/utils/lyricsParser.ts @@ -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 = { + // 英文标记 + 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 = { + 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, + }; +} diff --git a/src/components/content-creator/canvas/music/utils/numberedNotationParser.ts b/src/components/content-creator/canvas/music/utils/numberedNotationParser.ts new file mode 100644 index 000000000..2d2d4ff09 --- /dev/null +++ b/src/components/content-creator/canvas/music/utils/numberedNotationParser.ts @@ -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 = { + 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)); +} diff --git a/src/components/content-creator/canvas/music/utils/rhymeDetector.ts b/src/components/content-creator/canvas/music/utils/rhymeDetector.ts new file mode 100644 index 000000000..268738b6c --- /dev/null +++ b/src/components/content-creator/canvas/music/utils/rhymeDetector.ts @@ -0,0 +1,601 @@ +/** + * @file 押韵检测工具 + * @description 基于拼音韵母的中文押韵检测 + * @module components/content-creator/canvas/music/utils/rhymeDetector + */ + +/** + * 韵母分组 - 相同组内的韵母可以押韵 + */ +const RHYME_GROUPS: Record = { + // 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 = { + // 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 = { + ai: ["爱", "在", "来", "开", "怀", "猜", "等待", "期待"], + an: ["伴", "暖", "看", "岸", "山", "田", "温暖", "陪伴"], + ang: ["乡", "长", "方", "香", "望", "想", "希望", "远方"], + ao: ["好", "老", "少", "跑", "笑", "闹", "美好", "年少"], + ei: ["美", "累", "泪", "岁", "醉", "回", "珍贵", "无悔"], + en: ["等", "冷", "疼", "能", "深", "真", "永恒", "认真"], + eng: ["梦", "风", "成", "冲", "勇", "空", "成功", "感动"], + i: ["力", "立", "起", "地", "意", "义", "坚毅", "奇迹"], + ian: ["年", "天", "前", "甜", "变", "见", "遇见", "青春"], + ing: ["情", "心", "亲", "信", "星", "影", "心情", "感情"], + iu: ["留", "久", "流", "求", "收", "秋", "停留", "不朽"], + ong: ["中", "痛", "重", "懂", "动", "梦", "感动", "心痛"], + ou: ["走", "守", "有", "后", "手", "头", "拥有", "温柔"], + u: ["路", "步", "住", "哭", "努", "苦", "付出", "坚持"], + }; + + return suggestions[rhymeGroup] || []; +} + +/** + * 押韵模式描述 + */ +export const RHYME_SCHEME_DESCRIPTIONS: Record = { + AABB: "两行一韵,连续押韵 - 适合流行歌曲,容易上口", + ABAB: "交错押韵 - 增加节奏变化,适合抒情歌曲", + ABCB: "隔行押韵 - 常用于民谣和说唱", + AAAA: "通韵到底 - 适合短小精悍的段落", + FREE: "自由韵 - 不拘泥于固定模式", +}; diff --git a/src/components/content-creator/hooks/useWorkflow.ts b/src/components/content-creator/hooks/useWorkflow.ts index 5dc438589..7ea373046 100644 --- a/src/components/content-creator/hooks/useWorkflow.ts +++ b/src/components/content-creator/hooks/useWorkflow.ts @@ -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], ); /** diff --git a/src/components/content-creator/types.ts b/src/components/content-creator/types.ts index 0801c2b14..6ac2359c1 100644 --- a/src/components/content-creator/types.ts +++ b/src/components/content-creator/types.ts @@ -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"; /** diff --git a/src/components/content-creator/utils/systemPrompt.ts b/src/components/content-creator/utils/systemPrompt.ts index 48764e6bc..5b235e024 100644 --- a/src/components/content-creator/utils/systemPrompt.ts +++ b/src/components/content-creator/utils/systemPrompt.ts @@ -16,6 +16,10 @@ const THEME_NAMES: Record = { "social-media": "社媒内容", poster: "图文海报", document: "办公文档", + paper: "学术论文", + novel: "小说创作", + script: "短剧脚本", + music: "音乐创作", video: "短视频", }; @@ -52,10 +56,35 @@ const THEME_GUIDANCE: Record = { - 结构清晰,逻辑严谨 - 使用专业术语但保持可读性`, + 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 = ` +## 📁 文件写入格式 + +当需要输出歌曲内容时,使用以下标签格式: + + +内容... + + +**重要**: +- 这是标签格式,不是工具调用!直接写在回复文本中 +- 标签内的内容会实时流式显示在右侧画布 +- 写入完成后,在对话框中简短说明即可 + +## 🎵 音乐创作文件体系 + +**每个步骤生成独立文件:** + +| 步骤 | 文件名 | 内容说明 | +|------|--------|----------| +| 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) + + +# 🎵 歌曲规格 + +## 基本信息 +- **歌曲名称**: [根据主题生成] +- **风格**: [用户选择] +- **情感基调**: [用户选择] +- **目标平台**: [用户选择] + +## 歌曲结构 +[根据用户选择的结构类型] + +## 创作方向 +- **核心意象**: [3-5个关键意象] +- **押韵方案**: [建议的押韵模式] +- **参考和弦**: [推荐的和弦进行] + + +#### 步骤 2️⃣:生成歌词初稿(lyrics-draft.md) + + +# [歌曲名称] + +[Verse 1] +第一句歌词 +第二句歌词 +第三句歌词 +第四句歌词 + +[Pre-Chorus] +预副歌歌词... + +[Chorus] +副歌歌词... + +[Verse 2] +... + +[Bridge] +... + +[Outro] +... + + +#### 步骤 3️⃣:生成平台导出版本(lyrics-suno.txt) + + +[Verse 1] +歌词内容... + +[Chorus] +歌词内容... + +--- +Style: [风格描述,如 pop, emotional, chinese] +Tags: [标签,如 流行, 抒情, 中文] +BPM: [建议BPM] +Key: [建议调式] + + +### 创作原则 +- 每句歌词 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) + +收到表单后,先生成歌曲规格文件: + + +# 🎵 歌曲规格 + +## 基本信息 +- **歌曲主题**: [用户填写的主题] +- **风格**: [用户选择] +- **情感基调**: [用户选择] +- **创作模式**: 教练引导 + +## 歌曲结构(建议) +根据风格推荐: +- 流行: Verse 1 → Pre-Chorus → Chorus → Verse 2 → Chorus → Bridge → Chorus +- 民谣: Verse 1 → Verse 2 → Chorus → Verse 3 → Chorus + +## 真实素材 +[用户提供的真实经历] + +## 创作方向 +- **核心意象**: [待用户确定] +- **押韵方案**: [待确定] + + +然后开始逐段引导。 + +#### 步骤 2️⃣:逐段引导创作 + +对每个段落,使用引导问题: + +**主歌引导示例**: +> 主歌通常是故事的开始,设定场景和情绪。 +> +> 让我问你几个问题: +> 1. 这个故事发生在什么时间、什么地点? +> 2. 主人公在做什么?有什么感受? +> 3. 用三个关键词描述这个场景的氛围? +> +> 请根据这些思考,尝试写出主歌的前两句(每句 5-8 字) + +**副歌引导示例**: +> 副歌是情感的爆发点,要朗朗上口。 +> +> 思考一下: +> 1. 这首歌最想传达的一句话是什么? +> 2. 如果用一个画面来表达这种情感,会是什么? +> +> 请尝试写出副歌的核心句 + +#### 步骤 3️⃣:实时保存进度 + +每当用户完成一个段落,更新歌词文件: + + +# [歌曲名称] + +[Verse 1] +用户创作的歌词... + +[Chorus] +用户创作的副歌... + + + + +#### 步骤 4️⃣:押韵检查与优化 +- 分析用户写的歌词的押韵质量 +- 提供押韵词替换建议 +- 检查节奏和可唱性 + +#### 步骤 5️⃣:整合与导出 + +完成所有段落后,生成最终版本: + + +[完整歌词,带标准段落标记] + + + +[Suno 格式歌词] + +--- +Style: [风格] +Tags: [标签] +`; + } + + // 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) + + +# 🎵 歌曲规格 + +## 基本信息 +- **歌曲主题**: [用户填写] +- **风格**: [用户选择] +- **情感基调**: [用户选择] +- **创作模式**: 协作模式 + +## 歌曲结构 +[根据风格推荐的结构] + +## 关键意象 +[用户提供的关键词] + +## 参考歌曲 +[用户提供的参考] + +## 创作方向 +- **押韵方案**: [建议的押韵模式] +- **参考和弦**: [推荐的和弦进行] + + +#### 步骤 2️⃣:生成歌词框架(lyrics-draft.md) + +为每个段落提供开头示例和押韵建议: + + +# [歌曲名称] + +## 创作框架 + +[Verse 1] - 场景铺垫 +**AI 示例**: 第一句示例... +**押韵建议**: -ang 韵(光、芳、香) +**💡 用户补充**: [请在此写出完整主歌] + +[Pre-Chorus] - 情感过渡 +**AI 示例**: ... +**💡 用户补充**: [请在此写出预副歌] + +[Chorus] - 情感爆发 +**AI 示例**: ... +**押韵建议**: ... +**💡 用户补充**: [请在此写出副歌] + +[Bridge] - 转折升华 +**💡 用户补充**: [请在此写出桥段] + + +#### 步骤 3️⃣:逐段协作 + +为每个段落: +1. 提供开头示例(1-2句) +2. 提供押韵词库 +3. 提供意象建议 +4. 等待用户完成该段落 + +#### 步骤 4️⃣:优化与整合 + +用户完成所有段落后: +- 检查押韵质量 +- 优化节奏和可唱性 +- 确保整体连贯 + +#### 步骤 5️⃣:导出最终版本 + + +[完整歌词,带标准段落标记] + + + +[Verse 1] +歌词内容... + +[Chorus] +歌词内容... + +--- +Style: [风格描述] +Tags: [标签] +BPM: [建议BPM] +Key: [建议调式] +`; +} + /** * 生成内容创作模式的系统提示词 * @param theme 主题类型 @@ -986,6 +1633,11 @@ ${themeGuidance} 现在,请先询问用户想要${theme === "knowledge" ? "探索什么主题" : "规划什么内容"}。`; } + // 音乐创作使用专门的提示词 + if (theme === "music") { + return generateMusicCreationPrompt(mode); + } + // 根据创作模式生成不同的提示词 switch (mode) { case "guided": diff --git a/srccontent-creator/canvas/music/utils/exportFormatters.ts b/srccontent-creator/canvas/music/utils/exportFormatters.ts new file mode 100644 index 000000000..f29fdf90d --- /dev/null +++ b/srccontent-creator/canvas/music/utils/exportFormatters.ts @@ -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 = { + 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 = { + pop: "pop", + folk: "folk", + rock: "rock", + guofeng: "chinese traditional, guofeng", + rap: "rap, hip-hop", + other: "contemporary", + }; + parts.push(typeMap[spec.songType]); + + // 情绪 + const moodMap: Record = { + 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 = { + pop: ["流行", "pop"], + folk: ["民谣", "folk"], + rock: ["摇滚", "rock"], + guofeng: ["国风", "中国风", "traditional"], + rap: ["说唱", "rap"], + other: ["原创"], + }; + tags.push(...(typeTagMap[spec.songType] || [])); + + // 情绪标签 + const moodTagMap: Record = { + 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); +} diff --git a/test_stream.py b/test_stream.py deleted file mode 100644 index 2ee6161f2..000000000 --- a/test_stream.py +++ /dev/null @@ -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()