From 8a1fcf81dbab9e2eff3c4d558c6910bc8208e49e Mon Sep 17 00:00:00 2001 From: tanaka takashi Date: Sun, 12 Jul 2026 16:20:37 +0900 Subject: MML構文解析処理一部修正。ドキュメント最新化 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- doc/Design.md | 96 ++++++-------- doc/MML_BNF.md | 23 ++-- pyproject.toml | 1 + src/mml_composemusic_mcp/lexer.py | 30 ++++- src/mml_composemusic_mcp/parser_base.py | 13 ++ src/mml_composemusic_mcp/parser_ppmck.py | 91 ++++++++++---- src/mml_composemusic_mcp/parser_pyxel.py | 42 ++++--- tests/test_mml_conformance.py | 210 +++++++++++++++++++++++++++++++ 8 files changed, 398 insertions(+), 108 deletions(-) create mode 100644 tests/test_mml_conformance.py diff --git a/doc/Design.md b/doc/Design.md index 5dbe36b..1ce8aeb 100644 --- a/doc/Design.md +++ b/doc/Design.md @@ -1,6 +1,6 @@ # レトロチップ音源作曲MCPサーバ — 統合設計書 -> 作成日: 2026-07-11 | 更新日: 2026-07-12 | ステータス: **第2段階設計(パーサー再設計済み)** +> 作成日: 2026-07-11 | 更新日: 2026-07-12 | ステータス: **現行実装準拠** --- @@ -17,16 +17,16 @@ LLMがMML(Music Macro Language)を記述することで、レトロチップ | **LLMフレンドリ** | LLMが生成しやすいMML構文、明確なエラーメッセージ | | **2モード対応** | `ppmck`モード(クラシックAPU準拠)と`pyxel`モード(Pyxel準拠)を切り替え可能 | | **正確な音源エミュレーション** | レトロAPU 4チャンネルの特性を忠実に再現 | -| **拡張性** | DPCM、エンベロープ等の第2段階機能を前提とした設計 | +| **拡張性** | 共通AST・IRによりモード別の構文と音声合成を分離 | | **単一ツール** | `compose_mml` の1ツールで作曲・検証・テンプレート生成を完結 | ### 1.3 スコープ | 対象 | 内容 | |---|---| -| 第1段階(実装済み) | 4ch MML解析、WAV合成、バリデーション、テンプレート生成、基本エラーコード | -| 第2段階(将来) | DPCMチャンネル、NSFファイル出力、クラシックAPU実機風非線形ミキシング、エンベロープ合成、区間ループ(ppmck) | -| 対象外 | GUI / Webフロントエンド | +| 実装範囲 | Pulse1 / Pulse2 / Triangle / Noiseの4ch MML解析、WAV合成、バリデーション、テンプレート生成 | +| 構文拡張 | Pyxel `@ENV` / `@VIB` / `@GLI`をIRへ保持し、合成未対応のwarningを返す | +| 対象外 | GUI / Webフロントエンド、DPCM、NSF出力、未定義のppmck拡張構文 | ### 1.4 技術スタック @@ -36,7 +36,7 @@ LLMがMML(Music Macro Language)を記述することで、レトロチップ | MCP SDK | FastMCP >= 2.1.0 | 優良SDK | | 音声合成 | numpy >= 2.0.0 | 波形生成の数値計算 | | 音声出力 | 標準ライブラリ wave | WAV形式、外部依存なし | -| テスト | pytest, ruff | 単体テスト・リント・フォーマット | +| テスト | pytest, Hypothesis, ruff | 単体・生成テスト、リント、フォーマット | --- @@ -245,13 +245,13 @@ Event = NoteEvent | RestEvent | VolumeEvent | DutyEvent | EnvelopeEvent | VibratoEvent | GlideEvent ``` -(各イベントのフィールドは第1段階実装と同じ) +各イベントは位置・音長・音高・音量など、合成とMCPレスポンスに必要な値を保持する。 --- ## 5. MMLパーサ設計 -### 5.1 新アーキテクチャ: 4フェーズ分離 +### 5.1 4フェーズ分離 ``` MML文字列 @@ -270,13 +270,13 @@ MML文字列 ### 5.2 Lexer(共通) -両モードの字句解析を統一。モードごとに大文字/小文字を正規化した後にトークン化する。 +両モードでトークン型を共有し、モード別の字句規則でトークン化する。ppmckは小文字、Pyxelは大文字のコマンドを受理し、音名だけAST生成時に小文字へ正規化する。 #### 主なトークン種別 | カテゴリ | トークン | 備考 | |---|---|---| -| 音符 | `c`-`b` (ppmck) / `C`-`B` (pyxel) | 大文字小文字問わず判定 | +| 音符 | `c`-`b` (ppmck) / `C`-`B` (pyxel) | `+` / `#` / `-` は音名直後、音長より前 | | 休符 | `r` / `R` | | | オクターブ | `o`/`O` + 数値, `>`, `<` | | | 音長 | `l`/`L` + 数値, 音符直後の数値, `.` | | @@ -284,16 +284,16 @@ MML文字列 | デューティ/音色 | `q`+数値(ppmck), `@`+数値(pyxel) | | | テンポ | `t`/`T` + 数値 | | | ゲートタイム | `Q` + 数値 (pyxel) | | -| トランスポーズ | `K` + 数値 (pyxel) | | -| ディチューン | `Y` + 数値 (pyxel) | | +| トランスポーズ | `K` + 符号付き整数 (pyxel) | 例: `K-12`, `K+7` | +| ディチューン | `Y` + 符号付き整数 (pyxel) | 例: `Y-25`, `Y+10` | | タイ/スラー | `&` | | -| リピート | `[`, `]` + 数値 (pyxel) | | +| リピート | `[`, `]` + 数値 | ppmckはASTへ保持してwarning、Pyxelは展開 | | 小節線 | `\|` | 視覚用、再生に影響しない | | トラック識別 | ppmck: `A`/`B`/`T`/`N`/`L`, pyxel: `数字:` | | | ヘッダー | `#` で始まる行 (ppmck) | | | コメント | `;` 以降行末 (ppmck) | | -| 拡張コマンド | `@ENV`/`@VIB`/`@GLI` (pyxel) | 第1段階は保持のみ | -| 不正文字 | `INVALID` | 新規: 構文エラーとして報告 | +| 拡張コマンド | `@ENV`/`@VIB`/`@GLI` (pyxel) | 空白区切りの数値引数をIRへ保持、合成未対応warning | +| 不正文字 | `INVALID` | Parserが構文エラーとして報告 | ### 5.3 Parser(モード別) @@ -304,14 +304,14 @@ Parserの責務は**トークン列からASTを生成するのみ**。値範囲 - トラックヘッダー `A`/`B`/`T`/`N`/`L` を認識 - `#` ヘッダーを認識 - 各コマンドをASTノードに変換 -- リピート `[...]` のネスト構造をASTで保持 +- リピートの開始・終了をAST文として保持し、未対応warningを返す - タイ `&` の結合対象をASTで保持 #### pyxelパーサ - トラックヘッダー `0:`〜`3:` を認識 - `@ENV`/`@VIB`/`@GLI` のパラメータをASTノードに保持 -- リピート `[...]N` のネスト構造をASTで保持 +- リピート `[...]N` の開始・終了をAST文として保持し、SemanticAnalyzerでネストを展開 ### 5.4 SemanticAnalyzer(モード別) @@ -332,14 +332,14 @@ SemanticAnalyzerの責務は**ASTを検証・解釈してNoteSequence IRを生 - Triangle音量無視 - Noise音高無視 - 全体ループ `L` の処理 -- 第2段階コマンドのwarning +- リピートなど合成未対応のASTに対するwarning #### pyxel固有 - 音量 `V0-V127` → `0-15` 正規化 - ゲートタイム `Q0-Q100` → `0.0-1.0` - `@` コマンドのチャンネル適合性チェック -- `@ENV`/`@VIB`/`@GLI` のIR保持 +- `@ENV`/`@VIB`/`@GLI` のIR保持と合成未対応warning - 無限リピートの2回打ち切りwarning --- @@ -350,7 +350,7 @@ SemanticAnalyzerの責務は**ASTを検証・解釈してNoteSequence IRを生 - **モード共通**: ppmck/pyxel両方を1つのASTノード型セットで表現 - **位置情報**: 全ノードに `line`, `column` を保持 -- **構造保持**: リピートのネスト、タイの結合関係等を保持 +- **構造保持**: リピート開始・終了とタイの結合対象を、意味解析可能な文列として保持 ### 6.2 主要ASTノード @@ -439,7 +439,7 @@ class ExtCmdStmt(ASTNode): ## 7. エラーハンドリング -### 7.1 3層エラー分類 +### 7.1 エラー分類 ```python class ErrorPhase(Enum): @@ -472,33 +472,20 @@ class ErrorPhase(Enum): } ``` -### 7.3 新旧エラーコード対応 - -| 旧コード | 新コード | 理由 | -|---|---|---| -| `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 回復戦略 +### 7.3 回復戦略 | 状況 | 動作 | |---|---| -| ERROR時 | そのフェーズで解析を停止し、エラーを返す | -| WARNING時 | 警告を記録しつつ、妥当な値にクランプして処理を継続 | +| Parser ERROR時 | 同一フェーズ内で回復可能な位置まで読み進めて複数の診断を収集し、意味解析へは進まない | +| Semantic ERROR時 | 診断を収集し、不正な状態更新を行わず後続ASTの検証を継続 | +| WARNING時 | 警告を記録し、仕様で定めた無視・クランプ・有限回展開などの代替動作で継続 | | compose action | ERRORがある場合WAVを生成せず検証結果のみ返す | --- ## 8. BNF文法(概要) -詳細は [plans/mml-parser-refactor.md](plans/mml-parser-refactor.md) 第3章を参照。 +受理言語の正本は [MML_BNF.md](MML_BNF.md) とする。値を要求するコマンドで数値を省略した場合は既定値へ置換せず、`SYNTAX_INVALID_NUMBER`を返す。全トラックIDはモード内で一度だけ定義でき、ppmckヘッダーは既知のキーを用いてトラックより前に置き、値を指定する場合は二重引用符で囲む。 ### 8.1 共通要素 @@ -539,15 +526,21 @@ class ErrorPhase(Enum): --- +### 8.4 文法適合性の保証 + +BNFの各規則は Lexer → AST → IR/診断の一貫経路で検証する。`tests/test_mml_conformance.py`は、音符・休符・コマンド・トラック・ヘッダー・拡張コマンドについて正例、負例、境界値、位置情報を検査する。Hypothesisによる固定seedの生成テストで、有効音列のAST/IR保存と単一トークン破壊の非黙殺も確認する。 + +ppmckの将来予約構文は現在の受理言語に含めない。Pyxel拡張は空白区切り引数だけを受理し、AST/IRへ保持したうえで`SEMANTIC_UNSUPPORTED_FEATURE`をwarningとして返す。 + ## 9. APU音声合成エンジン -(第1段階実装と同じ。詳細は省略) +NoteSequence IRを入力としてPulse1、Pulse2、Triangle、Noiseを合成し、線形ミキシングと任意の正規化を経てWAVへ出力する。 --- ## 10. MMLコマンド仕様 -(第1段階実装と同じ。詳細は省略) +コマンド、値範囲、デフォルト値の完全な定義は [MML_BNF.md](MML_BNF.md) を参照する。設計書と実装で差異が生じた場合はBNFを受理言語の基準として監査し、実装変更と同時に適合テストを更新する。 --- @@ -562,7 +555,7 @@ class ErrorPhase(Enum): | **SemanticAnalyzer** | `semantic_ppmck.py` | `semantic_pyxel.py` | | **音量範囲** | 0-15(クラシックAPU準拠) | 0-127 | | **デューティ比** | `q0`〜`q3` で4種 | `@1`(50%), `@2`(25%) の2種のみ | -| **リピート** | `L`(全体), `[...]`(第2段階) | `[...]`(回数指定、省略時無限、ネスト対応) | +| **リピート** | `L`(構文上のループトラック)、`[...]`(AST保持・warning、合成未反映) | `[...]`(回数指定、省略時は2回で打ち切り、ネスト対応) | --- @@ -573,16 +566,14 @@ class ErrorPhase(Enum): | 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を定義 | +| 4 | BNF | [MML_BNF.md](MML_BNF.md) を受理言語の正本とし、適合テストで実装との一致を保証 | | 5 | pyxel `@` とトラック番号の矛盾 | トラック番号優先。`@` はPulse系のみデューティ比として有効 | | 6 | Noise note_number → period マッピング | ppmck: `period=8, mode=0` 固定。pyxel: マッピング式適用 | | 7 | WAVファイル名 | `output_YYYYMMDD_HHMMSS_mmm.wav` | --- -## 13. 実装状態と今後の予定 - -### 13.1 第1段階実装済み機能 +## 13. 実装状態 | カテゴリ | 実装内容 | |---|---| @@ -593,18 +584,7 @@ class ErrorPhase(Enum): | IR | NoteSequence、各種Event、ErrorDetail(phase付き) | | Synthesizer | Pulse/Triangle/Noise 4ch合成、線形ミキシング、WAV出力 | | Templates | basic/melody/chord/drum/empty の5種 × 2モード | -| テスト | pytestによる各種テスト、ruffによるリント/フォーマット | - -### 13.2 第2段階予定機能 - -| カテゴリ | 予定内容 | -|---|---| -| DPCMチャンネル | 5ch目のDPCM対応 | -| NSF出力 | NSFファイル出力の検討 | -| 非線形ミキシング | クラシックAPU実機風ミキシングテーブル | -| エンベロープ合成 | `@v`(ppmck), `@ENV`(pyxel) の合成反映 | -| ppmck 区間リピート | `[...]` の展開 | -| ppmck スイープ | `s` コマンド | +| テスト | pytestの固定・統合テスト、Hypothesisの生成テスト、ruffによるリント/フォーマット | --- diff --git a/doc/MML_BNF.md b/doc/MML_BNF.md index 3657ecd..599fd24 100644 --- a/doc/MML_BNF.md +++ b/doc/MML_BNF.md @@ -60,7 +60,6 @@ ::= * ::= "A" | "B" | "T" | "N" | "L" - | /* 第2段階: 複合ヘッダー "ACE" 等 */ ``` ### 2.2 ステートメント @@ -97,7 +96,9 @@ ::= ";" ``` -### 2.3 第2段階予定コマンド(未実装、BNF に含める) +### 2.3 将来予定コマンド(現在の受理文法には含めない) + +以下は設計予約であり、現在は構文エラーになる。実装時に受理文法へ移す。 ```bnf ::= @@ -172,8 +173,9 @@ ::= "Q" /* Q0 〜 Q100 */ ::= "@" /* @0 〜 @3, Pulse系のみ有効 */ ::= "T" /* T1 〜 */ - ::= "K" /* 半音単位 */ - ::= "Y" /* セント単位 */ + ::= ("+" | "-")? + ::= "K" /* 半音単位 */ + ::= "Y" /* セント単位 */ ::= ( | | ) ``` @@ -184,16 +186,13 @@ ::= | | ::= "@ENV" ? - ::= "{" "," ("," )* "}" - | + + ::= + ::= "@VIB" ? - ::= "{" "," "," "}" - | + + ::= + ::= "@GLI" ? - ::= "{" "," "}" - | + + ::= + ``` --- @@ -228,5 +227,7 @@ ## 6. 備考 - 本 BNF は `mml-composemusic-mcp` の**サポート範囲**を定義するものであり、オリジナルの PPMCK や Pyxel の全機能を網羅するものではない。 -- 第2段階予定コマンドは BNF に含めるが、第1段階実装では `SEMANTIC_UNSUPPORTED_FEATURE` として警告を返す。 +- 第2段階予定コマンドは設計予約として記載するが、現在の受理文法には含めない。 +- `@ENV` / `@VIB` / `@GLI` は構文解析してIRへ保持し、合成未対応を `SEMANTIC_UNSUPPORTED_FEATURE` で警告する。 +- コマンド文字は ppmck では小文字、pyxel では大文字を使用する。音符・休符も同じ規則に従う。 - 詳細な実装アーキテクチャは [Design.md](Design.md) を参照。 diff --git a/pyproject.toml b/pyproject.toml index 9070507..2bf29ae 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,6 +11,7 @@ dependencies = [ [dependency-groups] dev = [ + "hypothesis>=6.0", "pytest>=8.0", "ruff>=0.7.0", ] diff --git a/src/mml_composemusic_mcp/lexer.py b/src/mml_composemusic_mcp/lexer.py index 44e577a..94876bf 100644 --- a/src/mml_composemusic_mcp/lexer.py +++ b/src/mml_composemusic_mcp/lexer.py @@ -83,12 +83,23 @@ class Lexer: start += self._advance() return start + def _read_signed_number(self) -> str: + sign = "" + if self._peek() in "+-": + sign = self._advance() + number = self._read_number() + return sign + number if number else sign + def _read_until_newline(self) -> str: start = "" while self.pos < len(self.source) and self.source[self.pos] not in "\r\n": start += self._advance() return start + def _at_line_start(self) -> bool: + line_start = self.source.rfind("\n", 0, self.pos) + 1 + return not self.source[line_start : self.pos].strip() + 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) @@ -116,8 +127,13 @@ class Lexer: self._emit(TokenType.COMMENT, raw, raw) return if ch == "#": - raw = "#" + self._read_until_newline() - self._emit(TokenType.HEADER, raw, raw) + if self._at_line_start(): + self._advance() + raw = "#" + self._read_until_newline() + self._emit(TokenType.HEADER, raw, raw) + else: + self._advance() + self._emit(TokenType.SHARP, "#", "#") return if ch == "|": self._advance() @@ -152,6 +168,10 @@ class Lexer: self._advance() self._emit(TokenType.SHARP, "+", "+") return + if ch == "#": + self._advance() + self._emit(TokenType.SHARP, "#", "#") + return if ch == "-": self._advance() self._emit(TokenType.FLAT, "-", "-") @@ -232,6 +252,10 @@ class Lexer: self._advance() self._emit(TokenType.SHARP, "+", "+") return + if ch == "#": + self._advance() + self._emit(TokenType.SHARP, "#", "#") + return if ch == "-": self._advance() self._emit(TokenType.FLAT, "-", "-") @@ -259,7 +283,7 @@ class Lexer: if ch.isalpha(): cmd = self._advance() if cmd in "OLVTQKY": - num = self._read_number() + num = self._read_signed_number() if cmd in "KY" else self._read_number() raw = cmd + num if cmd == "O": self._emit(TokenType.OCTAVE, num, raw) diff --git a/src/mml_composemusic_mcp/parser_base.py b/src/mml_composemusic_mcp/parser_base.py index 1cdb40f..506a33e 100644 --- a/src/mml_composemusic_mcp/parser_base.py +++ b/src/mml_composemusic_mcp/parser_base.py @@ -68,6 +68,19 @@ class ParserContext: context=context, ) + def add_missing_number_error( + self, token: Token, command: str, context: str = "" + ) -> None: + self.add_error( + code=ErrorCode.SYNTAX_INVALID_NUMBER, + line=token.line, + column=token.column, + message=f"'{command}' の後に数値が必要です。", + severity="error", + hint=f"例: {command}4 のように数値を続けてください。", + context=context, + ) + class ParserError(Exception): """Raised by parsers to abort a single statement and recover.""" diff --git a/src/mml_composemusic_mcp/parser_ppmck.py b/src/mml_composemusic_mcp/parser_ppmck.py index 9540844..892c167 100644 --- a/src/mml_composemusic_mcp/parser_ppmck.py +++ b/src/mml_composemusic_mcp/parser_ppmck.py @@ -28,12 +28,25 @@ CHANNEL_MAP = { "N": "Noise", } +HEADER_KEYS = { + "#TITLE", + "#COMPOSER", + "#PROGRAMER", + "#OCTAVE-REV", + "#INCLUDE", + "#EX-DISKFM", + "#EX-NAMCO106", + "#BANK-CHANGE", + "#EFFECT-INCLUDE", +} + class PpmckParser: def __init__(self, source: str, tokens: list[Token]) -> None: self.source = source self.ctx = ParserContext(tokens) self.tracks_seen: set[str] = set() + self.track_ids_seen: set[str] = set() def parse(self) -> tuple[Program, list[ErrorDetail]]: program = Program(line=1, column=1, tracks=[]) @@ -42,6 +55,16 @@ class PpmckParser: while self.ctx.peek().type != TokenType.EOF: token = self.ctx.peek() if token.type == TokenType.HEADER: + if program.tracks: + self.ctx.add_error( + code=ErrorCode.SYNTAX_UNEXPECTED_TOKEN, + line=token.line, + column=token.column, + message="ヘッダーはトラック定義より前に記述してください。", + severity="error", + hint="すべての # ヘッダーを最初のトラックより前へ移動してください。", + context=context_line(self.source, token), + ) headers.append(self._parse_header()) continue if token.type == TokenType.COMMENT: @@ -76,6 +99,16 @@ class PpmckParser: key = parts[0] if parts else raw value = "" unterminated = False + if key.upper() not in HEADER_KEYS: + self.ctx.add_error( + code=ErrorCode.SYNTAX_UNEXPECTED_TOKEN, + line=token.line, + column=token.column, + message=f"未知のヘッダー '{key}' です。", + severity="error", + hint="MML_BNF.md に定義されたヘッダーを使用してください。", + context=context_line(self.source, token), + ) if len(parts) > 1: rest = parts[1] if rest.startswith('"'): @@ -95,6 +128,15 @@ class PpmckParser: value = rest[1:-1] else: value = rest + self.ctx.add_error( + code=ErrorCode.SYNTAX_UNEXPECTED_TOKEN, + line=token.line, + column=token.column, + message=f"ヘッダー '{key}' の値は二重引用符で囲んでください。", + severity="error", + hint=f'#TITLE "{value}" のように記述してください。', + context=context_line(self.source, token), + ) return Header( line=token.line, column=token.column, @@ -106,6 +148,17 @@ class PpmckParser: def _parse_track(self) -> Track | None: token = self.ctx.advance() ch = token.value.upper() + if ch in self.track_ids_seen: + self.ctx.add_error( + code=ErrorCode.SYNTAX_DUPLICATE_TRACK, + line=token.line, + column=token.column, + message=f"トラック '{ch}' が複数回定義されています。", + severity="error", + hint="各トラックは1回のみ定義できます。重複定義を削除してください。", + context=context_line(self.source, token), + ) + self.track_ids_seen.add(ch) if ch == "L": return Track( line=token.line, @@ -127,16 +180,6 @@ class PpmckParser: ) return None channel = CHANNEL_MAP[ch] - if channel in self.tracks_seen: - self.ctx.add_error( - code=ErrorCode.SYNTAX_DUPLICATE_TRACK, - line=token.line, - column=token.column, - message=f"トラック '{ch}' が複数回定義されています。", - severity="error", - hint="各トラックは1回のみ定義できます。重複定義を削除してください。", - context=context_line(self.source, token), - ) self.tracks_seen.add(channel) track = Track( line=token.line, @@ -215,15 +258,12 @@ class PpmckParser: def _parse_note(self) -> NoteStmt: token = self.ctx.advance() - length, dots = self._read_length(token) accidental = 0 - while True: - if self.ctx.match(TokenType.SHARP): - accidental += 1 - elif self.ctx.match(TokenType.FLAT): - accidental -= 1 - else: - break + if self.ctx.match(TokenType.SHARP): + accidental = 1 + elif self.ctx.match(TokenType.FLAT): + accidental = -1 + length, dots = self._read_length(token) return NoteStmt( line=token.line, column=token.column, @@ -245,6 +285,7 @@ class PpmckParser: def _parse_octave(self) -> OctaveStmt: token = self.ctx.advance() + self._require_value(token, "o") value = int(token.value) if token.value else 4 return OctaveStmt( line=token.line, @@ -273,6 +314,7 @@ class PpmckParser: def _parse_length(self) -> LengthStmt: token = self.ctx.advance() + self._require_value(token, "l") value = int(token.value) if token.value else 4 return LengthStmt( line=token.line, @@ -282,6 +324,7 @@ class PpmckParser: def _parse_volume(self) -> VolumeStmt: token = self.ctx.advance() + self._require_value(token, "v") value = int(token.value) if token.value else 0 return VolumeStmt( line=token.line, @@ -291,6 +334,7 @@ class PpmckParser: def _parse_duty(self) -> DutyStmt: token = self.ctx.advance() + self._require_value(token, "q") value = int(token.value) if token.value else 0 return DutyStmt( line=token.line, @@ -300,6 +344,7 @@ class PpmckParser: def _parse_tempo(self) -> TempoStmt: token = self.ctx.advance() + self._require_value(token, "t") value = int(token.value) if token.value else 120 return TempoStmt( line=token.line, @@ -307,6 +352,12 @@ class PpmckParser: value=value, ) + def _require_value(self, token: Token, command: str) -> None: + if not token.value: + self.ctx.add_missing_number_error( + token, command, context_line(self.source, token) + ) + def _parse_tie(self) -> TieStmt: token = self.ctx.advance() next_token = self.ctx.peek() @@ -373,8 +424,6 @@ class PpmckParser: ) -def parse_ppmck( - source: str, tokens: list[Token] -) -> tuple[Program, list[ErrorDetail]]: +def parse_ppmck(source: str, tokens: list[Token]) -> 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 db9787d..06cb613 100644 --- a/src/mml_composemusic_mcp/parser_pyxel.py +++ b/src/mml_composemusic_mcp/parser_pyxel.py @@ -163,15 +163,12 @@ class PyxelParser: def _parse_note(self) -> NoteStmt: token = self.ctx.advance() - length, dots = self._read_length(token) accidental = 0 - while True: - if self.ctx.match(TokenType.SHARP): - accidental += 1 - elif self.ctx.match(TokenType.FLAT): - accidental -= 1 - else: - break + if self.ctx.match(TokenType.SHARP): + accidental = 1 + elif self.ctx.match(TokenType.FLAT): + accidental = -1 + length, dots = self._read_length(token) return NoteStmt( line=token.line, column=token.column, @@ -193,6 +190,7 @@ class PyxelParser: def _parse_octave(self) -> OctaveStmt: token = self.ctx.advance() + self._require_value(token, "O") value = int(token.value) if token.value else 4 return OctaveStmt( line=token.line, @@ -221,6 +219,7 @@ class PyxelParser: def _parse_length(self) -> LengthStmt: token = self.ctx.advance() + self._require_value(token, "L") value = int(token.value) if token.value else 4 return LengthStmt( line=token.line, @@ -230,7 +229,8 @@ class PyxelParser: def _parse_volume(self) -> VolumeStmt: token = self.ctx.advance() - value = int(token.value) if token.value else 0 + self._require_value(token, "V") + value = int(token.value) if token.value not in ("", "+", "-") else 0 return VolumeStmt( line=token.line, column=token.column, @@ -239,6 +239,7 @@ class PyxelParser: def _parse_gate_time(self) -> GateTimeStmt: token = self.ctx.advance() + self._require_value(token, "Q") value = int(token.value) if token.value else 80 return GateTimeStmt( line=token.line, @@ -248,7 +249,8 @@ class PyxelParser: def _parse_at(self) -> DutyStmt: token = self.ctx.advance() - value = int(token.value) if token.value else 0 + self._require_value(token, "@") + value = int(token.value) if token.value not in ("", "+", "-") else 0 return DutyStmt( line=token.line, column=token.column, @@ -257,6 +259,7 @@ class PyxelParser: def _parse_tempo(self) -> TempoStmt: token = self.ctx.advance() + self._require_value(token, "T") value = int(token.value) if token.value else 120 return TempoStmt( line=token.line, @@ -266,7 +269,8 @@ class PyxelParser: def _parse_transpose(self) -> TransposeStmt: token = self.ctx.advance() - value = int(token.value) if token.value else 0 + self._require_value(token, "K", signed=True) + value = int(token.value) if token.value not in ("", "+", "-") else 0 return TransposeStmt( line=token.line, column=token.column, @@ -275,13 +279,23 @@ class PyxelParser: def _parse_detune(self) -> DetuneStmt: token = self.ctx.advance() - value = int(token.value) if token.value else 0 + self._require_value(token, "Y", signed=True) + value = int(token.value) if token.value not in ("", "+", "-") else 0 return DetuneStmt( line=token.line, column=token.column, value=value, ) + def _require_value( + self, token: Token, command: str, *, signed: bool = False + ) -> None: + invalid = not token.value or (signed and token.value in ("+", "-")) + if invalid: + self.ctx.add_missing_number_error( + token, command, context_line(self.source, token) + ) + def _parse_tie(self) -> TieStmt: token = self.ctx.advance() next_token = self.ctx.peek() @@ -369,8 +383,6 @@ class PyxelParser: ) -def parse_pyxel( - source: str, tokens: list[Token] -) -> tuple[Program, list[ErrorDetail]]: +def parse_pyxel(source: str, tokens: list[Token]) -> tuple[Program, list[ErrorDetail]]: parser = PyxelParser(source, tokens) return parser.parse() diff --git a/tests/test_mml_conformance.py b/tests/test_mml_conformance.py new file mode 100644 index 0000000..0e38945 --- /dev/null +++ b/tests/test_mml_conformance.py @@ -0,0 +1,210 @@ +"""BNF conformance tests spanning lexer, parser, semantics, and MCP validate.""" + +import pytest +from hypothesis import given, seed, settings +from hypothesis import strategies as st + +from mml_composemusic_mcp.ast_nodes import NoteStmt, TransposeStmt +from mml_composemusic_mcp.ir import ErrorCode, NoteEvent +from mml_composemusic_mcp.lexer import TokenType, 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 +from mml_composemusic_mcp.server import _parse_mml, compose_mml + + +def parse_pipeline(source: str, mode: str): + tokens = tokenize(source, mode) + parser = parse_ppmck if mode == "ppmck" else parse_pyxel + analyzer = analyze_ppmck if mode == "ppmck" else analyze_pyxel + program, errors = parser(source, tokens) + if any(error.severity == "error" for error in errors): + return tokens, program, None, errors + sequence, semantic_errors = analyzer(source, program) + return tokens, program, sequence, errors + semantic_errors + + +def error_codes(errors): + return {error.code for error in errors} + + +@pytest.mark.parametrize( + ("mode", "source", "accidental", "length", "dots"), + [ + ("ppmck", "A c+4.", 1, 4, 1), + ("ppmck", "A d#8", 1, 8, 0), + ("ppmck", "A e-16..", -1, 16, 2), + ("pyxel", "0: C+4.", 1, 4, 1), + ("pyxel", "0: D#8", 1, 8, 0), + ("pyxel", "0: E-16..", -1, 16, 2), + ], +) +def test_note_follows_bnf_accidental_before_length( + mode, source, accidental, length, dots +): + _, program, _, errors = parse_pipeline(source, mode) + assert not any(error.severity == "error" for error in errors) + note = next( + stmt for stmt in program.tracks[0].statements if isinstance(stmt, NoteStmt) + ) + assert (note.accidental, note.length, note.dots) == ( + accidental, + length, + dots, + ) + + +@pytest.mark.parametrize( + ("mode", "source"), + [ + ("ppmck", "A o l v q t c"), + ("pyxel", "0: O L V Q @ T K Y C"), + ("pyxel", "0: K+ Y- C"), + ], +) +def test_commands_requiring_numbers_reject_omission(mode, source): + _, _, _, errors = parse_pipeline(source, mode) + assert ErrorCode.SYNTAX_INVALID_NUMBER in error_codes(errors) + + +@pytest.mark.parametrize( + ("mode", "source"), [("ppmck", "A c++4"), ("pyxel", "0: C##4")] +) +def test_multiple_accidentals_are_rejected(mode, source): + _, _, _, errors = parse_pipeline(source, mode) + assert ErrorCode.SYNTAX_UNEXPECTED_TOKEN in error_codes(errors) + + +@pytest.mark.parametrize(("command", "value"), [("K-12", -12), ("K+7", 7)]) +def test_pyxel_signed_transpose_is_preserved(command, value): + _, program, sequence, errors = parse_pipeline(f"0: O4 {command} C", "pyxel") + assert not any(error.severity == "error" for error in errors) + transpose = next( + stmt for stmt in program.tracks[0].statements if isinstance(stmt, TransposeStmt) + ) + note = next( + event + for event in sequence.channels["Pulse1"].events + if isinstance(event, NoteEvent) + ) + assert transpose.value == value + assert note.note_number == 60 + value + + +def test_pyxel_signed_detune_reaches_ir(): + _, _, sequence, errors = parse_pipeline("0: Y-25 C", "pyxel") + assert not any(error.severity == "error" for error in errors) + note = next( + event + for event in sequence.channels["Pulse1"].events + if isinstance(event, NoteEvent) + ) + assert note.detune_cents == -25.0 + + +@pytest.mark.parametrize("source", ["L c\nL d", "A c\nA d"]) +def test_ppmck_all_track_ids_reject_duplicates(source): + _, _, _, errors = parse_pipeline(source, "ppmck") + assert ErrorCode.SYNTAX_DUPLICATE_TRACK in error_codes(errors) + + +@pytest.mark.parametrize( + "source", + ['#UNKNOWN "x"\nA c', "#TITLE unquoted\nA c", 'A c\n#TITLE "late"'], +) +def test_ppmck_header_key_and_placement_are_enforced(source): + _, _, _, errors = parse_pipeline(source, "ppmck") + assert ErrorCode.SYNTAX_UNEXPECTED_TOKEN in error_codes(errors) + + +def test_lexer_reports_precise_token_position_and_eof(): + tokens = tokenize("0: C\n ?", "pyxel") + invalid = next(token for token in tokens if token.type == TokenType.INVALID) + assert (invalid.line, invalid.column, invalid.raw) == (2, 3, "?") + assert tokens[-1].type == TokenType.EOF + + +@pytest.mark.parametrize("mode", ["ppmck", "pyxel"]) +def test_empty_and_unicode_inputs_do_not_crash_pipeline(mode): + sequence, errors = _parse_mml("", mode) + assert sequence is not None + assert isinstance(errors, list) + sequence, errors = _parse_mml("♬", mode) + assert sequence is None + assert ErrorCode.SYNTAX_INVALID_TOKEN in error_codes(errors) + + +def test_validate_full_pyxel_path_preserves_warning_and_summary(): + result = compose_mml( + action="validate", + mode="pyxel", + mml="0: T120 L4 O4 V100 Q80 @1 K-1 Y+5 @ENV1 100 10 C+4.", + ) + assert result["valid"] is True + assert result["channel_summary"] + assert any( + warning["code"] == ErrorCode.SEMANTIC_UNSUPPORTED_FEATURE.value + for warning in result["warnings"] + ) + + +note_names = st.sampled_from(tuple("cdefgab")) +accidentals = st.sampled_from(("", "+", "#", "-")) +lengths = st.one_of(st.just(""), st.integers(1, 192).map(str)) +dots = st.integers(0, 3).map(lambda count: "." * count) +spaces = st.sampled_from((" ", " ", "\t", "\n")) + + +@seed(20260712) +@settings(max_examples=100, deadline=None) +@given( + mode=st.sampled_from(("ppmck", "pyxel")), + notes=st.lists( + st.tuples(note_names, accidentals, lengths, dots), min_size=1, max_size=20 + ), + separator=spaces, +) +def test_generated_valid_notes_round_trip_to_ast_and_ir(mode, notes, separator): + rendered = [ + name + accidental + length + dot for name, accidental, length, dot in notes + ] + if mode == "pyxel": + rendered = [note.upper() for note in rendered] + source = "0: " + separator.join(rendered) + else: + source = "A " + separator.join(rendered) + _, program, sequence, errors = parse_pipeline(source, mode) + assert not any(error.severity == "error" for error in errors) + ast_notes = [ + stmt for stmt in program.tracks[0].statements if isinstance(stmt, NoteStmt) + ] + ir_notes = [ + event + for event in sequence.channels["Pulse1"].events + if isinstance(event, NoteEvent) + ] + assert len(ast_notes) == len(notes) == len(ir_notes) + + +@seed(20260712) +@settings(max_examples=50, deadline=None) +@given( + mode=st.sampled_from(("ppmck", "pyxel")), + bad=st.sampled_from(("?", "{", "}", ",", "♬")), + index=st.integers(min_value=0, max_value=4), +) +def test_generated_single_token_corruption_is_not_silent(mode, bad, index): + note = "cdefg"[index] + source = f"A {note}{bad}" if mode == "ppmck" else f"0: {note.upper()}{bad}" + _, _, _, errors = parse_pipeline(source, mode) + assert any(error.severity == "error" for error in errors) + + +@pytest.mark.parametrize( + ("mode", "source"), + [("ppmck", "A l" + "9" * 1000 + " c"), ("pyxel", "0: L" + "9" * 1000 + " C")], +) +def test_very_large_numbers_are_reported_without_crashing(mode, source): + _, _, _, errors = parse_pipeline(source, mode) + assert ErrorCode.SEMANTIC_VALUE_OUT_OF_RANGE in error_codes(errors) -- cgit v1.2.3