summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--doc/Design.md96
-rw-r--r--doc/MML_BNF.md23
-rw-r--r--pyproject.toml1
-rw-r--r--src/mml_composemusic_mcp/lexer.py30
-rw-r--r--src/mml_composemusic_mcp/parser_base.py13
-rw-r--r--src/mml_composemusic_mcp/parser_ppmck.py91
-rw-r--r--src/mml_composemusic_mcp/parser_pyxel.py42
-rw-r--r--tests/test_mml_conformance.py210
8 files changed, 398 insertions, 108 deletions
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 @@
<ppmck_track> ::= <track_header> <ppmck_statement>*
<track_header> ::= "A" | "B" | "T" | "N" | "L"
- | /* 第2段階: 複合ヘッダー "ACE" 等 */
```
### 2.2 ステートメント
@@ -97,7 +96,9 @@
<comment> ::= ";" <string_to_eol>
```
-### 2.3 第2段階予定コマンド(未実装、BNF に含める)
+### 2.3 将来予定コマンド(現在の受理文法には含めない)
+
+以下は設計予約であり、現在は構文エラーになる。実装時に受理文法へ移す。
```bnf
<ppmck_stage2> ::= <count_length>
@@ -172,8 +173,9 @@
<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> /* セント単位 */
+<signed_number> ::= ("+" | "-")? <number>
+<transpose_cmd> ::= "K" <signed_number> /* 半音単位 */
+<detune_cmd> ::= "Y" <signed_number> /* セント単位 */
<tie_cmd> ::= <tie> (<note> | <rest> | <number>)
```
@@ -184,16 +186,13 @@
<ext_cmd> ::= <env_cmd> | <vib_cmd> | <gli_cmd>
<env_cmd> ::= "@ENV" <number> <env_def>?
-<env_def> ::= "{" <number> "," <number> ("," <number>)* "}"
- | <number>+
+<env_def> ::= <number>+
<vib_cmd> ::= "@VIB" <number> <vib_def>?
-<vib_def> ::= "{" <number> "," <number> "," <number> "}"
- | <number>+
+<vib_def> ::= <number>+
<gli_cmd> ::= "@GLI" <number> <gli_def>?
-<gli_def> ::= "{" <number> "," <number> "}"
- | <number>+
+<gli_def> ::= <number>+
```
---
@@ -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)