summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.gitignore5
-rw-r--r--README.md55
-rw-r--r--doc/Design_err.md150
-rw-r--r--doc/Design_mml.md375
-rw-r--r--doc/Desing.md586
-rw-r--r--pyproject.toml14
-rw-r--r--src/mml_composemusic_mcp/__init__.py (renamed from src/__init__.py)0
-rw-r--r--src/mml_composemusic_mcp/ir.py185
-rw-r--r--src/mml_composemusic_mcp/lexer.py314
-rw-r--r--src/mml_composemusic_mcp/parser_base.py92
-rw-r--r--src/mml_composemusic_mcp/parser_ppmck.py451
-rw-r--r--src/mml_composemusic_mcp/parser_pyxel.py652
-rw-r--r--src/mml_composemusic_mcp/server.py254
-rw-r--r--src/mml_composemusic_mcp/synthesizer.py301
-rw-r--r--src/mml_composemusic_mcp/templates.py32
-rw-r--r--tests/test_lexer.py27
-rw-r--r--tests/test_parser.py53
-rw-r--r--tests/test_server.py33
18 files changed, 3555 insertions, 24 deletions
diff --git a/.gitignore b/.gitignore
index a6a7890..97cb7c9 100644
--- a/.gitignore
+++ b/.gitignore
@@ -130,4 +130,7 @@ $RECYCLE.BIN/
.LSOverride
# Logs
-*.log \ No newline at end of file
+*.log
+
+# Generated assets
+data/
diff --git a/README.md b/README.md
index 7d1d26c..351c9c9 100644
--- a/README.md
+++ b/README.md
@@ -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"])