summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--doc/Design.md875
-rw-r--r--doc/MML_BNF.md232
-rw-r--r--src/mml_composemusic_mcp/ast_nodes.py230
-rw-r--r--src/mml_composemusic_mcp/ir.py49
-rw-r--r--src/mml_composemusic_mcp/lexer.py62
-rw-r--r--src/mml_composemusic_mcp/parser_base.py45
-rw-r--r--src/mml_composemusic_mcp/parser_ppmck.py618
-rw-r--r--src/mml_composemusic_mcp/parser_pyxel.py815
-rw-r--r--src/mml_composemusic_mcp/semantic_base.py225
-rw-r--r--src/mml_composemusic_mcp/semantic_ppmck.py283
-rw-r--r--src/mml_composemusic_mcp/semantic_pyxel.py423
-rw-r--r--src/mml_composemusic_mcp/server.py30
-rw-r--r--src/mml_composemusic_mcp/synthesizer.py4
-rw-r--r--tests/test_error_codes.py87
-rw-r--r--tests/test_lexer.py10
-rw-r--r--tests/test_parser.py39
-rw-r--r--tests/test_parser_detailed.py99
-rw-r--r--tests/test_synthesizer.py2
18 files changed, 2423 insertions, 1705 deletions
diff --git a/doc/Design.md b/doc/Design.md
index e7196c3..5dbe36b 100644
--- a/doc/Design.md
+++ b/doc/Design.md
@@ -1,6 +1,6 @@
# レトロチップ音源作曲MCPサーバ — 統合設計書
-> 作成日: 2026-07-11 | ステータス: **実装済み(第1段階)**
+> 作成日: 2026-07-11 | 更新日: 2026-07-12 | ステータス: **第2段階設計(パーサー再設計済み)**
---
@@ -65,10 +65,15 @@ LLMがMML(Music Macro Language)を記述することで、レトロチップ
│ │ MML Parser Pipeline │ │
│ │ │ │
│ │ ┌─────────┐ ┌──────────────┐ ┌────────────┐ │ │
-│ │ │ Lexer │────▶│ Parser │────▶│NoteSequence│ │ │
-│ │ │ (共通) │ │(ppmck/pyxel) │ │ (共通IR) │ │ │
+│ │ │ Lexer │────▶│ Parser │────▶│ AST │ │ │
+│ │ │ (共通) │ │(ppmck/pyxel) │ │ (共通) │ │ │
│ │ └─────────┘ └──────────────┘ └─────┬──────┘ │ │
-│ └──────────────────────────────────────────────┼────────┘ │
+│ │ │ │ │
+│ │ ┌───────────────────────────────────────────┘ │ │
+│ │ │ SemanticAnalyzer │ │
+│ │ │ (ppmck/pyxel) ──▶ NoteSequence IR │ │
+│ │ └────────────────────────────────────────────────────│ │
+│ └─────────────────────────────────────────────────────────┘ │
│ │ │
│ ┌───────────────────────────────────────────────┴────────┐ │
│ │ APU Synthesis Engine │ │
@@ -92,17 +97,18 @@ LLMがMML(Music Macro Language)を記述することで、レトロチップ
MML文字列
│
▼
-[Lexer] ──トークン列──▶ [Parser] ──NoteSequence──▶ [Synthesizer] ──WAV
- │
- (validateの場合はここで停止)
- (templateの場合はテンプレート文字列を返却)
+[Lexer] ──トークン列──▶ [Parser] ──AST──▶ [SemanticAnalyzer] ──NoteSequence──▶ [Synthesizer] ──WAV
+ │ │
+ 構文エラー 意味エラー
+ (SyntaxError) (SemanticError)
```
-| ステップ | コンポーネント | 役割 |
-|---|---|---|
-| 1 | Lexer | MML文字列をトークン列に変換。両モード共通のトークン定義を持ち、大文字/小文字の正規化を行う |
-| 2 | Parser | トークン列を構文解析し、NoteSequence(中間表現)を生成。ppmckとpyxelで別のパーサーを使用 |
-| 3 | Synthesizer | NoteSequenceを入力として、numpyでAPU各チャンネルの波形を合成し、WAV形式で出力 |
+| ステップ | コンポーネント | 役割 | 検出するエラー |
+|---|---|---|---|
+| 1 | Lexer | MML文字列をトークン列に変換。両モード共通のトークン定義 | 不正文字(SyntaxError) |
+| 2 | Parser | トークン列を構文解析し、ASTを生成。ppmckとpyxelで別のパーサー | 構文エラー(SyntaxError) |
+| 3 | SemanticAnalyzer | ASTを検証・解釈し、NoteSequence(中間表現)を生成 | 意味エラー(SemanticError) |
+| 4 | Synthesizer | NoteSequenceを入力として、numpyでAPU各チャンネルの波形を合成 | 実行時エラー(RuntimeError) |
---
@@ -155,7 +161,7 @@ MML文字列
> `compose` は必ず検証を内包する。エラーがあればWAVは生成せず、検証結果のみ返す。
>
-> **WAVファイル名**: 固定名 `output.wav` ではなく、生成時刻を含む `output_YYYYMMDD_HHMMSS_mmm.wav` 形式で出力される。これにより、複数回の `compose` 実行でファイルが上書きされるのを防ぐ。ミリ秒まで含めることで、短時間連続実行時のファイル名衝突も回避する。
+> **WAVファイル名**: 固定名 `output.wav` ではなく、生成時刻を含む `output_YYYYMMDD_HHMMSS_mmm.wav` 形式で出力される。
**`action: "validate"`** — MMLの構文チェックのみ(音声生成なし)
@@ -176,14 +182,6 @@ MML文字列
}
```
-| フィールド | 型 | 説明 |
-|---|---|---|
-| `valid` | boolean | MMLにerrorがないか |
-| `errors` | array | エラーのリスト |
-| `warnings` | array | 警告のリスト |
-| `note_sequence` | object \| null | 中間表現(IR)のNoteSequence |
-| `channel_summary` | array | チャンネルごとの統計情報 |
-
**`action: "template"`** — MMLテンプレートを生成
```json
@@ -193,11 +191,6 @@ MML文字列
}
```
-| フィールド | 型 | 説明 |
-|---|---|---|
-| `mml` | string | 生成されたMMLテンプレート |
-| `description` | string | テンプレートの説明 |
-
### 3.3 テンプレート種別
| 種別 | 内容 |
@@ -216,7 +209,7 @@ MML文字列
| 項目 | 内容 |
|---|---|
-| 共通IR | 両モード(ppmck / pyxel)のパーサが共通で出力する |
+| 共通IR | 両モード(ppmck / pyxel)のSemanticAnalyzerが共通で出力する |
| 後段分離 | 後段のSynthesizerはNoteSequenceのみを見れば動く(MMLの文法を知らない) |
| JSON互換 | JSON互換の構造体で表現し、MCPレスポンスに直接埋め込める |
@@ -226,12 +219,6 @@ MML文字列
1拍(4分音符) = 192 ticks
```
-| 特徴 | 内容 |
-|---|---|
-| 192の理由 | 2, 3, 4, 6, 8, 12, 16, 24, 32, 48, 64, 96 で割り切れる |
-| 対応リズム | 3連符、16分音符、32分音符など主要な分割に対応 |
-| pyxel整合性 | pyxelモードのtick(1 tick = 4分音符の1/48)と整合: 192 / 48 = 4 |
-
### 4.3 データ構造
```
@@ -258,126 +245,30 @@ Event = NoteEvent | RestEvent | VolumeEvent | DutyEvent
| EnvelopeEvent | VibratoEvent | GlideEvent
```
-#### 基本イベント
-
-| イベント | フィールド | 型 | 説明 |
-|---|---|---|---|
-| `NoteEvent` | `type` | string | `"note"` |
-| | `tick_position` | int | 絶対開始位置 |
-| | `duration` | int | 音長(ticks) |
-| | `note_number` | int | MIDI音番号 (69 = A4 = 440Hz) |
-| | `velocity` | int | 音量 (0-15) |
-| | `duty` | int | デューティ比 (0-3, Pulseのみ) |
-| | `gate_time` | float | ゲートタイム割合 0.0-1.0 |
-| | `detune_cents` | float | ディチューン(セント単位、pyxel Yコマンド) |
-| `RestEvent` | `type` | string | `"rest"` |
-| | `tick_position` | int | 絶対開始位置 |
-| | `duration` | int | 音長(ticks) |
-| `VolumeEvent` | `type` | string | `"volume"` |
-| | `tick_position` | int | 絶対位置 |
-| | `value` | int | 0-15(pyxelは0-127から正規化) |
-| `DutyEvent` | `type` | string | `"duty"` |
-| | `tick_position` | int | 絶対位置 |
-| | `value` | int | 0-3 |
-| `TempoEvent` | `type` | string | `"tempo"` |
-| | `tick_position` | int | 絶対位置 |
-| | `bpm` | int | テンポ |
-| `RepeatEvent` | `type` | string | `"repeat"` |
-| | `tick_position` | int | イベント位置 |
-| | `start_tick` | int | リピート開始tick |
-| | `end_tick` | int | リピート終了tick |
-| | `repeat_count` | int | 0 = 無限 |
-| | `duration` | int | リピート区間の長さ(ticks) |
-
-#### 拡張イベント(第1段階は保持のみ、合成無視)
-
-| イベント | フィールド | 型 | 説明 |
-|---|---|---|---|
-| `EnvelopeEvent` | `type` | string | `"envelope"` |
-| | `tick_position` | int | 絶対位置 |
-| | `slot` | int | 1-15 (0はOFF) |
-| | `points` | list[dict] | `{target_volume, duration_ticks}` のリスト |
-| `VibratoEvent` | `type` | string | `"vibrato"` |
-| | `tick_position` | int | 絶対位置 |
-| | `slot` | int | 1-15 (0はOFF) |
-| | `params` | dict | `{delay_ticks, period_ticks, depth_cents}` |
-| `GlideEvent` | `type` | string | `"glide"` |
-| | `tick_position` | int | 絶対位置 |
-| | `slot` | int | 1-15 (0はOFF) |
-| | `params` | dict | `{initial_offset_cents, duration_ticks}` |
-
-> 第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)
-```
-
-#### 音量正規化
+(各イベントのフィールドは第1段階実装と同じ)
-| モード | 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) + `@2`(Pulse) | ✅ 正常。デューティ比25%として扱う |
-| トラック0(Pulse1) + `@0`(Triangle) | ⚠️ 警告。チャンネルはPulse1のまま、`@0`は無視 |
-| トラック2(Triangle) + `@1`(Square) | ⚠️ 警告。チャンネルはTriangleのまま、`@1`は無視 |
-| トラック3(Noise) + `@0`(Triangle) | ⚠️ 警告。チャンネルはNoiseのまま、`@0`は無視 |
-| トラック3(Noise) + `@3`(Noise) | ⚠️ 警告。チャンネルはNoiseのまま、`@3`は無視 |
-
-#### Noiseチャンネルの note_number → period マッピング
+---
-| モード | 処理 |
-|---|---|
-| **ppmck** | 音高不使用。Noiseチャンネルの音符はすべて `period=8, mode=0`(中域デフォルト)。音長と音量のみ反映 |
-| **pyxel** | note_number を noise period にマッピング |
+## 5. MMLパーサ設計
-pyxelモードのマッピング式:
+### 5.1 新アーキテクチャ: 4フェーズ分離
```
-period = clamp(15 - floor(note_number / 8), 0, 15)
-mode = 1 if period >= 13 else 0
+MML文字列
+ │
+ ▼
+[Lexer] ──トークン列──▶ [Parser] ──AST──▶ [SemanticAnalyzer] ──NoteSequence IR
+ │ │
+ SyntaxError SemanticError
```
-| note_number | period | mode | 特徴 |
+| フェーズ | 入力 | 出力 | エラー |
|---|---|---|---|
-| 0(最低音) | 15 | 1 (short) | 最高ノイズ |
-| 48(中域) | 9 | 0 (long) | 中域ノイズ |
-| 96(高域) | 3 | 0 (long) | 低ノイズ |
-| 120(最高音) | 0 | 0 (long) | 最低ノイズ |
+| Lexer | MML文字列 | トークン列 | 不正文字(SyntaxError) |
+| Parser | トークン列 | AST | 構文エラー(SyntaxError) |
+| SemanticAnalyzer | AST | NoteSequence IR | 意味エラー(SemanticError) |
----
-
-## 5. MMLパーサ設計
-
-### 5.1 Lexer(共通)
+### 5.2 Lexer(共通)
両モードの字句解析を統一。モードごとに大文字/小文字を正規化した後にトークン化する。
@@ -402,543 +293,309 @@ mode = 1 if period >= 13 else 0
| ヘッダー | `#` で始まる行 (ppmck) | |
| コメント | `;` 以降行末 (ppmck) | |
| 拡張コマンド | `@ENV`/`@VIB`/`@GLI` (pyxel) | 第1段階は保持のみ |
+| 不正文字 | `INVALID` | 新規: 構文エラーとして報告 |
-#### モード別正規化
+### 5.3 Parser(モード別)
-| モード | 正規化ルール |
-|---|---|
-| ppmck | 音符・コマンドは小文字に正規化、`#` 行はヘッダー、`;` 以降はコメント |
-| pyxel | 音符・コマンドは大文字に正規化、`数字:` はトラックヘッダー |
-
-### 5.2 Parser(モード別)
+Parserの責務は**トークン列からASTを生成するのみ**。値範囲チェックやチャンネル適合性の判断は行わない。
#### ppmckパーサ
-| 項目 | 内容 |
-|---|---|
-| トラックヘッダー | `A`/`B`/`T`/`N` でチャンネルを決定 |
-| 全体ループ | `L` は全体ループフラグ。省略時は1回のみ再生 |
-| メタ情報 | `#TITLE` 等のヘッダーはメタ情報として保持 |
-| デフォルト値 | octave=4, volume=15, length=4, duty=2 |
-| Triangle音量 | `v` コマンドを無視(警告なし) |
-| タイ | `&` で同音高を結合。音長のみの指定も可 |
-| 区間リピート | `[...]` は第2段階。現状はwarningで無視 |
-| 第2段階予定 | エンベロープ(`@v`), スイープ(`s`), 区間ループ(`[...]`), トランスポーズ(`K`), ノイズモード(`m`) |
+- トラックヘッダー `A`/`B`/`T`/`N`/`L` を認識
+- `#` ヘッダーを認識
+- 各コマンドをASTノードに変換
+- リピート `[...]` のネスト構造をASTで保持
+- タイ `&` の結合対象をASTで保持
#### pyxelパーサ
-| 項目 | 内容 |
-|---|---|
-| トラック番号 | `0:`〜`3:` でチャンネルを決定 |
-| `@` コマンド | Pulseチャンネルでのみデューティ比として有効。他チャンネルでは警告して無視 |
-| デフォルト値 | octave=4, volume=100, length=4, gate=80, duty=2 |
-| 音量 | `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の小数部として `detune_cents` に保持 |
-
-#### リピート処理の違い
-
-| モード | 記法 | 動作 |
-|---|---|---|
-| ppmck | `L` | 全体ループフラグ。最後にRepeatEventを追加 |
-| ppmck | `[...]` | 第2段階。現状はwarningで無視 |
-| pyxel | `[...]N` | 区間リピートを即座に展開。N省略時は無限(2回で打ち切りwarning) |
-| pyxel | ネスト | 対応済み |
-
-### 5.3 エラーハンドリング
+- トラックヘッダー `0:`〜`3:` を認識
+- `@ENV`/`@VIB`/`@GLI` のパラメータをASTノードに保持
+- リピート `[...]N` のネスト構造をASTで保持
-| カテゴリ | 例 | severity |
-|---|---|---|
-| 構文エラー | 不正なトークン、未終端のリピート | error |
-| 範囲エラー | オクターブ範囲外、音量範囲外 | error または warning |
-| チャンネルエラー | Triangleへの `q` コマンド、Noiseへの音高指定(ppmck) | warning(無視して継続) |
-| 音域エラー | MIDI音番号0-127を逸脱 | warning(クランプ) |
-| 参照エラー | 未定義エンベロープ/ビブラート参照 | error |
-| 未サポート | `@ENV`/`@VIB`/`@GLI`(第1段階) | warning(「第1段階では未サポート、無視されます」) |
+### 5.4 SemanticAnalyzer(モード別)
-#### 回復戦略
+SemanticAnalyzerの責務は**ASTを検証・解釈してNoteSequence IRを生成**する。
-| 状況 | 動作 |
-|---|---|
-| ERROR時 | そのトークンで解析を停止し、エラーを返す |
-| WARNING時 | 警告を記録しつつ、妥当な値にクランプして処理を継続 |
-| compose action | ERRORがある場合WAVを生成せず検証結果のみ返す |
+#### 共通処理
----
+- 状態管理(`octave`, `velocity`, `duty`, `tick_position`)
+- MIDI音番号変換
+- tick計算
+- タイ処理
+- リピート展開(pyxel)
+- 値範囲チェック
+- 音域チェック
-## 6. APU音声合成エンジン
+#### ppmck固有
-### 6.1 レトロAPU ハードウェア仕様
+- Triangle音量無視
+- Noise音高無視
+- 全体ループ `L` の処理
+- 第2段階コマンドのwarning
-#### 概要
+#### pyxel固有
-| 項目 | 仕様 |
-|---|---|
-| 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`=最大振幅 |
-| 周波数 | `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段階: レトロAPU非線形ミキシングテーブル
- │
- └─ 4. ノーマライズ → 16bit PCM → WAV出力
-```
-
-### 6.3 ミキシング仕様
-
-| 項目 | 第1段階(線形) | 第2段階(クラシックAPU実機風) |
-|---|---|---|
-| 方式 | 単純平均 `(ch1+ch2+ch3+ch4)/4` | レトロAPU非線形ミキシング |
-| クリッピング | -1.0 ~ 1.0 でクランプ | 実機と同様の段階的ミックス |
-| ノーマライズ | 有効時ピークを-1.0に正規化 | 無効化可能 |
-
-> クラシックAPU実機の非線形ミキシング: `output = 95.88 - (8128 / (sum_of_active_channels + 1))`。第2段階で実装。
+- 音量 `V0-V127` → `0-15` 正規化
+- ゲートタイム `Q0-Q100` → `0.0-1.0`
+- `@` コマンドのチャンネル適合性チェック
+- `@ENV`/`@VIB`/`@GLI` のIR保持
+- 無限リピートの2回打ち切りwarning
---
-## 7. MMLコマンド仕様
-
-### 7.1 モード1: `ppmck` — PPMCKインスパイア独自形式
-
-PPMCKの文法をベースにしつつ、不要な複雑さを排除した独自サブセット。LLMが生成しやすく、クラシックAPU音源の主要機能をカバーする。
-
-#### 参考ドキュメント
-
-| 内容 | URL |
-|---|---|
-| PPMCK mckc.txt(MMLコンパイラ仕様書) | https://github.com/munshkr/ppmck/blob/master/doc/mckc.txt |
-| MCK Wiki MMLリファレンス | https://wikiwiki.jp/mck/MMLリファレンス |
-| レトロAPUハードウェア仕様 (midi2nes) | https://github.com/matiaszanolli/midi2nes/blob/master/docs/NES_APU_REFERENCE.md |
-| レトロAPU仕様 (problemkaputt.de ミラー) | https://github.com/RigleGit/nes-specs/blob/main/audioprocessingunitapu.md |
-
-#### トラック構成例
-
+## 6. 抽象構文木(AST)
+
+### 6.1 設計方針
+
+- **モード共通**: ppmck/pyxel両方を1つのASTノード型セットで表現
+- **位置情報**: 全ノードに `line`, `column` を保持
+- **構造保持**: リピートのネスト、タイの結合関係等を保持
+
+### 6.2 主要ASTノード
+
+```python
+@dataclass
+class Program(ASTNode):
+ tracks: list[Track]
+
+@dataclass
+class Track(ASTNode):
+ track_id: str # "A"/"B"/"T"/"N"/"L" or "0"-"3"
+ channel: str # "Pulse1"/"Pulse2"/"Triangle"/"Noise"/"Loop"
+ mode: str # "ppmck"/"pyxel"
+ statements: list[Statement]
+ headers: list[Header]
+
+@dataclass
+class NoteStmt(ASTNode):
+ note_name: str # "c"-"b"(正規化済み)
+ accidental: int # +1=sharp, -1=flat, 0=none
+ length: int | None
+ dots: int
+
+@dataclass
+class RestStmt(ASTNode):
+ length: int | None
+ dots: int
+
+@dataclass
+class OctaveStmt(ASTNode):
+ value: int | None
+ direction: str | None # "up"/"down"
+
+@dataclass
+class LengthStmt(ASTNode):
+ value: int
+
+@dataclass
+class VolumeStmt(ASTNode):
+ value: int
+
+@dataclass
+class DutyStmt(ASTNode):
+ value: int
+
+@dataclass
+class TempoStmt(ASTNode):
+ value: int
+
+@dataclass
+class GateTimeStmt(ASTNode):
+ value: int
+
+@dataclass
+class TransposeStmt(ASTNode):
+ value: int
+
+@dataclass
+class DetuneStmt(ASTNode):
+ value: int
+
+@dataclass
+class TieStmt(ASTNode):
+ target: NoteStmt | RestStmt | int | None
+
+@dataclass
+class RepeatStartStmt(ASTNode):
+ pass
+
+@dataclass
+class RepeatEndStmt(ASTNode):
+ count: int | None # None=無限
+
+@dataclass
+class BarStmt(ASTNode):
+ pass
+
+@dataclass
+class ExtCmdStmt(ASTNode):
+ cmd: str # "ENV"/"VIB"/"GLI"
+ slot: int
+ params: list[int]
```
-#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
+## 7. エラーハンドリング
-N l8 v10 # Noise
- r c r c r c r c
+### 7.1 3層エラー分類
-L # 全体ループ(省略可)
+```python
+class ErrorPhase(Enum):
+ LEXER = "lexer"
+ SYNTAX = "syntax"
+ SEMANTIC = "semantic"
+ RUNTIME = "runtime"
+ API = "api"
```
-#### トラックヘッダー
-
-| 記法 | 意味 | 対象 | 備考 |
+| 層 | コード接頭辞 | 発生フェーズ | 例 |
|---|---|---|---|
-| `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&4` | 音長のみのタイ | 第1段階で対応 |
-
-音長とテンポの関係: `4分音符の秒数 = 60 / BPM`
-
-#### 音量
-
-| 記法 | 意味 | 対象 | 備考 |
-|---|---|---|---|
-| `v15` | 音量15(最大) | A, B, N | 0-15、クラシックAPU準拠 |
-| `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 | 最初のトラックで指定、全体に適用 |
-
-#### 区切り・フォーマット
+| **SyntaxError** | `SYNTAX_*` | Parser | 無効なトークン、未終端のリピート、不正なトラックヘッダー |
+| **SemanticError** | `SEMANTIC_*` | SemanticAnalyzer | 値範囲外、チャンネル不適合、音域外、未定義参照 |
+| **RuntimeError** | `RUNTIME_*` | Synthesizer | 音声合成失敗、WAV出力失敗、内部エラー |
+| **APIError** | `VALIDATION_*` | Tool Dispatcher | パラメータ欠落、無効なmode/action |
-| 記法 | 意味 |
-|---|---|
-| `\|` | 小節線(視覚用、再生には影響しない) |
-| 改行 | トラック内の区切り(視覚用) |
-| `#` 行 | ヘッダー・メタ情報 |
-| 空行 | 無視 |
-| `;` 以降 | コメント(行末まで無視) |
-
-#### 第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=ショート周期 |
-
-### 7.2 モード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 V100 @1 # トラック0 (Pulse1)
- C D E F G A B >C
-
-1: L8 O3 V80 @2 # トラック1 (Pulse2)
- E G B R E G B R
-
-2: L4 O3 V60 # トラック2 (Triangle)
- C2 G2 E2 C2
-
-3: L8 V80 # トラック3 (Noise)
- C R C R C R C R
-```
-
-#### 完全コマンド一覧
-
-##### テンポ・音長・音量
-
-| コマンド | 記法 | 意味 | 範囲 | デフォルト |
-|---|---|---|---|---|
-| テンポ | `T150` | BPM | 1- | 120 |
-| デフォルト音長 | `L4` | n分音符。`L12`で8分3連符 | 1-192 | 4 |
-| 音量 | `V100` | 音量 | **0-127** | 100 |
-| ゲートタイム | `Q80` | ノート実発音割合(%)。100=隙間なし、0=無音 | 0-100 | 80 |
-
-##### 音色・ピッチ
-
-| コマンド | 記法 | 意味 | 範囲 | デフォルト |
-|---|---|---|---|---|
-| 音色(トーン) | `@1` | 波形タイプ。1:Square(50%) / 2:Pulse(25%) | 1-2(Pulse系のみ有効) | — |
-| トランスポーズ | `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 | 0〜7 | 4 |
-| 相対上 | `>` | 1オクターブ上(最大7) | — | — |
-| 相対下 | `<` | 1オクターブ下(最小0) | — | — |
-| タイ/スラー | `&` | 同音高=タイ、異音高=レガート。`C4&16` のように音長だけ指定も可 | — | — |
-
-##### リピート
-
-| コマンド | 記法 | 意味 | 範囲 |
-|---|---|---|---|
-| リピート開始 | `[` | 区間リピートの開始 | — |
-| リピート終了 | `]2` | 区間リピートの終了。回数指定。省略時は**無限リピート**。**ネスト対応** | 1- |
-
-##### エンベロープ `@ENV`
-
-| 記法 | 意味 |
-|---|---|
-| `@ENV0` | エンベロープOFF |
-| `@ENV1` | スロット1に切り替え |
-| `@ENV1 30 20 100 50 0` | スロット1を定義して切り替え(簡易パース) |
-
-> 本実装では `{...}` ブレース記法ではなく、フラットな数値列を簡易パースする。1 tick = 4分音符の1/48。スロット0は指定不可(OFF専用)。
-
-##### ビブラート `@VIB`
-
-| 記法 | 意味 |
-|---|---|
-| `@VIB0` | ビブラートOFF |
-| `@VIB1` | スロット1に切り替え |
-| `@VIB1 24 12 100` | スロット1を定義して切り替え(簡易パース) |
-
-##### グライド(ピッチスライド)`@GLI`
-
-| 記法 | 意味 |
-|---|---|
-| `@GLI0` | グライドOFF |
-| `@GLI1` | スロット1に切り替え |
-| `@GLI1 -100 24` | スロット1を定義して切り替え(簡易パース) |
-
----
-
-## 8. エラー仕様
-
-### 8.1 ErrorDetail 構造
+### 7.2 ErrorDetail構造
```json
{
- "code": "SYNTAX_INVALID_TOKEN",
+ "code": "SEMANTIC_VALUE_OUT_OF_RANGE",
+ "phase": "semantic",
"line": 1,
"column": 5,
- "message": "無効な文字 'x' が見つかりました。",
+ "message": "'o' の値 9 は範囲外です。有効範囲: 0〜7。",
"severity": "error",
- "hint": "MMLコマンド(c,d,e,f,g,a,b,r,o,l,v,t,q など)を使用してください。",
- "context": "A t150 l8 o4 x c"
+ "hint": "例: o4 のように指定してください。",
+ "context": "A t120 l8 o9 c"
}
```
-| フィールド | 型 | 説明 |
+### 7.3 新旧エラーコード対応
+
+| 旧コード | 新コード | 理由 |
|---|---|---|
-| `code` | string | エラーコード(機械処理用、LLMがパターン認識可能) |
-| `line` | integer | 発生行(1始まり。システム/APIエラー時は0) |
-| `column` | integer | 発生列(1始まり。システム/APIエラー時は0) |
-| `message` | string | 人間が読めるメッセージ |
-| `severity` | string | `"error"` または `"warning"` |
-| `hint` | string | LLM向けの修正ヒント |
-| `context` | string | 該当行のコンテキスト |
+| `SYNTAX_VALUE_OUT_OF_RANGE` | `SEMANTIC_VALUE_OUT_OF_RANGE` | 値範囲は意味エラー |
+| `SYNTAX_NOTE_OUT_OF_RANGE` | `SEMANTIC_NOTE_OUT_OF_RANGE` | 音域は意味エラー |
+| `SYNTAX_CHANNEL_MISMATCH` | `SEMANTIC_CHANNEL_MISMATCH` | チャンネル適合性は意味エラー |
+| `SYNTAX_EMPTY_TRACK` | `SEMANTIC_EMPTY_TRACK` | 空トラックは意味エラー |
+| `SYNTAX_OUTSIDE_TRACK` | `SEMANTIC_OUTSIDE_TRACK` | トラック外コマンドは意味エラー |
+| `SYNTAX_UNDEFINED_REFERENCE` | `SEMANTIC_UNDEFINED_REFERENCE` | 未定義参照は意味エラー |
+| `SYSTEM_SYNTHESIS_FAILED` | `RUNTIME_SYNTHESIS_FAILED` | 実行時エラー |
+| `SYSTEM_WAV_WRITE_FAILED` | `RUNTIME_WAV_WRITE_FAILED` | 実行時エラー |
+| `SYSTEM_INTERNAL_ERROR` | `RUNTIME_INTERNAL_ERROR` | 実行時エラー |
+
+### 7.4 回復戦略
-> **注意**: `line` と `column` は **1始まり** です(最初の行・最初の列が1)。システムエラーやAPIレベルエラーなどで位置情報がない場合は `0` を使用します。
+| 状況 | 動作 |
+|---|---|
+| ERROR時 | そのフェーズで解析を停止し、エラーを返す |
+| WARNING時 | 警告を記録しつつ、妥当な値にクランプして処理を継続 |
+| compose action | ERRORがある場合WAVを生成せず検証結果のみ返す |
-### 8.2 構文エラー(SYNTAX_*)
+---
-| 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})。` | `オクターブを調整してください。有効範囲: o0〜o7。` | warning(クランプ) |
-| `SYNTAX_CHANNEL_MISMATCH` | チャンネルに不適切なコマンド | `チャンネル '{channel}' で '{command}' は使用できません。` | `コマンドを削除するか、適切なチャンネルに移動してください。` | 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:)に移動してください。` |
+## 8. BNF文法(概要)
-#### 値範囲外の具体例(SYNTAX_VALUE_OUT_OF_RANGE)
+詳細は [plans/mml-parser-refactor.md](plans/mml-parser-refactor.md) 第3章を参照。
-| 状況 | 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 |
+### 8.1 共通要素
-### 8.3 システムエラー(SYSTEM_*)
+```bnf
+<note_name> ::= "c" | "d" | "e" | "f" | "g" | "a" | "b"
+<accidental> ::= "+" | "#" | "-"
+<length> ::= <number> <dot>*
+<octave_up> ::= ">"
+<octave_down> ::= "<"
+<tie> ::= "&"
+```
-| 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 |
+### 8.2 ppmckモード
-> システムエラーは `compose` actionでのみ発生しうる。`validate` / `template` actionでは構文エラー以外は返らない設計。
+```bnf
+<ppmck_mml> ::= <ppmck_header>* <ppmck_track>*
+<ppmck_track> ::= <track_header> <ppmck_statement>*
+<track_header>::= "A" | "B" | "T" | "N" | "L"
+<ppmck_statement>
+ ::= <note> | <rest> | <octave_cmd> | <length_cmd>
+ | <volume_cmd> | <duty_cmd> | <tempo_cmd> | <tie_cmd>
+ | <repeat_start> | <repeat_end> | <bar>
+```
-### 8.4 APIレベルエラー(VALIDATION_*)
+### 8.3 pyxelモード
+
+```bnf
+<pyxel_mml> ::= <pyxel_track>*
+<pyxel_track> ::= <pyxel_track_header> <pyxel_statement>*
+<pyxel_track_header>
+ ::= <number> ":"
+<pyxel_statement>
+ ::= <note> | <rest> | <octave_cmd> | <length_cmd>
+ | <volume_cmd> | <gate_cmd> | <tone_cmd> | <tempo_cmd>
+ | <transpose_cmd> | <detune_cmd> | <tie_cmd>
+ | <repeat_start> | <repeat_end> | <bar> | <ext_cmd>
+```
-| code | trigger | message テンプレート | hint | severity |
-|---|---|---|---|---|
-| `VALIDATION_MISSING_PARAMETER` | compose/validateで必須パラメータ(mml, mode)が欠落 | `mml と mode は compose/validate の必須パラメータです。` | `mml に MML 文字列、mode に 'ppmck' または 'pyxel' を指定してください。` | error |
-| `VALIDATION_INVALID_MODE` | 未知のmodeが指定された | `未知のモード '{mode}' です。` | `mode は 'ppmck' または 'pyxel' を指定してください。` | error |
-| `VALIDATION_INVALID_ACTION` | 未知のactionが指定された | `未知の action '{action}' です。` | `action は 'compose', 'validate', 'template' のいずれかを指定してください。` | error |
+---
-### 8.5 エラー発生時のcompose_mml戻り値
+## 9. APU音声合成エンジン
-`compose` action で ERROR がある場合:
+(第1段階実装と同じ。詳細は省略)
-```json
-{
- "success": false,
- "wav_path": null,
- "duration_sec": 0,
- "note_sequence": null,
- "validation": {
- "errors": [ErrorDetail, ...],
- "warnings": [ErrorDetail, ...]
- }
-}
-```
+---
+
+## 10. MMLコマンド仕様
-> warnings も ErrorDetail 構造で返す(severity = "warning")。LLMは warnings を見て「修正しなくても動くが、改善できる」ことを把握できる。
+(第1段階実装と同じ。詳細は省略)
---
-## 9. 2モード比較サマリー
+## 11. 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非対応) |
+| **Parser** | `parser_ppmck.py` | `parser_pyxel.py` |
+| **SemanticAnalyzer** | `semantic_ppmck.py` | `semantic_pyxel.py` |
| **音量範囲** | 0-15(クラシックAPU準拠) | 0-127 |
-| **音量デフォルト** | 15 | 100 |
-| **デューティ比** | `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`(半音単位) |
-| **ディチューン** | 未対応 | `Y`(セント単位) |
+| **デューティ比** | `q0`〜`q3` で4種 | `@1`(50%), `@2`(25%) の2種のみ |
| **リピート** | `L`(全体), `[...]`(第2段階) | `[...]`(回数指定、省略時無限、ネスト対応) |
-| **タイ** | `c4&c4` / `c4&4` | `C4&C4` または `C4&16`(音長のみ可) |
-| **tick単位** | なし | 1 tick = 4分音符の1/48 |
-| **コメント** | `;` | 公式記載なし |
-| **ヘッダー** | `#TITLE` 等 | なし |
---
-## 10. 確定事項一覧
+## 12. 確定事項一覧
| # | 項目 | 決定内容 |
|---|---|---|
-| 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段階は線形ミキシング |
-| 7 | ErrorDetail line/column | 1始まり。システム/APIエラー時は0 |
-| 8 | pyxel 無限リピート | 省略時は2回で打ち切り、WARNINGを返す |
-| 9 | pyxel `@ENV`/`@VIB`/`@GLI` パース | 第1段階は `{...}` ブレースではなくフラット数値列を簡易パース |
-| 10 | WAVファイル名 | 固定名 `output.wav` から、生成時刻ベースの `output_YYYYMMDD_HHMMSS_mmm.wav` 形式に変更。毎回異なるファイル名で上書きを防止 |
+| 1 | パーサーアーキテクチャ | Lexer → Parser → SemanticAnalyzer → Synthesizer の4フェーズ分離 |
+| 2 | AST | モード共通ASTノード型セットを採用 |
+| 3 | エラー分類 | SyntaxError / SemanticError / RuntimeError / APIError の4層分離 |
+| 4 | BNF | [plans/mml-parser-refactor.md](plans/mml-parser-refactor.md) に両モードのBNFを定義 |
+| 5 | pyxel `@` とトラック番号の矛盾 | トラック番号優先。`@` はPulse系のみデューティ比として有効 |
+| 6 | Noise note_number → period マッピング | ppmck: `period=8, mode=0` 固定。pyxel: マッピング式適用 |
+| 7 | WAVファイル名 | `output_YYYYMMDD_HHMMSS_mmm.wav` |
---
-## 11. 実装状態と今後の予定
+## 13. 実装状態と今後の予定
-### 11.1 第1段階実装済み機能
+### 13.1 第1段階実装済み機能
| カテゴリ | 実装内容 |
|---|---|
| MCPツール | `compose_mml`(compose/validate/template) |
-| Lexer | ppmck/pyxel 両モード対応、共通トークン定義 |
-| Parser | ppmckパーサ、pyxelパーサ、エラーハンドリング |
-| IR | NoteSequence、各種Event、ErrorDetail、ErrorCode |
+| Lexer | ppmck/pyxel 両モード対応、共通トークン定義、INVALIDトークン |
+| Parser | ppmck/pyxel 別Parser、AST生成 |
+| SemanticAnalyzer | ppmck/pyxel 別Analyzer、AST→IR変換 |
+| IR | NoteSequence、各種Event、ErrorDetail(phase付き) |
| Synthesizer | Pulse/Triangle/Noise 4ch合成、線形ミキシング、WAV出力 |
| Templates | basic/melody/chord/drum/empty の5種 × 2モード |
-| CLI | `--output-dir`, `--transport`, `--host`, `--port` 対応 |
| テスト | pytestによる各種テスト、ruffによるリント/フォーマット |
-### 11.2 第2段階予定機能
+### 13.2 第2段階予定機能
| カテゴリ | 予定内容 |
|---|---|
@@ -948,22 +605,6 @@ PyxelのMML仕様に準拠。Pyxelのサウンドシステムに合わせた記æ
| エンベロープ合成 | `@v`(ppmck), `@ENV`(pyxel) の合成反映 |
| ppmck 区間リピート | `[...]` の展開 |
| ppmck スイープ | `s` コマンド |
-| その他 | カウント音長、ノイズモード、トランスポーズ等 |
-
-### 11.3 設計書と実装の差分
-
-| 項目 | 設計書(旧) | 実装(最新) |
-|---|---|---|
-| `NoteEvent` | `detune_cents` なし | `detune_cents: float = 0.0` 追加 |
-| `RepeatEvent` | `start_tick`, `end_tick`, `repeat_count` のみ | `tick_position`, `duration` も追加 |
-| pyxel `@` コマンド | `@1`(50%), `@2`(25%) の2種 | 同じだが `@0`, `@3` は警告で拒否 |
-| pyxel repeat | ネスト対応・省略時無限 | 実装済み、無限時は2回で打ち切り(warning) |
-| ppmck repeat `[...]` | 第2段階 | 実装も第2段階(warning で無視) |
-| pyxel `@ENV`/`@VIB`/`@GLI` | `{...}` ブレース記法 | 実装はフラット数値列で簡易パース |
-| pyxel `Y`(ディチューン) | note_numberの小数部として保持 | `detune_cents` フィールドとして保持 |
-| トランスポート | 設計書に記載なし | `stdio`/`http`/`sse`/`streamable-http` + CLI引数 |
-| ErrorDetail line/column | 旧資料で表記揺れあり | 実装は1始まり(システムエラー時は0) |
-| WAVファイル名 | 固定 `output.wav` | 可変 `output_YYYYMMDD_HHMMSS_mmm.wav`。生成時刻ベースで上書き防止 |
---
diff --git a/doc/MML_BNF.md b/doc/MML_BNF.md
new file mode 100644
index 0000000..3657ecd
--- /dev/null
+++ b/doc/MML_BNF.md
@@ -0,0 +1,232 @@
+# MML 構文規則(BNF)
+
+> 本ドキュメントは `mml-composemusic-mcp` がサポートする MML の構文を BNF(Backus-Naur Form)で定義する。
+> 対象モード: `ppmck`(PPMCK インスパイア形式)と `pyxel`(Pyxel MML 準拠)。
+
+---
+
+## 1. 共通字句要素
+
+```bnf
+/* 基本文字 */
+<digit> ::= "0" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9"
+<number> ::= <digit>+
+
+/* 音符名(Lexer で正規化: ppmck は小文字、pyxel は大文字 → 内部は小文字) */
+<note_name> ::= "c" | "d" | "e" | "f" | "g" | "a" | "b"
+
+/* 変化記号 */
+<sharp> ::= "+" | "#"
+<flat> ::= "-"
+<accidental> ::= <sharp> | <flat>
+
+/* 音長 */
+<dot> ::= "."
+<length> ::= <number> <dot>*
+ | /* 省略時: デフォルト音長を使用 */
+
+/* オクターブ */
+<octave_up> ::= ">"
+<octave_down> ::= "<"
+
+/* タイ・スラー */
+<tie> ::= "&"
+
+/* リピート */
+<repeat_start>::= "["
+<repeat_end> ::= "]" <number>?
+
+/* 小節線 */
+<bar> ::= "|"
+
+/* 改行・空白 */
+<whitespace> ::= " " | "\t" | "\r" | "\n"
+```
+
+---
+
+## 2. ppmck モード
+
+### 2.1 全体構造
+
+```bnf
+<ppmck_mml> ::= <ppmck_header>* <ppmck_track>*
+
+<ppmck_header> ::= "#" <header_key> <header_value>?
+<header_key> ::= "TITLE" | "COMPOSER" | "PROGRAMER" | "OCTAVE-REV"
+ | "INCLUDE" | "EX-DISKFM" | "EX-NAMCO106" | "BANK-CHANGE"
+ | "EFFECT-INCLUDE"
+<header_value> ::= "\"" <string> "\""
+
+<ppmck_track> ::= <track_header> <ppmck_statement>*
+<track_header> ::= "A" | "B" | "T" | "N" | "L"
+ | /* 第2段階: 複合ヘッダー "ACE" 等 */
+```
+
+### 2.2 ステートメント
+
+```bnf
+<ppmck_statement> ::= <note>
+ | <rest>
+ | <octave_cmd>
+ | <length_cmd>
+ | <volume_cmd>
+ | <duty_cmd>
+ | <tempo_cmd>
+ | <tie_cmd>
+ | <repeat_start>
+ | <repeat_end>
+ | <bar>
+ | <comment>
+ | <newline>
+
+<note> ::= <note_name> <accidental>? <length>
+<rest> ::= "r" <length>
+
+<octave_cmd> ::= "o" <number> /* o0 〜 o7 */
+ | <octave_up>
+ | <octave_down>
+
+<length_cmd> ::= "l" <number> /* l1 〜 l192 */
+<volume_cmd> ::= "v" <number> /* v0 〜 v15 */
+<duty_cmd> ::= "q" <number> /* q0 〜 q3, Pulse系のみ */
+<tempo_cmd> ::= "t" <number> /* t1 〜 */
+
+<tie_cmd> ::= <tie> (<note> | <rest> | <number>)
+
+<comment> ::= ";" <string_to_eol>
+```
+
+### 2.3 第2段階予定コマンド(未実装、BNF に含める)
+
+```bnf
+<ppmck_stage2> ::= <count_length>
+ | <envelope_def>
+ | <envelope_use>
+ | <duty_envelope_def>
+ | <sweep_cmd>
+ | <section_repeat>
+ | <transpose_cmd>
+ | <noise_mode_cmd>
+
+<count_length> ::= <note_name> <accidental>? "%" <number>
+
+<envelope_def> ::= "@v" <number> "=" "{" <number_list> "|" <number_list>? "}"
+<envelope_use> ::= "@v" <number>
+
+<duty_envelope_def>::= "@" <number> "=" "{" <number_list> "|" <number_list>? "}"
+
+<sweep_cmd> ::= "s" <number> "," <number>
+
+<section_repeat> ::= "[" <ppmck_statement>+ "]" <number>?
+
+<transpose_cmd> ::= "K" <number>
+<noise_mode_cmd> ::= "m" <number>
+
+<number_list> ::= <number> (","? <number>)*
+```
+
+---
+
+## 3. pyxel モード
+
+### 3.1 全体構造
+
+```bnf
+<pyxel_mml> ::= <pyxel_track>*
+
+<pyxel_track> ::= <pyxel_track_header> <pyxel_statement>*
+<pyxel_track_header>
+ ::= <number> ":" /* 0: 1: 2: 3: */
+```
+
+### 3.2 ステートメント
+
+```bnf
+<pyxel_statement> ::= <note>
+ | <rest>
+ | <octave_cmd>
+ | <length_cmd>
+ | <volume_cmd>
+ | <gate_cmd>
+ | <tone_cmd>
+ | <tempo_cmd>
+ | <transpose_cmd>
+ | <detune_cmd>
+ | <tie_cmd>
+ | <repeat_start>
+ | <repeat_end>
+ | <bar>
+ | <ext_cmd>
+ | <newline>
+
+<note> ::= <note_name> <accidental>? <length>
+<rest> ::= "R" <length>
+
+<octave_cmd> ::= "O" <number> /* O0 〜 O7 */
+ | <octave_up>
+ | <octave_down>
+
+<length_cmd> ::= "L" <number> /* L1 〜 L192 */
+<volume_cmd> ::= "V" <number> /* V0 〜 V127 */
+<gate_cmd> ::= "Q" <number> /* Q0 〜 Q100 */
+<tone_cmd> ::= "@" <number> /* @0 〜 @3, Pulse系のみ有効 */
+<tempo_cmd> ::= "T" <number> /* T1 〜 */
+<transpose_cmd> ::= "K" <number> /* 半音単位 */
+<detune_cmd> ::= "Y" <number> /* セント単位 */
+
+<tie_cmd> ::= <tie> (<note> | <rest> | <number>)
+```
+
+### 3.3 拡張コマンド(第1段階: IR 保持のみ、合成無視)
+
+```bnf
+<ext_cmd> ::= <env_cmd> | <vib_cmd> | <gli_cmd>
+
+<env_cmd> ::= "@ENV" <number> <env_def>?
+<env_def> ::= "{" <number> "," <number> ("," <number>)* "}"
+ | <number>+
+
+<vib_cmd> ::= "@VIB" <number> <vib_def>?
+<vib_def> ::= "{" <number> "," <number> "," <number> "}"
+ | <number>+
+
+<gli_cmd> ::= "@GLI" <number> <gli_def>?
+<gli_def> ::= "{" <number> "," <number> "}"
+ | <number>+
+```
+
+---
+
+## 4. トラック・チャンネル対応
+
+| ppmck | pyxel | チャンネル | 種別 |
+|---|---|---|---|
+| `A` | `0:` | Pulse1 | 矩形波 |
+| `B` | `1:` | Pulse2 | 矩形波 |
+| `T` | `2:` | Triangle | 三角波 |
+| `N` | `3:` | Noise | ノイズ |
+| `L` | — | Loop | 全体ループ(ppmck のみ) |
+
+---
+
+## 5. 値範囲
+
+| コマンド | 有効範囲 | デフォルト |
+|---|---|---|
+| `o` / `O` | 0 〜 7 | 4 |
+| `l` / `L` | 1 〜 192 | 4 |
+| `v` (ppmck) | 0 〜 15 | 15 |
+| `V` (pyxel) | 0 〜 127 | 100 |
+| `q` (ppmck) | 0 〜 3 | 2 |
+| `@` (pyxel) | 0 〜 3 | — |
+| `Q` (pyxel) | 0 〜 100 | 80 |
+| `t` / `T` | 1 〜 | 120 |
+
+---
+
+## 6. 備考
+
+- 本 BNF は `mml-composemusic-mcp` の**サポート範囲**を定義するものであり、オリジナルの PPMCK や Pyxel の全機能を網羅するものではない。
+- 第2段階予定コマンドは BNF に含めるが、第1段階実装では `SEMANTIC_UNSUPPORTED_FEATURE` として警告を返す。
+- 詳細な実装アーキテクチャは [Design.md](Design.md) を参照。
diff --git a/src/mml_composemusic_mcp/ast_nodes.py b/src/mml_composemusic_mcp/ast_nodes.py
new file mode 100644
index 0000000..bc4fdc4
--- /dev/null
+++ b/src/mml_composemusic_mcp/ast_nodes.py
@@ -0,0 +1,230 @@
+"""Common AST node definitions for ppmck and pyxel MML."""
+
+from dataclasses import dataclass, field
+from typing import Any
+
+
+@dataclass
+class ASTNode:
+ """Base class for all AST nodes."""
+
+ line: int
+ column: int
+
+
+@dataclass
+class Program(ASTNode):
+ """Root program node."""
+
+ tracks: list[Track] = field(default_factory=list)
+
+
+@dataclass
+class Track(ASTNode):
+ """A single track definition."""
+
+ track_id: str # "A" / "B" / "T" / "N" / "L" (ppmck) or "0"-"3" (pyxel)
+ channel: str # "Pulse1" / "Pulse2" / "Triangle" / "Noise" / "Loop"
+ mode: str # "ppmck" / "pyxel"
+ statements: list[Statement] = field(default_factory=list)
+ headers: list[Header] = field(default_factory=list)
+
+
+@dataclass
+class Header(ASTNode):
+ """ppmck # header line."""
+
+ key: str
+ value: str
+ unterminated: bool = False
+
+
+# --- Statements ---
+
+
+@dataclass
+class NoteStmt(ASTNode):
+ """A note command (c, d, e, ...)."""
+
+ note_name: str # normalized to lowercase: "c"-"b"
+ accidental: int = 0 # +1 sharp, -1 flat, 0 none
+ length: int | None = None # None means use default length
+ dots: int = 0
+
+
+@dataclass
+class RestStmt(ASTNode):
+ """A rest command (r / R)."""
+
+ length: int | None = None
+ dots: int = 0
+
+
+@dataclass
+class OctaveStmt(ASTNode):
+ """Octave command or relative octave shift."""
+
+ value: int | None = None # absolute octave number
+ direction: str | None = None # "up" or "down" for relative shifts
+
+
+@dataclass
+class LengthStmt(ASTNode):
+ """Default length command (l / L)."""
+
+ value: int
+
+
+@dataclass
+class VolumeStmt(ASTNode):
+ """Volume command (v / V)."""
+
+ value: int
+
+
+@dataclass
+class DutyStmt(ASTNode):
+ """Duty/tone command (q / @)."""
+
+ value: int
+
+
+@dataclass
+class TempoStmt(ASTNode):
+ """Tempo command (t / T)."""
+
+ value: int
+
+
+@dataclass
+class GateTimeStmt(ASTNode):
+ """Gate time command (Q, pyxel only)."""
+
+ value: int
+
+
+@dataclass
+class TransposeStmt(ASTNode):
+ """Transpose command (K, pyxel only)."""
+
+ value: int
+
+
+@dataclass
+class DetuneStmt(ASTNode):
+ """Detune command (Y, pyxel only)."""
+
+ value: int
+
+
+@dataclass
+class TieStmt(ASTNode):
+ """Tie/slur command (&)."""
+
+ target: NoteStmt | RestStmt | int | None = None
+
+
+@dataclass
+class RepeatStartStmt(ASTNode):
+ """Start of repeat section ([)."""
+
+ pass
+
+
+@dataclass
+class RepeatEndStmt(ASTNode):
+ """End of repeat section (] with optional count)."""
+
+ count: int | None = None # None means infinite
+
+
+@dataclass
+class BarStmt(ASTNode):
+ """Bar line (|), visual only."""
+
+ pass
+
+
+@dataclass
+class ExtCmdStmt(ASTNode):
+ """pyxel @ENV / @VIB / @GLI command."""
+
+ cmd: str # "ENV" / "VIB" / "GLI"
+ slot: int
+ params: list[int] = field(default_factory=list)
+
+
+# --- Stage 2 statements (kept in AST but ignored by semantic analyzer) ---
+
+
+@dataclass
+class CountLengthStmt(ASTNode):
+ """ppmck count length note (c%48)."""
+
+ note_name: str
+ accidental: int
+ count: int
+
+
+@dataclass
+class EnvelopeDefStmt(ASTNode):
+ """ppmck envelope definition (@v0 = { ... })."""
+
+ slot: int
+ points: list[int] = field(default_factory=list)
+ loop_points: list[int] = field(default_factory=list)
+
+
+@dataclass
+class SweepStmt(ASTNode):
+ """ppmck sweep command (s1,2)."""
+
+ speed: int
+ depth: int
+
+
+@dataclass
+class NoiseModeStmt(ASTNode):
+ """ppmck noise mode command (m0 / m1)."""
+
+ mode: int
+
+
+Statement = (
+ NoteStmt
+ | RestStmt
+ | OctaveStmt
+ | LengthStmt
+ | VolumeStmt
+ | DutyStmt
+ | TempoStmt
+ | GateTimeStmt
+ | TransposeStmt
+ | DetuneStmt
+ | TieStmt
+ | RepeatStartStmt
+ | RepeatEndStmt
+ | BarStmt
+ | ExtCmdStmt
+ | CountLengthStmt
+ | EnvelopeDefStmt
+ | SweepStmt
+ | NoiseModeStmt
+)
+
+
+def _node_to_dict(node: Any) -> Any:
+ """Recursively convert an AST node to a JSON-compatible dict."""
+ if isinstance(node, list):
+ return [_node_to_dict(item) for item in node]
+ if not isinstance(node, ASTNode):
+ return node
+ data: dict[str, Any] = {"__type__": node.__class__.__name__}
+ for key, value in node.__dict__.items():
+ data[key] = _node_to_dict(value)
+ return data
+
+
+def ast_to_dict(node: ASTNode) -> dict[str, Any]:
+ """Convert an AST node to a JSON-compatible dict."""
+ return _node_to_dict(node)
diff --git a/src/mml_composemusic_mcp/ir.py b/src/mml_composemusic_mcp/ir.py
index bd741a6..8d1bc25 100644
--- a/src/mml_composemusic_mcp/ir.py
+++ b/src/mml_composemusic_mcp/ir.py
@@ -5,30 +5,53 @@ from enum import Enum
from typing import Any
+class ErrorPhase(Enum):
+ """Phase where an error occurred."""
+
+ LEXER = "lexer"
+ SYNTAX = "syntax"
+ SEMANTIC = "semantic"
+ RUNTIME = "runtime"
+ API = "api"
+
+
class ErrorCode(Enum):
- """Machine-readable error codes."""
+ """Machine-readable error codes.
+ Errors are classified by phase:
+ - SYNTAX_*: token sequence violates the BNF grammar.
+ - SEMANTIC_*: syntactically valid but semantically invalid (range,
+ channel mismatch, undefined reference, etc.).
+ - RUNTIME_*: failure during synthesis or WAV output.
+ - VALIDATION_*: API-level parameter validation errors.
+ """
+
+ # --- Syntax errors (Parser phase) ---
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_OUTSIDE_TRACK = "SYNTAX_OUTSIDE_TRACK"
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"
- # API-level errors (parameter validation, not MML syntax)
+ # --- Semantic errors (SemanticAnalyzer phase) ---
+ SEMANTIC_VALUE_OUT_OF_RANGE = "SEMANTIC_VALUE_OUT_OF_RANGE"
+ SEMANTIC_NOTE_OUT_OF_RANGE = "SEMANTIC_NOTE_OUT_OF_RANGE"
+ SEMANTIC_CHANNEL_MISMATCH = "SEMANTIC_CHANNEL_MISMATCH"
+ SEMANTIC_EMPTY_TRACK = "SEMANTIC_EMPTY_TRACK"
+ SEMANTIC_OUTSIDE_TRACK = "SEMANTIC_OUTSIDE_TRACK"
+ SEMANTIC_UNDEFINED_REFERENCE = "SEMANTIC_UNDEFINED_REFERENCE"
+ SEMANTIC_UNSUPPORTED_FEATURE = "SEMANTIC_UNSUPPORTED_FEATURE"
+
+ # --- Runtime errors (Synthesizer phase) ---
+ RUNTIME_SYNTHESIS_FAILED = "RUNTIME_SYNTHESIS_FAILED"
+ RUNTIME_WAV_WRITE_FAILED = "RUNTIME_WAV_WRITE_FAILED"
+ RUNTIME_INTERNAL_ERROR = "RUNTIME_INTERNAL_ERROR"
+
+ # --- API-level errors (parameter validation, not MML syntax) ---
VALIDATION_MISSING_PARAMETER = "VALIDATION_MISSING_PARAMETER"
VALIDATION_INVALID_MODE = "VALIDATION_INVALID_MODE"
VALIDATION_INVALID_ACTION = "VALIDATION_INVALID_ACTION"
@@ -39,6 +62,7 @@ class ErrorDetail:
"""Structured error/warning detail."""
code: ErrorCode
+ phase: ErrorPhase
line: int
column: int
message: str
@@ -49,6 +73,7 @@ class ErrorDetail:
def to_dict(self) -> dict[str, Any]:
return {
"code": self.code.value,
+ "phase": self.phase.value,
"line": self.line,
"column": self.column,
"message": self.message,
diff --git a/src/mml_composemusic_mcp/lexer.py b/src/mml_composemusic_mcp/lexer.py
index 05861cf..44e577a 100644
--- a/src/mml_composemusic_mcp/lexer.py
+++ b/src/mml_composemusic_mcp/lexer.py
@@ -27,11 +27,11 @@ class TokenType(Enum):
SHARP = auto()
FLAT = auto()
DOT = auto()
- TEXT = auto()
AT = auto()
TRANSPOSE = auto()
DETUNE = auto()
EXT_CMD = auto() # @ENV, @VIB, @GLI
+ INVALID = auto() # unrecognised character
EOF = auto()
@@ -89,18 +89,10 @@ class Lexer:
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 _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(self) -> list[Token]:
while self.pos < len(self.source):
@@ -108,22 +100,14 @@ class Lexer:
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)
+ self._tokenize_ppmck(ch)
else:
- self._tokenize_pyxel(ch, start_line, start_col)
+ self._tokenize_pyxel(ch)
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
+ def _tokenize_ppmck(self, ch: str) -> None:
if ch == "\n":
self._advance()
return
@@ -182,36 +166,36 @@ class Lexer:
return
if ch.isalpha():
cmd = self._advance()
- if ch in "olvtq":
+ lower = cmd.lower()
+ if lower in "olvtq":
num = self._read_number()
raw = cmd + num
- if ch == "o":
+ if lower == "o":
self._emit(TokenType.OCTAVE, num, raw)
- elif ch == "l":
+ elif lower == "l":
self._emit(TokenType.LENGTH, num, raw)
- elif ch == "v":
+ elif lower == "v":
self._emit(TokenType.VOLUME, num, raw)
- elif ch == "t":
+ elif lower == "t":
self._emit(TokenType.TEMPO, num, raw)
- elif ch == "q":
+ elif lower == "q":
self._emit(TokenType.DUTY, num, raw)
return
- # Note commands
- note = ch.lower()
+ note = 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)
+ # Unknown command: emit as invalid token for parser to report
+ self._emit(TokenType.INVALID, cmd, cmd)
return
# Fallback invalid char
raw = self._advance()
- self._emit(TokenType.TEXT, raw, raw)
+ self._emit(TokenType.INVALID, raw, raw)
- def _tokenize_pyxel(self, ch: str, start_line: int, start_col: int) -> None: # noqa: ARG002
+ def _tokenize_pyxel(self, ch: str) -> None:
if ch == "\n":
self._advance()
return
@@ -299,11 +283,11 @@ class Lexer:
else:
self._emit(TokenType.NOTE, note, note)
return
- self._emit(TokenType.TEXT, cmd, cmd)
+ self._emit(TokenType.INVALID, cmd, cmd)
return
- # Fallback
+ # Fallback invalid char
raw = self._advance()
- self._emit(TokenType.TEXT, raw, raw)
+ self._emit(TokenType.INVALID, raw, raw)
def tokenize(source: str, mode: str = "ppmck") -> list[Token]:
diff --git a/src/mml_composemusic_mcp/parser_base.py b/src/mml_composemusic_mcp/parser_base.py
index 0427a4c..1cdb40f 100644
--- a/src/mml_composemusic_mcp/parser_base.py
+++ b/src/mml_composemusic_mcp/parser_base.py
@@ -1,8 +1,9 @@
"""Base parser utilities shared between ppmck and pyxel parsers."""
-from dataclasses import dataclass
+from dataclasses import dataclass, field
-from .ir import ErrorCode, ErrorDetail
+from .ast_nodes import ASTNode
+from .ir import ErrorCode, ErrorDetail, ErrorPhase
from .lexer import Token, TokenType
@@ -10,11 +11,7 @@ from .lexer import Token, TokenType
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 = []
+ errors: list[ErrorDetail] = field(default_factory=list)
def peek(self, offset: int = 0) -> Token:
idx = self.pos + offset
@@ -46,9 +43,11 @@ class ParserContext:
hint: str = "",
context: str = "",
) -> None:
+ phase = _code_to_phase(code)
self.errors.append(
ErrorDetail(
code=code,
+ phase=phase,
line=line,
column=column,
message=message,
@@ -58,11 +57,34 @@ class ParserContext:
)
)
+ def add_invalid_token_error(self, token: Token, context: str = "") -> None:
+ self.add_error(
+ code=ErrorCode.SYNTAX_INVALID_TOKEN,
+ line=token.line,
+ column=token.column,
+ message=f"無効な文字 '{token.raw}' が見つかりました。",
+ severity="error",
+ hint="MMLコマンド(c,d,e,f,g,a,b,r,o,l,v,t,q など)を使用してください。",
+ context=context,
+ )
+
class ParserError(Exception):
+ """Raised by parsers to abort a single statement and recover."""
+
pass
+def _code_to_phase(code: ErrorCode) -> ErrorPhase:
+ if code.value.startswith("SYNTAX_"):
+ return ErrorPhase.SYNTAX
+ if code.value.startswith("SEMANTIC_"):
+ return ErrorPhase.SEMANTIC
+ if code.value.startswith("RUNTIME_"):
+ return ErrorPhase.RUNTIME
+ return ErrorPhase.API
+
+
def length_value_to_ticks(length: int, dots: int, ticks_per_quarter: int = 192) -> int:
base = ticks_per_quarter * 4 // length
total = base
@@ -90,3 +112,12 @@ def note_to_midi(note: str, octave: int, accidental: int = 0) -> int:
def clamp(value: int, min_val: int, max_val: int) -> int:
return max(min_val, min(value, max_val))
+
+
+def context_line(source: str, node: ASTNode | Token) -> str:
+ """Return the source line for a node or token."""
+ lines = source.splitlines()
+ line = getattr(node, "line", 1)
+ if 1 <= line <= len(lines):
+ return lines[line - 1]
+ return ""
diff --git a/src/mml_composemusic_mcp/parser_ppmck.py b/src/mml_composemusic_mcp/parser_ppmck.py
index 67e5093..9540844 100644
--- a/src/mml_composemusic_mcp/parser_ppmck.py
+++ b/src/mml_composemusic_mcp/parser_ppmck.py
@@ -1,22 +1,25 @@
-"""PPMCK-style MML parser."""
-
-from .ir import (
- DutyEvent,
- ErrorCode,
- ErrorDetail,
- NoteEvent,
- NoteSequence,
- RepeatEvent,
- RestEvent,
- TempoEvent,
+"""PPMCK-style MML parser: tokens -> AST."""
+
+from .ast_nodes import (
+ ASTNode,
+ BarStmt,
+ DutyStmt,
+ Header,
+ LengthStmt,
+ NoteStmt,
+ OctaveStmt,
+ Program,
+ RepeatEndStmt,
+ RepeatStartStmt,
+ RestStmt,
+ TempoStmt,
+ TieStmt,
+ Track,
+ VolumeStmt,
)
+from .ir import ErrorCode, ErrorDetail
from .lexer import Token, TokenType
-from .parser_base import (
- ParserContext,
- clamp,
- length_value_to_ticks,
- note_to_midi,
-)
+from .parser_base import ParserContext, context_line
CHANNEL_MAP = {
"A": "Pulse1",
@@ -24,111 +27,94 @@ CHANNEL_MAP = {
"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()
- self._tie_pending: bool = False
- 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[Program, list[ErrorDetail]]:
+ program = Program(line=1, column=1, tracks=[])
+ headers: list[Header] = []
- 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()
+ headers.append(self._parse_header())
continue
if token.type == TokenType.COMMENT:
self.ctx.advance()
continue
if token.type == TokenType.TRACK_HEADER:
- self._parse_track_header()
+ track = self._parse_track()
+ if track is not None:
+ track.headers = list(headers)
+ program.tracks.append(track)
continue
- if self.current_channel is None:
- self._warn_outside_track(token)
- self.ctx.advance()
+ if token.type == TokenType.INVALID:
+ self._handle_invalid(token)
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,
- )
- )
+ # Anything else before a track header is outside a track.
+ self._warn_outside_track(token)
+ self.ctx.advance()
- 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,
- )
+ # Attach headers to the first track if no track exists yet
+ if headers and not program.tracks:
+ pass # headers are simply discarded if no tracks
+ elif headers and program.tracks:
+ # Headers already attached to first parsed track; nothing to do
+ pass
- return self.note_sequence, self.ctx.errors
+ return program, self.ctx.errors
- def _parse_header(self) -> None:
+ def _parse_header(self) -> Header:
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 _warn_outside_track(self, token: Token) -> None:
- if token.type in (TokenType.EOF, TokenType.COMMENT):
- return
- self.ctx.add_error(
- code=ErrorCode.SYNTAX_OUTSIDE_TRACK,
+ unterminated = False
+ if len(parts) > 1:
+ rest = parts[1]
+ if rest.startswith('"'):
+ if not rest.endswith('"') or rest == '"':
+ unterminated = True
+ value = rest[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=context_line(self.source, token),
+ )
+ else:
+ value = rest[1:-1]
+ else:
+ value = rest
+ return Header(
line=token.line,
column=token.column,
- message=f"'{token.raw}' はトラック外に書かれています。",
- severity="warning",
- hint="コマンドはトラックヘッダー(A, B, T, N)の後に記述してください。",
- context=self._context_line(token),
+ key=key,
+ value=value,
+ unterminated=unterminated,
)
- def _parse_track_header(self) -> None:
+ def _parse_track(self) -> Track | None:
token = self.ctx.advance()
ch = token.value.upper()
if ch == "L":
- self.loop_start = 0
- return
+ return Track(
+ line=token.line,
+ column=token.column,
+ track_id="L",
+ channel="Loop",
+ mode="ppmck",
+ statements=[],
+ )
if ch not in CHANNEL_MAP:
self.ctx.add_error(
code=ErrorCode.SYNTAX_INVALID_TRACK_HEADER,
@@ -137,12 +123,11 @@ class PpmckParser:
message=f"無効なトラックヘッダー '{ch}' です。",
severity="error",
hint="A, B, T, N, L のいずれかを使用してください。",
- context=self._context_line(token),
+ context=context_line(self.source, token),
)
- self.current_channel = None
- return
- self.current_channel = CHANNEL_MAP[ch]
- if self.current_channel in self.tracks_seen:
+ return None
+ channel = CHANNEL_MAP[ch]
+ if channel in self.tracks_seen:
self.ctx.add_error(
code=ErrorCode.SYNTAX_DUPLICATE_TRACK,
line=token.line,
@@ -150,64 +135,87 @@ class PpmckParser:
message=f"トラック '{ch}' が複数回定義されています。",
severity="error",
hint="各トラックは1回のみ定義できます。重複定義を削除してください。",
- context=self._context_line(token),
+ context=context_line(self.source, 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:
+ self.tracks_seen.add(channel)
+ track = Track(
+ line=token.line,
+ column=token.column,
+ track_id=ch,
+ channel=channel,
+ mode="ppmck",
+ statements=[],
+ )
+ while self.ctx.peek().type not in (
+ TokenType.TRACK_HEADER,
+ TokenType.EOF,
+ ):
+ stmt = self._parse_statement()
+ if stmt is not None:
+ track.statements.append(stmt)
+ return track
+
+ def _parse_statement(self) -> ASTNode | 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),
- )
+ if token.type == TokenType.INVALID:
+ self._handle_invalid(token)
+ return None
+ if token.type == TokenType.COMMENT:
self.ctx.advance()
+ return None
+ if token.type == TokenType.NOTE:
+ return self._parse_note()
+ if token.type == TokenType.REST:
+ return self._parse_rest()
+ if token.type == TokenType.OCTAVE:
+ return self._parse_octave()
+ if token.type == TokenType.OCTAVE_UP:
+ return self._parse_octave_up()
+ if token.type == TokenType.OCTAVE_DOWN:
+ return self._parse_octave_down()
+ if token.type == TokenType.LENGTH:
+ return self._parse_length()
+ if token.type == TokenType.VOLUME:
+ return self._parse_volume()
+ if token.type == TokenType.DUTY:
+ return self._parse_duty()
+ if token.type == TokenType.TEMPO:
+ return self._parse_tempo()
+ if token.type == TokenType.TIE:
+ return self._parse_tie()
+ if token.type == TokenType.REPEAT_START:
+ return self._parse_repeat_start()
+ if token.type == TokenType.REPEAT_END:
+ return self._parse_repeat_end()
+ if token.type == TokenType.BAR:
+ self.ctx.advance()
+ return BarStmt(line=token.line, column=token.column)
+ # Unknown token in track context
+ self.ctx.add_error(
+ code=ErrorCode.SYNTAX_UNEXPECTED_TOKEN,
+ line=token.line,
+ column=token.column,
+ message=f"'{token.raw}' はここでは使用できません。",
+ severity="error",
+ hint="音符、休符、コマンド、小節線を配置してください。",
+ context=context_line(self.source, token),
+ )
+ self.ctx.advance()
+ return None
- def _read_length(self) -> tuple[int, int]:
- dots = 0
+ def _read_length(self, token: Token) -> tuple[int | None, int]:
length_token = self.ctx.match(TokenType.NUMBER)
- if length_token is None:
- length = self.default_length
- else:
+ length: int | None = None
+ if length_token is not None:
length = int(length_token.value)
+ dots = 0
while self.ctx.match(TokenType.DOT):
dots += 1
return length, dots
- def _parse_note(self) -> None:
+ def _parse_note(self) -> NoteStmt:
token = self.ctx.advance()
- note = token.value.lower()
- length, dots = self._read_length()
- ticks = length_value_to_ticks(length, dots)
+ length, dots = self._read_length(token)
accidental = 0
while True:
if self.ctx.match(TokenType.SHARP):
@@ -216,263 +224,157 @@ class PpmckParser:
accidental -= 1
else:
break
+ return NoteStmt(
+ line=token.line,
+ column=token.column,
+ note_name=token.value.lower(),
+ accidental=accidental,
+ length=length,
+ dots=dots,
+ )
- if self._tie_pending:
- # Tie: extend the last note/rest's duration instead of creating a new event
- self._extend_last_note(ticks)
- self.tick_position += ticks
- self._tie_pending = False
- return
-
- 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)
+ def _parse_rest(self) -> RestStmt:
+ token = self.ctx.advance()
+ length, dots = self._read_length(token)
+ return RestStmt(
+ line=token.line,
+ column=token.column,
+ length=length,
+ dots=dots,
+ )
- event = NoteEvent(
- tick_position=self.tick_position,
- duration=ticks,
- note_number=midi,
- velocity=self.velocity,
- duty=self.duty,
- gate_time=1.0,
+ def _parse_octave(self) -> OctaveStmt:
+ token = self.ctx.advance()
+ value = int(token.value) if token.value else 4
+ return OctaveStmt(
+ line=token.line,
+ column=token.column,
+ value=value,
+ direction=None,
)
- 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)
- if self._tie_pending:
- self._extend_last_note(ticks)
- self.tick_position += ticks
- self._tie_pending = False
- return
- event = RestEvent(tick_position=self.tick_position, duration=ticks)
- self._add_event(event)
- self.tick_position += ticks
+ def _parse_octave_up(self) -> OctaveStmt:
+ token = self.ctx.advance()
+ return OctaveStmt(
+ line=token.line,
+ column=token.column,
+ value=None,
+ direction="up",
+ )
- def _parse_octave(self) -> None:
+ def _parse_octave_down(self) -> OctaveStmt:
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
+ return OctaveStmt(
+ line=token.line,
+ column=token.column,
+ value=None,
+ direction="down",
+ )
- def _parse_octave_up(self) -> None:
- self.ctx.advance()
- if self.octave < 7:
- self.octave += 1
+ def _parse_length(self) -> LengthStmt:
+ token = self.ctx.advance()
+ value = int(token.value) if token.value else 4
+ return LengthStmt(
+ line=token.line,
+ column=token.column,
+ value=value,
+ )
- def _parse_octave_down(self) -> None:
- self.ctx.advance()
- if self.octave > 0:
- self.octave -= 1
+ def _parse_volume(self) -> VolumeStmt:
+ token = self.ctx.advance()
+ value = int(token.value) if token.value else 0
+ return VolumeStmt(
+ line=token.line,
+ column=token.column,
+ value=value,
+ )
- def _parse_length(self) -> None:
+ def _parse_duty(self) -> DutyStmt:
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
+ value = int(token.value) if token.value else 0
+ return DutyStmt(
+ line=token.line,
+ column=token.column,
+ value=value,
+ )
- def _parse_volume(self) -> None:
+ def _parse_tempo(self) -> TempoStmt:
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
+ value = int(token.value) if token.value else 120
+ return TempoStmt(
+ line=token.line,
+ column=token.column,
+ value=value,
+ )
- def _parse_duty(self) -> None:
+ def _parse_tie(self) -> TieStmt:
token = self.ctx.advance()
- if self.current_channel not in PULSE_CHANNELS:
- self.ctx.add_error(
- code=ErrorCode.SYNTAX_CHANNEL_MISMATCH,
+ next_token = self.ctx.peek()
+ if next_token.type == TokenType.NUMBER:
+ self.ctx.advance()
+ return TieStmt(
line=token.line,
column=token.column,
- message=f"チャンネル '{self.current_channel}' で 'q' は使用できません。",
- severity="warning",
- hint="Triangleチャンネルはデューティ比を持ちません。q コマンドを削除してください。",
- context=self._context_line(token),
+ target=int(next_token.value),
)
- return
- value = int(token.value)
- if not 0 <= value <= 3:
- self.ctx.add_error(
- code=ErrorCode.SYNTAX_VALUE_OUT_OF_RANGE,
+ if next_token.type == TokenType.NOTE:
+ return TieStmt(
line=token.line,
column=token.column,
- message=f"'q' の値 {value} は範囲外です。有効範囲: 0〜3。",
- severity="error",
- hint="例: q2 のように指定してください。",
- context=self._context_line(token),
+ target=self._parse_note(),
)
- 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,
+ if next_token.type == TokenType.REST:
+ return TieStmt(
line=token.line,
column=token.column,
- message=f"'t' の値 {value} は範囲外です。有効範囲: 1以上。",
- severity="error",
- hint="例: t150 のように指定してください。",
- context=self._context_line(token),
+ target=self._parse_rest(),
)
- return
- self.note_sequence.bpm = value
- self._add_event(TempoEvent(tick_position=self.tick_position, bpm=value))
+ self.ctx.add_error(
+ code=ErrorCode.SYNTAX_UNTERMINATED_TIE,
+ line=token.line,
+ column=token.column,
+ message="タイ '&' の後に音符が見つかりません。",
+ severity="error",
+ hint="& の後に音符(例: c4)を続けてください。音長のみ(例: &16)も可能です。",
+ context=context_line(self.source, token),
+ )
+ return TieStmt(line=token.line, column=token.column, target=None)
- def _parse_tie(self) -> None:
+ def _parse_repeat_start(self) -> RepeatStartStmt:
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: back up tick_position so the next event starts at the
- # end of the last note/rest. The next note's duration will be added
- # to the previous note's duration via _tie_pending flag.
- self.tick_position = self._last_note_end()
- self._tie_pending = True
+ return RepeatStartStmt(line=token.line, column=token.column)
- 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 + ev.duration
- 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
+ def _parse_repeat_end(self) -> RepeatEndStmt:
token = self.ctx.advance()
- self.ctx.add_error(
- code=ErrorCode.SYNTAX_UNEXPECTED_TOKEN,
+ count: int | None = None
+ if token.value:
+ count = int(token.value)
+ return RepeatEndStmt(
line=token.line,
column=token.column,
- message="区間リピート '[' は第2段階で実装されます。",
- severity="warning",
- hint="現時点では無視されます。",
- context=self._context_line(token),
+ count=count,
)
- def _parse_repeat_end(self) -> None:
- token = self.ctx.advance()
+ def _handle_invalid(self, token: Token) -> None:
+ self.ctx.add_invalid_token_error(token, context_line(self.source, token))
+ self.ctx.advance()
+
+ def _warn_outside_track(self, token: Token) -> None:
+ if token.type in (TokenType.EOF, TokenType.COMMENT):
+ return
self.ctx.add_error(
- code=ErrorCode.SYNTAX_UNEXPECTED_TOKEN,
+ code=ErrorCode.SEMANTIC_OUTSIDE_TRACK,
line=token.line,
column=token.column,
- message="区間リピート ']' は第2段階で実装されます。",
+ message=f"'{token.raw}' はトラック外に書かれています。",
severity="warning",
- hint="現時点では無視されます。",
- context=self._context_line(token),
+ hint="コマンドはトラックヘッダー(A, B, T, N)の後に記述してください。",
+ context=context_line(self.source, 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]]:
+) -> tuple[Program, 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
index f75c169..db9787d 100644
--- a/src/mml_composemusic_mcp/parser_pyxel.py
+++ b/src/mml_composemusic_mcp/parser_pyxel.py
@@ -1,26 +1,28 @@
-"""Pyxel MML parser."""
-
-from .ir import (
- DutyEvent,
- EnvelopeEvent,
- ErrorCode,
- ErrorDetail,
- GlideEvent,
- NoteEvent,
- NoteSequence,
- RepeatEvent,
- RestEvent,
- TempoEvent,
- VibratoEvent,
- VolumeEvent,
+"""Pyxel MML parser: tokens -> AST."""
+
+from .ast_nodes import (
+ ASTNode,
+ BarStmt,
+ DetuneStmt,
+ DutyStmt,
+ ExtCmdStmt,
+ GateTimeStmt,
+ LengthStmt,
+ NoteStmt,
+ OctaveStmt,
+ Program,
+ RepeatEndStmt,
+ RepeatStartStmt,
+ RestStmt,
+ TempoStmt,
+ TieStmt,
+ Track,
+ TransposeStmt,
+ VolumeStmt,
)
+from .ir import ErrorCode, ErrorDetail
from .lexer import Token, TokenType
-from .parser_base import (
- ParserContext,
- clamp,
- length_value_to_ticks,
- note_to_midi,
-)
+from .parser_base import ParserContext, context_line
CHANNEL_MAP = {
"0": "Pulse1",
@@ -28,95 +30,33 @@ CHANNEL_MAP = {
"2": "Triangle",
"3": "Noise",
}
-PULSE_CHANNELS = {"Pulse1", "Pulse2"}
-
-PYXEL_DUTY = {
- "1": 2, # Square 50%
- "2": 1, # Pulse 25%
-}
-
-
-class RepeatFrame:
- """Stack frame for nested repeat expansion."""
-
- def __init__(self, start_tick: int) -> None:
- self.start_tick = start_tick
- # Snapshot of event indices that existed before the repeat section
- self.event_count: int = 0
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[RepeatFrame] = []
- self._tie_pending: bool = False
- 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[Program, list[ErrorDetail]]:
+ program = Program(line=1, column=1, tracks=[])
- 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()
+ track = self._parse_track()
+ if track is not None:
+ program.tracks.append(track)
continue
- if self.current_channel is None:
- self._warn_outside_track(token)
- self.ctx.advance()
+ if token.type == TokenType.INVALID:
+ self._handle_invalid(token)
continue
- self._parse_statement()
-
- if self.repeat_stack:
- for _frame 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,
- )
+ self._warn_outside_track(token)
+ self.ctx.advance()
- return self.note_sequence, self.ctx.errors
+ return program, self.ctx.errors
- def _warn_outside_track(self, token: Token) -> None:
- if token.type == TokenType.EOF:
- return
- self.ctx.add_error(
- code=ErrorCode.SYNTAX_OUTSIDE_TRACK,
- line=token.line,
- column=token.column,
- message=f"'{token.raw}' はトラック外に書かれています。",
- severity="warning",
- hint="コマンドはトラックヘッダー(0:, 1:, 2:, 3:)の後に記述してください。",
- context=self._context_line(token),
- )
-
- def _parse_track_header(self) -> None:
+ def _parse_track(self) -> Track | None:
token = self.ctx.advance()
ch = token.value
if ch not in CHANNEL_MAP:
@@ -127,12 +67,11 @@ class PyxelParser:
message=f"無効なトラックヘッダー '{ch}:' です。",
severity="error",
hint="0:, 1:, 2:, 3: のいずれかを使用してください。",
- context=self._context_line(token),
+ context=context_line(self.source, token),
)
- self.current_channel = None
- return
- self.current_channel = CHANNEL_MAP[ch]
- if self.current_channel in self.tracks_seen:
+ return None
+ channel = CHANNEL_MAP[ch]
+ if channel in self.tracks_seen:
self.ctx.add_error(
code=ErrorCode.SYNTAX_DUPLICATE_TRACK,
line=token.line,
@@ -140,70 +79,91 @@ class PyxelParser:
message=f"トラック '{ch}:' が複数回定義されています。",
severity="error",
hint="各トラックは1回のみ定義できます。重複定義を削除してください。",
- context=self._context_line(token),
+ context=context_line(self.source, 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:
+ self.tracks_seen.add(channel)
+ track = Track(
+ line=token.line,
+ column=token.column,
+ track_id=ch,
+ channel=channel,
+ mode="pyxel",
+ statements=[],
+ )
+ while self.ctx.peek().type not in (
+ TokenType.TRACK_HEADER,
+ TokenType.EOF,
+ ):
+ stmt = self._parse_statement()
+ if stmt is not None:
+ track.statements.append(stmt)
+ return track
+
+ def _parse_statement(self) -> ASTNode | 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.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),
- )
+ if token.type == TokenType.INVALID:
+ self._handle_invalid(token)
+ return None
+ if token.type == TokenType.NOTE:
+ return self._parse_note()
+ if token.type == TokenType.REST:
+ return self._parse_rest()
+ if token.type == TokenType.OCTAVE:
+ return self._parse_octave()
+ if token.type == TokenType.OCTAVE_UP:
+ return self._parse_octave_up()
+ if token.type == TokenType.OCTAVE_DOWN:
+ return self._parse_octave_down()
+ if token.type == TokenType.LENGTH:
+ return self._parse_length()
+ if token.type == TokenType.VOLUME:
+ return self._parse_volume()
+ if token.type == TokenType.GATE_TIME:
+ return self._parse_gate_time()
+ if token.type == TokenType.AT:
+ return self._parse_at()
+ if token.type == TokenType.TEMPO:
+ return self._parse_tempo()
+ if token.type == TokenType.TRANSPOSE:
+ return self._parse_transpose()
+ if token.type == TokenType.DETUNE:
+ return self._parse_detune()
+ if token.type == TokenType.TIE:
+ return self._parse_tie()
+ if token.type == TokenType.REPEAT_START:
+ return self._parse_repeat_start()
+ if token.type == TokenType.REPEAT_END:
+ return self._parse_repeat_end()
+ if token.type == TokenType.EXT_CMD:
+ return self._parse_ext_cmd()
+ if token.type == TokenType.BAR:
self.ctx.advance()
+ return BarStmt(line=token.line, column=token.column)
+ self.ctx.add_error(
+ code=ErrorCode.SYNTAX_UNEXPECTED_TOKEN,
+ line=token.line,
+ column=token.column,
+ message=f"'{token.raw}' はここでは使用できません。",
+ severity="error",
+ hint="音符、休符、コマンド、小節線を配置してください。",
+ context=context_line(self.source, token),
+ )
+ self.ctx.advance()
+ return None
- def _read_length(self) -> tuple[int, int]:
- dots = 0
+ def _read_length(self, token: Token) -> tuple[int | None, int]:
length_token = self.ctx.match(TokenType.NUMBER)
- if length_token is None:
- length = self.default_length
- else:
+ length: int | None = None
+ if length_token is not None:
length = int(length_token.value)
+ dots = 0
while self.ctx.match(TokenType.DOT):
dots += 1
return length, dots
- def _parse_note(self) -> None:
+ def _parse_note(self) -> NoteStmt:
token = self.ctx.advance()
- note = token.value.lower()
- length, dots = self._read_length()
- ticks = length_value_to_ticks(length, dots)
+ length, dots = self._read_length(token)
accidental = 0
while True:
if self.ctx.match(TokenType.SHARP):
@@ -212,454 +172,205 @@ class PyxelParser:
accidental -= 1
else:
break
-
- if self._tie_pending:
- # Tie: extend the last note/rest's duration instead of creating a new event
- self._extend_last_note(ticks)
- self.tick_position += ticks
- self._tie_pending = False
- return
-
- 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,
- detune_cents=float(self.detune_cents),
+ return NoteStmt(
+ line=token.line,
+ column=token.column,
+ note_name=token.value.lower(),
+ accidental=accidental,
+ length=length,
+ dots=dots,
)
- 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)
- if self._tie_pending:
- self._extend_last_note(ticks)
- self.tick_position += ticks
- self._tie_pending = False
- return
- event = RestEvent(tick_position=self.tick_position, duration=ticks)
- self._add_event(event)
- self.tick_position += ticks
-
- def _parse_octave(self) -> None:
+ def _parse_rest(self) -> RestStmt:
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
+ length, dots = self._read_length(token)
+ return RestStmt(
+ line=token.line,
+ column=token.column,
+ length=length,
+ dots=dots,
+ )
- def _parse_octave_up(self) -> None:
- self.ctx.advance()
- if self.octave < 7:
- self.octave += 1
+ def _parse_octave(self) -> OctaveStmt:
+ token = self.ctx.advance()
+ value = int(token.value) if token.value else 4
+ return OctaveStmt(
+ line=token.line,
+ column=token.column,
+ value=value,
+ direction=None,
+ )
- def _parse_octave_down(self) -> None:
- self.ctx.advance()
- if self.octave > 0:
- self.octave -= 1
+ def _parse_octave_up(self) -> OctaveStmt:
+ token = self.ctx.advance()
+ return OctaveStmt(
+ line=token.line,
+ column=token.column,
+ value=None,
+ direction="up",
+ )
- def _parse_length(self) -> None:
+ def _parse_octave_down(self) -> OctaveStmt:
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
+ return OctaveStmt(
+ line=token.line,
+ column=token.column,
+ value=None,
+ direction="down",
+ )
- def _parse_volume(self) -> None:
+ def _parse_length(self) -> LengthStmt:
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),
- )
+ value = int(token.value) if token.value else 4
+ return LengthStmt(
+ line=token.line,
+ column=token.column,
+ value=value,
)
- def _normalize_velocity(self, value: int) -> int:
- return round(value / 127 * 15)
+ def _parse_volume(self) -> VolumeStmt:
+ token = self.ctx.advance()
+ value = int(token.value) if token.value else 0
+ return VolumeStmt(
+ line=token.line,
+ column=token.column,
+ value=value,
+ )
- def _parse_gate_time(self) -> None:
+ def _parse_gate_time(self) -> GateTimeStmt:
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
+ value = int(token.value) if token.value else 80
+ return GateTimeStmt(
+ line=token.line,
+ column=token.column,
+ value=value,
+ )
- def _parse_at(self) -> None:
+ def _parse_at(self) -> DutyStmt:
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),
- )
+ value = int(token.value) if token.value else 0
+ return DutyStmt(
+ line=token.line,
+ column=token.column,
+ value=value,
+ )
- def _parse_tempo(self) -> None:
+ def _parse_tempo(self) -> TempoStmt:
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))
+ value = int(token.value) if token.value else 120
+ return TempoStmt(
+ line=token.line,
+ column=token.column,
+ value=value,
+ )
- def _parse_transpose(self) -> None:
+ def _parse_transpose(self) -> TransposeStmt:
token = self.ctx.advance()
- self.transpose = int(token.value)
+ value = int(token.value) if token.value else 0
+ return TransposeStmt(
+ line=token.line,
+ column=token.column,
+ value=value,
+ )
- def _parse_detune(self) -> None:
+ def _parse_detune(self) -> DetuneStmt:
token = self.ctx.advance()
- self.detune_cents = int(token.value)
+ value = int(token.value) if token.value else 0
+ return DetuneStmt(
+ line=token.line,
+ column=token.column,
+ value=value,
+ )
- def _parse_tie(self) -> None:
+ def _parse_tie(self) -> TieStmt:
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
- # For note/rest: back up tick_position so the next event starts at the
- # end of the last note/rest. The next note's duration will be added
- # to the previous note's duration via _tie_pending flag.
- self.tick_position = self._last_note_end()
- self._tie_pending = True
-
- 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 + ev.duration
- 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()
- ch = self.note_sequence.channels[self.current_channel] # type: ignore[index]
- frame = RepeatFrame(start_tick=self.tick_position)
- frame.event_count = len(ch.events)
- self.repeat_stack.append(frame)
-
- 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,
+ return TieStmt(
line=token.line,
column=token.column,
- message="']' に対応する '[' が見つかりません。",
- severity="error",
- hint="直前の [ を確認するか、余分な ] を削除してください。",
- context=self._context_line(token),
+ target=int(next_token.value),
)
- return
- frame = self.repeat_stack.pop()
- start_tick = frame.start_tick
- ch = self.note_sequence.channels[self.current_channel] # type: ignore[index]
-
- count_str = token.value
- if count_str == "":
- count = 0 # infinite
- self.ctx.add_error(
- code=ErrorCode.SYNTAX_UNEXPECTED_TOKEN,
+ if next_token.type == TokenType.NOTE:
+ return TieStmt(
line=token.line,
column=token.column,
- message="無限リピートはWAV生成時に2回で打ち切られます。",
- severity="warning",
- hint="回数を指定するか、2回の再生で十分であることを確認してください。",
- context=self._context_line(token),
+ target=self._parse_note(),
)
- else:
- count = int(count_str)
-
- # Collect events that belong to this repeat section (created after the frame snapshot)
- segment_events = ch.events[frame.event_count :]
- segment_length = self.tick_position - start_tick
-
- repeat_count = 2 if count == 0 else count
- for i in range(1, repeat_count):
- offset = i * segment_length
- for ev in segment_events:
- 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
-
- ch.events.sort(key=lambda e: e.tick_position)
- self._add_event(
- RepeatEvent(
- start_tick=start_tick,
- end_tick=self.tick_position,
- repeat_count=count,
+ if next_token.type == TokenType.REST:
+ return TieStmt(
+ line=token.line,
+ column=token.column,
+ target=self._parse_rest(),
)
+ self.ctx.add_error(
+ code=ErrorCode.SYNTAX_UNTERMINATED_TIE,
+ line=token.line,
+ column=token.column,
+ message="タイ '&' の後に音符が見つかりません。",
+ severity="error",
+ hint="& の後に音符(例: C4)を続けてください。音長のみ(例: &16)も可能です。",
+ context=context_line(self.source, token),
)
+ return TieStmt(line=token.line, column=token.column, target=None)
- def _clone_event(self, event, tick_position: int): # type: ignore[no-untyped-def]
- import copy
+ def _parse_repeat_start(self) -> RepeatStartStmt:
+ token = self.ctx.advance()
+ return RepeatStartStmt(line=token.line, column=token.column)
- new = copy.copy(event)
- new.tick_position = tick_position
- return new
+ def _parse_repeat_end(self) -> RepeatEndStmt:
+ token = self.ctx.advance()
+ count: int | None = None
+ if token.value:
+ count = int(token.value)
+ return RepeatEndStmt(
+ line=token.line,
+ column=token.column,
+ count=count,
+ )
- def _parse_ext_cmd(self) -> None:
+ def _parse_ext_cmd(self) -> ExtCmdStmt:
token = self.ctx.advance()
- cmd = token.value # ENV, VIB, GLI
+ cmd = token.value
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,
+ slot = 0
+ if slot_token is not None:
+ slot = int(slot_token.value)
+ params: list[int] = []
+ while True:
+ num = self.ctx.match(TokenType.NUMBER)
+ if num is None:
+ break
+ params.append(int(num.value))
+ return ExtCmdStmt(
line=token.line,
column=token.column,
- message=f"@{cmd} は第1段階では未サポート、無視されます。",
- severity="warning",
- hint="合成には反映されませんが、IRとして保持されます。",
- context=self._context_line(token),
+ cmd=cmd,
+ slot=slot,
+ params=params,
)
- def _parse_bar(self) -> None:
+ def _handle_invalid(self, token: Token) -> None:
+ self.ctx.add_invalid_token_error(token, context_line(self.source, token))
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 _warn_outside_track(self, token: Token) -> None:
+ if token.type == TokenType.EOF:
+ return
+ self.ctx.add_error(
+ code=ErrorCode.SEMANTIC_OUTSIDE_TRACK,
+ line=token.line,
+ column=token.column,
+ message=f"'{token.raw}' はトラック外に書かれています。",
+ severity="warning",
+ hint="コマンドはトラックヘッダー(0:, 1:, 2:, 3:)の後に記述してください。",
+ context=context_line(self.source, token),
+ )
def parse_pyxel(
source: str, tokens: list[Token]
-) -> tuple[NoteSequence, list[ErrorDetail]]:
+) -> tuple[Program, list[ErrorDetail]]:
parser = PyxelParser(source, tokens)
return parser.parse()
diff --git a/src/mml_composemusic_mcp/semantic_base.py b/src/mml_composemusic_mcp/semantic_base.py
new file mode 100644
index 0000000..0318a84
--- /dev/null
+++ b/src/mml_composemusic_mcp/semantic_base.py
@@ -0,0 +1,225 @@
+"""Base semantic analyzer: AST -> NoteSequence IR."""
+
+from dataclasses import dataclass, field
+
+from .ast_nodes import (
+ ASTNode,
+ BarStmt,
+ DetuneStmt,
+ DutyStmt,
+ EnvelopeDefStmt,
+ ExtCmdStmt,
+ GateTimeStmt,
+ LengthStmt,
+ NoteStmt,
+ OctaveStmt,
+ Program,
+ RepeatEndStmt,
+ RepeatStartStmt,
+ RestStmt,
+ Statement,
+ TempoStmt,
+ TieStmt,
+ Track,
+ TransposeStmt,
+ VolumeStmt,
+)
+from .ir import (
+ ErrorCode,
+ ErrorDetail,
+ ErrorPhase,
+ NoteEvent,
+ NoteSequence,
+ RestEvent,
+)
+from .parser_base import length_value_to_ticks
+
+
+@dataclass
+class SemanticContext:
+ errors: list[ErrorDetail] = field(default_factory=list)
+
+ 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,
+ phase=ErrorPhase.SEMANTIC,
+ line=line,
+ column=column,
+ message=message,
+ severity=severity,
+ hint=hint,
+ context=context,
+ )
+ )
+
+
+class SemanticAnalyzer:
+ """Base class for converting AST to NoteSequence IR."""
+
+ def __init__(self, source: str, program: Program) -> None:
+ self.source = source
+ self.program = program
+ self.ctx = SemanticContext()
+ self.note_sequence = NoteSequence()
+ self.current_channel: str | None = None
+ self.octave = 4
+ self.default_length = 4
+ self.velocity = 15
+ self.gate_time = 1.0
+ self.duty = 2
+ self.tick_position = 0
+
+ def analyze(self) -> tuple[NoteSequence, list[ErrorDetail]]:
+ for track in self.program.tracks:
+ self._analyze_track(track)
+ self._finalize()
+ return self.note_sequence, self.ctx.errors
+
+ def _analyze_track(self, track: Track) -> None:
+ self.current_channel = track.channel
+ self._reset_state(track)
+ if track.channel == "Loop":
+ self._handle_loop_track(track)
+ return
+ for stmt in track.statements:
+ self._analyze_statement(stmt)
+
+ def _reset_state(self, track: Track) -> None:
+ self.octave = 4
+ self.default_length = 4
+ self.velocity = 15
+ self.gate_time = 1.0
+ self.duty = 2
+ self.tick_position = 0
+
+ def _handle_loop_track(self, track: Track) -> None:
+ # Loop track is handled after all tracks are processed.
+ pass
+
+ def _finalize(self) -> None:
+ 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,
+ )
+
+ def _analyze_statement(self, stmt: Statement) -> None:
+ if isinstance(stmt, NoteStmt):
+ self._analyze_note(stmt)
+ elif isinstance(stmt, RestStmt):
+ self._analyze_rest(stmt)
+ elif isinstance(stmt, OctaveStmt):
+ self._analyze_octave(stmt)
+ elif isinstance(stmt, LengthStmt):
+ self._analyze_length(stmt)
+ elif isinstance(stmt, VolumeStmt):
+ self._analyze_volume(stmt)
+ elif isinstance(stmt, DutyStmt):
+ self._analyze_duty(stmt)
+ elif isinstance(stmt, TempoStmt):
+ self._analyze_tempo(stmt)
+ elif isinstance(stmt, GateTimeStmt):
+ self._analyze_gate_time(stmt)
+ elif isinstance(stmt, TransposeStmt):
+ self._analyze_transpose(stmt)
+ elif isinstance(stmt, DetuneStmt):
+ self._analyze_detune(stmt)
+ elif isinstance(stmt, TieStmt):
+ self._analyze_tie(stmt)
+ elif isinstance(stmt, RepeatStartStmt):
+ self._analyze_repeat_start(stmt)
+ elif isinstance(stmt, RepeatEndStmt):
+ self._analyze_repeat_end(stmt)
+ elif isinstance(stmt, BarStmt):
+ pass
+ elif isinstance(stmt, ExtCmdStmt):
+ self._analyze_ext_cmd(stmt)
+ elif isinstance(stmt, EnvelopeDefStmt):
+ self._analyze_stage2(stmt)
+ else:
+ self._analyze_stage2(stmt)
+
+ def _ticks_for(self, stmt: NoteStmt | RestStmt) -> int:
+ length = stmt.length if stmt.length is not None else self.default_length
+ return length_value_to_ticks(length, stmt.dots)
+
+ def _analyze_note(self, stmt: NoteStmt) -> None:
+ raise NotImplementedError
+
+ def _analyze_rest(self, stmt: RestStmt) -> None:
+ raise NotImplementedError
+
+ def _analyze_octave(self, stmt: OctaveStmt) -> None:
+ raise NotImplementedError
+
+ def _analyze_length(self, stmt: LengthStmt) -> None:
+ raise NotImplementedError
+
+ def _analyze_volume(self, stmt: VolumeStmt) -> None:
+ raise NotImplementedError
+
+ def _analyze_duty(self, stmt: DutyStmt) -> None:
+ raise NotImplementedError
+
+ def _analyze_tempo(self, stmt: TempoStmt) -> None:
+ raise NotImplementedError
+
+ def _analyze_gate_time(self, stmt: GateTimeStmt) -> None:
+ raise NotImplementedError
+
+ def _analyze_tie(self, stmt: TieStmt) -> None:
+ raise NotImplementedError
+
+ def _analyze_repeat_start(self, stmt: RepeatStartStmt) -> None:
+ raise NotImplementedError
+
+ def _analyze_repeat_end(self, stmt: RepeatEndStmt) -> None:
+ raise NotImplementedError
+
+ def _analyze_ext_cmd(self, stmt: ExtCmdStmt) -> None:
+ raise NotImplementedError
+
+ def _analyze_stage2(self, stmt: ASTNode) -> None:
+ self.ctx.add_error(
+ code=ErrorCode.SEMANTIC_UNSUPPORTED_FEATURE,
+ line=stmt.line,
+ column=stmt.column,
+ message=f"'{stmt.__class__.__name__}' は第2段階で実装されます。",
+ severity="warning",
+ hint="現時点では無視されます。",
+ context=self._context_line(stmt),
+ )
+
+ 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 _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 + ev.duration
+ 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 _context_line(self, node: ASTNode) -> str:
+ lines = self.source.splitlines()
+ if 1 <= node.line <= len(lines):
+ return lines[node.line - 1]
+ return ""
diff --git a/src/mml_composemusic_mcp/semantic_ppmck.py b/src/mml_composemusic_mcp/semantic_ppmck.py
new file mode 100644
index 0000000..b4393c0
--- /dev/null
+++ b/src/mml_composemusic_mcp/semantic_ppmck.py
@@ -0,0 +1,283 @@
+"""PPMCK semantic analyzer: AST -> NoteSequence IR."""
+
+
+from .ast_nodes import (
+ DutyStmt,
+ EnvelopeDefStmt,
+ ExtCmdStmt,
+ GateTimeStmt,
+ LengthStmt,
+ NoteStmt,
+ OctaveStmt,
+ Program,
+ RepeatEndStmt,
+ RepeatStartStmt,
+ RestStmt,
+ SweepStmt,
+ TempoStmt,
+ TieStmt,
+ Track,
+ VolumeStmt,
+)
+from .ir import (
+ DutyEvent,
+ ErrorCode,
+ ErrorDetail,
+ NoteEvent,
+ NoteSequence,
+ RepeatEvent,
+ RestEvent,
+ TempoEvent,
+ VolumeEvent,
+)
+from .parser_base import clamp, length_value_to_ticks, note_to_midi
+from .semantic_base import SemanticAnalyzer
+
+PULSE_CHANNELS = {"Pulse1", "Pulse2"}
+
+
+class PpmckSemanticAnalyzer(SemanticAnalyzer):
+ def __init__(self, source: str, program: Program) -> None:
+ super().__init__(source, program)
+ self.loop_start: int | None = None
+
+ def analyze(self) -> tuple[NoteSequence, list[ErrorDetail]]:
+ ns, errors = super().analyze()
+ if self.loop_start is not None:
+ max_tick = max(
+ ch.total_ticks for ch in ns.channels.values()
+ )
+ for ch in ns.channels.values():
+ ch.events.append(
+ RepeatEvent(
+ start_tick=self.loop_start,
+ end_tick=max_tick,
+ repeat_count=0,
+ )
+ )
+ # Recompute total_ticks after adding loop events
+ for ch in ns.channels.values():
+ ch.total_ticks = max(
+ (e.tick_position + getattr(e, "duration", 0) for e in ch.events),
+ default=0,
+ )
+ return ns, errors
+
+ def _reset_state(self, track: Track) -> None:
+ self.octave = 4
+ self.default_length = 4
+ self.velocity = 15
+ self.gate_time = 1.0
+ self.duty = 2
+ self.tick_position = 0
+
+ def _analyze_track(self, track: Track) -> None:
+ if track.channel == "Loop":
+ self.loop_start = 0
+ return
+ super()._analyze_track(track)
+
+ def _analyze_note(self, stmt: NoteStmt) -> None:
+ ticks = self._ticks_for(stmt)
+ midi = note_to_midi(stmt.note_name, self.octave, stmt.accidental)
+ if self.current_channel == "Noise":
+ self.ctx.add_error(
+ code=ErrorCode.SEMANTIC_CHANNEL_MISMATCH,
+ line=stmt.line,
+ column=stmt.column,
+ message="チャンネル 'Noise' で音高指定は使用できません。",
+ severity="warning",
+ hint="Noiseチャンネルでは音高ではなく音長と音量のみ指定してください。",
+ context=self._context_line(stmt),
+ )
+ midi = 0
+ if not 0 <= midi <= 127:
+ self.ctx.add_error(
+ code=ErrorCode.SEMANTIC_NOTE_OUT_OF_RANGE,
+ line=stmt.line,
+ column=stmt.column,
+ message=f"音高が範囲外です(MIDI音番号 {midi})。",
+ severity="warning",
+ hint="オクターブを調整してください。有効範囲: o0〜o7。",
+ context=self._context_line(stmt),
+ )
+ 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=self.gate_time,
+ )
+ self._add_event(event)
+ self.tick_position += ticks
+
+ def _analyze_rest(self, stmt: RestStmt) -> None:
+ ticks = self._ticks_for(stmt)
+ event = RestEvent(tick_position=self.tick_position, duration=ticks)
+ self._add_event(event)
+ self.tick_position += ticks
+
+ def _analyze_octave(self, stmt: OctaveStmt) -> None:
+ if stmt.direction == "up":
+ if self.octave < 7:
+ self.octave += 1
+ return
+ if stmt.direction == "down":
+ if self.octave > 0:
+ self.octave -= 1
+ return
+ if stmt.value is None:
+ return
+ if not 0 <= stmt.value <= 7:
+ self.ctx.add_error(
+ code=ErrorCode.SEMANTIC_VALUE_OUT_OF_RANGE,
+ line=stmt.line,
+ column=stmt.column,
+ message=f"'o' の値 {stmt.value} は範囲外です。有効範囲: 0〜7。",
+ severity="error",
+ hint="例: o4 のように指定してください。",
+ context=self._context_line(stmt),
+ )
+ return
+ self.octave = stmt.value
+
+ def _analyze_length(self, stmt: LengthStmt) -> None:
+ if not 1 <= stmt.value <= 192:
+ self.ctx.add_error(
+ code=ErrorCode.SEMANTIC_VALUE_OUT_OF_RANGE,
+ line=stmt.line,
+ column=stmt.column,
+ message=f"'l' の値 {stmt.value} は範囲外です。有効範囲: 1〜192。",
+ severity="error",
+ hint="例: l8 のように指定してください。",
+ context=self._context_line(stmt),
+ )
+ return
+ self.default_length = stmt.value
+
+ def _analyze_volume(self, stmt: VolumeStmt) -> None:
+ if self.current_channel == "Triangle":
+ # Silently ignored per spec
+ return
+ if not 0 <= stmt.value <= 15:
+ self.ctx.add_error(
+ code=ErrorCode.SEMANTIC_VALUE_OUT_OF_RANGE,
+ line=stmt.line,
+ column=stmt.column,
+ message=f"'v' の値 {stmt.value} は範囲外です。有効範囲: 0〜15。",
+ severity="error",
+ hint="例: v15 のように指定してください。",
+ context=self._context_line(stmt),
+ )
+ return
+ self.velocity = stmt.value
+ self._add_event(VolumeEvent(tick_position=self.tick_position, value=stmt.value))
+
+ def _analyze_duty(self, stmt: DutyStmt) -> None:
+ if self.current_channel not in PULSE_CHANNELS:
+ self.ctx.add_error(
+ code=ErrorCode.SEMANTIC_CHANNEL_MISMATCH,
+ line=stmt.line,
+ column=stmt.column,
+ message=f"チャンネル '{self.current_channel}' で 'q' は使用できません。",
+ severity="warning",
+ hint="Triangleチャンネルはデューティ比を持ちません。q コマンドを削除してください。",
+ context=self._context_line(stmt),
+ )
+ return
+ if not 0 <= stmt.value <= 3:
+ self.ctx.add_error(
+ code=ErrorCode.SEMANTIC_VALUE_OUT_OF_RANGE,
+ line=stmt.line,
+ column=stmt.column,
+ message=f"'q' の値 {stmt.value} は範囲外です。有効範囲: 0〜3。",
+ severity="error",
+ hint="例: q2 のように指定してください。",
+ context=self._context_line(stmt),
+ )
+ return
+ self.duty = stmt.value
+ self._add_event(DutyEvent(tick_position=self.tick_position, value=stmt.value))
+
+ def _analyze_tempo(self, stmt: TempoStmt) -> None:
+ if stmt.value < 1:
+ self.ctx.add_error(
+ code=ErrorCode.SEMANTIC_VALUE_OUT_OF_RANGE,
+ line=stmt.line,
+ column=stmt.column,
+ message=f"'t' の値 {stmt.value} は範囲外です。有効範囲: 1以上。",
+ severity="error",
+ hint="例: t150 のように指定してください。",
+ context=self._context_line(stmt),
+ )
+ return
+ self.note_sequence.bpm = stmt.value
+ self._add_event(TempoEvent(tick_position=self.tick_position, bpm=stmt.value))
+
+ def _analyze_gate_time(self, stmt: GateTimeStmt) -> None:
+ # ppmck does not support gate time
+ self._analyze_stage2(stmt)
+
+ def _analyze_tie(self, stmt: TieStmt) -> None:
+ if stmt.target is None:
+ return
+ if isinstance(stmt.target, int):
+ ticks = length_value_to_ticks(stmt.target, 0)
+ self._extend_last_note(ticks)
+ self.tick_position += ticks
+ return
+ if isinstance(stmt.target, NoteStmt):
+ target_ticks = self._ticks_for(stmt.target)
+ self._extend_last_note(target_ticks)
+ self.tick_position += target_ticks
+ return
+ if isinstance(stmt.target, RestStmt):
+ target_ticks = self._ticks_for(stmt.target)
+ self._extend_last_note(target_ticks)
+ self.tick_position += target_ticks
+ return
+
+ def _analyze_repeat_start(self, stmt: RepeatStartStmt) -> None:
+ # Stage 2 feature
+ self._analyze_stage2(stmt)
+
+ def _analyze_repeat_end(self, stmt: RepeatEndStmt) -> None:
+ # Stage 2 feature
+ self._analyze_stage2(stmt)
+
+ def _analyze_ext_cmd(self, stmt: ExtCmdStmt) -> None:
+ # ppmck does not use pyxel ext commands
+ self._analyze_stage2(stmt)
+
+ def _analyze_stage2(self, stmt) -> None: # type: ignore[no-untyped-def]
+ if isinstance(stmt, EnvelopeDefStmt):
+ self.ctx.add_error(
+ code=ErrorCode.SEMANTIC_UNSUPPORTED_FEATURE,
+ line=stmt.line,
+ column=stmt.column,
+ message="エンベロープ定義 '@v' は第2段階で実装されます。",
+ severity="warning",
+ hint="現時点では無視されます。",
+ context=self._context_line(stmt),
+ )
+ elif isinstance(stmt, SweepStmt):
+ self.ctx.add_error(
+ code=ErrorCode.SEMANTIC_UNSUPPORTED_FEATURE,
+ line=stmt.line,
+ column=stmt.column,
+ message="スイープ 's' は第2段階で実装されます。",
+ severity="warning",
+ hint="現時点では無視されます。",
+ context=self._context_line(stmt),
+ )
+ else:
+ super()._analyze_stage2(stmt)
+
+
+def analyze_ppmck(
+ source: str, program: Program
+) -> tuple[NoteSequence, list[ErrorDetail]]:
+ analyzer = PpmckSemanticAnalyzer(source, program)
+ return analyzer.analyze()
diff --git a/src/mml_composemusic_mcp/semantic_pyxel.py b/src/mml_composemusic_mcp/semantic_pyxel.py
new file mode 100644
index 0000000..32808d1
--- /dev/null
+++ b/src/mml_composemusic_mcp/semantic_pyxel.py
@@ -0,0 +1,423 @@
+"""Pyxel semantic analyzer: AST -> NoteSequence IR."""
+
+import copy
+
+from .ast_nodes import (
+ DutyStmt,
+ ExtCmdStmt,
+ GateTimeStmt,
+ LengthStmt,
+ NoteStmt,
+ OctaveStmt,
+ Program,
+ RepeatEndStmt,
+ RepeatStartStmt,
+ RestStmt,
+ TempoStmt,
+ TieStmt,
+ Track,
+ TransposeStmt,
+ VolumeStmt,
+)
+from .ir import (
+ DutyEvent,
+ EnvelopeEvent,
+ ErrorCode,
+ ErrorDetail,
+ GlideEvent,
+ NoteEvent,
+ NoteSequence,
+ RepeatEvent,
+ RestEvent,
+ TempoEvent,
+ VibratoEvent,
+ VolumeEvent,
+)
+from .parser_base import clamp, length_value_to_ticks, note_to_midi
+from .semantic_base import SemanticAnalyzer
+
+PULSE_CHANNELS = {"Pulse1", "Pulse2"}
+PYXEL_DUTY = {
+ 1: 2, # Square 50%
+ 2: 1, # Pulse 25%
+}
+
+
+class RepeatFrame:
+ """Stack frame for nested repeat expansion."""
+
+ def __init__(self, start_tick: int) -> None:
+ self.start_tick = start_tick
+ self.event_count: int = 0
+
+
+class PyxelSemanticAnalyzer(SemanticAnalyzer):
+ def __init__(self, source: str, program: Program) -> None:
+ super().__init__(source, program)
+ self.transpose = 0
+ self.detune_cents = 0.0
+ self.repeat_stack: list[RepeatFrame] = []
+
+ def _reset_state(self, track: Track) -> 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.0
+ self.tick_position = 0
+ self.repeat_stack = []
+
+ def _analyze_track(self, track: Track) -> None:
+ self.current_channel = track.channel
+ self._reset_state(track)
+ for stmt in track.statements:
+ self._analyze_statement(stmt)
+ if self.repeat_stack:
+ for _frame in self.repeat_stack:
+ self.ctx.add_error(
+ code=ErrorCode.SYNTAX_UNTERMINATED_REPEAT,
+ line=1,
+ column=1,
+ message="リピート '[' に対応する ']' が見つかりません。",
+ severity="error",
+ hint="] を追加してリピートを閉じてください。回数指定(例: ]2)も可能です。",
+ context="",
+ )
+
+ def _analyze_note(self, stmt: NoteStmt) -> None:
+ ticks = self._ticks_for(stmt)
+ midi = note_to_midi(stmt.note_name, self.octave, stmt.accidental)
+ if self.current_channel != "Noise":
+ midi += self.transpose
+ if not 0 <= midi <= 127:
+ self.ctx.add_error(
+ code=ErrorCode.SEMANTIC_NOTE_OUT_OF_RANGE,
+ line=stmt.line,
+ column=stmt.column,
+ message=f"音高が範囲外です(MIDI音番号 {midi})。",
+ severity="warning",
+ hint="オクターブ/トランスポーズを調整してください。有効範囲: O0〜O7。",
+ context=self._context_line(stmt),
+ )
+ 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,
+ detune_cents=self.detune_cents,
+ )
+ self._add_event(event)
+ self.tick_position += ticks
+
+ def _analyze_rest(self, stmt: RestStmt) -> None:
+ ticks = self._ticks_for(stmt)
+ event = RestEvent(tick_position=self.tick_position, duration=ticks)
+ self._add_event(event)
+ self.tick_position += ticks
+
+ def _analyze_octave(self, stmt: OctaveStmt) -> None:
+ if stmt.direction == "up":
+ if self.octave < 7:
+ self.octave += 1
+ return
+ if stmt.direction == "down":
+ if self.octave > 0:
+ self.octave -= 1
+ return
+ if stmt.value is None:
+ return
+ if not 0 <= stmt.value <= 7:
+ self.ctx.add_error(
+ code=ErrorCode.SEMANTIC_VALUE_OUT_OF_RANGE,
+ line=stmt.line,
+ column=stmt.column,
+ message=f"'O' の値 {stmt.value} は範囲外です。有効範囲: 0〜7。",
+ severity="error",
+ hint="例: O4 のように指定してください。",
+ context=self._context_line(stmt),
+ )
+ return
+ self.octave = stmt.value
+
+ def _analyze_length(self, stmt: LengthStmt) -> None:
+ if not 1 <= stmt.value <= 192:
+ self.ctx.add_error(
+ code=ErrorCode.SEMANTIC_VALUE_OUT_OF_RANGE,
+ line=stmt.line,
+ column=stmt.column,
+ message=f"'L' の値 {stmt.value} は範囲外です。有効範囲: 1〜192。",
+ severity="error",
+ hint="例: L8 のように指定してください。",
+ context=self._context_line(stmt),
+ )
+ return
+ self.default_length = stmt.value
+
+ def _analyze_volume(self, stmt: VolumeStmt) -> None:
+ if not 0 <= stmt.value <= 127:
+ self.ctx.add_error(
+ code=ErrorCode.SEMANTIC_VALUE_OUT_OF_RANGE,
+ line=stmt.line,
+ column=stmt.column,
+ message=f"'V' の値 {stmt.value} は範囲外です。有効範囲: 0〜127。",
+ severity="error",
+ hint="例: V100 のように指定してください。",
+ context=self._context_line(stmt),
+ )
+ return
+ self.velocity = stmt.value
+ self._add_event(
+ VolumeEvent(
+ tick_position=self.tick_position,
+ value=self._normalize_velocity(stmt.value),
+ )
+ )
+
+ def _normalize_velocity(self, value: int) -> int:
+ return round(value / 127 * 15)
+
+ def _analyze_gate_time(self, stmt: GateTimeStmt) -> None:
+ if not 0 <= stmt.value <= 100:
+ self.ctx.add_error(
+ code=ErrorCode.SEMANTIC_VALUE_OUT_OF_RANGE,
+ line=stmt.line,
+ column=stmt.column,
+ message=f"'Q' の値 {stmt.value} は範囲外です。有効範囲: 0〜100。",
+ severity="error",
+ hint="例: Q80 のように指定してください。",
+ context=self._context_line(stmt),
+ )
+ return
+ self.gate_time = stmt.value / 100.0
+
+ def _analyze_duty(self, stmt: DutyStmt) -> None:
+ if stmt.value in PYXEL_DUTY:
+ if self.current_channel not in PULSE_CHANNELS:
+ self.ctx.add_error(
+ code=ErrorCode.SEMANTIC_CHANNEL_MISMATCH,
+ line=stmt.line,
+ column=stmt.column,
+ message=f"チャンネル '{self.current_channel}' で '@{stmt.value}' は使用できません。",
+ severity="warning",
+ hint="トラック番号がチャンネルを決定します。@ コマンドを削除するか、Pulseチャンネル(0: または 1:)に移動してください。",
+ context=self._context_line(stmt),
+ )
+ return
+ self.duty = PYXEL_DUTY[stmt.value]
+ self._add_event(
+ DutyEvent(tick_position=self.tick_position, value=self.duty)
+ )
+ return
+ if stmt.value == 0:
+ self.ctx.add_error(
+ code=ErrorCode.SEMANTIC_CHANNEL_MISMATCH,
+ line=stmt.line,
+ column=stmt.column,
+ message=f"チャンネル '{self.current_channel}' で '@0' (Triangle) は使用できません。",
+ severity="warning",
+ hint="トラック番号がチャンネルを決定します。@ コマンドを削除するか、適切なチャンネルに移動してください。",
+ context=self._context_line(stmt),
+ )
+ elif stmt.value == 3:
+ self.ctx.add_error(
+ code=ErrorCode.SEMANTIC_CHANNEL_MISMATCH,
+ line=stmt.line,
+ column=stmt.column,
+ message=f"チャンネル '{self.current_channel}' で '@3' (Noise) は使用できません。",
+ severity="warning",
+ hint="トラック番号がチャンネルを決定します。@ コマンドを削除するか、適切なチャンネルに移動してください。",
+ context=self._context_line(stmt),
+ )
+ else:
+ self.ctx.add_error(
+ code=ErrorCode.SYNTAX_INVALID_NUMBER,
+ line=stmt.line,
+ column=stmt.column,
+ message=f"'@' の後に無効な値 '{stmt.value}' が見つかりました。",
+ severity="error",
+ hint="例: @1 のように指定してください。",
+ context=self._context_line(stmt),
+ )
+
+ def _analyze_tempo(self, stmt: TempoStmt) -> None:
+ if stmt.value < 1:
+ self.ctx.add_error(
+ code=ErrorCode.SEMANTIC_VALUE_OUT_OF_RANGE,
+ line=stmt.line,
+ column=stmt.column,
+ message=f"'T' の値 {stmt.value} は範囲外です。有効範囲: 1以上。",
+ severity="error",
+ hint="例: T150 のように指定してください。",
+ context=self._context_line(stmt),
+ )
+ return
+ self.note_sequence.bpm = stmt.value
+ self._add_event(TempoEvent(tick_position=self.tick_position, bpm=stmt.value))
+
+ def _analyze_transpose(self, stmt: TransposeStmt) -> None:
+ self.transpose = stmt.value
+
+ def _analyze_detune(self, stmt) -> None: # type: ignore[no-untyped-def]
+ self.detune_cents = float(stmt.value)
+
+ def _analyze_tie(self, stmt: TieStmt) -> None:
+ if stmt.target is None:
+ return
+ if isinstance(stmt.target, int):
+ ticks = length_value_to_ticks(stmt.target, 0)
+ self._extend_last_note(ticks)
+ self.tick_position += ticks
+ return
+ if isinstance(stmt.target, NoteStmt):
+ target_ticks = self._ticks_for(stmt.target)
+ self._extend_last_note(target_ticks)
+ self.tick_position += target_ticks
+ return
+ if isinstance(stmt.target, RestStmt):
+ target_ticks = self._ticks_for(stmt.target)
+ self._extend_last_note(target_ticks)
+ self.tick_position += target_ticks
+ return
+
+ def _analyze_repeat_start(self, stmt: RepeatStartStmt) -> None:
+ ch = self.note_sequence.channels[self.current_channel] # type: ignore[index]
+ frame = RepeatFrame(start_tick=self.tick_position)
+ frame.event_count = len(ch.events)
+ self.repeat_stack.append(frame)
+
+ def _analyze_repeat_end(self, stmt: RepeatEndStmt) -> None:
+ if not self.repeat_stack:
+ self.ctx.add_error(
+ code=ErrorCode.SYNTAX_UNMATCHED_REPEAT_END,
+ line=stmt.line,
+ column=stmt.column,
+ message="']' に対応する '[' が見つかりません。",
+ severity="error",
+ hint="直前の [ を確認するか、余分な ] を削除してください。",
+ context=self._context_line(stmt),
+ )
+ return
+ frame = self.repeat_stack.pop()
+ start_tick = frame.start_tick
+ ch = self.note_sequence.channels[self.current_channel] # type: ignore[index]
+
+ count = stmt.count
+ if count is None:
+ count = 0 # infinite
+ self.ctx.add_error(
+ code=ErrorCode.SEMANTIC_UNSUPPORTED_FEATURE,
+ line=stmt.line,
+ column=stmt.column,
+ message="無限リピートはWAV生成時に2回で打ち切られます。",
+ severity="warning",
+ hint="回数を指定するか、2回の再生で十分であることを確認してください。",
+ context=self._context_line(stmt),
+ )
+
+ segment_events = ch.events[frame.event_count :]
+ segment_length = self.tick_position - start_tick
+
+ repeat_count = 2 if count == 0 else count
+ for i in range(1, repeat_count):
+ offset = i * segment_length
+ for ev in segment_events:
+ 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
+
+ ch.events.sort(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]
+ new = copy.copy(event)
+ new.tick_position = tick_position
+ return new
+
+ def _analyze_ext_cmd(self, stmt: ExtCmdStmt) -> None:
+ if stmt.slot == 0:
+ if stmt.cmd == "ENV":
+ ev = EnvelopeEvent(tick_position=self.tick_position, slot=0)
+ elif stmt.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 stmt.params:
+ if stmt.cmd == "ENV":
+ points = []
+ for i in range(1, len(stmt.params), 2):
+ duration = stmt.params[i]
+ target = stmt.params[i - 1] if i - 1 < len(stmt.params) else stmt.params[-1]
+ points.append({"target_volume": target, "duration_ticks": duration})
+ ev = EnvelopeEvent(
+ tick_position=self.tick_position,
+ slot=stmt.slot,
+ points=points,
+ )
+ elif stmt.cmd == "VIB":
+ if len(stmt.params) >= 3:
+ params = {
+ "delay_ticks": stmt.params[0],
+ "period_ticks": stmt.params[1],
+ "depth_cents": stmt.params[2],
+ }
+ else:
+ params = {}
+ ev = VibratoEvent(
+ tick_position=self.tick_position,
+ slot=stmt.slot,
+ params=params,
+ )
+ else:
+ if len(stmt.params) >= 2:
+ params = {
+ "initial_offset_cents": stmt.params[0],
+ "duration_ticks": stmt.params[1],
+ }
+ else:
+ params = {}
+ ev = GlideEvent(
+ tick_position=self.tick_position,
+ slot=stmt.slot,
+ params=params,
+ )
+ self._add_event(ev)
+ else:
+ if stmt.cmd == "ENV":
+ ev = EnvelopeEvent(tick_position=self.tick_position, slot=stmt.slot)
+ elif stmt.cmd == "VIB":
+ ev = VibratoEvent(tick_position=self.tick_position, slot=stmt.slot)
+ else:
+ ev = GlideEvent(tick_position=self.tick_position, slot=stmt.slot)
+ self._add_event(ev)
+ self.ctx.add_error(
+ code=ErrorCode.SEMANTIC_UNSUPPORTED_FEATURE,
+ line=stmt.line,
+ column=stmt.column,
+ message=f"@{stmt.cmd} は第1段階では未サポート、無視されます。",
+ severity="warning",
+ hint="合成には反映されませんが、IRとして保持されます。",
+ context=self._context_line(stmt),
+ )
+
+
+def analyze_pyxel(
+ source: str, program: Program
+) -> tuple[NoteSequence, list[ErrorDetail]]:
+ analyzer = PyxelSemanticAnalyzer(source, program)
+ return analyzer.analyze()
diff --git a/src/mml_composemusic_mcp/server.py b/src/mml_composemusic_mcp/server.py
index 0a09131..de0ea82 100644
--- a/src/mml_composemusic_mcp/server.py
+++ b/src/mml_composemusic_mcp/server.py
@@ -6,10 +6,12 @@ from pathlib import Path
from fastmcp import FastMCP
-from .ir import ErrorCode, ErrorDetail, NoteSequence
+from .ir import ErrorCode, ErrorDetail, ErrorPhase, NoteSequence
from .lexer import tokenize
from .parser_ppmck import parse_ppmck
from .parser_pyxel import parse_pyxel
+from .semantic_ppmck import analyze_ppmck
+from .semantic_pyxel import analyze_pyxel
from .synthesizer import build_channel_summary, synthesize, write_wav
from .templates import get_template
@@ -40,16 +42,25 @@ def _split_errors(errors: list[ErrorDetail]) -> tuple[list[dict], list[dict]]:
return errs, warns
-def _parse_mml(mml: str, mode: str) -> tuple[dict, list[ErrorDetail]]:
+def _parse_mml(mml: str, mode: str) -> tuple[dict | None, list[ErrorDetail]]:
+ """Run lexer, parser, and semantic analyzer for the given mode."""
tokens = tokenize(mml, mode)
+
if mode == "ppmck":
- note_sequence, errors = parse_ppmck(mml, tokens)
+ program, errors = parse_ppmck(mml, tokens)
+ if not _is_error(errors):
+ note_sequence, semantic_errors = analyze_ppmck(mml, program)
+ errors.extend(semantic_errors)
elif mode == "pyxel":
- note_sequence, errors = parse_pyxel(mml, tokens)
+ program, errors = parse_pyxel(mml, tokens)
+ if not _is_error(errors):
+ note_sequence, semantic_errors = analyze_pyxel(mml, program)
+ errors.extend(semantic_errors)
else:
errors = [
ErrorDetail(
code=ErrorCode.VALIDATION_INVALID_MODE,
+ phase=ErrorPhase.API,
line=0,
column=0,
message=f"未知のモード '{mode}' です。",
@@ -58,6 +69,9 @@ def _parse_mml(mml: str, mode: str) -> tuple[dict, list[ErrorDetail]]:
)
]
note_sequence = None
+
+ if _is_error(errors):
+ note_sequence = None
return (note_sequence.to_dict() if note_sequence else None), errors
@@ -89,6 +103,7 @@ def compose_mml(
"errors": [
{
"code": ErrorCode.VALIDATION_MISSING_PARAMETER.value,
+ "phase": ErrorPhase.API.value,
"line": 0,
"column": 0,
"message": "mml と mode は compose/validate の必須パラメータです。",
@@ -136,7 +151,8 @@ def compose_mml(
"validation": {
"errors": [
{
- "code": ErrorCode.SYSTEM_SYNTHESIS_FAILED.value,
+ "code": ErrorCode.RUNTIME_SYNTHESIS_FAILED.value,
+ "phase": ErrorPhase.RUNTIME.value,
"line": 0,
"column": 0,
"message": f"音声合成中にエラーが発生しました: {exc}",
@@ -177,7 +193,8 @@ def compose_mml(
"validation": {
"errors": [
{
- "code": ErrorCode.SYSTEM_INTERNAL_ERROR.value,
+ "code": ErrorCode.RUNTIME_INTERNAL_ERROR.value,
+ "phase": ErrorPhase.RUNTIME.value,
"line": 0,
"column": 0,
"message": f"内部エラーが発生しました: {exc}",
@@ -195,6 +212,7 @@ def compose_mml(
"errors": [
{
"code": ErrorCode.VALIDATION_INVALID_ACTION.value,
+ "phase": ErrorPhase.API.value,
"line": 0,
"column": 0,
"message": f"未知の action '{action}' です。",
diff --git a/src/mml_composemusic_mcp/synthesizer.py b/src/mml_composemusic_mcp/synthesizer.py
index 6d9ffe2..b314aa5 100644
--- a/src/mml_composemusic_mcp/synthesizer.py
+++ b/src/mml_composemusic_mcp/synthesizer.py
@@ -10,6 +10,7 @@ from .ir import (
DutyEvent,
ErrorCode,
ErrorDetail,
+ ErrorPhase,
NoteEvent,
NoteSequence,
RestEvent,
@@ -262,7 +263,8 @@ def write_wav(path: Path, wave_data: np.ndarray, sample_rate: int) -> list[Error
except Exception as exc:
errors.append(
ErrorDetail(
- code=ErrorCode.SYSTEM_WAV_WRITE_FAILED,
+ code=ErrorCode.RUNTIME_WAV_WRITE_FAILED,
+ phase=ErrorPhase.RUNTIME,
line=0,
column=0,
message=f"WAVファイルの出力に失敗しました: {exc}",
diff --git a/tests/test_error_codes.py b/tests/test_error_codes.py
index eca73ad..f9bd5bb 100644
--- a/tests/test_error_codes.py
+++ b/tests/test_error_codes.py
@@ -1,4 +1,4 @@
-"""Exhaustive tests for all error codes defined in Design_err.md."""
+"""Exhaustive tests for all error codes defined in Design.md."""
from unittest.mock import patch
@@ -33,44 +33,44 @@ def test_syntax_invalid_token_mode(compose_mml):
def test_syntax_invalid_number_at(compose_mml):
result = compose_mml(action="validate", mml="0: T120 L4 O4 @x\n C", mode="pyxel")
assert not result["valid"]
- # @x -> lexer emits TEXT, parser reports unexpected or invalid number
+ # @x -> lexer emits INVALID, parser reports invalid token
assert result["errors"]
-# --- SYNTAX_VALUE_OUT_OF_RANGE ---
-def test_syntax_value_out_of_range_octave_ppmck(compose_mml):
+# --- SEMANTIC_VALUE_OUT_OF_RANGE ---
+def test_semantic_value_out_of_range_octave_ppmck(compose_mml):
result = compose_mml(action="validate", mml="A t120 l4 o9\n c", mode="ppmck")
assert not result["valid"]
codes = [e["code"] for e in result["errors"]]
- assert ErrorCode.SYNTAX_VALUE_OUT_OF_RANGE.value in codes
+ assert ErrorCode.SEMANTIC_VALUE_OUT_OF_RANGE.value in codes
-def test_syntax_value_out_of_range_volume_ppmck(compose_mml):
+def test_semantic_value_out_of_range_volume_ppmck(compose_mml):
result = compose_mml(action="validate", mml="A t120 l4 o4 v20\n c", mode="ppmck")
assert not result["valid"]
codes = [e["code"] for e in result["errors"]]
- assert ErrorCode.SYNTAX_VALUE_OUT_OF_RANGE.value in codes
+ assert ErrorCode.SEMANTIC_VALUE_OUT_OF_RANGE.value in codes
-def test_syntax_value_out_of_range_volume_pyxel(compose_mml):
+def test_semantic_value_out_of_range_volume_pyxel(compose_mml):
result = compose_mml(action="validate", mml="0: T120 L4 O4 V200\n C", mode="pyxel")
assert not result["valid"]
codes = [e["code"] for e in result["errors"]]
- assert ErrorCode.SYNTAX_VALUE_OUT_OF_RANGE.value in codes
+ assert ErrorCode.SEMANTIC_VALUE_OUT_OF_RANGE.value in codes
-def test_syntax_value_out_of_range_length(compose_mml):
+def test_semantic_value_out_of_range_length(compose_mml):
result = compose_mml(action="validate", mml="A t120 l0\n c", mode="ppmck")
assert not result["valid"]
codes = [e["code"] for e in result["errors"]]
- assert ErrorCode.SYNTAX_VALUE_OUT_OF_RANGE.value in codes
+ assert ErrorCode.SEMANTIC_VALUE_OUT_OF_RANGE.value in codes
-def test_syntax_value_out_of_range_tempo(compose_mml):
+def test_semantic_value_out_of_range_tempo(compose_mml):
result = compose_mml(action="validate", mml="A t0\n c", mode="ppmck")
assert not result["valid"]
codes = [e["code"] for e in result["errors"]]
- assert ErrorCode.SYNTAX_VALUE_OUT_OF_RANGE.value in codes
+ assert ErrorCode.SEMANTIC_VALUE_OUT_OF_RANGE.value in codes
# --- SYNTAX_UNEXPECTED_TOKEN ---
@@ -82,17 +82,17 @@ def test_syntax_unexpected_token(compose_mml):
assert result["valid"] is True
-# --- SYNTAX_OUTSIDE_TRACK ---
-def test_syntax_outside_track_ppmck(compose_mml):
+# --- SEMANTIC_OUTSIDE_TRACK ---
+def test_semantic_outside_track_ppmck(compose_mml):
result = compose_mml(action="validate", mml="t120\nA l4\n c", mode="ppmck")
assert result["valid"] is True
- assert _find_code(result, ErrorCode.SYNTAX_OUTSIDE_TRACK.value, "warnings")
+ assert _find_code(result, ErrorCode.SEMANTIC_OUTSIDE_TRACK.value, "warnings")
-def test_syntax_outside_track_pyxel(compose_mml):
+def test_semantic_outside_track_pyxel(compose_mml):
result = compose_mml(action="validate", mml="T120\n0: L4\n C", mode="pyxel")
assert result["valid"] is True
- assert _find_code(result, ErrorCode.SYNTAX_OUTSIDE_TRACK.value, "warnings")
+ assert _find_code(result, ErrorCode.SEMANTIC_OUTSIDE_TRACK.value, "warnings")
# --- SYNTAX_UNTERMINATED_REPEAT ---
@@ -153,27 +153,26 @@ def test_syntax_duplicate_track_pyxel(compose_mml):
assert _find_code(result, ErrorCode.SYNTAX_DUPLICATE_TRACK.value)
-# --- SYNTAX_EMPTY_TRACK ---
-def test_syntax_empty_track_ppmck(compose_mml):
+# --- SEMANTIC_EMPTY_TRACK ---
+def test_semantic_empty_track_ppmck(compose_mml):
# B track header with no notes/rests -> empty track error
result = compose_mml(action="validate", mml="A t120 l4\n c\nB", mode="ppmck")
# B alone sets track but has no events; check for empty track or just verify no crash
- # The parser may or may not flag this; verify result is a dict
assert isinstance(result, dict)
if not result["valid"]:
- assert _find_code(result, ErrorCode.SYNTAX_EMPTY_TRACK.value)
+ assert _find_code(result, ErrorCode.SEMANTIC_EMPTY_TRACK.value)
-def test_syntax_empty_track_pyxel(compose_mml):
+def test_semantic_empty_track_pyxel(compose_mml):
# 1: track with only tempo, no notes -> may or may not be flagged as empty
result = compose_mml(action="validate", mml="0: T120\n C\n1: T120", mode="pyxel")
assert isinstance(result, dict)
if not result["valid"]:
- assert _find_code(result, ErrorCode.SYNTAX_EMPTY_TRACK.value)
+ assert _find_code(result, ErrorCode.SEMANTIC_EMPTY_TRACK.value)
-# --- SYNTAX_NOTE_OUT_OF_RANGE ---
-def test_syntax_note_out_of_range_ppmck(compose_mml):
+# --- SEMANTIC_NOTE_OUT_OF_RANGE ---
+def test_semantic_note_out_of_range_ppmck(compose_mml):
# o7 b = MIDI 95, need many octave ups to exceed 127
result = compose_mml(
action="validate", mml="A t120 l4 o7\n b > > > > > c", mode="ppmck"
@@ -183,7 +182,7 @@ def test_syntax_note_out_of_range_ppmck(compose_mml):
assert any(e["severity"] == "warning" for e in result["warnings"])
-def test_syntax_note_out_of_range_pyxel(compose_mml):
+def test_semantic_note_out_of_range_pyxel(compose_mml):
result = compose_mml(
action="validate", mml="0: T120 L4 O7\n B > > > > > C", mode="pyxel"
)
@@ -191,20 +190,20 @@ def test_syntax_note_out_of_range_pyxel(compose_mml):
assert any(e["severity"] == "warning" for e in result["warnings"])
-# --- SYNTAX_CHANNEL_MISMATCH ---
-def test_syntax_channel_mismatch_ppmck_triangle_duty(compose_mml):
+# --- SEMANTIC_CHANNEL_MISMATCH ---
+def test_semantic_channel_mismatch_ppmck_triangle_duty(compose_mml):
result = compose_mml(action="validate", mml="T t120 l4 q2\n c", mode="ppmck")
- assert _find_code(result, ErrorCode.SYNTAX_CHANNEL_MISMATCH.value, "warnings")
+ assert _find_code(result, ErrorCode.SEMANTIC_CHANNEL_MISMATCH.value, "warnings")
-def test_syntax_channel_mismatch_ppmck_noise_pitch(compose_mml):
+def test_semantic_channel_mismatch_ppmck_noise_pitch(compose_mml):
result = compose_mml(action="validate", mml="N t120 l4\n c", mode="ppmck")
- assert _find_code(result, ErrorCode.SYNTAX_CHANNEL_MISMATCH.value, "warnings")
+ assert _find_code(result, ErrorCode.SEMANTIC_CHANNEL_MISMATCH.value, "warnings")
-def test_syntax_channel_mismatch_pyxel_triangle_at(compose_mml):
+def test_semantic_channel_mismatch_pyxel_triangle_at(compose_mml):
result = compose_mml(action="validate", mml="2: T120 L4 @1\n C", mode="pyxel")
- assert _find_code(result, ErrorCode.SYNTAX_CHANNEL_MISMATCH.value, "warnings")
+ assert _find_code(result, ErrorCode.SEMANTIC_CHANNEL_MISMATCH.value, "warnings")
# --- SYNTAX_UNTERMINATED_HEADER ---
@@ -237,8 +236,8 @@ def test_validation_invalid_action(compose_mml):
assert _find_code(result, ErrorCode.VALIDATION_INVALID_ACTION.value)
-# --- SYSTEM_SYNTHESIS_FAILED ---
-def test_system_synthesis_failed(compose_mml):
+# --- RUNTIME_SYNTHESIS_FAILED ---
+def test_runtime_synthesis_failed(compose_mml):
with patch(
"mml_composemusic_mcp.server.synthesize",
side_effect=RuntimeError("overflow"),
@@ -249,11 +248,11 @@ def test_system_synthesis_failed(compose_mml):
mode="ppmck",
)
assert result["success"] is False
- assert _find_code(result, ErrorCode.SYSTEM_SYNTHESIS_FAILED.value)
+ assert _find_code(result, ErrorCode.RUNTIME_SYNTHESIS_FAILED.value)
-# --- SYSTEM_WAV_WRITE_FAILED ---
-def test_system_wav_write_failed(tmp_path):
+# --- RUNTIME_WAV_WRITE_FAILED ---
+def test_runtime_wav_write_failed(tmp_path):
bad_path = tmp_path / "nonexistent_dir" / "output.wav"
data = np.array([0.0, 0.1, -0.1], dtype=np.float64)
errors = write_wav(bad_path, data, 44100)
@@ -266,11 +265,11 @@ def test_system_wav_write_failed(tmp_path):
bad_path2 = blocker / "output.wav"
errors = write_wav(bad_path2, data, 44100)
if errors:
- assert errors[0].code == ErrorCode.SYSTEM_WAV_WRITE_FAILED
+ assert errors[0].code == ErrorCode.RUNTIME_WAV_WRITE_FAILED
-# --- SYSTEM_INTERNAL_ERROR ---
-def test_system_internal_error(compose_mml):
+# --- RUNTIME_INTERNAL_ERROR ---
+def test_runtime_internal_error(compose_mml):
with patch(
"mml_composemusic_mcp.server._parse_mml",
side_effect=RuntimeError("unexpected internal error"),
@@ -281,9 +280,9 @@ def test_system_internal_error(compose_mml):
mode="ppmck",
)
assert result["success"] is False
- assert _find_code(result, ErrorCode.SYSTEM_INTERNAL_ERROR.value)
+ assert _find_code(result, ErrorCode.RUNTIME_INTERNAL_ERROR.value)
-def test_system_internal_error_not_exposed(compose_mml):
+def test_runtime_internal_error_not_exposed(compose_mml):
result = compose_mml(action="compose", mml="A t120 l4 o4\n c", mode="ppmck")
assert isinstance(result, dict)
diff --git a/tests/test_lexer.py b/tests/test_lexer.py
index 1bb2213..64fa17c 100644
--- a/tests/test_lexer.py
+++ b/tests/test_lexer.py
@@ -25,3 +25,13 @@ 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)
+
+
+def test_ppmck_invalid_token():
+ tokens = tokenize("A t120 l4 o4 x c", "ppmck")
+ assert any(t.type == TokenType.INVALID for t in tokens)
+
+
+def test_pyxel_invalid_token():
+ tokens = tokenize("0: T120 L4 O4 X C", "pyxel")
+ assert any(t.type == TokenType.INVALID for t in tokens)
diff --git a/tests/test_parser.py b/tests/test_parser.py
index b263aac..a0f11c7 100644
--- a/tests/test_parser.py
+++ b/tests/test_parser.py
@@ -1,6 +1,6 @@
-"""Tests for PPMCK and Pyxel parsers."""
+"""Tests for PPMCK and Pyxel parsers (AST output)."""
-from mml_composemusic_mcp.ir import NoteEvent
+from mml_composemusic_mcp.ast_nodes import NoteStmt, Program, Track
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
@@ -21,33 +21,42 @@ PYXEL_EXAMPLE = """0: T150 L8 O4 V100 @1
def test_ppmck_parse():
tokens = tokenize(PPMCK_EXAMPLE, "ppmck")
- ns, errors = parse_ppmck(PPMCK_EXAMPLE, tokens)
+ program, errors = parse_ppmck(PPMCK_EXAMPLE, tokens)
+ assert isinstance(program, Program)
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
+ assert len(program.tracks) == 2
+ assert program.tracks[0].channel == "Pulse1"
+ assert program.tracks[1].channel == "Pulse2"
+ notes = [
+ s for s in program.tracks[0].statements if isinstance(s, NoteStmt)
+ ]
+ assert notes[0].note_name == "c"
def test_pyxel_parse():
tokens = tokenize(PYXEL_EXAMPLE, "pyxel")
- ns, errors = parse_pyxel(PYXEL_EXAMPLE, tokens)
+ program, errors = parse_pyxel(PYXEL_EXAMPLE, tokens)
+ assert isinstance(program, Program)
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
+ assert len(program.tracks) == 2
+ assert program.tracks[0].channel == "Pulse1"
+ notes = [
+ s for s in program.tracks[0].statements if isinstance(s, NoteStmt)
+ ]
+ assert notes[0].note_name == "c"
-def test_pyxel_repeat_expansion():
+def test_pyxel_repeat_ast():
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
+ program, errors = parse_pyxel(source, tokens)
+ assert not any(e.severity == "error" for e in errors)
+ assert isinstance(program.tracks[0], Track)
def test_invalid_mode_error():
from mml_composemusic_mcp.server import _parse_mml
seq, errors = _parse_mml("A c", "unknown")
+ assert seq is None
assert any(e.severity == "error" for e in errors)
diff --git a/tests/test_parser_detailed.py b/tests/test_parser_detailed.py
index 3752562..7033d54 100644
--- a/tests/test_parser_detailed.py
+++ b/tests/test_parser_detailed.py
@@ -1,4 +1,4 @@
-"""Detailed parser tests for ppmck and pyxel modes."""
+"""Detailed parser/semantic tests for ppmck and pyxel modes."""
from mml_composemusic_mcp.ir import (
DutyEvent,
@@ -8,14 +8,32 @@ from mml_composemusic_mcp.ir import (
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
+from mml_composemusic_mcp.semantic_ppmck import analyze_ppmck
+from mml_composemusic_mcp.semantic_pyxel import analyze_pyxel
+
+
+def _analyze_ppmck(source: str):
+ tokens = tokenize(source, "ppmck")
+ program, errors = parse_ppmck(source, tokens)
+ if any(e.severity == "error" for e in errors):
+ return None, errors
+ return analyze_ppmck(source, program)
+
+
+def _analyze_pyxel(source: str):
+ tokens = tokenize(source, "pyxel")
+ program, errors = parse_pyxel(source, tokens)
+ if any(e.severity == "error" for e in errors):
+ return None, errors
+ return analyze_pyxel(source, program)
+
# --- Channel assignment ---
def test_ppmck_channel_assignment():
source = "A t120 l4 o4\n c\nB o4\n d\nT o3\n e\nN v10\n r"
- tokens = tokenize(source, "ppmck")
- ns, errors = parse_ppmck(source, tokens)
+ ns, errors = _analyze_ppmck(source)
assert not any(e.severity == "error" for e in errors)
assert ns.channels["Pulse1"].events
assert ns.channels["Pulse2"].events
@@ -25,8 +43,7 @@ def test_ppmck_channel_assignment():
def test_pyxel_channel_assignment():
source = "0: T120 L4 O4\n C\n1: O4\n D\n2: O3\n E\n3: V80\n R"
- tokens = tokenize(source, "pyxel")
- ns, errors = parse_pyxel(source, tokens)
+ ns, errors = _analyze_pyxel(source)
assert not any(e.severity == "error" for e in errors)
assert ns.channels["Pulse1"].events
assert ns.channels["Pulse2"].events
@@ -39,24 +56,21 @@ def test_pyxel_channel_assignment():
def test_ppmck_duty_q():
source = "A t120 l4 o4 q3\n c"
- tokens = tokenize(source, "ppmck")
- ns, errors = parse_ppmck(source, tokens)
+ ns, errors = _analyze_ppmck(source)
duties = [e for e in ns.channels["Pulse1"].events if isinstance(e, DutyEvent)]
assert duties and duties[0].value == 3
def test_pyxel_at_duty():
source = "0: T120 L4 O4 @1\n C"
- tokens = tokenize(source, "pyxel")
- ns, errors = parse_pyxel(source, tokens)
+ ns, errors = _analyze_pyxel(source)
duties = [e for e in ns.channels["Pulse1"].events if isinstance(e, DutyEvent)]
assert duties and duties[0].value == 2 # @1 maps to duty 2 (50%)
def test_pyxel_at2_duty():
source = "0: T120 L4 O4 @2\n C"
- tokens = tokenize(source, "pyxel")
- ns, errors = parse_pyxel(source, tokens)
+ ns, errors = _analyze_pyxel(source)
duties = [e for e in ns.channels["Pulse1"].events if isinstance(e, DutyEvent)]
assert duties and duties[0].value == 1 # @2 maps to duty 1 (25%)
@@ -66,8 +80,7 @@ def test_pyxel_at2_duty():
def test_pyxel_volume_normalization():
source = "0: T120 L4 O4 V100\n C"
- tokens = tokenize(source, "pyxel")
- ns, errors = parse_pyxel(source, tokens)
+ ns, errors = _analyze_pyxel(source)
notes = [e for e in ns.channels["Pulse1"].events if isinstance(e, NoteEvent)]
assert notes
# V100 -> round(100/127*15) = 12
@@ -76,16 +89,14 @@ def test_pyxel_volume_normalization():
def test_pyxel_volume_zero():
source = "0: T120 L4 O4 V0\n C"
- tokens = tokenize(source, "pyxel")
- ns, errors = parse_pyxel(source, tokens)
+ ns, errors = _analyze_pyxel(source)
notes = [e for e in ns.channels["Pulse1"].events if isinstance(e, NoteEvent)]
assert notes and notes[0].velocity == 0
def test_pyxel_volume_max():
source = "0: T120 L4 O4 V127\n C"
- tokens = tokenize(source, "pyxel")
- ns, errors = parse_pyxel(source, tokens)
+ ns, errors = _analyze_pyxel(source)
notes = [e for e in ns.channels["Pulse1"].events if isinstance(e, NoteEvent)]
assert notes and notes[0].velocity == 15
@@ -95,8 +106,7 @@ def test_pyxel_volume_max():
def test_pyxel_gate_time():
source = "0: T120 L4 O4 Q50\n C"
- tokens = tokenize(source, "pyxel")
- ns, errors = parse_pyxel(source, tokens)
+ ns, errors = _analyze_pyxel(source)
notes = [e for e in ns.channels["Pulse1"].events if isinstance(e, NoteEvent)]
assert notes and notes[0].gate_time == 0.5
@@ -106,8 +116,7 @@ def test_pyxel_gate_time():
def test_pyxel_transpose():
source = "0: T120 L4 O4 K2\n C"
- tokens = tokenize(source, "pyxel")
- ns, errors = parse_pyxel(source, tokens)
+ ns, errors = _analyze_pyxel(source)
notes = [e for e in ns.channels["Pulse1"].events if isinstance(e, NoteEvent)]
# C4=60, transpose+2 -> 62
assert notes and notes[0].note_number == 62
@@ -118,8 +127,7 @@ def test_pyxel_transpose():
def test_pyxel_detune():
source = "0: T120 L4 O4 Y10\n C"
- tokens = tokenize(source, "pyxel")
- ns, errors = parse_pyxel(source, tokens)
+ ns, errors = _analyze_pyxel(source)
notes = [e for e in ns.channels["Pulse1"].events if isinstance(e, NoteEvent)]
assert notes and notes[0].detune_cents == 10.0
@@ -129,24 +137,21 @@ def test_pyxel_detune():
def test_pyxel_repeat_expansion():
source = "0: T120 L4 O4\n[ C D ]2"
- tokens = tokenize(source, "pyxel")
- ns, errors = parse_pyxel(source, tokens)
+ ns, errors = _analyze_pyxel(source)
notes = [e for e in ns.channels["Pulse1"].events if isinstance(e, NoteEvent)]
assert len(notes) == 4
def test_pyxel_repeat_count3():
source = "0: T120 L4 O4\n[ C D ]3"
- tokens = tokenize(source, "pyxel")
- ns, errors = parse_pyxel(source, tokens)
+ ns, errors = _analyze_pyxel(source)
notes = [e for e in ns.channels["Pulse1"].events if isinstance(e, NoteEvent)]
assert len(notes) == 6
def test_pyxel_nested_repeat():
source = "0: T120 L4 O4\n[ [ C D ]2 E ]2"
- tokens = tokenize(source, "pyxel")
- ns, errors = parse_pyxel(source, tokens)
+ ns, errors = _analyze_pyxel(source)
assert not any(e.severity == "error" for e in errors)
notes = [e for e in ns.channels["Pulse1"].events if isinstance(e, NoteEvent)]
# Inner: [C D]2 -> C D C D (4 notes)
@@ -156,8 +161,7 @@ def test_pyxel_nested_repeat():
def test_pyxel_nested_repeat_count3():
source = "0: T120 L4 O4\n[ [ C D ]2 E ]3"
- tokens = tokenize(source, "pyxel")
- ns, errors = parse_pyxel(source, tokens)
+ ns, errors = _analyze_pyxel(source)
assert not any(e.severity == "error" for e in errors)
notes = [e for e in ns.channels["Pulse1"].events if isinstance(e, NoteEvent)]
# Inner: [C D]2 -> C D C D (4 notes)
@@ -170,8 +174,7 @@ def test_pyxel_nested_repeat_count3():
def test_ppmck_tie_note():
source = "A t120 l4 o4\n c4 & c4"
- tokens = tokenize(source, "ppmck")
- ns, errors = parse_ppmck(source, tokens)
+ ns, errors = _analyze_ppmck(source)
# Tie should merge notes; check no error
assert not any(e.severity == "error" for e in errors)
notes = [e for e in ns.channels["Pulse1"].events if isinstance(e, NoteEvent)]
@@ -182,8 +185,7 @@ def test_ppmck_tie_note():
def test_pyxel_tie_note():
source = "0: T120 L4 O4\n C4 & C4"
- tokens = tokenize(source, "pyxel")
- ns, errors = parse_pyxel(source, tokens)
+ ns, errors = _analyze_pyxel(source)
assert not any(e.severity == "error" for e in errors)
notes = [e for e in ns.channels["Pulse1"].events if isinstance(e, NoteEvent)]
# C4 & C4 should produce a single note with doubled duration (192*2=384)
@@ -193,8 +195,7 @@ def test_pyxel_tie_note():
def test_pyxel_tie_length():
source = "0: T120 L4 O4\n C4 & 4"
- tokens = tokenize(source, "pyxel")
- ns, errors = parse_pyxel(source, tokens)
+ ns, errors = _analyze_pyxel(source)
notes = [e for e in ns.channels["Pulse1"].events if isinstance(e, NoteEvent)]
# & 4 extends last note by l4 ticks
assert notes and notes[0].duration > 192 # 192 + 48 = 240
@@ -205,8 +206,7 @@ def test_pyxel_tie_length():
def test_ppmck_octave_up():
source = "A t120 l4 o4\n c > d"
- tokens = tokenize(source, "ppmck")
- ns, errors = parse_ppmck(source, tokens)
+ ns, errors = _analyze_ppmck(source)
notes = [e for e in ns.channels["Pulse1"].events if isinstance(e, NoteEvent)]
assert notes[0].note_number == 60 # o4 c
assert notes[1].note_number == 74 # o5 d
@@ -214,8 +214,7 @@ def test_ppmck_octave_up():
def test_pyxel_octave_down():
source = "0: T120 L4 O4\n C < D"
- tokens = tokenize(source, "pyxel")
- ns, errors = parse_pyxel(source, tokens)
+ ns, errors = _analyze_pyxel(source)
notes = [e for e in ns.channels["Pulse1"].events if isinstance(e, NoteEvent)]
assert notes[0].note_number == 60 # O4 C
assert notes[1].note_number == 50 # O3 D
@@ -226,8 +225,7 @@ def test_pyxel_octave_down():
def test_ppmck_dotted_note():
source = "A t120 l4 o4\n c."
- tokens = tokenize(source, "ppmck")
- ns, errors = parse_ppmck(source, tokens)
+ ns, errors = _analyze_ppmck(source)
notes = [e for e in ns.channels["Pulse1"].events if isinstance(e, NoteEvent)]
# l4 = 192 ticks, dotted = 192 + 96 = 288
assert notes and notes[0].duration == 288
@@ -238,8 +236,7 @@ def test_ppmck_dotted_note():
def test_ppmck_triangle_volume_ignored():
source = "T t120 l4 o3 v7\n c"
- tokens = tokenize(source, "ppmck")
- ns, errors = parse_ppmck(source, tokens)
+ ns, errors = _analyze_ppmck(source)
notes = [e for e in ns.channels["Triangle"].events if isinstance(e, NoteEvent)]
assert notes and notes[0].velocity == 15 # default, v ignored
@@ -249,16 +246,14 @@ def test_ppmck_triangle_volume_ignored():
def test_pyxel_env_event():
source = "0: T120 L4 O4\n@ENV1 100 10\n C"
- tokens = tokenize(source, "pyxel")
- ns, errors = parse_pyxel(source, tokens)
+ ns, errors = _analyze_pyxel(source)
envs = [e for e in ns.channels["Pulse1"].events if isinstance(e, EnvelopeEvent)]
assert envs and envs[0].slot == 1
def test_pyxel_vib_event():
source = "0: T120 L4 O4\n@VIB1 10 20 5\n C"
- tokens = tokenize(source, "pyxel")
- ns, errors = parse_pyxel(source, tokens)
+ ns, errors = _analyze_pyxel(source)
from mml_composemusic_mcp.ir import VibratoEvent
vibs = [e for e in ns.channels["Pulse1"].events if isinstance(e, VibratoEvent)]
@@ -267,8 +262,7 @@ def test_pyxel_vib_event():
def test_pyxel_gli_event():
source = "0: T120 L4 O4\n@GLI1 100 50\n C"
- tokens = tokenize(source, "pyxel")
- ns, errors = parse_pyxel(source, tokens)
+ ns, errors = _analyze_pyxel(source)
from mml_composemusic_mcp.ir import GlideEvent
glis = [e for e in ns.channels["Pulse1"].events if isinstance(e, GlideEvent)]
@@ -280,8 +274,7 @@ def test_pyxel_gli_event():
def test_ppmck_noise_ignores_pitch():
source = "N t120 l4 v10\n c r"
- tokens = tokenize(source, "ppmck")
- ns, errors = parse_ppmck(source, tokens)
+ ns, errors = _analyze_ppmck(source)
notes = [e for e in ns.channels["Noise"].events if isinstance(e, NoteEvent)]
assert notes # NoteEvent is created but with midi=0
assert notes[0].note_number == 0 # pitch ignored
diff --git a/tests/test_synthesizer.py b/tests/test_synthesizer.py
index 722e64b..c3e110e 100644
--- a/tests/test_synthesizer.py
+++ b/tests/test_synthesizer.py
@@ -158,7 +158,7 @@ def test_write_wav_failure(tmp_path):
if errors:
from mml_composemusic_mcp.ir import ErrorCode
- assert errors[0].code == ErrorCode.SYSTEM_WAV_WRITE_FAILED
+ assert errors[0].code == ErrorCode.RUNTIME_WAV_WRITE_FAILED
# --- Channel summary ---