diff options
| -rw-r--r-- | doc/Design.md | 875 | ||||
| -rw-r--r-- | doc/MML_BNF.md | 232 | ||||
| -rw-r--r-- | src/mml_composemusic_mcp/ast_nodes.py | 230 | ||||
| -rw-r--r-- | src/mml_composemusic_mcp/ir.py | 49 | ||||
| -rw-r--r-- | src/mml_composemusic_mcp/lexer.py | 62 | ||||
| -rw-r--r-- | src/mml_composemusic_mcp/parser_base.py | 45 | ||||
| -rw-r--r-- | src/mml_composemusic_mcp/parser_ppmck.py | 618 | ||||
| -rw-r--r-- | src/mml_composemusic_mcp/parser_pyxel.py | 815 | ||||
| -rw-r--r-- | src/mml_composemusic_mcp/semantic_base.py | 225 | ||||
| -rw-r--r-- | src/mml_composemusic_mcp/semantic_ppmck.py | 283 | ||||
| -rw-r--r-- | src/mml_composemusic_mcp/semantic_pyxel.py | 423 | ||||
| -rw-r--r-- | src/mml_composemusic_mcp/server.py | 30 | ||||
| -rw-r--r-- | src/mml_composemusic_mcp/synthesizer.py | 4 | ||||
| -rw-r--r-- | tests/test_error_codes.py | 87 | ||||
| -rw-r--r-- | tests/test_lexer.py | 10 | ||||
| -rw-r--r-- | tests/test_parser.py | 39 | ||||
| -rw-r--r-- | tests/test_parser_detailed.py | 99 | ||||
| -rw-r--r-- | tests/test_synthesizer.py | 2 |
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 --- |
