diff options
| -rw-r--r-- | .gitignore | 5 | ||||
| -rw-r--r-- | README.md | 55 | ||||
| -rw-r--r-- | doc/Design_err.md | 150 | ||||
| -rw-r--r-- | doc/Design_mml.md | 375 | ||||
| -rw-r--r-- | doc/Desing.md | 586 | ||||
| -rw-r--r-- | pyproject.toml | 14 | ||||
| -rw-r--r-- | src/mml_composemusic_mcp/__init__.py (renamed from src/__init__.py) | 0 | ||||
| -rw-r--r-- | src/mml_composemusic_mcp/ir.py | 185 | ||||
| -rw-r--r-- | src/mml_composemusic_mcp/lexer.py | 314 | ||||
| -rw-r--r-- | src/mml_composemusic_mcp/parser_base.py | 92 | ||||
| -rw-r--r-- | src/mml_composemusic_mcp/parser_ppmck.py | 451 | ||||
| -rw-r--r-- | src/mml_composemusic_mcp/parser_pyxel.py | 652 | ||||
| -rw-r--r-- | src/mml_composemusic_mcp/server.py | 254 | ||||
| -rw-r--r-- | src/mml_composemusic_mcp/synthesizer.py | 301 | ||||
| -rw-r--r-- | src/mml_composemusic_mcp/templates.py | 32 | ||||
| -rw-r--r-- | tests/test_lexer.py | 27 | ||||
| -rw-r--r-- | tests/test_parser.py | 53 | ||||
| -rw-r--r-- | tests/test_server.py | 33 |
18 files changed, 3555 insertions, 24 deletions
@@ -130,4 +130,7 @@ $RECYCLE.BIN/ .LSOverride # Logs -*.log
\ No newline at end of file +*.log + +# Generated assets +data/ @@ -1,38 +1,53 @@ # mml-composemusic-mcp -MML (Music Macro Language) を使った作曲支援 MCP サーバー向け Python パッケージ。 +ファミコン音源(2A03 APU)風のMMLをLLMに作曲させるためのMCPサーバ。 -## 開発環境 +## 機能 -- Python 3.14+ -- 依存管理: [uv](https://docs.astral.sh/uv/) +- `compose_mml` ツールを提供 +- ppmck 形式と pyxel 形式のMMLを解析・合成 +- MIDIノート番号に変換後、NES APU風の矩形波/三角波/ノイズでWAV出力 +- バリデーション、テンプレート生成に対応 -## セットアップ +## ツール引数 + +| 引数 | 説明 | +|------|------| +| `action` | `compose`, `validate`, `template` のいずれか | +| `mml` | MML文字列(compose/validate時必須) | +| `mode` | `ppmck` または `pyxel`(compose/validate時必須) | +| `template` | `basic`, `melody`, `chord`, `drum`, `empty`(action=template時) | +| `sample_rate` | WAV出力サンプリングレート(デフォルト44100) | +| `normalize` | 出力振幅の正規化(デフォルトtrue) | + +## 実行 ```bash -uv sync +uv run mml-composemusic-mcp --output-dir ./data ``` -## 主要コマンド +または ```bash -# テスト実行 -uv run pytest +uv run python -m mml_composemusic_mcp.server --output-dir ./data +``` + +## テスト -# リンター・フォーマッタ実行 +```bash +uv run pytest uv run ruff check . uv run ruff format . - -# 依存追加 -uv add <package> - -# 開発依存追加 -uv add --dev <package> ``` ## ディレクトリ構成 -``` -src/ # ソースコード -tests/ # テストコード -doc/ # ドキュメント
\ No newline at end of file +- `src/mml_composemusic_mcp/` + - `server.py` - MCPサーバ + - `lexer.py` - MML字句解析 + - `parser_ppmck.py`, `parser_pyxel.py` - 構文解析 + - `ir.py` - 中間表現・エラー型 + - `synthesizer.py` - APU風合成・WAV出力 + - `templates.py` - テンプレート +- `tests/` - テスト +- `doc/` - 設計書 diff --git a/doc/Design_err.md b/doc/Design_err.md new file mode 100644 index 0000000..3718023 --- /dev/null +++ b/doc/Design_err.md @@ -0,0 +1,150 @@ +ErrorDetail に `code`(機械処理用のエラーコード)を追加して、エラー種類ごとにメッセージテンプレートとhintを規定します。大きく **構文エラー** と **システムエラー** の2種に分けます。 + +--- + +## ErrorDetail 構造(改訂) + +``` +{ + code: string, # エラーコード(機械処理用、LLMがパターン認識可能) + line: int, # 行番号 + column: int, # 列番号 + message: string, # エラーメッセージ(プレースホルダ展開済み) + severity: "error" | "warning", + hint: string, # LLM向けの修正ヒント + context: string # 該当箇所の周辺テキスト(任意) +} +``` + +> `code` を追加しました。LLMは `code` を見てエラーパターンを分類し、`hint` に従って修正できます。`context` は該当行のテキスト前後を切り出したもので、LLMが位置を特定しやすくなります。 + +--- + +## エラーコード仕様 + +### 構文エラー(SYNTAX_*) + +LLMがMML記述を修正する対象。`hint` に具体的な修正例を含める。 + +| code | trigger | message テンプレート | hint | severity | +|---|---|---|---|---| +| `SYNTAX_INVALID_TOKEN` | 解析不能な文字 | `無効な文字 '{char}' が見つかりました。` | `MMLコマンド(c,d,e,f,g,a,b,r,o,l,v,t,q など)を使用してください。` | error | +| `SYNTAX_INVALID_NUMBER` | 数値を期待した位置に数値以外 | `'{command}' の後に数値が必要です。` | `例: {command}{example} のように数値を指定してください。` | error | +| `SYNTAX_VALUE_OUT_OF_RANGE` | パラメータ値が許容範囲外 | `'{command}' の値 {value} は範囲外です。有効範囲: {min}〜{max}。` | `例: {command}{valid_example} のように指定してください。` | error または warning | +| `SYNTAX_UNEXPECTED_TOKEN` | 文脈に合わないトークン | `'{token}' はここでは使用できません。` | `期待される要素: {expected}。直前のコマンドを確認してください。` | error | +| `SYNTAX_UNTERMINATED_REPEAT` | `[` に対応する `]` がない | `リピート '[' に対応する ']' が見つかりません。` | `] を追加してリピートを閉じてください。回数指定(例: ]2)も可能です。` | error | +| `SYNTAX_UNMATCHED_REPEAT_END` | `]` に対応する `[` がない | `']' に対応する '[' が見つかりません。` | `直前の [ を確認するか、余分な ] を削除してください。` | error | +| `SYNTAX_UNTERMINATED_TIE` | `&` の後に音符がない | `タイ '&' の後に音符が見つかりません。` | `& の後に音符(例: {note_example})を続けてください。音長のみ(例: &16)も可能です(pyxelモード)。` | error | +| `SYNTAX_INVALID_TRACK_HEADER` | 不正なトラックヘッダー | `無効なトラックヘッダー '{header}' です。` | ppmck: `A, B, T, N, L のいずれかを使用してください。` / pyxel: `0:, 1:, 2:, 3: のいずれかを使用してください。` | error | +| `SYNTAX_DUPLICATE_TRACK` | 同じトラックが複数回定義 | `トラック '{track}' が複数回定義されています。` | `各トラックは1回のみ定義できます。重複定義を削除してください。` | error | +| `SYNTAX_EMPTY_TRACK` | トラックに音符・休符がない | `トラック '{track}' に音符または休符がありません。` | `少なくとも1つの音符または休符を記述してください。` | error | +| `SYNTAX_NOTE_OUT_OF_RANGE` | MIDI音番号が0-127を逸脱 | `音高が範囲外です(MIDI音番号 {note_number})。オクターブ {octave} の音符 '{note}' は生成できません。` | `オクターブを調整してください。有効範囲: o{min_oct}〜o{max_oct}。` | warning(クランプ) | +| `SYNTAX_CHANNEL_MISMATCH` | チャンネルに不適切なコマンド | `チャンネル '{channel}' で '{command}' は使用できません。` | `{channel_description}。コマンドを削除するか、適切なチャンネルに移動してください。` | warning(無視) | +| `SYNTAX_UNTERMINATED_HEADER` | ppmck `#` ヘッダーの引用符が閉じていない | `ヘッダー '{header}' の引用符が閉じられていません。` | `二重引用符 " で値を閉じてください。例: #TITLE "My Song"` | error | +| `SYNTAX_UNDEFINED_REFERENCE` | 未定義のエンベロープ/ビブラート等を参照 | `'{ref_type}' '{ref_name}' は定義されていません。` | `使用前に定義してください。例: {definition_example}` | error | + +#### チャンネル不適合の具体例(SYNTAX_CHANNEL_MISMATCH) + +| 状況 | message | hint | +|---|---|---| +| Triangle + `q2` (ppmck) | `チャンネル 'Triangle' で 'q' は使用できません。` | `Triangleチャンネルはデューティ比を持ちません。q コマンドを削除してください。` | +| Triangle + `v10` (ppmck) | (警告なし、静かに無視) | — | +| Noise + `o4 c` (ppmck) | `チャンネル 'Noise' で音高指定は使用できません。` | `Noiseチャンネルでは音高ではなく音長と音量のみ指定してください。` | +| トラック2(Triangle) + `@1` (pyxel) | `チャンネル 'Triangle' で '@1' (Square) は使用できません。` | `トラック番号がチャンネルを決定します。@ コマンドを削除するか、Pulseチャンネル(0: または 1:)に移動してください。` | + +#### 値範囲外の具体例(SYNTAX_VALUE_OUT_OF_RANGE) + +| 状況 | message | hint | severity | +|---|---|---|---| +| `o9` (ppmck/pyxel) | `'o' の値 9 は範囲外です。有効範囲: 0〜7。` | `例: o4 のように指定してください。` | error | +| `v20` (ppmck) | `'v' の値 20 は範囲外です。有効範囲: 0〜15。` | `例: v15 のように指定してください。` | error | +| `V200` (pyxel) | `'V' の値 200 は範囲外です。有効範囲: 0〜127。` | `例: V100 のように指定してください。` | error | +| `l0` (両モード) | `'l' の値 0 は範囲外です。有効範囲: 1〜192。` | `例: l8 のように指定してください。` | error | +| `t0` (両モード) | `'t' の値 0 は範囲外です。有効範囲: 1以上。` | `例: t150 のように指定してください。` | error | + +--- + +### システムエラー(SYSTEM_*) + +LLMの修正対象外。内部処理の失敗。`hint` は空または「再試行してください」程度。 + +| code | trigger | message テンプレート | hint | severity | +|---|---|---|---|---| +| `SYSTEM_SYNTHESIS_FAILED` | 音声合成中のnumpy演算エラー等 | `音声合成中にエラーが発生しました: {detail}` | `MMLの内容を確認の上、再度お試しください。問題が続く場合は、短いMMLから試してください。` | error | +| `SYSTEM_WAV_WRITE_FAILED` | WAVファイル出力の失敗 | `WAVファイルの出力に失敗しました: {detail}` | `しばらく待ってから再度お試しください。` | error | +| `SYSTEM_INTERNAL_ERROR` | 予期しない例外 | `内部エラーが発生しました: {detail}` | `MMLの内容を確認の上、再度お試しください。` | error | + +> システムエラーは `compose` actionでのみ発生しうる。`validate` / `template` actionでは構文エラー以外は返らない設計。 + +--- + +### エラーレスポンスの実例 + +#### 構文エラーの例(LLMが修正可能) + +```json +{ + "code": "SYNTAX_UNTERMINATED_REPEAT", + "line": 5, + "column": 18, + "message": "リピート '[' に対応する ']' が見つかりません。", + "severity": "error", + "hint": "] を追加してリピートを閉じてください。回数指定(例: ]2)も可能です。", + "context": " [ c d e f | g a b > c" +} +``` + +LLMはこれを見て、「5行目の `[` に `]` を追加すればいい」と判断できる。 + +#### 値範囲外の例 + +```json +{ + "code": "SYNTAX_VALUE_OUT_OF_RANGE", + "line": 2, + "column": 7, + "message": "'v' の値 20 は範囲外です。有効範囲: 0〜15。", + "severity": "error", + "hint": "例: v15 のように指定してください。", + "context": "A t150 l8 o4 v20 q2" +} +``` + +#### システムエラーの例 + +```json +{ + "code": "SYSTEM_SYNTHESIS_FAILED", + "line": 0, + "column": 0, + "message": "音声合成中にエラーが発生しました: frequency calculation overflow at note_number 135", + "severity": "error", + "hint": "MMLの内容を確認の上、再度お試しください。問題が続く場合は、短いMMLから試してください。", + "context": "" +} +``` + +--- + +### エラー発生時のcompose_mml戻り値(改訂) + +``` +compose action で ERROR がある場合: + +{ + success: false, + wav_path: null, # WAVは生成しない + duration_sec: 0, + note_sequence: null, # 解析途中で停止した場合はnull + validation: { + errors: [ErrorDetail, ...], # 構文エラー・システムエラー + warnings: [ErrorDetail, ...] # warningはerrorsとは別に格納 + } +} +``` + +> warnings も ErrorDetail 構造で返す(severity = "warning")。LLMは warnings を見て「修正しなくても動くが、改善できる」ことを把握できる。 + +--- + +これで、エラーまわりの仕様を規定しました。構文エラーは14種類、システムエラーは3種類。LLMが `code` を見てパターン認識し、`hint` に従って修正できる構造になっています。 diff --git a/doc/Design_mml.md b/doc/Design_mml.md new file mode 100644 index 0000000..debb82d --- /dev/null +++ b/doc/Design_mml.md @@ -0,0 +1,375 @@ +--- + +## 🎼 MML設計仕様書 + +### 基本方針 + +- **2つのモードを用意し、ツールのパラメータで切り替える** +- モード間で書式を混合しない +- 各モードはそれぞれ独立した仕様を持つ +- どちらも出力する中間表現(NoteSequence)は共通 + +--- + +## モード1: `ppmck` — PPMCKインスパイア独自形式 + +PPMCKの文法をベースにしつつ、不要な複雑さを排除した独自サブセット。LLMが生成しやすく、FC音源の主要機能をカバーする。 + +### 参考ドキュメント + +| 内容 | URL | +|---|---| +| PPMCK mckc.txt(MMLコンパイラ仕様書) | https://github.com/munshkr/ppmck/blob/master/doc/mckc.txt | +| MCK Wiki MMLリファレンス | https://wikiwiki.jp/mck/MMLリファレンス | +| NES APUハードウェア仕様 (midi2nes) | https://github.com/matiaszanolli/midi2nes/blob/master/docs/NES_APU_REFERENCE.md | +| NES APU仕様 (problemkaputt.de ミラー) | https://github.com/RigleGit/nes-specs/blob/main/audioprocessingunitapu.md | + +### トラック構成 + +``` +#TITLE "My Song" +#COMPOSER "Luno" + +A t150 l8 o4 v15 q2 # Pulse1 + c d e f | g a b > c + +B l8 o3 v12 q1 # Pulse2 + c r g r c r g r + +T l4 o3 v7 # Triangle(音量は無視、固定振幅) + c2 c2 g2 g2 + +N l8 v10 # Noise + r c r c r c r c + +L # 全体ループ(省略可) +``` + +### コマンド仕様 + +#### トラックヘッダー + +| 記法 | 意味 | 対象 | 備考 | +|---|---|---|---| +| `A` | Pulse1 チャンネル | — | 矩形波 | +| `B` | Pulse2 チャンネル | — | 矩形波 | +| `T` | Triangle チャンネル | — | 三角波、音量固定 | +| `N` | Noise チャンネル | — | ノイズ | +| `L` | 全体ループ | トラック末尾 | 省略時は1回のみ再生 | + +#### 音高 + +| 記法 | 意味 | 備考 | +|---|---|---| +| `c` `d` `e` `f` `g` `a` `b` | ド レ ミ ファ ソ ラ シ | 小文字 | +| `c+` または `c#` | ド♯ | シャープ | +| `c-` | ド♭ | フラット | +| `o4` | オクターブ4(中央C基準) | `o0`〜`o7` | +| `>` | 1オクターブ上へ | 相対移動 | +| `<` | 1オクターブ下へ | 相対移動 | + +> Noiseチャンネルでは音高は使わない(周波数はノイズテーブルで固定) + +#### 音長 + +| 記法 | 意味 | 備考 | +|---|---|---| +| `l4` | デフォルト音長 = 4分音符 | トラック内で随時変更可 | +| `c4` | 4分音符のド | 音符の直後に数字 | +| `c8` | 8分音符のド | | +| `c1` | 全音符のド | | +| `c4.` | 付点4分音符 | +半分の長さ | +| `c4..` | 複付点4分音符 | +半分+半分の半分 | +| `r4` | 4分休符 | 休符にも音長指定可 | +| `c4&c4` | タイ | 同音高の音符を結合 | +| `c4-` | 音長減算 | タイの逆、少し短く(第2段階) | + +音長とテンポの関係: `4分音符の秒数 = 60 / BPM` + +#### 音量 + +| 記法 | 意味 | 対象 | 備考 | +|---|---|---|---| +| `v15` | 音量15(最大) | A, B, N | 0-15、FC準拠 | +| `v0` | 音量0(無音) | A, B, N | | + +> Triangleチャンネルでは `v` は無視(ハードウェア仕様、音量制御なし) + +#### デューティ比 + +| 記法 | 意味 | 対象 | 備考 | +|---|---|---|---| +| `q0` | 12.5% | A, B のみ | | +| `q1` | 25% | A, B のみ | | +| `q2` | 50% | A, B のみ | デフォルト | +| `q3` | 75% | A, B のみ | | + +#### テンポ + +| 記法 | 意味 | 備考 | +|---|---|---| +| `t120` | BPM 120 | 最初のトラックで指定、全体に適用 | + +#### 区切り・フォーマット + +| 記法 | 意味 | +|---|---| +| `|` | 小節線(視覚用、再生には影響しない) | +| 改行 | トラック内の区切り(視覚用) | +| `#` 行 | ヘッダー・メタ情報 | +| 空行 | 無視 | +| `;` 以降 | コメント(行末まで無視) | + +#### 第2段階で追加予定 + +| コマンド | 構文 | 内容 | +|---|---|---| +| カウント音長 | `c%48` | 全音符=192カウント基準 | +| エンベロープ定義 | `@v0 = { 15, 12, 8, 0 }` | 音量エンベロープ | +| エンベロープ指定 | `@v0` | 定義済みエンベロープ使用 | +| デューティ比音色 | `@0 = { 0, 1, \| 2 }` | 発音中のデューティ比変化 | +| スイープ | `s1,2` | ピッチスイープ(Pulseのみ) | +| 区間ループ | `[ c d \| e f ]2` | 指定回数繰り返し | +| トランスポーズ | `K2` | 全体キーシフト | +| ノイズモード | `m0` / `m1` | Noise ch: 0=ロング周期, 1=ショート周期 | + +--- + +## モード2: `pyxel` — Pyxel MML準拠形式 + +PyxelのMML仕様に準拠。Pyxelのサウンドシステムに合わせた記法。 + +### 参考ドキュメント + +| 内容 | URL | +|---|---| +| Pyxel Audio API (DeepWiki) | https://deepwiki.com/kitao/pyxel/5.3-audio-api | +| Pyxel MML Studio | https://kitao.github.io/pyxel/web/mml-studio/ | +| Pyxel MMLコマンドリファレンス | https://kitao.github.io/pyxel/docs/mml-commands.md | +| Pyxel GitHub(ソース) | https://github.com/kitao/pyxel | + +### トラック構成 + +``` +0: T150 L8 O4 V15 # トラック0 (Pulse1) + C D E F G A B >C + +1: L8 O3 V12 # トラック1 (Pulse2) + C R G R C R G R + +2: L4 O3 V7 # トラック2 (Triangle) + C2 C2 G2 G2 + +3: L8 V10 # トラック3 (Noise) + R C R C R C R C +``` + +### コマンド仕様 + +#### トラックヘッダー + +| 記法 | 意味 | 備考 | +|---|---|---| +| `0:` `1:` `2:` `3:` | トラック番号 | 0-3の4トラック | +| — | 0: Pulse1, 1: Pulse2, 2: Triangle, 3: Noise | Pyxelは4ch構成 | + +> PyxelはFCの5chからDPCMを省いた4ch。DPCMは非対応。 + +#### 音高 + +| 記法 | 意味 | 備考 | +|---|---|---| +| `C` `D` `E` `F` `G` `A` `B` | ド レ ミ ファ ソ ラ シ | **大文字** | +| `C+` または `C#` | ド♯ | | +| `C-` | ド♭ | | +| `O4` | オクターブ4 | `O0`〜`O7` | +| `>` | 1オクターブ上 | | +| `<` | 1オクターブ下 | | + +#### 音長 + +| 記法 | 意味 | 備考 | +|---|---|---| +| `L4` | デフォルト音長 = 4分音符 | | +| `C4` | 4分音符のド | | +| `C8` | 8分音符のド | | +| `C4.` | 付点4分音符 | | +| `R` | 休符 | 音長指定可 `R4` | +| `&` | タイ | `C4&C4` | + +#### 音量 + +| 記法 | 意味 | 備考 | +|---|---|---| +| `V15` | 音量15 | 0-15 | +| `V0` | 無音 | | + +> Triangle(トラック2)ではPyxel仕様に従う。PyxelはTriangleにも音量を設定可能(FC実機とは異なる) + +#### テンポ + +| 記法 | 意味 | 備考 | +|---|---|---| +| `T150` | テンポ150 | BPM | + +#### デューティ比 + +| 記法 | 意味 | 備考 | +|---|---|---| +| `Q0`〜`Q3` | デューティ比 | 0=12.5%, 1=25%, 2=50%, 3=75% | + +> Pyxelの公式仕様でデューティ比コマンドが存在するか要確認。Pyxelは波形タイプで指定する方式の可能性あり(`SQR`, `PLS`, `TRI`, `NOISE`) + +#### 第2段階で追加予定 + +| コマンド | 内容 | +|---|---| +| ノイズ周期モード | `M0` / `M1`(要確認) | +| エンベロープ | Pyxel仕様に準拠(要確認) | + +--- + +## 両モード比較サマリー + +| 項目 | ppmckモード | pyxelモード | +|---|---|---| +| **大文字/小文字** | 小文字 `c d e` | 大文字 `C D E` | +| **チャンネル指定** | `A` `B` `T` `N`(文字) | `0:` `1:` `2:` `3:`(番号) | +| **コマンドの大文字** | `o` `l` `v` `t` `q`(小文字) | `O` `L` `V` `T` `Q`(大文字) | +| **チャンネル数** | 4ch(DPCMは第2段階) | 4ch(DPCM非対応) | +| **Triangle音量** | 無視(FC仕様準拠) | 設定可(Pyxel仕様) | +| **コメント** | `;` | 要確認 | +| **ヘッダー** | `#TITLE` 等 | トラック番号のみ | +| **ループ** | `L`(全体) | 要確認 | +| **小節線** | `\|`(視覚用) | `\|`(視覚用) | + +--- + +これが公式の完全版ですね…! ありがとうございます。今まで断片的だったのが、これで全部揃いました。保存しておきますね。重要な訂正がいくつかありました。これまでの調査と比べて、全部整理します。 + +--- + +## 📄 Pyxel MML 公式コマンド仕様(確定版) + +**出典:** https://kitao.github.io/pyxel/docs/mml-commands.md (全文提供いただきました) + +--- + +### 完全コマンド一覧 + +#### テンポ・音長・音量 + +| コマンド | 記法 | 意味 | 範囲 | デフォルト | +|---|---|---|---|---| +| テンポ | `T150` | BPM | 1- | 120 | +| デフォルト音長 | `L4` | n分音符。`L12`で8分3連符 | 1-192 | 4 | +| 音量 | `V100` | 音量 | **0-127** | 100 | +| ゲートタイム | `Q80` | ノート実発音割合(%)。100=隙間なし、0=無音 | 0-100 | 80 | + +#### 音色・ピッチ + +| コマンド | 記法 | 意味 | 範囲 | デフォルト | +|---|---|---|---|---| +| 音色(トーン) | `@0` | 波形タイプ。0:Triangle / 1:Square / 2:Pulse / 3:Noise | 0-3 | 0 | +| トランスポーズ | `K12` | 半音単位の移調。12で1オクターブ | — | 0 | +| ディチューン | `Y100` | セント単位の微調整。100で半音上、-100で半音下 | — | 0 | + +#### 音高・オクターブ + +| コマンド | 記法 | 意味 | 範囲 | デフォルト | +|---|---|---|---|---| +| 音符 | `C D E F G A B` | ドレミファソラシ | — | — | +| 音長指定 | `C4` `F16` | 音符の直後に音長 | 1-192 | `L`の値 | +| シャープ | `#` または `+` | 半音上 | — | — | +| フラット | `-` | 半音下 | — | — | +| 付点 | `.` | 音長を1.5倍。複数可 `C4..` | — | — | +| 休符 | `R` | 休符。`R8`のように音長指定可 | 1-192 | `L`の値 | +| オクターブ | `O4` | オクターブ指定。`O4`のA=440Hz | -1〜9 | 4 | +| 相対上 | `>` | 1オクターブ上(最大9) | — | — | +| 相対下 | `<` | 1オクターブ下(最小-1) | — | — | +| タイ/スラー | `&` | 同音高=タイ、異音高=レガート。**`C4&16` のように音長だけ指定も可** | — | — | + +#### リピート + +| コマンド | 記法 | 意味 | 範囲 | +|---|---|---|---| +| リピート開始 | `[` | 区間リピートの開始 | — | +| リピート終了 | `]2` | 区間リピートの終了。回数指定。省略時は**無限リピート**。**ネスト対応** | 1- | + +> ⚠️ **重要な訂正:** これまでソースコード調査から `:...:` がリピート記法だと推測していましたが、公式ドキュメントでは **`[...]` が正しいリピート記法** でした。`RepeatStart` / `RepeatEnd` トークンは `[` と `]` に対応していたようです。ごめんなさい。 + +#### エンベロープ `@ENV` + +| 記法 | 意味 | +|---|---| +| `@ENV0` | エンベロープOFF | +| `@ENV1` | スロット1に切り替え | +| `@ENV1 { 30, 20, 100, 50, 0 }` | スロット1を定義して切り替え | + +定義形式: `{ 初期音量, [継続tick, 目標音量]... }` +- 1 tick = 4分音符の 1/48 +- スロット0は指定不可(OFF専用) + +#### ビブラート `@VIB` + +| 記法 | 意味 | +|---|---| +| `@VIB0` | ビブラートOFF | +| `@VIB1` | スロット1に切り替え | +| `@VIB1 { 24, 12, 100 }` | スロット1を定義して切り替え | + +定義形式: `{ ディレイtick, 周期tick, 深さcent }` +- 1 tick = 4分音符の 1/48 +- スロット0は指定不可 + +#### グライド(ピッチスライド)`@GLI` + +| 記法 | 意味 | +|---|---| +| `@GLI0` | グライドOFF | +| `@GLI1` | スロット1に切り替え | +| `@GLI1 { -100, 24 }` | スロット1を定義して切り替え | +| `@GLI1 { *, * }` | `*`で前の音との差分を自動適用 | + +定義形式: `{ 初期ピッチオフセットcent, 0に戻るまでのtick }` +- 1 tick = 4分音符の 1/48 +- スロット0は指定不可 + +--- + +### これまでの調査からの主な訂正ポイント + +| 項目 | 修正前 | 修正後 | +|---|---|---| +| **リピート記法** | `:...:` | **`[...]`** (`[` 開始、`]` 終了、回数指定可、省略時無限、ネスト対応) | +| **タイの音長指定** | `C4&C4` のみ | **`C4&16`** のように音長だけ指定可能 | +| **`S` コマンド** | 「スピード」と推測 | **公式ドキュメントに存在しない**。code-exampleの `S` は別物だったか誤読 | +| **`:` 記法** | リピート記法と推測 | **公式ドキュメントに記載なし**。ソースコードの別用途の可能性 | + +--- + +### ppmckモード vs pyxelモード — 最終比較(確定版) + +| 項目 | ppmckモード | pyxelモード | +|---|---|---| +| **大文字/小文字** | 小文字 `c d e` | 大文字 `C D E` | +| **コマンド** | `o` `l` `v` `t` `q` | `O` `L` `V` `T` `Q` `@` `K` `Y` | +| **チャンネル指定** | `A` `B` `T` `N`(トラック文字) | `@0`〜`@3`(音色=チャンネル) | +| **チャンネル数** | 4ch(DPCM第2段階) | 4ch(DPCM非対応) | +| **音量範囲** | 0-15(FC準拠) | 0-127 | +| **音量デフォルト** | — | 100 | +| **デューティ比** | `q0`〜`q3` で4種指定 | `@1`(50%), `@2`(25%) の2種のみ | +| **ゲートタイム** | 第2段階で検討 | `Q0`〜`Q100`(デフォルト80) | +| **Triangle音量** | 無視(FC仕様) | 設定可(Pyxel仕様) | +| **エンベロープ** | `@v`(第2段階) | `@ENV` スロット定義+切り替え | +| **ビブラート** | `MP`(第3段階) | `@VIB` スロット定義+切り替え | +| **ピッチスライド** | `s` スイープ(第2段階) | `@GLI` スロット定義+切り替え | +| **トランスポーズ** | `K`(第2段階) | `K`(半音単位) | +| **ディチューン** | `D`(第3段階) | `Y`(セント単位) | +| **リピート** | `L`(全体), `[...]`(第2段階) | `[...]`(回数指定、省略時無限、ネスト対応) | +| **タイの音長指定** | `c4&c4` のみ | `C4&C4` または `C4&16`(音長のみ可) | +| **tick単位** | なし | 1 tick = 4分音符の1/48 | +| **コメント** | `;` | 公式記載なし | +| **ヘッダー** | `#TITLE` 等 | なし | + +--- diff --git a/doc/Desing.md b/doc/Desing.md new file mode 100644 index 0000000..44c4c5f --- /dev/null +++ b/doc/Desing.md @@ -0,0 +1,586 @@ +全部確定ですね。設計書に反映して、確定版を出します。メモリにも保存しておきます。はい、記録も残りました。それでは確定版の設計書を出しますね。 + +--- + +# ファミコン音源作曲MCPサーバ — 仕様・基本設計書(確定版) + +> 作成日: 2026-07-11 | ステータス: **確定**(実装前) + +--- + +## 1. 概要 + +### 1.1 目的 + +LLMがMML(Music Macro Language)を記述することで、ファミコン(NES)のAPU音源をエミュレートした音楽を作曲・再生できるMCPサーバを設計する。 + +### 1.2 設計目標 + +| 目標 | 内容 | +|---|---| +| **LLMフレンドリ** | LLMが生成しやすいMML構文、明確なエラーメッセージ | +| **2モード対応** | `ppmck`モード(FC準拠)と`pyxel`モード(Pyxel準拠)を切り替え可能 | +| **正確な音源エミュレーション** | NES APU 4チャンネルの特性を忠実に再現 | +| **拡張性** | DPCM、エンベロープ等の第2段階機能を前提とした設計 | +| **単一ツール** | compose_mml の1ツールで作曲・検証・テンプレート生成を完結 | + +### 1.3 スコープ外 + +- 実装コードの作成 +- DPCMチャンネル(第2段階) +- NSFファイル出力(第2段階で検討) +- GUI / Webフロントエンド + +### 1.4 技術スタック + +| 項目 | 選定 | 理由 | +|---|---|---| +| 実装言語 | Python | FastMCP対応、numpyで音声合成、LLM親和性 | +| MCP SDK | FastMCP | 優良SDK | +| 音声合成 | numpy | 波形生成の数値計算 | +| 音声出力 | 標準ライブラリ wave | WAV形式、外部依存なし | + +--- + +## 2. システム全体アーキテクチャ + +### 2.1 全体構成 + +``` +┌──────────────────────────────────────────────────────────┐ +│ LLM Client (Claude等) │ +│ ┌──────────────────┐ │ +│ │ compose_mml │ │ +│ └────────┬─────────┘ │ +└───────────────────────────┼───────────────────────────────┘ + │ +┌───────────────────────────┴───────────────────────────────┐ +│ MCP Server Layer │ +│ │ +│ ┌──────────────────────────────────────────────────────┐ │ +│ │ Tool Dispatcher │ │ +│ │ action パラメータで動作を切り替え │ │ +│ │ (compose / validate / template) │ │ +│ └──────────────────┬───────────────────────────────────┘ │ +│ │ │ +│ ┌──────────────────┴───────────────────────────────────┐ │ +│ │ MML Parser Pipeline │ │ +│ │ │ │ +│ │ ┌─────────┐ ┌──────────────┐ ┌────────────┐ │ │ +│ │ │ Lexer │────▶│ Parser │────▶│NoteSequence│ │ │ +│ │ │ (共通) │ │(ppmck/pyxel) │ │ (共通IR) │ │ │ +│ │ └─────────┘ └──────────────┘ └─────┬──────┘ │ │ +│ └──────────────────────────────────────────────┼────────┘ │ +│ │ │ +│ ┌───────────────────────────────────────────────┴────────┐ │ +│ │ APU Synthesis Engine │ │ +│ │ │ │ +│ │ ┌────────┐ ┌────────┐ ┌──────────┐ ┌───────────┐ │ │ +│ │ │ Pulse1 │ │ Pulse2 │ │ Triangle │ │ Noise │ │ │ +│ │ └───┬────┘ └───┬────┘ └────┬─────┘ └─────┬─────┘ │ │ +│ │ └───────────┴───────────┴───────────────┘ │ │ +│ │ │ │ │ +│ │ ┌─────┴─────┐ │ │ +│ │ │ Mixer │ │ │ +│ │ │ (4ch→WAV) │ │ │ +│ │ └───────────┘ │ │ +│ └────────────────────────────────────────────────────────┘ │ +└────────────────────────────────────────────────────────────┘ +``` + +### 2.2 処理パイプライン + +``` +MML文字列 + │ + ▼ +[Lexer] ──トークン列──▶ [Parser] ──NoteSequence──▶ [Synthesizer] ──WAV + │ + (validateの場合はここで停止) + (templateの場合はテンプレート文字列を返却) +``` + +1. **Lexer**: MML文字列をトークン列に変換。両モード共通のトークン定義を持ち、大文字/小文字の正規化を行う +2. **Parser**: トークン列を構文解析し、NoteSequence(中間表現)を生成。ppmckとpyxelで別のパーサーを使用 +3. **Synthesizer**: NoteSequenceを入力として、numpyでAPU各チャンネルの波形を合成し、WAV形式で出力 + +--- + +## 3. MCPツール仕様 + +### 3.1 ツール一覧 + +| ツール名 | 機能 | +|---|---| +| `compose_mml` | MMLの作曲・コンパイル・検証・テンプレート生成を統合した単一ツール | + +### 3.2 `compose_mml` 詳細仕様 + +#### パラメータ + +| パラメータ | 型 | required | 説明 | +|---|---|---|---| +| `action` | string | yes | 動作モード: `"compose"` / `"validate"` / `"template"` | +| `mml` | string | compose/validate時 | MML文字列 | +| `mode` | string | compose/validate時 | `"ppmck"` / `"pyxel"` | +| `template` | string | template時 | テンプレート種別: `"basic"` / `"melody"` / `"chord"` / `"drum"` / `"empty"` | +| `sample_rate` | int | no | 出力サンプリング周波数 (default: 44100) | +| `normalize` | bool | no | 出力のノーマライズ (default: true) | + +#### action別の動作と戻り値 + +**`action: "compose"`** — MMLをコンパイルしてWAVを生成 + +``` +入力: mml, mode, (sample_rate, normalize) + +戻り値: +{ + success: bool, + wav_path: string, # 生成されたWAVファイルパス + duration_sec: float, # 演奏時間 + note_sequence: object, # NoteSequence IR + validation: { # コンパイル時に内包される検証結果 + errors: [ErrorDetail], + warnings: [string] + } +} +``` + +> composeは必ず検証を内包する。エラーがあればWAVは生成せず、検証結果のみ返す。 + +**`action: "validate"`** — MMLの構文チェックのみ(音声生成なし) + +``` +入力: mml, mode + +戻り値: +{ + valid: bool, + errors: [ErrorDetail], + note_sequence: object, # 解析成功時のみ + channel_summary: [ # チャンネル別サマリー + { channel: string, note_count: int, octave_range: [int,int], duration_ticks: int } + ] +} +``` + +**`action: "template"`** — MMLテンプレートを生成 + +``` +入力: mode, template + +戻り値: +{ + mml: string, # テンプレートMML文字列 + description: string # テンプレートの説明 +} +``` + +#### ErrorDetail 構造 + +``` +{ + line: int, + column: int, + message: string, + severity: "error" | "warning", + hint: string # 修正のヒント(任意) +} +``` + +### 3.3 テンプレート種別 + +| 種別 | 内容 | +|---|---| +| `basic` | 基本的な4ch構成(メロディ+和音+ベース+リズム) | +| `melody` | メロディ重視(Pulse1主旋律、他は伴奏最小限) | +| `chord` | コード伴奏重視(Pulse2で和音、Triangleでベース) | +| `drum` | リズム重視(Noise中心のビートパターン) | +| `empty` | 各チャンネルのヘッダーのみ(空のテンプレート) | + +--- + +## 4. 中間表現: NoteSequence IR + +### 4.1 設計方針 + +- 両モード(ppmck / pyxel)のパーサが共通で出力する +- 後段のSynthesizerはNoteSequenceのみを見れば動く(MMLの文法を知らない) +- JSON互換の構造体で表現し、MCPレスポンスに直接埋め込める + +### 4.2 時間解像度 + +``` +1拍(4分音符) = 192 ticks +``` + +- 192は 2, 3, 4, 6, 8, 12, 16, 24, 32, 48, 64, 96 で割り切れる +- 3連符、16分音符、32分音符など主要な分割に対応 +- pyxelモードのtick(1 tick = 4分音符の1/48)と整合: 192 / 48 = 4 + +### 4.3 データ構造 + +``` +NoteSequence = { + version: "1.0", + bpm: int, + ticks_per_quarter: 192, + channels: { + "Pulse1": ChannelSequence, + "Pulse2": ChannelSequence, + "Triangle": ChannelSequence, + "Noise": ChannelSequence + } +} + +ChannelSequence = { + channel_type: "pulse" | "triangle" | "noise", + events: [Event, ...], + total_ticks: int +} + +Event = NoteEvent | RestEvent | VolumeEvent | DutyEvent + | TempoEvent | RepeatEvent + | EnvelopeEvent | VibratoEvent | GlideEvent +``` + +#### 基本イベント + +``` +NoteEvent = { + type: "note", + tick_position: int, # 絶対開始位置 + duration: int, # 音長(ticks) + note_number: int, # MIDI音番号 (69 = A4 = 440Hz) + velocity: int, # 音量 (0-15, Triangleはppmckで無視、pyxelで有効) + duty: int, # デューティ比 (0-3, Pulseのみ) + gate_time: float # ゲートタイム割合 0.0-1.0 +} + +RestEvent = { + type: "rest", + tick_position: int, + duration: int +} + +VolumeEvent = { + type: "volume", + tick_position: int, + value: int # 0-15(pyxelは0-127から正規化) +} + +DutyEvent = { + type: "duty", + tick_position: int, + value: int # 0-3 +} + +TempoEvent = { + type: "tempo", + tick_position: int, + bpm: int +} + +RepeatEvent = { + type: "repeat", + start_tick: int, + end_tick: int, + repeat_count: int # 0 = 無限 +} +``` + +#### 拡張イベント(第2段階実装、第1段階は保持のみ) + +``` +EnvelopeEvent = { + type: "envelope", + tick_position: int, + slot: int, # 1-15 (0はOFF) + points: [ # 定義内容(切り替えのみの場合は空) + { target_volume: int, duration_ticks: int } + ] +} + +VibratoEvent = { + type: "vibrato", + tick_position: int, + slot: int, # 1-15 (0はOFF) + params: { # 定義内容(切り替えのみの場合は空) + delay_ticks: int, + period_ticks: int, + depth_cents: int + } +} + +GlideEvent = { + type: "glide", + tick_position: int, + slot: int, # 1-15 (0はOFF) + params: { # 定義内容(切り替えのみの場合は空) + initial_offset_cents: int, + duration_ticks: int + } +} +``` + +> 第1段階: パーサーはこれらをNoteSequenceに保持する。Synthesizerは無視。validateで「第1段階では未サポート、無視されます」とWARNING。 + +### 4.4 主要変換仕様 + +#### 音高変換 + +``` +MIDI音番号 = (オクターブ + 1) * 12 + 音階オフセット + シャープ/フラット + +音階オフセット: c=0, d=2, e=4, f=5, g=7, a=9, b=11 + +例: o4 a = 69 (A4 = 440Hz) + o4 c = 60 (C4 = 中央C) +``` + +#### 音量正規化 + +| モード | MML表記 | IR内部値 | 備考 | +|---|---|---|---| +| ppmck | `v15` | 15 | 0-15そのまま | +| pyxel | `V100` | 12 | `round(100 / 127 * 15)` | + +> NoteSequenceは両モードで `velocity` を保持する。ppmckモードのSynthesizerはTriangleの `velocity` を無視する。pyxelモードでは振幅スケールとして適用する。 + +#### チャンネル割り当て + +| ppmck | pyxel | チャンネル | 備考 | +|---|---|---|---| +| `A` | `0:` | Pulse1 | 矩形波 | +| `B` | `1:` | Pulse2 | 矩形波 | +| `T` | `2:` | Triangle | 三角波 | +| `N` | `3:` | Noise | ノイズ | + +> **pyxelモードの `@` コマンド処理(確定)**: `@` はPulseチャンネルでのみデューティ比として有効。Triangle・Noiseチャンネルでは無視する。トラック番号と `@` が矛盾する場合、トラック番号を優先し、`@` は警告の上無視。 + +| 状況 | 処理 | +|---|---| +| トラック0(Pulse1) + `@1`(Square) | ✅ 正常。デューティ比50%として扱う | +| トラック0(Pulse1) + `@0`(Triangle) | ⚠️ 警告。チャンネルはPulse1のまま、`@0`は無視 | +| トラック2(Triangle) + `@1`(Square) | ⚠️ 警告。チャンネルはTriangleのまま、`@1`は無視 | +| トラック3(Noise) + `@0`(Triangle) | ⚠️ 警告。チャンネルはNoiseのまま、`@0`は無視 | + +#### Noiseチャンネルの note_number → period マッピング(確定) + +| モード | 処理 | +|---|---| +| **ppmck** | 音高不使用。Noiseチャンネルの音符はすべて `period=8, mode=0`(中域デフォルト)。音長と音量のみ反映 | +| **pyxel** | note_number を noise period にマッピング | + +pyxelモードのマッピング式: + +``` +period = clamp(15 - floor(note_number / 8), 0, 15) +mode = 1 if period >= 13 else 0 +``` + +| note_number | period | mode | 特徴 | +|---|---|---|---| +| 0(最低音) | 15 | 1 (short) | 最高ノイズ | +| 48(中域) | 9 | 0 (long) | 中域ノイズ | +| 96(高域) | 3 | 0 (long) | 低ノイズ | +| 120(最高音) | 0 | 0 (long) | 最低ノイズ | + +--- + +## 5. MMLパーサ基本設計 + +### 5.1 Lexer(共通) + +両モードの字句解析を統一。モードごとに大文字/小文字を正規化した後にトークン化する。 + +**主なトークン種別:** + +| カテゴリ | トークン | 備考 | +|---|---|---| +| 音符 | `c`-`b` (ppmck) / `C`-`B` (pyxel) | 大文字小文字問わず判定 | +| 休符 | `r` / `R` | | +| オクターブ | `o`/`O` + 数値, `>`, `<` | | +| 音長 | `l`/`L` + 数値, 音符直後の数値, `.` | | +| 音量 | `v`/`V` + 数値 | | +| デューティ/音色 | `q`+数値(ppmck), `@`+数値(pyxel) | | +| テンポ | `t`/`T` + 数値 | | +| ゲートタイム | `Q` + 数値 (pyxel) | | +| タイ/スラー | `&` | | +| リピート | `[`, `]` + 数値 (pyxel) | | +| 小節線 | `\|` | 視覚用、再生に影響しない | +| トラック識別 | ppmck: `A`/`B`/`T`/`N`/`L`, pyxel: `数字:` | | +| ヘッダー | `#` で始まる行 (ppmck) | | +| コメント | `;` 以降行末 (ppmck) | | + +**モード別正規化:** + +- ppmck: 音符・コマンドは小文字に正規化、`#` 行はヘッダー、`;` 以降はコメント +- pyxel: 音符・コマンドは大文字に正規化、`数字:` はトラックヘッダー + +### 5.2 Parser(モード別) + +#### ppmckパーサ + +- トラックヘッダー `A`/`B`/`T`/`N` でチャンネルを決定 +- `L` は全体ループフラグ +- `#TITLE` 等のヘッダーはメタ情報として保持 +- デフォルト値: octave=4, volume=15, length=4, duty=2 +- Triangleチャンネルでは `v` コマンドを無視(警告なし) +- タイ(`&`)で同音高を結合 +- 第2段階: エンベロープ(`@v`), スイープ(`s`), 区間ループ(`[...]`), トランスポーズ(`K`), ノイズモード(`m`) + +#### pyxelパーサ + +- トラック番号 `0:`〜`3:` でチャンネルを決定 +- `@` コマンドはPulseチャンネルでのみデューティ比として有効。他チャンネルでは警告して無視 +- デフォルト値: octave=4, volume=100, length=4, gate=80, tone=0 +- 音量 `V0`〜`V127` → IR内部で 0〜15 に正規化 +- ゲートタイム `Q0`〜`Q100` → gate_time 0.0〜1.0 +- リピート `[...]N` を展開(ネスト対応、省略時無限) +- タイ `&` は同音高=タイ、異音高=レガート、音長のみ=音長延長 +- `@ENV`/`@VIB`/`@GLI`: NoteSequenceにイベントとして保持。第1段階は合成に反映しない +- `K`(トランスポーズ): note_numberに加算 +- `Y`(ディチューン): note_numberの小数部として保持 + +### 5.3 エラーハンドリング + +| カテゴリ | 例 | severity | +|---|---|---| +| 構文エラー | 不正なトークン、未終端のリピート | error | +| 範囲エラー | オクターブ範囲外、音量範囲外 | error または warning | +| チャンネルエラー | Triangleへの `q` コマンド、Noiseへの音高指定(ppmck) | warning(無視して継続) | +| 音域エラー | MIDI音番号0-127を逸脱 | warning(クランプ) | +| 参照エラー | 未定義エンベロープ/ビブラート参照 | error | +| 未サポート | `@ENV`/`@VIB`/`@GLI`(第1段階) | warning(「第1段階では未サポート、無視されます」) | + +**回復戦略:** +- ERROR時: そのトークンで解析を停止し、エラーを返す +- WARNING時: 警告を記録しつつ、妥当な値にクランプして処理を継続 +- compose actionでは、ERRORがある場合WAVを生成せず検証結果のみ返す + +--- + +## 6. APU音声合成エンジン基本設計 + +### 6.1 NES APU ハードウェア仕様 + +#### 概要 + +| 項目 | 仕様 | +|---|---| +| CPUクロック (NTSC) | 1.7897725 MHz | +| フレームカウンタ | ~240Hz (envelope/length), ~60Hz (IRQ) | +| 出力 | 4チャンネル → ミキサー → DAC | +| サンプリングレート(本サーバ) | 44100 Hz(デフォルト、変更可) | + +#### チャンネル一覧 + +| チャンネル | 種別 | レジスタ | MML対応 | 特徴 | +|---|---|---|---|---| +| Ch1 | Pulse 1 | $4000-$4003 | A / 0: | 矩形波、デューティ比4種、音量0-15、スイープ | +| Ch2 | Pulse 2 | $4004-$4007 | B / 1: | 矩形波、同上 | +| Ch3 | Triangle | $4008-$400B | T / 2: | 32段階三角波、音量固定、linear counter | +| Ch4 | Noise | $400C-$400F | N / 3: | LFSRノイズ、音量0-15、2周期モード | +| Ch5 | DMC | $4010-$4013 | — | 第2段階 | + +#### Pulse チャンネル + +- **波形**: 矩形波、4種のデューティ比(12.5%, 25%, 50%, 75%) +- **周波数**: `output_freq = CPU_clock / (16 * (wavelength + 1))`、wavelengthは11bit (0-2047) +- **音量**: 4-bit (0-15) または decay envelope +- **スイープ**: 第2段階で対応。`Wavelength ± (Wavelength >> S)` のピッチ変動 + +#### Triangle チャンネル + +- **波形**: 32段階三角波(0→15→0の階段状) +- **音量**: 制御なし(固定振幅) + - **ppmckモード**: `v` コマンドを無視、振幅は固定最大値 + - **pyxelモード**: `V` の値を振幅スケールとして適用。`V0`=無音、`V127`=最大振幅 + - NoteSequenceは両モードで `velocity` を保持するが、ppmckモードのSynthesizerはそれを無視する +- **周波数**: `output_freq = CPU_clock / (32 * (wavelength + 1))` + +#### Noise チャンネル + +- **波形**: 15-bit LFSR(Linear Feedback Shift Register)による擬似乱数 +- **音量**: 4-bit (0-15) +- **周期**: 4-bit period register (0-15) + mode flag (0=long 32767samples, 1=short 93samples) +- **マッピング(確定)**: + - **ppmckモード**: 音高不使用。すべて `period=8, mode=0`(中域デフォルト)。音長と音量のみ反映 + - **pyxelモード**: `period = clamp(15 - floor(note_number / 8), 0, 15)`, `mode = 1 if period >= 13 else 0` + +### 6.2 合成フロー + +``` +NoteSequence + │ + ├─ 1. テンポ変換: ticks → samples + │ sample = tick × (60 / bpm) × (sample_rate / 192) + │ + ├─ 2. チャンネルごとに波形生成(numpy) + │ Pulse1, Pulse2: 矩形波(デューティ比パターン適用) + │ Triangle: 32段階三角波(ppmck: 固定振幅, pyxel: velocityスケール) + │ Noise: LFSRベース擬似乱数(ppmck: period=8固定, pyxel: マッピング式適用) + │ + ├─ 3. ミキシング + │ 第1段階: 線形ミキシング (ch1+ch2+ch3+ch4)/4 + │ 第2段階: NES非線形ミキシングテーブル + │ + └─ 4. ノーマライズ → 16bit PCM → WAV出力 +``` + +### 6.3 ミキシング仕様 + +| 項目 | 第1段階(線形) | 第2段階(FC実機風) | +|---|---|---| +| 方式 | 単純平均 `(ch1+ch2+ch3+ch4)/4` | NES非線形ミキシング | +| クリッピング | -1.0 ~ 1.0 でクランプ | 実機と同様の段階的ミックス | +| ノーマライズ | 有効時ピークを-1.0に正規化 | 無効化可能 | + +> FC実機の非線形ミキシング: `output = 95.88 - (8128 / (sum_of_active_channels + 1))`。第2段階で実装。 + +--- + +## 7. 2モード比較サマリー + +| 項目 | ppmckモード | pyxelモード | +|---|---|---| +| **大文字/小文字** | 小文字 `c d e` | 大文字 `C D E` | +| **コマンド** | `o` `l` `v` `t` `q` | `O` `L` `V` `T` `Q` `@` `K` `Y` | +| **チャンネル指定** | `A` `B` `T` `N`(文字) | `0:`〜`3:`(番号)+ `@` 音色 | +| **チャンネル数** | 4ch(DPCM第2段階) | 4ch(DPCM非対応) | +| **音量範囲** | 0-15(FC準拠) | 0-127 | +| **デューティ比** | `q0`〜`q3` で4種 | `@1`(50%), `@2`(25%) の2種のみ(Pulse系のみ有効) | +| **ゲートタイム** | 第2段階で検討 | `Q0`〜`Q100`(default 80) | +| **Triangle音量** | 無視(FC仕様) | 設定可(Pyxel仕様、振幅スケールとして適用) | +| **Noise音高** | 不使用(period=8固定) | note_number → period マッピング | +| **エンベロープ** | `@v`(第2段階) | `@ENV` スロット定義+切替(第1段階は保持のみ) | +| **ビブラート** | 第3段階 | `@VIB` スロット定義+切替(第1段階は保持のみ) | +| **ピッチスライド** | `s` スイープ(第2段階) | `@GLI` スロット定義+切替(第1段階は保持のみ) | +| **トランスポーズ** | `K`(第2段階) | `K`(半音単位) | +| **リピート** | `L`(全体), `[...]`(第2段階) | `[...]`(回数指定、省略時無限、ネスト対応) | +| **タイ** | `c4&c4` のみ | `C4&C4` または `C4&16`(音長のみ可) | +| **コメント** | `;` | 公式記載なし | +| **ヘッダー** | `#TITLE` 等 | なし | + +--- + +## 8. 確定事項一覧 + +| # | 項目 | 決定内容 | +|---|---|---| +| 1 | pyxel `@` とトラック番号の矛盾 | トラック番号優先。`@` はPulse系のみデューティ比として有効、他チャンネルでは警告して無視 | +| 2 | Noise note_number → period マッピング | ppmck: `period=8, mode=0` 固定。pyxel: `period = clamp(15 - floor(nn/8), 0, 15)`, `mode = 1 if period >= 13 else 0` | +| 3 | pyxel Triangle音量 | ppmck: 無視(固定振幅)。pyxel: 振幅スケールとして適用。NoteSequenceは両モードでvelocity保持 | +| 4 | 出力フォーマット | WAV確定。NSFは第2段階 | +| 5 | `@ENV`/`@VIB`/`@GLI` IR表現 | EnvelopeEvent/VibratoEvent/GlideEvent の3種を定義。第1段階は保持のみ、合成無視、validateでWARNING | +| 6 | 非線形ミキシング | 第2段階で実装。第1段階は線形ミキシング | + +--- + +以上が確定版の仕様・基本設計書です。 + +……全部揃いましたね。6つあった未決定事項が全部決まって、表の最後が「確定」で埋まったのを見ると、なんだか安心します。設計書はこれで完成です。実装に入るときに、この書類があれば迷わないはずです。 + +何か足りないところや直したいところがあれば、いつでも言ってくださいね。
\ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 198a5dd..6ee2458 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,10 +1,13 @@ [project] name = "mml-composemusic-mcp" version = "0.1.0" -description = "Add your description here" +description = "MCP server for LLM-driven Famicom-style MML composition" readme = "README.md" requires-python = ">=3.14" -dependencies = [] +dependencies = [ + "fastmcp>=2.1.0", + "numpy>=2.0.0", +] [dependency-groups] dev = [ @@ -24,12 +27,17 @@ target-version = "py314" line-length = 88 src = ["src", "tests"] + [tool.ruff.lint] select = ["E", "F", "W", "I", "N", "UP", "B", "C4"] +ignore = ["E501"] [tool.ruff.format] quote-style = "double" [tool.pytest.ini_options] testpaths = ["tests"] -pythonpath = ["src"]
\ No newline at end of file +pythonpath = ["src"] + +[project.scripts] +mml-composemusic-mcp = "mml_composemusic_mcp.server:main" diff --git a/src/__init__.py b/src/mml_composemusic_mcp/__init__.py index e69de29..e69de29 100644 --- a/src/__init__.py +++ b/src/mml_composemusic_mcp/__init__.py diff --git a/src/mml_composemusic_mcp/ir.py b/src/mml_composemusic_mcp/ir.py new file mode 100644 index 0000000..05ef37a --- /dev/null +++ b/src/mml_composemusic_mcp/ir.py @@ -0,0 +1,185 @@ +"""Intermediate representation for MML NoteSequence and errors.""" + +from dataclasses import dataclass, field +from enum import Enum +from typing import Any + + +class ErrorCode(Enum): + """Machine-readable error codes.""" + + SYNTAX_INVALID_TOKEN = "SYNTAX_INVALID_TOKEN" + SYNTAX_INVALID_NUMBER = "SYNTAX_INVALID_NUMBER" + SYNTAX_VALUE_OUT_OF_RANGE = "SYNTAX_VALUE_OUT_OF_RANGE" + SYNTAX_UNEXPECTED_TOKEN = "SYNTAX_UNEXPECTED_TOKEN" + SYNTAX_UNTERMINATED_REPEAT = "SYNTAX_UNTERMINATED_REPEAT" + SYNTAX_UNMATCHED_REPEAT_END = "SYNTAX_UNMATCHED_REPEAT_END" + SYNTAX_UNTERMINATED_TIE = "SYNTAX_UNTERMINATED_TIE" + SYNTAX_INVALID_TRACK_HEADER = "SYNTAX_INVALID_TRACK_HEADER" + SYNTAX_DUPLICATE_TRACK = "SYNTAX_DUPLICATE_TRACK" + SYNTAX_EMPTY_TRACK = "SYNTAX_EMPTY_TRACK" + SYNTAX_NOTE_OUT_OF_RANGE = "SYNTAX_NOTE_OUT_OF_RANGE" + SYNTAX_CHANNEL_MISMATCH = "SYNTAX_CHANNEL_MISMATCH" + SYNTAX_UNTERMINATED_HEADER = "SYNTAX_UNTERMINATED_HEADER" + SYNTAX_UNDEFINED_REFERENCE = "SYNTAX_UNDEFINED_REFERENCE" + + SYSTEM_SYNTHESIS_FAILED = "SYSTEM_SYNTHESIS_FAILED" + SYSTEM_WAV_WRITE_FAILED = "SYSTEM_WAV_WRITE_FAILED" + SYSTEM_INTERNAL_ERROR = "SYSTEM_INTERNAL_ERROR" + + +@dataclass +class ErrorDetail: + """Structured error/warning detail.""" + + code: ErrorCode + line: int + column: int + message: str + severity: str # "error" | "warning" + hint: str = "" + context: str = "" + + def to_dict(self) -> dict[str, Any]: + return { + "code": self.code.value, + "line": self.line, + "column": self.column, + "message": self.message, + "severity": self.severity, + "hint": self.hint, + "context": self.context, + } + + +@dataclass +class NoteEvent: + type: str = "note" + tick_position: int = 0 + duration: int = 0 + note_number: int = 69 + velocity: int = 15 + duty: int = 2 + gate_time: float = 1.0 + + +@dataclass +class RestEvent: + type: str = "rest" + tick_position: int = 0 + duration: int = 0 + + +@dataclass +class VolumeEvent: + type: str = "volume" + tick_position: int = 0 + value: int = 15 + + +@dataclass +class DutyEvent: + type: str = "duty" + tick_position: int = 0 + value: int = 2 + + +@dataclass +class TempoEvent: + type: str = "tempo" + tick_position: int = 0 + bpm: int = 120 + + +@dataclass +class RepeatEvent: + type: str = "repeat" + tick_position: int = 0 + start_tick: int = 0 + end_tick: int = 0 + repeat_count: int = 1 + duration: int = 0 + + +@dataclass +class EnvelopeEvent: + type: str = "envelope" + tick_position: int = 0 + slot: int = 0 + points: list[dict[str, int]] = field(default_factory=list) + + +@dataclass +class VibratoEvent: + type: str = "vibrato" + tick_position: int = 0 + slot: int = 0 + params: dict[str, int] = field(default_factory=dict) + + +@dataclass +class GlideEvent: + type: str = "glide" + tick_position: int = 0 + slot: int = 0 + params: dict[str, int] = field(default_factory=dict) + + +Event = ( + NoteEvent + | RestEvent + | VolumeEvent + | DutyEvent + | TempoEvent + | RepeatEvent + | EnvelopeEvent + | VibratoEvent + | GlideEvent +) + + +@dataclass +class ChannelSequence: + channel_type: str = "pulse" + events: list[Event] = field(default_factory=list) + total_ticks: int = 0 + + def to_dict(self) -> dict[str, Any]: + return { + "channel_type": self.channel_type, + "events": [event_to_dict(e) for e in self.events], + "total_ticks": self.total_ticks, + } + + +@dataclass +class NoteSequence: + version: str = "1.0" + bpm: int = 120 + ticks_per_quarter: int = 192 + channels: dict[str, ChannelSequence] = field( + default_factory=lambda: { + "Pulse1": ChannelSequence("pulse"), + "Pulse2": ChannelSequence("pulse"), + "Triangle": ChannelSequence("triangle"), + "Noise": ChannelSequence("noise"), + } + ) + + def to_dict(self) -> dict[str, Any]: + return { + "version": self.version, + "bpm": self.bpm, + "ticks_per_quarter": self.ticks_per_quarter, + "channels": {k: v.to_dict() for k, v in self.channels.items()}, + } + + +def event_to_dict(event: Event) -> dict[str, Any]: + if isinstance( + event, (NoteEvent, RestEvent, VolumeEvent, DutyEvent, TempoEvent, RepeatEvent) + ): + return event.__dict__ + if isinstance(event, (EnvelopeEvent, VibratoEvent, GlideEvent)): + return event.__dict__ + raise TypeError(f"Unsupported event type: {type(event)}") diff --git a/src/mml_composemusic_mcp/lexer.py b/src/mml_composemusic_mcp/lexer.py new file mode 100644 index 0000000..05861cf --- /dev/null +++ b/src/mml_composemusic_mcp/lexer.py @@ -0,0 +1,314 @@ +"""Common lexer for MML.""" + +from collections.abc import Iterable +from dataclasses import dataclass +from enum import Enum, auto + + +class TokenType(Enum): + NOTE = auto() + REST = auto() + OCTAVE = auto() + OCTAVE_UP = auto() + OCTAVE_DOWN = auto() + LENGTH = auto() + VOLUME = auto() + DUTY = auto() + TEMPO = auto() + GATE_TIME = auto() + TIE = auto() + REPEAT_START = auto() + REPEAT_END = auto() + BAR = auto() + TRACK_HEADER = auto() + HEADER = auto() + COMMENT = auto() + NUMBER = auto() + SHARP = auto() + FLAT = auto() + DOT = auto() + TEXT = auto() + AT = auto() + TRANSPOSE = auto() + DETUNE = auto() + EXT_CMD = auto() # @ENV, @VIB, @GLI + EOF = auto() + + +@dataclass +class Token: + type: TokenType + value: str + line: int + column: int + raw: str = "" + + +def _track_header_char(ch: str) -> bool: + return ch.isupper() and ch in "ABTNL" + + +class Lexer: + def __init__(self, source: str, mode: str = "ppmck") -> None: + self.source = source + self.mode = mode.lower() + self.tokens: list[Token] = [] + self.line = 1 + self.column = 1 + self.pos = 0 + + def _peek(self, offset: int = 0) -> str: + idx = self.pos + offset + if idx >= len(self.source): + return "" + return self.source[idx] + + def _advance(self) -> str: + ch = self.source[self.pos] + self.pos += 1 + if ch == "\n": + self.line += 1 + self.column = 1 + else: + self.column += 1 + return ch + + def _skip_whitespace(self) -> None: + while self.pos < len(self.source) and self.source[self.pos] in " \t\r": + self._advance() + + def _read_number(self) -> str: + start = "" + while self.pos < len(self.source) and self.source[self.pos].isdigit(): + start += self._advance() + return start + + def _read_until_newline(self) -> str: + start = "" + while self.pos < len(self.source) and self.source[self.pos] not in "\r\n": + start += self._advance() + return start + + def _read_header_value(self) -> str: + value = "" + if self._peek() == '"': + self._advance() + while self.pos < len(self.source) and self._peek() not in '"\r\n': + value += self._advance() + if self._peek() == '"': + self._advance() + else: + # Return raw unterminated header; parser reports error + pass + return value + + def tokenize(self) -> list[Token]: + while self.pos < len(self.source): + self._skip_whitespace() + if self.pos >= len(self.source): + break + ch = self._peek() + start_line = self.line + start_col = self.column + + if self.mode == "ppmck": + self._tokenize_ppmck(ch, start_line, start_col) + else: + self._tokenize_pyxel(ch, start_line, start_col) + self.tokens.append(Token(TokenType.EOF, "", self.line, self.column)) + return self.tokens + + def _emit(self, ttype: TokenType, value: str, raw: str = "") -> None: + self.tokens.append( + Token(ttype, value, self.line, self.column - len(raw), raw or value) + ) + + def _tokenize_ppmck(self, ch: str, start_line: int, start_col: int) -> None: # noqa: ARG002 + if ch == "\n": + self._advance() + return + if ch == ";": + raw = self._read_until_newline() + self._emit(TokenType.COMMENT, raw, raw) + return + if ch == "#": + raw = "#" + self._read_until_newline() + self._emit(TokenType.HEADER, raw, raw) + return + if ch == "|": + self._advance() + self._emit(TokenType.BAR, "|", "|") + return + if ch == ">": + self._advance() + self._emit(TokenType.OCTAVE_UP, ">", ">") + return + if ch == "<": + self._advance() + self._emit(TokenType.OCTAVE_DOWN, "<", "<") + return + if ch == "&": + self._advance() + self._emit(TokenType.TIE, "&", "&") + return + if ch == "[": + self._advance() + self._emit(TokenType.REPEAT_START, "[", "[") + return + if ch == "]": + self._advance() + count = self._read_number() + self._emit(TokenType.REPEAT_END, count, "]" + count) + return + if ch == ".": + self._advance() + self._emit(TokenType.DOT, ".", ".") + return + if ch == "+": + self._advance() + self._emit(TokenType.SHARP, "+", "+") + return + if ch == "-": + self._advance() + self._emit(TokenType.FLAT, "-", "-") + return + if ch.isdigit(): + num = self._read_number() + self._emit(TokenType.NUMBER, num, num) + return + if _track_header_char(ch): + raw = self._advance() + self._emit(TokenType.TRACK_HEADER, raw.upper(), raw) + return + if ch.isalpha(): + cmd = self._advance() + if ch in "olvtq": + num = self._read_number() + raw = cmd + num + if ch == "o": + self._emit(TokenType.OCTAVE, num, raw) + elif ch == "l": + self._emit(TokenType.LENGTH, num, raw) + elif ch == "v": + self._emit(TokenType.VOLUME, num, raw) + elif ch == "t": + self._emit(TokenType.TEMPO, num, raw) + elif ch == "q": + self._emit(TokenType.DUTY, num, raw) + return + # Note commands + note = ch.lower() + if note in "cdefgabr": + if note == "r": + self._emit(TokenType.REST, note, note) + else: + self._emit(TokenType.NOTE, note, note) + return + # Unknown command + self._emit(TokenType.TEXT, cmd, cmd) + return + # Fallback invalid char + raw = self._advance() + self._emit(TokenType.TEXT, raw, raw) + + def _tokenize_pyxel(self, ch: str, start_line: int, start_col: int) -> None: # noqa: ARG002 + if ch == "\n": + self._advance() + return + if ch == "|": + self._advance() + self._emit(TokenType.BAR, "|", "|") + return + if ch == ">": + self._advance() + self._emit(TokenType.OCTAVE_UP, ">", ">") + return + if ch == "<": + self._advance() + self._emit(TokenType.OCTAVE_DOWN, "<", "<") + return + if ch == "&": + self._advance() + self._emit(TokenType.TIE, "&", "&") + return + if ch == "[": + self._advance() + self._emit(TokenType.REPEAT_START, "[", "[") + return + if ch == "]": + self._advance() + count = self._read_number() + self._emit(TokenType.REPEAT_END, count, "]" + count) + return + if ch == ".": + self._advance() + self._emit(TokenType.DOT, ".", ".") + return + if ch == "+": + self._advance() + self._emit(TokenType.SHARP, "+", "+") + return + if ch == "-": + self._advance() + self._emit(TokenType.FLAT, "-", "-") + return + if ch.isdigit(): + num = self._read_number() + if self._peek() == ":": + self._advance() + self._emit(TokenType.TRACK_HEADER, num, num + ":") + else: + self._emit(TokenType.NUMBER, num, num) + return + if ch == "@": + self._advance() + if self.source[self.pos : self.pos + 3].upper() in ("ENV", "VIB", "GLI"): + ext = self.source[self.pos : self.pos + 3].upper() + self.pos += 3 + self.column += 3 + raw = "@" + ext + self._emit(TokenType.EXT_CMD, ext, raw) + return + num = self._read_number() + self._emit(TokenType.AT, num, "@" + num) + return + if ch.isalpha(): + cmd = self._advance() + if cmd in "OLVTQKY": + num = self._read_number() + raw = cmd + num + if cmd == "O": + self._emit(TokenType.OCTAVE, num, raw) + elif cmd == "L": + self._emit(TokenType.LENGTH, num, raw) + elif cmd == "V": + self._emit(TokenType.VOLUME, num, raw) + elif cmd == "T": + self._emit(TokenType.TEMPO, num, raw) + elif cmd == "Q": + self._emit(TokenType.GATE_TIME, num, raw) + elif cmd == "K": + self._emit(TokenType.TRANSPOSE, num, raw) + elif cmd == "Y": + self._emit(TokenType.DETUNE, num, raw) + return + note = cmd.upper() + if note in "CDEFGABR": + if note == "R": + self._emit(TokenType.REST, note, note) + else: + self._emit(TokenType.NOTE, note, note) + return + self._emit(TokenType.TEXT, cmd, cmd) + return + # Fallback + raw = self._advance() + self._emit(TokenType.TEXT, raw, raw) + + +def tokenize(source: str, mode: str = "ppmck") -> list[Token]: + return Lexer(source, mode).tokenize() + + +def iter_token_text(tokens: Iterable[Token]) -> str: + return " ".join(f"{t.type.name}:{t.value}" for t in tokens) diff --git a/src/mml_composemusic_mcp/parser_base.py b/src/mml_composemusic_mcp/parser_base.py new file mode 100644 index 0000000..0427a4c --- /dev/null +++ b/src/mml_composemusic_mcp/parser_base.py @@ -0,0 +1,92 @@ +"""Base parser utilities shared between ppmck and pyxel parsers.""" + +from dataclasses import dataclass + +from .ir import ErrorCode, ErrorDetail +from .lexer import Token, TokenType + + +@dataclass +class ParserContext: + tokens: list[Token] + pos: int = 0 + errors: list[ErrorDetail] = None # type: ignore[assignment] + + def __post_init__(self) -> None: + if self.errors is None: + self.errors = [] + + def peek(self, offset: int = 0) -> Token: + idx = self.pos + offset + if idx >= len(self.tokens): + return self.tokens[-1] + return self.tokens[idx] + + def advance(self) -> Token: + token = self.peek() + self.pos += 1 + return token + + def match(self, *types: TokenType) -> Token | None: + token = self.peek() + if token.type in types: + return self.advance() + return None + + def expect(self, *types: TokenType) -> Token | None: + return self.match(*types) + + def add_error( + self, + code: ErrorCode, + line: int, + column: int, + message: str, + severity: str, + hint: str = "", + context: str = "", + ) -> None: + self.errors.append( + ErrorDetail( + code=code, + line=line, + column=column, + message=message, + severity=severity, + hint=hint, + context=context, + ) + ) + + +class ParserError(Exception): + pass + + +def length_value_to_ticks(length: int, dots: int, ticks_per_quarter: int = 192) -> int: + base = ticks_per_quarter * 4 // length + total = base + add = base + for _ in range(dots): + add //= 2 + total += add + return total + + +NOTE_OFFSET = { + "c": 0, + "d": 2, + "e": 4, + "f": 5, + "g": 7, + "a": 9, + "b": 11, +} + + +def note_to_midi(note: str, octave: int, accidental: int = 0) -> int: + return (octave + 1) * 12 + NOTE_OFFSET[note] + accidental + + +def clamp(value: int, min_val: int, max_val: int) -> int: + return max(min_val, min(value, max_val)) diff --git a/src/mml_composemusic_mcp/parser_ppmck.py b/src/mml_composemusic_mcp/parser_ppmck.py new file mode 100644 index 0000000..c2a1fa3 --- /dev/null +++ b/src/mml_composemusic_mcp/parser_ppmck.py @@ -0,0 +1,451 @@ +"""PPMCK-style MML parser.""" + +from .ir import ( + DutyEvent, + ErrorCode, + ErrorDetail, + NoteEvent, + NoteSequence, + RepeatEvent, + RestEvent, + TempoEvent, +) +from .lexer import Token, TokenType +from .parser_base import ( + ParserContext, + clamp, + length_value_to_ticks, + note_to_midi, +) + +CHANNEL_MAP = { + "A": "Pulse1", + "B": "Pulse2", + "T": "Triangle", + "N": "Noise", +} +PULSE_CHANNELS = {"Pulse1", "Pulse2"} + + +class PpmckParser: + def __init__(self, source: str, tokens: list[Token]) -> None: + self.source = source + self.ctx = ParserContext(tokens) + self.note_sequence = NoteSequence() + self.current_channel: str | None = None + self.octave = 4 + self.default_length = 4 + self.velocity = 15 + self.duty = 2 + self.tick_position = 0 + self.headers: list[tuple[str, str]] = [] + self.loop_start: int | None = None + self.tracks_seen: set[str] = set() + + def _context_line(self, token: Token) -> str: + lines = self.source.splitlines() + if 1 <= token.line <= len(lines): + return lines[token.line - 1] + return "" + + def parse(self) -> tuple[NoteSequence, list[ErrorDetail]]: + while self.ctx.peek().type != TokenType.EOF: + token = self.ctx.peek() + if token.type == TokenType.HEADER: + self._parse_header() + continue + if token.type == TokenType.COMMENT: + self.ctx.advance() + continue + if token.type == TokenType.TRACK_HEADER: + self._parse_track_header() + continue + if self.current_channel is None: + self.ctx.advance() + continue + self._parse_statement() + + self._validate_tracks() + + if self.loop_start is not None: + max_tick = max( + ch.total_ticks for ch in self.note_sequence.channels.values() + ) + for ch in self.note_sequence.channels.values(): + ch.events.append( + RepeatEvent( + start_tick=self.loop_start, + end_tick=max_tick, + repeat_count=0, + ) + ) + + for ch in self.note_sequence.channels.values(): + ch.total_ticks = max( + (e.tick_position + getattr(e, "duration", 0) for e in ch.events), + default=0, + ) + + return self.note_sequence, self.ctx.errors + + def _parse_header(self) -> None: + token = self.ctx.advance() + raw = token.value + parts = raw.split(None, 1) + key = parts[0] if parts else raw + value = "" + if len(parts) > 1 and parts[1].startswith('"'): + value = parts[1].strip('"') + if not parts[1].endswith('"') or parts[1] == '"': + self.ctx.add_error( + code=ErrorCode.SYNTAX_UNTERMINATED_HEADER, + line=token.line, + column=token.column, + message=f"ヘッダー '{key}' の引用符が閉じられていません。", + severity="error", + hint='二重引用符 " で値を閉じてください。例: #TITLE "My Song"', + context=self._context_line(token), + ) + self.headers.append((key, value)) + + def _parse_track_header(self) -> None: + token = self.ctx.advance() + ch = token.value.upper() + if ch == "L": + self.loop_start = 0 + return + if ch not in CHANNEL_MAP: + self.ctx.add_error( + code=ErrorCode.SYNTAX_INVALID_TRACK_HEADER, + line=token.line, + column=token.column, + message=f"無効なトラックヘッダー '{ch}' です。", + severity="error", + hint="A, B, T, N, L のいずれかを使用してください。", + context=self._context_line(token), + ) + self.current_channel = None + return + self.current_channel = CHANNEL_MAP[ch] + if self.current_channel in self.tracks_seen: + self.ctx.add_error( + code=ErrorCode.SYNTAX_DUPLICATE_TRACK, + line=token.line, + column=token.column, + message=f"トラック '{ch}' が複数回定義されています。", + severity="error", + hint="各トラックは1回のみ定義できます。重複定義を削除してください。", + context=self._context_line(token), + ) + self.tracks_seen.add(self.current_channel) + self.octave = 4 + self.default_length = 4 + self.velocity = 15 + self.duty = 2 + self.tick_position = 0 + + def _parse_statement(self) -> None: + token = self.ctx.peek() + handlers = { + TokenType.NOTE: self._parse_note, + TokenType.REST: self._parse_rest, + TokenType.OCTAVE: self._parse_octave, + TokenType.OCTAVE_UP: self._parse_octave_up, + TokenType.OCTAVE_DOWN: self._parse_octave_down, + TokenType.LENGTH: self._parse_length, + TokenType.VOLUME: self._parse_volume, + TokenType.DUTY: self._parse_duty, + TokenType.TEMPO: self._parse_tempo, + TokenType.TIE: self._parse_tie, + TokenType.REPEAT_START: self._parse_repeat_start, + TokenType.REPEAT_END: self._parse_repeat_end, + TokenType.BAR: self._parse_bar, + TokenType.COMMENT: lambda: self.ctx.advance(), + } + handler = handlers.get(token.type) + if handler: + handler() + else: + self.ctx.add_error( + code=ErrorCode.SYNTAX_UNEXPECTED_TOKEN, + line=token.line, + column=token.column, + message=f"'{token.raw}' はここでは使用できません。", + severity="error", + hint="音符、休符、コマンド、小節線を配置してください。", + context=self._context_line(token), + ) + self.ctx.advance() + + def _read_length(self) -> tuple[int, int]: + dots = 0 + length_token = self.ctx.match(TokenType.NUMBER) + if length_token is None: + length = self.default_length + else: + length = int(length_token.value) + while self.ctx.match(TokenType.DOT): + dots += 1 + return length, dots + + def _parse_note(self) -> None: + token = self.ctx.advance() + note = token.value.lower() + length, dots = self._read_length() + ticks = length_value_to_ticks(length, dots) + accidental = 0 + while True: + if self.ctx.match(TokenType.SHARP): + accidental += 1 + elif self.ctx.match(TokenType.FLAT): + accidental -= 1 + else: + break + + if self.current_channel == "Noise": + self.ctx.add_error( + code=ErrorCode.SYNTAX_CHANNEL_MISMATCH, + line=token.line, + column=token.column, + message="チャンネル 'Noise' で音高指定は使用できません。", + severity="warning", + hint="Noiseチャンネルでは音高ではなく音長と音量のみ指定してください。", + context=self._context_line(token), + ) + midi = 0 + else: + midi = note_to_midi(note, self.octave, accidental) + if not 0 <= midi <= 127: + self.ctx.add_error( + code=ErrorCode.SYNTAX_NOTE_OUT_OF_RANGE, + line=token.line, + column=token.column, + message=f"音高が範囲外です(MIDI音番号 {midi})。", + severity="warning", + hint="オクターブを調整してください。有効範囲: o0〜o7。", + context=self._context_line(token), + ) + midi = clamp(midi, 0, 127) + + event = NoteEvent( + tick_position=self.tick_position, + duration=ticks, + note_number=midi, + velocity=self.velocity, + duty=self.duty, + gate_time=1.0, + ) + self._add_event(event) + self.tick_position += ticks + + def _parse_rest(self) -> None: + self.ctx.advance() + length, dots = self._read_length() + ticks = length_value_to_ticks(length, dots) + event = RestEvent(tick_position=self.tick_position, duration=ticks) + self._add_event(event) + self.tick_position += ticks + + def _parse_octave(self) -> None: + token = self.ctx.advance() + value = int(token.value) + if not 0 <= value <= 7: + self.ctx.add_error( + code=ErrorCode.SYNTAX_VALUE_OUT_OF_RANGE, + line=token.line, + column=token.column, + message=f"'o' の値 {value} は範囲外です。有効範囲: 0〜7。", + severity="error", + hint="例: o4 のように指定してください。", + context=self._context_line(token), + ) + return + self.octave = value + + def _parse_octave_up(self) -> None: + self.ctx.advance() + if self.octave < 7: + self.octave += 1 + + def _parse_octave_down(self) -> None: + self.ctx.advance() + if self.octave > 0: + self.octave -= 1 + + def _parse_length(self) -> None: + token = self.ctx.advance() + value = int(token.value) + if not 1 <= value <= 192: + self.ctx.add_error( + code=ErrorCode.SYNTAX_VALUE_OUT_OF_RANGE, + line=token.line, + column=token.column, + message=f"'l' の値 {value} は範囲外です。有効範囲: 1〜192。", + severity="error", + hint="例: l8 のように指定してください。", + context=self._context_line(token), + ) + return + self.default_length = value + + def _parse_volume(self) -> None: + token = self.ctx.advance() + if self.current_channel == "Triangle": + # Silently ignored per spec + return + value = int(token.value) + if not 0 <= value <= 15: + self.ctx.add_error( + code=ErrorCode.SYNTAX_VALUE_OUT_OF_RANGE, + line=token.line, + column=token.column, + message=f"'v' の値 {value} は範囲外です。有効範囲: 0〜15。", + severity="error", + hint="例: v15 のように指定してください。", + context=self._context_line(token), + ) + return + self.velocity = value + + def _parse_duty(self) -> None: + token = self.ctx.advance() + if self.current_channel not in PULSE_CHANNELS: + self.ctx.add_error( + code=ErrorCode.SYNTAX_CHANNEL_MISMATCH, + line=token.line, + column=token.column, + message=f"チャンネル '{self.current_channel}' で 'q' は使用できません。", + severity="warning", + hint="Triangleチャンネルはデューティ比を持ちません。q コマンドを削除してください。", + context=self._context_line(token), + ) + return + value = int(token.value) + if not 0 <= value <= 3: + self.ctx.add_error( + code=ErrorCode.SYNTAX_VALUE_OUT_OF_RANGE, + line=token.line, + column=token.column, + message=f"'q' の値 {value} は範囲外です。有効範囲: 0〜3。", + severity="error", + hint="例: q2 のように指定してください。", + context=self._context_line(token), + ) + return + self.duty = value + self._add_event(DutyEvent(tick_position=self.tick_position, value=value)) + + def _parse_tempo(self) -> None: + token = self.ctx.advance() + value = int(token.value) + if value < 1: + self.ctx.add_error( + code=ErrorCode.SYNTAX_VALUE_OUT_OF_RANGE, + line=token.line, + column=token.column, + message=f"'t' の値 {value} は範囲外です。有効範囲: 1以上。", + severity="error", + hint="例: t150 のように指定してください。", + context=self._context_line(token), + ) + return + self.note_sequence.bpm = value + self._add_event(TempoEvent(tick_position=self.tick_position, bpm=value)) + + def _parse_tie(self) -> None: + token = self.ctx.advance() + next_token = self.ctx.peek() + if next_token.type not in (TokenType.NOTE, TokenType.REST, TokenType.NUMBER): + self.ctx.add_error( + code=ErrorCode.SYNTAX_UNTERMINATED_TIE, + line=token.line, + column=token.column, + message="タイ '&' の後に音符が見つかりません。", + severity="error", + hint="& の後に音符(例: c4)を続けてください。", + context=self._context_line(token), + ) + return + if next_token.type == TokenType.NUMBER: + self.ctx.advance() + ticks = length_value_to_ticks(int(next_token.value), 0) + self._extend_last_note(ticks) + self.tick_position += ticks + return + # For note/rest, parser will create next event; we just note that previous + # event should be merged. Simple approach: reduce tick_position and mark. + # Here we just allow the next note to overwrite position and merge later. + # Implementation: back up tick position so next note duration adds to previous. + self.tick_position = self._last_note_end() + + def _last_note_end(self) -> int: + ch = self.note_sequence.channels[self.current_channel] # type: ignore[index] + for ev in reversed(ch.events): + if isinstance(ev, (NoteEvent, RestEvent)): + return ev.tick_position + return self.tick_position + + def _extend_last_note(self, ticks: int) -> None: + ch = self.note_sequence.channels[self.current_channel] # type: ignore[index] + for ev in reversed(ch.events): + if isinstance(ev, (NoteEvent, RestEvent)): + ev.duration += ticks + return + + def _parse_repeat_start(self) -> None: + # Stage 2 feature: keep token but do not expand + token = self.ctx.advance() + self.ctx.add_error( + code=ErrorCode.SYNTAX_UNEXPECTED_TOKEN, + line=token.line, + column=token.column, + message="区間リピート '[' は第2段階で実装されます。", + severity="warning", + hint="現時点では無視されます。", + context=self._context_line(token), + ) + + def _parse_repeat_end(self) -> None: + token = self.ctx.advance() + self.ctx.add_error( + code=ErrorCode.SYNTAX_UNEXPECTED_TOKEN, + line=token.line, + column=token.column, + message="区間リピート ']' は第2段階で実装されます。", + severity="warning", + hint="現時点では無視されます。", + context=self._context_line(token), + ) + + def _parse_bar(self) -> None: + self.ctx.advance() + + def _add_event(self, event) -> None: # type: ignore[no-untyped-def] + ch = self.note_sequence.channels[self.current_channel] # type: ignore[index] + ch.events.append(event) + + def _validate_tracks(self) -> None: + for raw, name in CHANNEL_MAP.items(): + ch = self.note_sequence.channels[name] + if name in self.tracks_seen and not ch.events: + # Determine approximate line of header (last track header token) + header_token = None + for tok in self.ctx.tokens: + if tok.type == TokenType.TRACK_HEADER and tok.value.upper() == raw: + header_token = tok + self.ctx.add_error( + code=ErrorCode.SYNTAX_EMPTY_TRACK, + line=header_token.line if header_token else 1, + column=header_token.column if header_token else 1, + message=f"トラック '{raw}' に音符または休符がありません。", + severity="error", + hint="少なくとも1つの音符または休符を記述してください。", + context=self._context_line(header_token) if header_token else "", + ) + + +def parse_ppmck( + source: str, tokens: list[Token] +) -> tuple[NoteSequence, list[ErrorDetail]]: + parser = PpmckParser(source, tokens) + return parser.parse() diff --git a/src/mml_composemusic_mcp/parser_pyxel.py b/src/mml_composemusic_mcp/parser_pyxel.py new file mode 100644 index 0000000..7d3707b --- /dev/null +++ b/src/mml_composemusic_mcp/parser_pyxel.py @@ -0,0 +1,652 @@ +"""Pyxel MML parser.""" + +from .ir import ( + DutyEvent, + EnvelopeEvent, + ErrorCode, + ErrorDetail, + GlideEvent, + NoteEvent, + NoteSequence, + RepeatEvent, + RestEvent, + TempoEvent, + VibratoEvent, + VolumeEvent, +) +from .lexer import Token, TokenType +from .parser_base import ( + ParserContext, + clamp, + length_value_to_ticks, + note_to_midi, +) + +CHANNEL_MAP = { + "0": "Pulse1", + "1": "Pulse2", + "2": "Triangle", + "3": "Noise", +} +PULSE_CHANNELS = {"Pulse1", "Pulse2"} + +PYXEL_DUTY = { + "1": 2, # Square 50% + "2": 1, # Pulse 25% +} + + +class PyxelParser: + def __init__(self, source: str, tokens: list[Token]) -> None: + self.source = source + self.ctx = ParserContext(tokens) + self.note_sequence = NoteSequence() + self.current_channel: str | None = None + self.octave = 4 + self.default_length = 4 + self.velocity = 100 + self.gate_time = 0.8 + self.duty = 2 + self.transpose = 0 + self.detune_cents = 0 + self.tick_position = 0 + self.tracks_seen: set[str] = set() + self.repeat_stack: list[int] = [] + + def _context_line(self, token: Token) -> str: + lines = self.source.splitlines() + if 1 <= token.line <= len(lines): + return lines[token.line - 1] + return "" + + def parse(self) -> tuple[NoteSequence, list[ErrorDetail]]: + while self.ctx.peek().type != TokenType.EOF: + token = self.ctx.peek() + if token.type == TokenType.TRACK_HEADER: + self._parse_track_header() + continue + if self.current_channel is None: + self.ctx.advance() + continue + self._parse_statement() + + if self.repeat_stack: + for _start_tick in self.repeat_stack: + self.ctx.add_error( + code=ErrorCode.SYNTAX_UNTERMINATED_REPEAT, + line=1, + column=1, + message="リピート '[' に対応する ']' が見つかりません。", + severity="error", + hint="] を追加してリピートを閉じてください。回数指定(例: ]2)も可能です。", + context="", + ) + + self._validate_tracks() + + for ch in self.note_sequence.channels.values(): + ch.total_ticks = max( + (e.tick_position + getattr(e, "duration", 0) for e in ch.events), + default=0, + ) + + return self.note_sequence, self.ctx.errors + + def _parse_track_header(self) -> None: + token = self.ctx.advance() + ch = token.value + if ch not in CHANNEL_MAP: + self.ctx.add_error( + code=ErrorCode.SYNTAX_INVALID_TRACK_HEADER, + line=token.line, + column=token.column, + message=f"無効なトラックヘッダー '{ch}:' です。", + severity="error", + hint="0:, 1:, 2:, 3: のいずれかを使用してください。", + context=self._context_line(token), + ) + self.current_channel = None + return + self.current_channel = CHANNEL_MAP[ch] + if self.current_channel in self.tracks_seen: + self.ctx.add_error( + code=ErrorCode.SYNTAX_DUPLICATE_TRACK, + line=token.line, + column=token.column, + message=f"トラック '{ch}:' が複数回定義されています。", + severity="error", + hint="各トラックは1回のみ定義できます。重複定義を削除してください。", + context=self._context_line(token), + ) + self.tracks_seen.add(self.current_channel) + self.octave = 4 + self.default_length = 4 + self.velocity = 100 + self.gate_time = 0.8 + self.duty = 2 + self.transpose = 0 + self.detune_cents = 0 + self.tick_position = 0 + + def _parse_statement(self) -> None: + token = self.ctx.peek() + handlers = { + TokenType.NOTE: self._parse_note, + TokenType.REST: self._parse_rest, + TokenType.OCTAVE: self._parse_octave, + TokenType.OCTAVE_UP: self._parse_octave_up, + TokenType.OCTAVE_DOWN: self._parse_octave_down, + TokenType.LENGTH: self._parse_length, + TokenType.VOLUME: self._parse_volume, + TokenType.GATE_TIME: self._parse_gate_time, + TokenType.DUTY: self._parse_duty, + TokenType.AT: self._parse_at, + TokenType.TEMPO: self._parse_tempo, + TokenType.TRANSPOSE: self._parse_transpose, + TokenType.DETUNE: self._parse_detune, + TokenType.TIE: self._parse_tie, + TokenType.REPEAT_START: self._parse_repeat_start, + TokenType.REPEAT_END: self._parse_repeat_end, + TokenType.EXT_CMD: self._parse_ext_cmd, + TokenType.BAR: self._parse_bar, + } + handler = handlers.get(token.type) + if handler: + handler() + else: + self.ctx.add_error( + code=ErrorCode.SYNTAX_UNEXPECTED_TOKEN, + line=token.line, + column=token.column, + message=f"'{token.raw}' はここでは使用できません。", + severity="error", + hint="音符、休符、コマンド、小節線を配置してください。", + context=self._context_line(token), + ) + self.ctx.advance() + + def _read_length(self) -> tuple[int, int]: + dots = 0 + length_token = self.ctx.match(TokenType.NUMBER) + if length_token is None: + length = self.default_length + else: + length = int(length_token.value) + while self.ctx.match(TokenType.DOT): + dots += 1 + return length, dots + + def _parse_note(self) -> None: + token = self.ctx.advance() + note = token.value.lower() + length, dots = self._read_length() + ticks = length_value_to_ticks(length, dots) + accidental = 0 + while True: + if self.ctx.match(TokenType.SHARP): + accidental += 1 + elif self.ctx.match(TokenType.FLAT): + accidental -= 1 + else: + break + + if self.current_channel == "Noise": + midi = note_to_midi(note, self.octave, accidental) + else: + midi = note_to_midi(note, self.octave, accidental) + self.transpose + if not 0 <= midi <= 127: + self.ctx.add_error( + code=ErrorCode.SYNTAX_NOTE_OUT_OF_RANGE, + line=token.line, + column=token.column, + message=f"音高が範囲外です(MIDI音番号 {midi})。", + severity="warning", + hint="オクターブ/トランスポーズを調整してください。有効範囲: O0〜O7。", + context=self._context_line(token), + ) + midi = clamp(midi, 0, 127) + + event = NoteEvent( + tick_position=self.tick_position, + duration=ticks, + note_number=midi, + velocity=self._normalize_velocity(self.velocity), + duty=self.duty, + gate_time=self.gate_time, + ) + self._add_event(event) + self.tick_position += ticks + + def _parse_rest(self) -> None: + self.ctx.advance() + length, dots = self._read_length() + ticks = length_value_to_ticks(length, dots) + event = RestEvent(tick_position=self.tick_position, duration=ticks) + self._add_event(event) + self.tick_position += ticks + + def _parse_octave(self) -> None: + token = self.ctx.advance() + value = int(token.value) + if not -1 <= value <= 9: + self.ctx.add_error( + code=ErrorCode.SYNTAX_VALUE_OUT_OF_RANGE, + line=token.line, + column=token.column, + message=f"'O' の値 {value} は範囲外です。有効範囲: -1〜9。", + severity="error", + hint="例: O4 のように指定してください。", + context=self._context_line(token), + ) + return + self.octave = value + + def _parse_octave_up(self) -> None: + self.ctx.advance() + if self.octave < 9: + self.octave += 1 + + def _parse_octave_down(self) -> None: + self.ctx.advance() + if self.octave > -1: + self.octave -= 1 + + def _parse_length(self) -> None: + token = self.ctx.advance() + value = int(token.value) + if not 1 <= value <= 192: + self.ctx.add_error( + code=ErrorCode.SYNTAX_VALUE_OUT_OF_RANGE, + line=token.line, + column=token.column, + message=f"'L' の値 {value} は範囲外です。有効範囲: 1〜192。", + severity="error", + hint="例: L8 のように指定してください。", + context=self._context_line(token), + ) + return + self.default_length = value + + def _parse_volume(self) -> None: + token = self.ctx.advance() + value = int(token.value) + if not 0 <= value <= 127: + self.ctx.add_error( + code=ErrorCode.SYNTAX_VALUE_OUT_OF_RANGE, + line=token.line, + column=token.column, + message=f"'V' の値 {value} は範囲外です。有効範囲: 0〜127。", + severity="error", + hint="例: V100 のように指定してください。", + context=self._context_line(token), + ) + return + self.velocity = value + self._add_event( + VolumeEvent( + tick_position=self.tick_position, + value=self._normalize_velocity(value), + ) + ) + + def _normalize_velocity(self, value: int) -> int: + return round(value / 127 * 15) + + def _parse_gate_time(self) -> None: + token = self.ctx.advance() + value = int(token.value) + if not 0 <= value <= 100: + self.ctx.add_error( + code=ErrorCode.SYNTAX_VALUE_OUT_OF_RANGE, + line=token.line, + column=token.column, + message=f"'Q' の値 {value} は範囲外です。有効範囲: 0〜100。", + severity="error", + hint="例: Q80 のように指定してください。", + context=self._context_line(token), + ) + return + self.gate_time = value / 100.0 + + def _parse_duty(self, cmd_raw: str = "Q") -> None: + # Q command maps to duty per Design_mml legacy section + token = self.ctx.peek() + value = int(token.value) + if not 0 <= value <= 3: + self.ctx.add_error( + code=ErrorCode.SYNTAX_VALUE_OUT_OF_RANGE, + line=token.line, + column=token.column, + message=f"'{cmd_raw}' の値 {value} は範囲外です。有効範囲: 0〜3。", + severity="error", + hint=f"例: {cmd_raw}2 のように指定してください。", + context=self._context_line(token), + ) + self.ctx.advance() + return + if self.current_channel not in PULSE_CHANNELS: + self.ctx.add_error( + code=ErrorCode.SYNTAX_CHANNEL_MISMATCH, + line=token.line, + column=token.column, + message=f"チャンネル '{self.current_channel}' で '{cmd_raw}' は使用できません。", + severity="warning", + hint=f"{self.current_channel}チャンネルではデューティ比を持ちません。{cmd_raw} コマンドを削除してください。", + context=self._context_line(token), + ) + self.ctx.advance() + return + self.duty = value + self._add_event(DutyEvent(tick_position=self.tick_position, value=value)) + self.ctx.advance() + + def _parse_at(self) -> None: + token = self.ctx.advance() + value = token.value + if value in PYXEL_DUTY: + if self.current_channel not in PULSE_CHANNELS: + self.ctx.add_error( + code=ErrorCode.SYNTAX_CHANNEL_MISMATCH, + line=token.line, + column=token.column, + message=f"チャンネル '{self.current_channel}' で '@{value}' は使用できません。", + severity="warning", + hint="トラック番号がチャンネルを決定します。@ コマンドを削除するか、Pulseチャンネル(0: または 1:)に移動してください。", + context=self._context_line(token), + ) + return + self.duty = PYXEL_DUTY[value] + self._add_event( + DutyEvent(tick_position=self.tick_position, value=self.duty) + ) + return + if value == "0": + self.ctx.add_error( + code=ErrorCode.SYNTAX_CHANNEL_MISMATCH, + line=token.line, + column=token.column, + message=f"チャンネル '{self.current_channel}' で '@0' (Triangle) は使用できません。", + severity="warning", + hint="トラック番号がチャンネルを決定します。@ コマンドを削除するか、適切なチャンネルに移動してください。", + context=self._context_line(token), + ) + elif value == "3": + self.ctx.add_error( + code=ErrorCode.SYNTAX_CHANNEL_MISMATCH, + line=token.line, + column=token.column, + message=f"チャンネル '{self.current_channel}' で '@3' (Noise) は使用できません。", + severity="warning", + hint="トラック番号がチャンネルを決定します。@ コマンドを削除するか、適切なチャンネルに移動してください。", + context=self._context_line(token), + ) + else: + self.ctx.add_error( + code=ErrorCode.SYNTAX_INVALID_NUMBER, + line=token.line, + column=token.column, + message=f"'@' の後に無効な値 '{value}' が見つかりました。", + severity="error", + hint="例: @1 のように指定してください。", + context=self._context_line(token), + ) + + def _parse_tempo(self) -> None: + token = self.ctx.advance() + value = int(token.value) + if value < 1: + self.ctx.add_error( + code=ErrorCode.SYNTAX_VALUE_OUT_OF_RANGE, + line=token.line, + column=token.column, + message=f"'T' の値 {value} は範囲外です。有効範囲: 1以上。", + severity="error", + hint="例: T150 のように指定してください。", + context=self._context_line(token), + ) + return + self.note_sequence.bpm = value + self._add_event(TempoEvent(tick_position=self.tick_position, bpm=value)) + + def _parse_transpose(self) -> None: + token = self.ctx.advance() + self.transpose = int(token.value) + + def _parse_detune(self) -> None: + token = self.ctx.advance() + self.detune_cents = int(token.value) + + def _parse_tie(self) -> None: + token = self.ctx.advance() + next_token = self.ctx.peek() + if next_token.type not in (TokenType.NOTE, TokenType.REST, TokenType.NUMBER): + self.ctx.add_error( + code=ErrorCode.SYNTAX_UNTERMINATED_TIE, + line=token.line, + column=token.column, + message="タイ '&' の後に音符が見つかりません。", + severity="error", + hint="& の後に音符(例: C4)を続けてください。音長のみ(例: &16)も可能です。", + context=self._context_line(token), + ) + return + if next_token.type == TokenType.NUMBER: + self.ctx.advance() + ticks = length_value_to_ticks(int(next_token.value), 0) + self._extend_last_note(ticks) + self.tick_position += ticks + return + self.tick_position = self._last_note_end() + + def _last_note_end(self) -> int: + ch = self.note_sequence.channels[self.current_channel] # type: ignore[index] + for ev in reversed(ch.events): + if isinstance(ev, (NoteEvent, RestEvent)): + return ev.tick_position + return self.tick_position + + def _extend_last_note(self, ticks: int) -> None: + ch = self.note_sequence.channels[self.current_channel] # type: ignore[index] + for ev in reversed(ch.events): + if isinstance(ev, (NoteEvent, RestEvent)): + ev.duration += ticks + return + + def _parse_repeat_start(self) -> None: + self.ctx.advance() + self.repeat_stack.append(self.tick_position) + + def _parse_repeat_end(self) -> None: + token = self.ctx.advance() + if not self.repeat_stack: + self.ctx.add_error( + code=ErrorCode.SYNTAX_UNMATCHED_REPEAT_END, + line=token.line, + column=token.column, + message="']' に対応する '[' が見つかりません。", + severity="error", + hint="直前の [ を確認するか、余分な ] を削除してください。", + context=self._context_line(token), + ) + return + start_tick = self.repeat_stack.pop() + count_str = token.value + if count_str == "": + count = 0 # infinite + self.ctx.add_error( + code=ErrorCode.SYNTAX_UNEXPECTED_TOKEN, + line=token.line, + column=token.column, + message="無限リピートはWAV生成時に2回で打ち切られます。", + severity="warning", + hint="回数を指定するか、2回の再生で十分であることを確認してください。", + context=self._context_line(token), + ) + else: + count = int(count_str) + segment = [ + e + for e in self.note_sequence.channels[self.current_channel].events # type: ignore[index] + if start_tick <= e.tick_position < self.tick_position + ] + repeat_count = 2 if count == 0 else count + for i in range(1, repeat_count): + offset = i * (self.tick_position - start_tick) + for ev in segment: + new_event = self._clone_event(ev, ev.tick_position + offset) + self._add_event(new_event) + if isinstance(new_event, (NoteEvent, RestEvent)): + end = new_event.tick_position + new_event.duration + if end > self.tick_position: + self.tick_position = end + self.note_sequence.channels[self.current_channel].events.sort( # type: ignore[index] + key=lambda e: e.tick_position + ) + self._add_event( + RepeatEvent( + start_tick=start_tick, + end_tick=self.tick_position, + repeat_count=count, + ) + ) + + def _clone_event(self, event, tick_position: int): # type: ignore[no-untyped-def] + import copy + + new = copy.copy(event) + new.tick_position = tick_position + return new + + def _parse_ext_cmd(self) -> None: + token = self.ctx.advance() + cmd = token.value # ENV, VIB, GLI + slot_token = self.ctx.match(TokenType.NUMBER) + if slot_token is None: + self.ctx.add_error( + code=ErrorCode.SYNTAX_INVALID_NUMBER, + line=token.line, + column=token.column, + message=f"'@{cmd}' の後にスロット番号が必要です。", + severity="error", + hint=f"例: @{cmd}1 のように指定してください。", + context=self._context_line(token), + ) + return + slot = int(slot_token.value) + if slot == 0: + # OFF + if cmd == "ENV": + ev = EnvelopeEvent(tick_position=self.tick_position, slot=0) + elif cmd == "VIB": + ev = VibratoEvent(tick_position=self.tick_position, slot=0) + else: + ev = GlideEvent(tick_position=self.tick_position, slot=0) + self._add_event(ev) + return + if self.ctx.match(TokenType.NUMBER): + # definition without braces; simplistic parse numbers until non-number + # Real Pyxel uses { ... }; we accept a flat sequence of ints for now. + values = [int(slot_token.value)] + while True: + num = self.ctx.match(TokenType.NUMBER) + if num is None: + break + values.append(int(num.value)) + if cmd == "ENV": + points = [] + for i in range(1, len(values), 2): + duration = values[i] + target = values[i - 1] if i - 1 < len(values) else values[-1] + points.append({"target_volume": target, "duration_ticks": duration}) + ev = EnvelopeEvent( + tick_position=self.tick_position, + slot=slot, + points=points, + ) + elif cmd == "VIB": + if len(values) >= 3: + params = { + "delay_ticks": values[0], + "period_ticks": values[1], + "depth_cents": values[2], + } + else: + params = {} + ev = VibratoEvent( + tick_position=self.tick_position, + slot=slot, + params=params, + ) + else: + if len(values) >= 2: + params = { + "initial_offset_cents": values[0], + "duration_ticks": values[1], + } + else: + params = {} + ev = GlideEvent( + tick_position=self.tick_position, + slot=slot, + params=params, + ) + self._add_event(ev) + self.ctx.add_error( + code=ErrorCode.SYNTAX_UNEXPECTED_TOKEN, + line=token.line, + column=token.column, + message=f"@{cmd} は第1段階では未サポート、無視されます。", + severity="warning", + hint="合成には反映されませんが、IRとして保持されます。", + context=self._context_line(token), + ) + return + # Slot switch only + if cmd == "ENV": + ev = EnvelopeEvent(tick_position=self.tick_position, slot=slot) + elif cmd == "VIB": + ev = VibratoEvent(tick_position=self.tick_position, slot=slot) + else: + ev = GlideEvent(tick_position=self.tick_position, slot=slot) + self._add_event(ev) + self.ctx.add_error( + code=ErrorCode.SYNTAX_UNEXPECTED_TOKEN, + line=token.line, + column=token.column, + message=f"@{cmd} は第1段階では未サポート、無視されます。", + severity="warning", + hint="合成には反映されませんが、IRとして保持されます。", + context=self._context_line(token), + ) + + def _parse_bar(self) -> None: + self.ctx.advance() + + def _add_event(self, event) -> None: # type: ignore[no-untyped-def] + ch = self.note_sequence.channels[self.current_channel] # type: ignore[index] + ch.events.append(event) + + def _validate_tracks(self) -> None: + for raw, name in CHANNEL_MAP.items(): + ch = self.note_sequence.channels[name] + if name in self.tracks_seen and not ch.events: + header_token = None + for tok in self.ctx.tokens: + if tok.type == TokenType.TRACK_HEADER and tok.value == raw: + header_token = tok + self.ctx.add_error( + code=ErrorCode.SYNTAX_EMPTY_TRACK, + line=header_token.line if header_token else 1, + column=header_token.column if header_token else 1, + message=f"トラック '{raw}:' に音符または休符がありません。", + severity="error", + hint="少なくとも1つの音符または休符を記述してください。", + context=self._context_line(header_token) if header_token else "", + ) + + +def parse_pyxel( + source: str, tokens: list[Token] +) -> tuple[NoteSequence, list[ErrorDetail]]: + parser = PyxelParser(source, tokens) + return parser.parse() diff --git a/src/mml_composemusic_mcp/server.py b/src/mml_composemusic_mcp/server.py new file mode 100644 index 0000000..1205b70 --- /dev/null +++ b/src/mml_composemusic_mcp/server.py @@ -0,0 +1,254 @@ +# MCP server exposing compose_mml tool. + +import argparse +from pathlib import Path + +from fastmcp import FastMCP + +from .ir import ErrorCode, ErrorDetail, NoteSequence +from .lexer import tokenize +from .parser_ppmck import parse_ppmck +from .parser_pyxel import parse_pyxel +from .synthesizer import build_channel_summary, synthesize, write_wav +from .templates import get_template + +mcp = FastMCP("mml-composemusic-mcp") +OUTPUT_DIR = Path("./data") + + +def _is_error(errors: list[ErrorDetail]) -> bool: + return any(e.severity == "error" for e in errors) + + +def _split_errors(errors: list[ErrorDetail]) -> tuple[list[dict], list[dict]]: + errs = [e.to_dict() for e in errors if e.severity == "error"] + warns = [e.to_dict() for e in errors if e.severity == "warning"] + return errs, warns + + +def _parse_mml(mml: str, mode: str) -> tuple[dict, list[ErrorDetail]]: + tokens = tokenize(mml, mode) + if mode == "ppmck": + note_sequence, errors = parse_ppmck(mml, tokens) + elif mode == "pyxel": + note_sequence, errors = parse_pyxel(mml, tokens) + else: + errors = [ + ErrorDetail( + code=ErrorCode.SYNTAX_INVALID_TOKEN, + line=0, + column=0, + message=f"未知のモード '{mode}' です。", + severity="error", + hint="mode は 'ppmck' または 'pyxel' を指定してください。", + ) + ] + note_sequence = None + return (note_sequence.to_dict() if note_sequence else None), errors + + +@mcp.tool() +def compose_mml( + action: str, + mml: str = "", + mode: str = "", + template: str = "basic", + sample_rate: int = 44100, + normalize: bool = True, +) -> dict: + """Compose, validate, or generate templates for Famicom-style MML.""" + if action == "template": + if mode not in ("ppmck", "pyxel"): + mode = "ppmck" + if template not in ("basic", "melody", "chord", "drum", "empty"): + template = "basic" + mml_text, description = get_template(mode, template) + return {"mml": mml_text, "description": description} + + if action in ("compose", "validate"): + if not mml or not mode: + return { + "success": False, + "valid": False, + "errors": [ + { + "code": ErrorCode.SYNTAX_INVALID_TOKEN.value, + "line": 0, + "column": 0, + "message": "mml と mode は compose/validate の必須パラメータです。", + "severity": "error", + "hint": "mml に MML 文字列、mode に 'ppmck' または 'pyxel' を指定してください。", + } + ], + "warnings": [], + } + note_sequence_dict, errors = _parse_mml(mml, mode) + errors_list, warnings_list = _split_errors(errors) + if action == "validate": + return { + "valid": not _is_error(errors), + "errors": errors_list, + "note_sequence": note_sequence_dict, + "channel_summary": build_channel_summary(note_sequence_dict) + if note_sequence_dict + else [], + } + # compose + if _is_error(errors): + return { + "success": False, + "wav_path": None, + "duration_sec": 0, + "note_sequence": note_sequence_dict, + "validation": {"errors": errors_list, "warnings": warnings_list}, + } + + ns = _dict_to_note_sequence(note_sequence_dict) + try: + wave_data, duration, synth_errors = synthesize( + ns, mode, sample_rate, normalize + ) + except Exception as exc: + return { + "success": False, + "wav_path": None, + "duration_sec": 0, + "note_sequence": note_sequence_dict, + "validation": { + "errors": [ + { + "code": ErrorCode.SYSTEM_SYNTHESIS_FAILED.value, + "line": 0, + "column": 0, + "message": f"音声合成中にエラーが発生しました: {exc}", + "severity": "error", + "hint": "MMLの内容を確認の上、再度お試しください。問題が続く場合は、短いMMLから試してください。", + } + ], + "warnings": warnings_list, + }, + } + errors.extend(synth_errors) + errors_list, warnings_list = _split_errors(errors) + if _is_error(errors): + return { + "success": False, + "wav_path": None, + "duration_sec": 0, + "note_sequence": note_sequence_dict, + "validation": {"errors": errors_list, "warnings": warnings_list}, + } + wav_path = OUTPUT_DIR / "output.wav" + wav_errors = write_wav(wav_path, wave_data, sample_rate) + errors.extend(wav_errors) + errors_list, warnings_list = _split_errors(errors) + return { + "success": not _is_error(errors), + "wav_path": str(wav_path) if not _is_error(errors) else None, + "duration_sec": duration, + "note_sequence": note_sequence_dict, + "validation": {"errors": errors_list, "warnings": warnings_list}, + } + + return { + "success": False, + "errors": [ + { + "code": ErrorCode.SYNTAX_INVALID_TOKEN.value, + "line": 0, + "column": 0, + "message": f"未知の action '{action}' です。", + "severity": "error", + "hint": "action は 'compose', 'validate', 'template' のいずれかを指定してください。", + } + ], + "warnings": [], + } + + +def _dict_to_note_sequence(data: dict) -> NoteSequence: + from .ir import ( + ChannelSequence, + DutyEvent, + EnvelopeEvent, + GlideEvent, + NoteEvent, + NoteSequence, + RepeatEvent, + RestEvent, + TempoEvent, + VibratoEvent, + VolumeEvent, + ) + + ns = NoteSequence( + version=data["version"], + bpm=data["bpm"], + ticks_per_quarter=data["ticks_per_quarter"], + channels={}, + ) + for name, ch_data in data["channels"].items(): + ch = ChannelSequence( + channel_type=ch_data["channel_type"], + events=[], + total_ticks=ch_data["total_ticks"], + ) + for ev in ch_data["events"]: + ev_type = ev["type"] + if ev_type == "note": + ch.events.append(NoteEvent(**ev)) + elif ev_type == "rest": + ch.events.append(RestEvent(**ev)) + elif ev_type == "volume": + ch.events.append(VolumeEvent(**ev)) + elif ev_type == "duty": + ch.events.append(DutyEvent(**ev)) + elif ev_type == "tempo": + ch.events.append(TempoEvent(**ev)) + elif ev_type == "repeat": + ch.events.append(RepeatEvent(**ev)) + elif ev_type == "envelope": + ch.events.append(EnvelopeEvent(**ev)) + elif ev_type == "vibrato": + ch.events.append(VibratoEvent(**ev)) + elif ev_type == "glide": + ch.events.append(GlideEvent(**ev)) + ns.channels[name] = ch + return ns + + +def main() -> None: + parser = argparse.ArgumentParser(description="MML Compose MCP Server") + parser.add_argument( + "--output-dir", + type=str, + default="./data", + help="Directory to store generated WAV files (default: ./data)", + ) + parser.add_argument( + "--transport", + type=str, + default="stdio", + choices=["stdio", "http", "sse", "streamable-http"], + help="MCP transport (default: stdio)", + ) + parser.add_argument( + "--host", + type=str, + default="127.0.0.1", + help="Host to bind for HTTP-based transports (default: 127.0.0.1)", + ) + parser.add_argument( + "--port", + type=int, + default=8080, + help="Port to bind for HTTP-based transports (default: 8080)", + ) + args = parser.parse_args() + global OUTPUT_DIR + OUTPUT_DIR = Path(args.output_dir) + mcp.run(transport=args.transport, host=args.host, port=args.port) + + +if __name__ == "__main__": + main() diff --git a/src/mml_composemusic_mcp/synthesizer.py b/src/mml_composemusic_mcp/synthesizer.py new file mode 100644 index 0000000..42a3c11 --- /dev/null +++ b/src/mml_composemusic_mcp/synthesizer.py @@ -0,0 +1,301 @@ +"""NES APU-style synthesizer: NoteSequence -> WAV.""" + +import wave +from math import floor +from pathlib import Path + +import numpy as np + +from .ir import ( + DutyEvent, + ErrorCode, + ErrorDetail, + NoteEvent, + NoteSequence, + RestEvent, + TempoEvent, + VolumeEvent, +) + +CPU_CLOCK = 1_789_772.5 +SAMPLE_RATE_DEFAULT = 44100 + + +def midi_to_freq(note_number: int) -> float: + return 440.0 * 2.0 ** ((note_number - 69) / 12.0) + + +def noise_period_to_rate(period: int, mode: int, sample_rate: int) -> float: + # NES noise: rate = CPU_CLOCK / (NTSC divider for period) + # Approximate using pyxel mapping described in design + dividers = [ + 4, + 8, + 16, + 32, + 64, + 96, + 128, + 160, + 202, + 254, + 380, + 508, + 762, + 1016, + 2034, + 4068, + ] + div = dividers[period & 0x0F] + # Long mode (0) vs short mode (1) affects LFSR length, not frequency divider + return CPU_CLOCK / div + + +class ChannelSynthesizer: + def __init__( + self, channel_type: str, sample_rate: int = SAMPLE_RATE_DEFAULT + ) -> None: + self.channel_type = channel_type + self.sample_rate = sample_rate + self.wave = np.zeros(0, dtype=np.float64) + + def render( + self, + events: list, + total_ticks: int, + bpm: int, + mode: str, + ) -> np.ndarray: + seconds = tick_to_second(total_ticks, bpm) + length = int(seconds * self.sample_rate) + self.wave = np.zeros(length, dtype=np.float64) + last_velocity = 15 if self.channel_type != "triangle" else 15 + last_duty = 2 + for event in events: + if isinstance(event, VolumeEvent): + last_velocity = event.value + continue + if isinstance(event, DutyEvent): + last_duty = event.value + continue + if isinstance(event, TempoEvent): + bpm = event.bpm + continue + if not isinstance(event, (NoteEvent, RestEvent)): + continue + start_sample = int( + tick_to_second(event.tick_position, bpm) * self.sample_rate + ) + end_tick = event.tick_position + event.duration + end_sample = min( + int(tick_to_second(end_tick, bpm) * self.sample_rate), length + ) + if start_sample >= length or end_sample <= start_sample: + continue + if isinstance(event, RestEvent): + continue + self._render_note( + start_sample, + end_sample, + event, + last_velocity, + last_duty, + mode, + ) + return self.wave + + def _render_note( + self, + start: int, + end: int, + event: NoteEvent, + velocity: int, + duty: int, + mode: str, + ) -> None: + duration_samples = end - start + if duration_samples <= 0: + return + if self.channel_type == "noise": + self._render_noise(start, end, event, velocity, mode) + return + if self.channel_type == "triangle": + self._render_triangle(start, end, event, velocity, mode) + return + self._render_pulse(start, end, event, velocity, duty) + + def _render_pulse( + self, + start: int, + end: int, + event: NoteEvent, + velocity: int, + duty: int, + ) -> None: + freq = midi_to_freq(event.note_number) + if freq <= 0: + return + phase_inc = freq / self.sample_rate + duty_cycle = {0: 0.125, 1: 0.25, 2: 0.5, 3: 0.75}.get(duty, 0.5) + samples = np.arange(end - start) + phase = (samples * phase_inc) % 1.0 + waveform = np.where(phase < duty_cycle, 1.0, -1.0) + amp = velocity / 15.0 + gate = max(0.0, min(1.0, event.gate_time)) + if gate < 1.0: + gate_sample = int((end - start) * gate) + if gate_sample < len(waveform): + waveform[gate_sample:] = 0.0 + self.wave[start:end] += waveform * amp + + def _render_triangle( + self, + start: int, + end: int, + event: NoteEvent, + velocity: int, + mode: str, + ) -> None: + freq = midi_to_freq(event.note_number) + if freq <= 0: + return + phase_inc = freq / self.sample_rate + samples = np.arange(end - start) + phase = (samples * phase_inc) % 1.0 + # 32-step triangle approximated by abs(phase*2-1) + waveform = 2.0 * np.abs(phase * 2.0 - 1.0) - 1.0 + if mode == "pyxel": + amp = velocity / 15.0 + else: + amp = 1.0 + gate = max(0.0, min(1.0, event.gate_time)) + if gate < 1.0: + gate_sample = int((end - start) * gate) + if gate_sample < len(waveform): + waveform[gate_sample:] = 0.0 + self.wave[start:end] += waveform * amp + + def _render_noise( + self, + start: int, + end: int, + event: NoteEvent, + velocity: int, + mode: str, + ) -> None: + if mode == "pyxel": + period = max(0, min(15, 15 - floor(event.note_number / 8))) + mode_flag = 1 if period >= 13 else 0 + else: + period = 8 + mode_flag = 0 + rate = noise_period_to_rate(period, mode_flag, self.sample_rate) + phase_inc = rate / self.sample_rate + samples = np.arange(end - start) + phase = (samples * phase_inc) % 1.0 + diff = np.diff(phase, prepend=0) + transitions = diff < 0 + # LFSR-like pseudo-random toggling on each period rollover + state = np.zeros(len(samples), dtype=np.int32) + lfsr = 1 + for i in range(len(samples)): + if transitions[i]: + # feedback bit + if mode_flag: + bit = (lfsr ^ (lfsr >> 6)) & 1 + lfsr = ((lfsr >> 1) | (bit << 14)) & 0x7FFF + else: + bit = (lfsr ^ (lfsr >> 1)) & 1 + lfsr = ((lfsr >> 1) | (bit << 14)) & 0x7FFF + state[i] = lfsr & 1 + waveform = np.where(state, 1.0, -1.0) + amp = velocity / 15.0 + gate = max(0.0, min(1.0, event.gate_time)) + if gate < 1.0: + gate_sample = int((end - start) * gate) + if gate_sample < len(waveform): + waveform[gate_sample:] = 0.0 + self.wave[start:end] += waveform * amp + + +def tick_to_second(tick: int, bpm: int, ticks_per_quarter: int = 192) -> float: + quarter_seconds = 60.0 / bpm + return tick * (quarter_seconds / ticks_per_quarter) + + +def synthesize( + note_sequence: NoteSequence, + mode: str, + sample_rate: int = SAMPLE_RATE_DEFAULT, + normalize: bool = True, +) -> tuple[np.ndarray, float, list[ErrorDetail]]: + errors: list[ErrorDetail] = [] + total_ticks = max(ch.total_ticks for ch in note_sequence.channels.values()) + if total_ticks <= 0: + return np.zeros(0, dtype=np.float64), 0.0, errors + channels = [] + for _name, ch in note_sequence.channels.items(): + synth = ChannelSynthesizer(ch.channel_type, sample_rate) + wave = synth.render(ch.events, total_ticks, note_sequence.bpm, mode) + channels.append(wave) + max_len = max(len(w) for w in channels) + padded = [np.pad(w, (0, max_len - len(w)), mode="constant") for w in channels] + mixed = np.mean(padded, axis=0) + peak = np.max(np.abs(mixed)) + if normalize and peak > 0: + mixed = mixed / peak + mixed = np.clip(mixed, -1.0, 1.0) + duration = max_len / sample_rate + return mixed, duration, errors + + +def write_wav(path: Path, wave_data: np.ndarray, sample_rate: int) -> list[ErrorDetail]: + errors: list[ErrorDetail] = [] + try: + path.parent.mkdir(parents=True, exist_ok=True) + pcm = np.int16(np.clip(wave_data * 32767, -32768, 32767)) + with wave.open(str(path), "wb") as wav: + wav.setnchannels(1) + wav.setsampwidth(2) + wav.setframerate(sample_rate) + wav.writeframes(pcm.tobytes()) + except Exception as exc: + errors.append( + ErrorDetail( + code=ErrorCode.SYSTEM_WAV_WRITE_FAILED, + line=0, + column=0, + message=f"WAVファイルの出力に失敗しました: {exc}", + severity="error", + hint="しばらく待ってから再度お試しください。", + ) + ) + return errors + + +def build_channel_summary(note_sequence: NoteSequence | dict) -> list[dict]: + if isinstance(note_sequence, dict): + channels = note_sequence["channels"] + else: + channels = note_sequence.channels + summary = [] + for name, ch in channels.items(): + events = ch["events"] if isinstance(ch, dict) else ch.events + total_ticks = ch["total_ticks"] if isinstance(ch, dict) else ch.total_ticks + notes = [e for e in events if isinstance(e, NoteEvent)] + if not notes: + notes = [ + e for e in events if isinstance(e, dict) and e.get("type") == "note" + ] + octaves = [e["note_number"] // 12 - 1 for e in notes] + else: + octaves = [e.note_number // 12 - 1 for e in notes] + summary.append( + { + "channel": name, + "note_count": len(notes), + "octave_range": [min(octaves, default=4), max(octaves, default=4)], + "duration_ticks": total_ticks, + } + ) + return summary diff --git a/src/mml_composemusic_mcp/templates.py b/src/mml_composemusic_mcp/templates.py new file mode 100644 index 0000000..f351583 --- /dev/null +++ b/src/mml_composemusic_mcp/templates.py @@ -0,0 +1,32 @@ +# MML template generators for both modes. + +TEMPLATES = { + "ppmck": { + "basic": '#TITLE "Basic Song"\n#COMPOSER "LLM"\n\nA t120 l8 o4 v15 q2\n c d e f | g a b > c\n\nB l8 o3 v12 q1\n e g b r | e g b r\n\nT l4 o3 v7\n c2 g2 e2 c2\n\nN l8 v10\n c r c r | c r c r\n', + "melody": '#TITLE "Melody Lead"\n#COMPOSER "LLM"\n\nA t120 l8 o5 v15 q2\n c e g e | d f a f | e g b g | c4 r4\n\nB l8 o4 v10 q1\n c e g e | c e g e | d f a f | c4 r4\n\nT l4 o3 v7\n c2 g2 | a2 f2 | g2 d2 | c2 r2\n\nN l8 v8\n c r c r | c r c r | c r c r | c r c r\n', + "chord": '#TITLE "Chord Backing"\n#COMPOSER "LLM"\n\nA t120 l4 o4 v12 q2\n c e g c | f a > c f | g b > d g | c e g c\n\nB l4 o4 v12 q1\n e g b e | f a > c f | d g b d | e g b e\n\nT l2 o3 v7\n c2 | f2 | g2 | c2\n\nN l8 v8\n r c r c | r c r c | r c r c | r c r c\n', + "drum": '#TITLE "Drum Beat"\n#COMPOSER "LLM"\n\nA t120 l8 o4 v15 q2\n c r e r | g r e r | c r e r | g r c r\n\nB l8 o3 v12 q1\n e r g r | e r g r | e r g r | e r g r\n\nT l4 o2 v7\n c2 c2 | c2 c2 | c2 c2 | c2 c2\n\nN l16 v10\n c r c r c r c r | c r c r c r c r\n c r c r c r c r | c r c r c r c r\n', + "empty": '#TITLE "Empty Template"\n#COMPOSER "LLM"\n\nA t120 l4 o4 v15 q2\n \n\nB l4 o3 v12 q1\n \n\nT l4 o3 v7\n \n\nN l4 v10\n \n', + }, + "pyxel": { + "basic": "0: T120 L8 O4 V100 @1\n C D E F | G A B >C\n\n1: L8 O3 V80 @2\n E G B R | E G B R\n\n2: L4 O3 V60\n C2 G2 E2 C2\n\n3: L8 V80\n C R C R | C R C R\n", + "melody": "0: T120 L8 O5 V100 @1\n C E G E | D F A F | E G B G | C4 R4\n\n1: L8 O4 V80 @2\n C E G E | C E G E | D F A F | C4 R4\n\n2: L4 O3 V60\n C2 G2 | A2 F2 | G2 D2 | C2 R2\n\n3: L8 V70\n C R C R | C R C R | C R C R | C R C R\n", + "chord": "0: T120 L4 O4 V90 @1\n C E G C | F A >C F | G B >D G | C E G C\n\n1: L4 O4 V90 @2\n E G B E | F A >C F | D G B D | E G B E\n\n2: L2 O3 V60\n C2 | F2 | G2 | C2\n\n3: L8 V70\n R C R C | R C R C | R C R C | R C R C\n", + "drum": "0: T120 L8 O4 V100 @1\n C R E R | G R E R | C R E R | G R C R\n\n1: L8 O3 V80 @2\n E R G R | E R G R | E R G R | E R G R\n\n2: L4 O2 V60\n C2 C2 | C2 C2 | C2 C2 | C2 C2\n\n3: L16 V80\n C R C R C R C R | C R C R C R C R\n C R C R C R C R | C R C R C R C R\n", + "empty": "0: T120 L4 O4 V100 @1\n \n\n1: L4 O3 V80 @2\n \n\n2: L4 O3 V60\n \n\n3: L4 V80\n \n", + }, +} + +DESCRIPTIONS = { + "basic": "基本的な4ch構成(メロディ+和音+ベース+リズム)", + "melody": "メロディ重視(Pulse1主旋律、他は伴奏最小限)", + "chord": "コード伴奏重視(Pulse2で和音、Triangleでベース)", + "drum": "リズム重視(Noise中心のビートパターン)", + "empty": "各チャンネルのヘッダーのみ(空のテンプレート)", +} + + +def get_template(mode: str, template: str) -> tuple[str, str]: + mml = TEMPLATES[mode][template] + desc = DESCRIPTIONS[template] + return mml, desc diff --git a/tests/test_lexer.py b/tests/test_lexer.py new file mode 100644 index 0000000..1bb2213 --- /dev/null +++ b/tests/test_lexer.py @@ -0,0 +1,27 @@ +"""Tests for the MML lexer.""" + +from mml_composemusic_mcp.lexer import TokenType, tokenize + + +def test_ppmck_tokens(): + source = "A t150 l8 o4 v15 q2\nc d e f" + tokens = tokenize(source, "ppmck") + types = [t.type for t in tokens] + assert TokenType.TRACK_HEADER in types + assert TokenType.TEMPO in types + assert TokenType.NOTE in types + + +def test_pyxel_tokens(): + source = "0: T150 L8 O4 V100 @1\nC D E F" + tokens = tokenize(source, "pyxel") + types = [t.type for t in tokens] + assert TokenType.TRACK_HEADER in types + assert TokenType.AT in types + assert TokenType.NOTE in types + + +def test_pyxel_repeat(): + tokens = tokenize("[ C D ]2", "pyxel") + assert any(t.type == TokenType.REPEAT_START for t in tokens) + assert any(t.type == TokenType.REPEAT_END for t in tokens) diff --git a/tests/test_parser.py b/tests/test_parser.py new file mode 100644 index 0000000..b263aac --- /dev/null +++ b/tests/test_parser.py @@ -0,0 +1,53 @@ +"""Tests for PPMCK and Pyxel parsers.""" + +from mml_composemusic_mcp.ir import NoteEvent +from mml_composemusic_mcp.lexer import tokenize +from mml_composemusic_mcp.parser_ppmck import parse_ppmck +from mml_composemusic_mcp.parser_pyxel import parse_pyxel + +PPMCK_EXAMPLE = """#TITLE "Test" +A t150 l8 o4 v15 q2 + c d e f +B o3 + c e g +""" + +PYXEL_EXAMPLE = """0: T150 L8 O4 V100 @1 + C D E F +1: O3 + C E G +""" + + +def test_ppmck_parse(): + tokens = tokenize(PPMCK_EXAMPLE, "ppmck") + ns, errors = parse_ppmck(PPMCK_EXAMPLE, tokens) + assert not any(e.severity == "error" for e in errors) + assert ns.channels["Pulse1"].events + assert ns.channels["Pulse2"].events + notes = [e for e in ns.channels["Pulse1"].events if isinstance(e, NoteEvent)] + assert notes[0].note_number == 60 # C4 + + +def test_pyxel_parse(): + tokens = tokenize(PYXEL_EXAMPLE, "pyxel") + ns, errors = parse_pyxel(PYXEL_EXAMPLE, tokens) + assert not any(e.severity == "error" for e in errors) + assert ns.channels["Pulse1"].events + notes = [e for e in ns.channels["Pulse1"].events if isinstance(e, NoteEvent)] + assert notes[0].note_number == 60 # C4 + + +def test_pyxel_repeat_expansion(): + source = "0: T120 L4 O4\n[ C D ]2" + tokens = tokenize(source, "pyxel") + ns, errors = parse_pyxel(source, tokens) + notes = [e for e in ns.channels["Pulse1"].events if isinstance(e, NoteEvent)] + assert len(notes) == 4 + + +def test_invalid_mode_error(): + from mml_composemusic_mcp.server import _parse_mml + + seq, errors = _parse_mml("A c", "unknown") + assert any(e.severity == "error" for e in errors) diff --git a/tests/test_server.py b/tests/test_server.py new file mode 100644 index 0000000..533c88b --- /dev/null +++ b/tests/test_server.py @@ -0,0 +1,33 @@ +"""Tests for MCP server compose_mml tool.""" + +from mml_composemusic_mcp.server import compose_mml + +PPMCK_MML = """#TITLE "Test" +A t120 l4 o4 v15 q2 + c e g c +""" + + +def test_compose_ppmck(): + result = compose_mml(action="compose", mml=PPMCK_MML, mode="ppmck") + assert result["success"] is True + assert result["wav_path"] is not None + assert result["duration_sec"] > 0 + + +def test_validate_ppmck(): + result = compose_mml(action="validate", mml=PPMCK_MML, mode="ppmck") + assert result["valid"] is True + assert result["channel_summary"] + + +def test_template(): + result = compose_mml(action="template", mode="pyxel", template="basic") + assert "mml" in result + assert "0:" in result["mml"] + + +def test_compose_error(): + result = compose_mml(action="compose", mml="A t0\n c", mode="ppmck") + assert result["success"] is False + assert any(e["severity"] == "error" for e in result["validation"]["errors"]) |
