From 7249ca3a6fdf51f6b7ea66ac6854edd5e02ae5ec Mon Sep 17 00:00:00 2001 From: tanaka takashi Date: Wed, 15 Jul 2026 20:00:24 +0900 Subject: feat: MMLパーサーと意味解析を厳格化 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ppmckの終端分岐付きリピートとL制御トラックを実装 - case、構文構造、音長、エフェクト値域の検証を強化 - Pyxel拡張コマンドのarityと参照検証を追加 - リピート展開上限と構文・意味エラー時の合成停止を追加 - BNF、設計書、適合監査、回帰テストを更新 --- doc/Design.md | 56 ++- doc/MML_BNF.md | 102 ++-- src/mml_composemusic_mcp/ast_nodes.py | 8 + src/mml_composemusic_mcp/lexer.py | 46 +- src/mml_composemusic_mcp/parser_base.py | 5 + src/mml_composemusic_mcp/parser_ppmck.py | 230 +++++++-- src/mml_composemusic_mcp/parser_pyxel.py | 38 +- src/mml_composemusic_mcp/semantic_base.py | 190 +++++++- src/mml_composemusic_mcp/semantic_ppmck.py | 214 +++++--- src/mml_composemusic_mcp/semantic_pyxel.py | 239 ++++----- tests/test_compose_mml_tool.py | 4 +- tests/test_error_codes.py | 18 +- tests/test_mml_conformance.py | 759 ++++++++++++++++++++++++++++- 13 files changed, 1542 insertions(+), 367 deletions(-) diff --git a/doc/Design.md b/doc/Design.md index f345d82..7c3d076 100644 --- a/doc/Design.md +++ b/doc/Design.md @@ -292,10 +292,10 @@ MML文字列 | 相対音量 | `v+` / `v-` (ppmck) | 0〜15へクランプして合成 | | スイープ | `s`+数値,符号付き数値 (ppmck) | 2A03 Pulse sweepへ反映 | | タイ | `^` (ppmck), `&` (両モード) | `^`はタイ、`&`はスラー | -| リピート | `[`, `]` + 数値 | ppmckはASTへ保持してwarning、Pyxelは展開 | +| リピート | `[`, `]` + 数値, `\|` | 両モードで構造検証。ppmckのリピート内`\|`は終端分岐 | | エンベロープ/LFO | `@v`/`@@`/`@MP`/`@EP`/`@EN` (ppmck), `@ENV`/`@VIB`/`@GLI` (pyxel) | IR v2定義表を参照して合成 | | 小節線 | `\|` | 視覚用、再生に影響しない | -| トラック識別 | ppmck: `A`/`B`/`T`/`N`/`L`, pyxel: `数字:` | | +| トラック識別 | ppmck: `A`/`B`/`T`/`N`/`L`, pyxel: `0:`〜`3:` | | | ヘッダー | `#` で始まる行 (ppmck) | | | コメント | `;` 以降行末 (ppmck) | | | 拡張コマンド | `@ENV`/`@VIB`/`@GLI` (pyxel) | 空白区切りの数値引数をIR v2へ保持して合成 | @@ -303,14 +303,16 @@ MML文字列 ### 5.3 Parser(モード別) -Parserの責務は**トークン列からASTを生成するのみ**。値範囲チェックやチャンネル適合性の判断は行わない。 +Parserの責務は**トークン列から構造的に妥当なASTを生成すること**である。必須token、トップレベル順序、リピート対応、空定義を検証する。値範囲チェックやチャンネル適合性の判断は行わない。構文errorが1件でもあればSemanticAnalyzerへ進まない。 #### ppmckパーサ - トラックヘッダー `A`/`B`/`T`/`N`/`L` を認識 - `#` ヘッダーを認識 - 各コマンドをASTノードに変換 -- リピートの開始・終了をAST文として保持し、未対応warningを返す +- トップレベル順序を`headers → global definitions → tracks`として検証 +- `L`を専用文法で解析し、`l`、休符、リピート、小節線以外を拒否 +- リピートの開始・終端分岐・終了を`RepeatStartStmt`/`RepeatBreakStmt`/`RepeatEndStmt`として保持し、対応、分岐重複、ネストを検証 - タイ `^` / スラー `&` の結合対象をASTで保持 - `@v`/`@@`/`@MP`/`@EP`/`@EN` エンベロープ定義 `{...|...}` と使用をASTで保持 @@ -318,7 +320,7 @@ Parserの責務は**トークン列からASTを生成するのみ**。値範囲 - トラックヘッダー `0:`〜`3:` を認識 - `@ENV`/`@VIB`/`@GLI` のパラメータをASTノードに保持 -- リピート `[...]N` の開始・終了をAST文として保持し、SemanticAnalyzerでネストを展開 +- リピート `[...]N` の対応とネストを検証し、開始・終了をAST文として保持 ### 5.4 SemanticAnalyzer(モード別) @@ -330,7 +332,7 @@ SemanticAnalyzerの責務は**ASTを検証・解釈してNoteSequence IRを生 - MIDI音番号変換 - tick計算 - タイ処理 -- リピート展開(pyxel) +- ppmck/pyxel共通のトランザクション型リピート展開(各トラック最大100,000イベント) - 値範囲チェック - 音域チェック @@ -338,11 +340,11 @@ SemanticAnalyzerの責務は**ASTを検証・解釈してNoteSequence IRを生 - Triangle音量無視 - Noise音高無視 -- 全体ループ `L` の処理 +- `L`の時間軸から全チャンネル共通`loop_start`を算出し、4音声チャンネルへ`RepeatEvent(repeat_count=0)`を追加 - `@` = デューティ比、`q` = クオンタイズ(gate_time = value / 8)として解釈 - `^` タイを前の音符の音長延長として解釈 -- `D`/`s`/`v+`/`v-`、各種エンベロープ/LFOをIRへ保持し `SEMANTIC_UNSUPPORTED_FEATURE` warningを返す -- リピートなど合成未対応のASTに対するwarning +- `D`/`s`/`v+`/`v-`、各種エンベロープ/LFOを値域・チャンネル検証後にIRへ保持 +- mckc型の`[前半|後半]N`を展開。回数省略時だけ2周期プレビューwarningを返す #### pyxel固有 @@ -350,7 +352,7 @@ SemanticAnalyzerの責務は**ASTを検証・解釈してNoteSequence IRを生 - ゲートタイム `Q0-Q100` → `0.0-1.0` - `@` コマンドのチャンネル適合性チェック - `@ENV`/`@VIB`/`@GLI` のIR v2定義、選択、解除、合成 -- 無限リピートの2回打ち切りwarning +- 無限リピートの2周期プレビューwarning - `&` スラーを前の音符の音長延長として解釈 --- @@ -361,7 +363,7 @@ SemanticAnalyzerの責務は**ASTを検証・解釈してNoteSequence IRを生 - **モード共通**: ppmck/pyxel両方を1つのASTノード型セットで表現 - **位置情報**: 全ノードに `line`, `column` を保持 -- **構造保持**: リピート開始・終了とタイの結合対象を、意味解析可能な文列として保持 +- **構造保持**: リピート開始・終端分岐・終了とタイの結合対象を、意味解析可能な文列として保持 ### 6.2 主要ASTノード @@ -514,6 +516,10 @@ class NoteEnvOffStmt(ASTNode): class RepeatStartStmt(ASTNode): pass +@dataclass +class RepeatBreakStmt(ASTNode): + pass # ppmckの現在のリピート階層に属する終端分岐 + @dataclass class RepeatEndStmt(ASTNode): count: int | None # None=無限 @@ -595,9 +601,10 @@ class ErrorPhase(Enum): ### 8.2 ppmckモード ```bnf - ::= * * - ::= * -::= "A" | "B" | "T" | "N" | "L" + ::= * * * + ::= | + ::= ("A" | "B" | "T" | "N") * + ::= "L" ( | | | )* ::= | | | | | | @@ -608,7 +615,10 @@ class ErrorPhase(Enum): | | | | | | | | | - | | | + | | +::= "[" * ("|" *)? "]" ? + ::= "[" ( | | )* + ("|" ( | | )*)? "]" ? ``` ### 8.3 pyxelモード @@ -617,12 +627,13 @@ class ErrorPhase(Enum): ::= * ::= * - ::= ":" + ::= "0:" | "1:" | "2:" | "3:" ::= | | | | | | | | | | - | | | | + | | | +::= "[" * "]" ? ``` --- @@ -651,7 +662,7 @@ NoteSequence IRを入力としてPulse1、Pulse2、Triangle、Noiseを合成し |---|---|---| | **大文字/小文字** | 小文字 `c d e`(`D` ディチューンは大文字のみ) | 大文字 `C D E` | | **コマンド** | `o` `l` `v` `v+`/`v-` `t` `@` `q` `^` `&` `D` `s` | `O` `L` `V` `T` `Q` `@` `K` `Y` `&` | -| **チャンネル指定** | `A` `B` `T` `N`(文字) | `0:`〜`3:`(番号) | +| **チャンネル指定** | `A` `B` `T` `N`、制御用`L` | `0:`〜`3:`(番号) | | **Parser** | `parser_ppmck.py` | `parser_pyxel.py` | | **SemanticAnalyzer** | `semantic_ppmck.py` | `semantic_pyxel.py` | | **音量範囲** | 0-15(クラシックAPU準拠) | 0-127 | @@ -660,7 +671,7 @@ NoteSequence IRを入力としてPulse1、Pulse2、Triangle、Noiseを合成し | **タイ/スラー** | `^` = タイ、`&` = スラー | `&` = スラー(タイなし) | | **ディチューン** | `D-127`〜`D126`(セント) | `Y-127`〜`Y127`(セント) | | **エンベロープ/LFO** | `@v`/`@@`/`@MP`/`@EP`/`@EN`(合成対応) | `@ENV`/`@VIB`/`@GLI`(合成対応) | -| **リピート** | `L`(構文上のループトラック)、`[...]`(AST保持・warning、合成未反映) | `[...]`(回数指定、省略時は2回で打ち切り、ネスト対応) | +| **リピート** | `L`からloop_startを算出。`[...]N`と`[前半\|後半]N`をネスト展開 | `[...]N`をネスト展開 | --- @@ -675,6 +686,7 @@ NoteSequence IRを入力としてPulse1、Pulse2、Triangle、Noiseを合成し | 5 | pyxel `@` とトラック番号の矛盾 | トラック番号優先。`@` はPulse系のみデューティ比として有効 | | 6 | Noise note_number → period マッピング | ppmck: `period=8, mode=0` 固定。pyxel: マッピング式適用 | | 7 | 出力構成 | `YYYYMMDD_HHMMSS_mmm/output.wav`, `YYYYMMDD_HHMMSS_mmm/output.mml` | +| 8 | リピート安全上限 | 各トラックの展開後イベント数は100,000件以下。超過時は展開前に意味error | --- @@ -684,12 +696,12 @@ NoteSequence IRを入力としてPulse1、Pulse2、Triangle、Noiseを合成し |---|---| | MCPツール | `compose_mml`(compose/validate/template) | | Lexer | ppmck/pyxel 両モード対応、共通トークン定義、INVALIDトークン。ppmck向けに `@v`/`@@`/`@MP`/`@EP`/`@EN`/`^`/`D`/`s`/`v+`/`v-` トークンを追加 | -| Parser | ppmck/pyxel 別Parser、AST生成。ppmckでエンベロープ定義 `{...\|...}` と各種IR保持コマンドをパース | -| SemanticAnalyzer | ppmck/pyxel 別Analyzer、AST→IR変換。ppmckで `@`=duty、`q`=quantize、`^`=tie を解釈し、未対応コマンドをIR保持+warning | +| Parser | ppmck/pyxel 別Parser。必須token、トップレベル順序、case、リピート対応を検証し、ppmckの`RepeatBreakStmt`と専用L文法を構築 | +| SemanticAnalyzer | ppmck/pyxel別Analyzer、AST→IR変換。共通反復展開、事前イベント上限、値域→チャンネル→状態→IR順の検証、Lのloop_start算出を実装 | | IR | NoteSequence、各種Event(Quantize/Detune/Sweep/RelativeVolume/VolumeEnvelope/DutyEnvelope/Lfo/PitchEnv/NoteEnv 等)、ErrorDetail(phase付き) | | Synthesizer | Pulse/Triangle/Noise 4ch合成、線形ミキシング、WAV出力 | | Templates | basic/melody/chord/drum/empty の5種 × 2モード。ppmckテンプレートは `@` でデューティ比を指定 | -| テスト | pytestの固定・統合テスト、Hypothesisの生成テスト、ruffによるリント/フォーマット。全200テスト通過 | +| テスト | pytestの固定・統合テスト、Hypothesisの生成テスト、ruffによるリント/フォーマット | --- diff --git a/doc/MML_BNF.md b/doc/MML_BNF.md index 0c3f3b0..6093911 100644 --- a/doc/MML_BNF.md +++ b/doc/MML_BNF.md @@ -12,8 +12,9 @@ ::= "0" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9" ::= + -/* 音符名(Lexer で正規化: ppmck は小文字、pyxel は大文字 → 内部は小文字) */ - ::= "c" | "d" | "e" | "f" | "g" | "a" | "b" +/* 音符名(case-sensitive。受理後のASTでは小文字へ正規化) */ + ::= "c" | "d" | "e" | "f" | "g" | "a" | "b" + ::= "C" | "D" | "E" | "F" | "G" | "A" | "B" /* 変化記号 */ ::= "+" | "#" @@ -43,6 +44,8 @@ ::= " " | "\t" | "\r" | "\n" ``` +``は字句間の区切りであり、statementやASTノードではない。`#`はppmckの行頭ヘッダー開始、または両モードで音符名の直後に置くsharpとしてのみ有効である。 + --- ## 2. ppmck モード @@ -50,7 +53,7 @@ ### 2.1 全体構造 ```bnf - ::= * * + ::= * * * ::= "#" ? ::= "TITLE" | "COMPOSER" | "PROGRAMER" | "OCTAVE-REV" @@ -58,15 +61,30 @@ | "EFFECT-INCLUDE" ::= "\"" "\"" - ::= * - ::= "A" | "B" | "T" | "N" | "L" + + ::= | | + | | + + ::= | + ::= * + + ::= "A" | "B" | "T" | "N" + ::= "L" * + ::= | | | +::= | | + ::= "[" * ("|" *)? "]" ? ``` +グローバル定義は全トラック共通である。既存入力との互換性のため、同じ定義を音声トラック内の``として置くこともできる。`L`は音声を生成しない制御トラックで、0回または1回だけ記述できる。 + ### 2.2 ステートメント ```bnf - ::= - | + ::= | | + + + ::= + | | | | @@ -76,14 +94,10 @@ | | | - | - | - | | - | - ::= ? - ::= "r" + ::= ? + ::= "r" ::= "o" /* o0 〜 o7 */ | @@ -96,12 +110,18 @@ ::= "q" /* q1 〜 q8 */ ::= "t" /* t1 〜 */ - ::= "^" ( | ) - ::= "&" ( | | ) + ::= "^" ( | ) + ::= "&" ( | | ) ::= ";" + + + ::= | + ::= "[" * ("|" *)? "]" ? ``` +`[本体]N`は本体をN回再生する。`[前半|後半]N`は`前半+後半`をN−1回、最後に前半だけを再生する。`N=1`では前半だけとなる。`|`は現在のリピート階層で高々1個で、ネストした`|`は内側に所属する。リピート外の`|`は小節線である。回数省略は無限を表し、IRプレビューでは`前半+後半`を2回展開して`repeat_count=0`を保持する。 + ### 2.3 合成拡張コマンド ```bnf @@ -123,27 +143,27 @@ | - ::= "v+" ? /* 1 〜 15 */ - | "v-" ? /* 1 〜 15 */ + ::= "v+" ? /* 省略時1、1 〜 15 */ + | "v-" ? /* 省略時1、1 〜 15 */ ::= "D" /* -127 〜 126 */ ::= "s" "," -::= "@v" "=" "{" "|" ? "}" +::= "@v" "=" "{" ("|" ?)? "}" ::= "@v" -::= "@" "=" "{" "|" ? "}" +::= "@" "=" "{" ("|" ?)? "}" ::= "@@" ::= "@MP" "=" "{" "," "," "," "}" ::= "MP" ::= "MPOF" - ::= "@EP" "=" "{" "|" ? "}" + ::= "@EP" "=" "{" ("|" ?)? "}" ::= "EP" ::= "EPOF" - ::= "@EN" "=" "{" "|" ? "}" + ::= "@EN" "=" "{" ("|" ?)? "}" ::= "EN" ::= "ENOF" @@ -158,13 +178,10 @@ ```bnf ::= - | | | - ::= ? "%" - - ::= "[" + "]" ? + ::= ? "%" ::= "K" ::= "m" @@ -182,14 +199,14 @@ ::= * - ::= ":" /* 0: 1: 2: 3: */ + ::= "0:" | "1:" | "2:" | "3:" ``` ### 3.2 ステートメント ```bnf - ::= - | + ::= + | | | | @@ -199,14 +216,12 @@ | | | - | - | + | | | - | - ::= ? - ::= "R" + ::= ? + ::= "R" ::= "O" /* O0 〜 O7 */ | @@ -221,24 +236,29 @@ ::= "K" /* 半音単位 */ ::= "Y" /* セント単位 */ - ::= ( | | ) + ::= ( | | ) + ::= "[" * "]" ? ``` +Pyxelリピートはネストできる。指定回数は1以上で、回数省略は無限を表す。IRプレビューでは本体を2回展開し、`repeat_count=0`を保持する。Pyxelの`|`は常に小節線であり、終端分岐ではない。 + ### 3.3 合成拡張コマンド ```bnf ::= | | ::= "@ENV" ? - ::= + + ::= ( )* ::= "@VIB" ? - ::= + + ::= ::= "@GLI" ? - ::= + + ::= ``` +slot 0は解除専用で、パラメータを指定できない。slot 1以上ではパラメータ付きの初出を定義、パラメータなしを既定義slotの選択として扱う。Pyxel公式の波括弧、カンマ、負数、`*`を使う拡張形式は本受理文法に含めない。 + --- ## 4. トラック・チャンネル対応 @@ -259,6 +279,8 @@ |---|---|---|---|---| | `o` / `O` | 両方 | 0 〜 7 | 4 | | | `l` / `L` | 両方 | 1 〜 192 | 4 | | +| 音符・休符の明示音長 | 両方 | 1 〜 192 | 現在の`l`/`L` | | +| タイ対象の音長 | 両方 | 1 〜 192 | — | | | `v` | ppmck | 0 〜 15 | 15 | | | `V` | pyxel | 0 〜 127 | 100 | | | `v+` / `v-` | ppmck | 1 〜 15 | 1 | 相対音量(0〜15へクランプ) | @@ -274,8 +296,14 @@ | `@MP` / `MP` / `MPOF` | ppmck | 0 〜 255 | — | 三角LFO | | `@EP` / `EP` / `EPOF` | ppmck | 0 〜 255 | — | cent単位ピッチエンベロープ | | `@EN` / `EN` / `ENOF` | ppmck | 0 〜 255 | — | 絶対半音オフセット列 | +| ppmck envelope slot | ppmck | 0 〜 255 | — | 全定義・参照共通 | +| volume envelope要素 | ppmck | 0 〜 15 | — | | +| duty envelope要素 | ppmck | 0 〜 3 | — | | +| pitch/note envelope要素 | ppmck | -127 〜 126 | — | | +| `@MP`要素 | ppmck | delay 0〜255, period 1〜255, depth 0〜255, 第4値0 | — | | | `K` | pyxel | -127 〜 127 | 0 | トランスポーズ(半音) | | `Y` | pyxel | -127 〜 127 | 0 | ディチューン(セント) | +| リピート回数 | 両方 | 1以上、または省略(無限) | 省略 | 展開後は各トラック100,000イベント以下 | --- diff --git a/src/mml_composemusic_mcp/ast_nodes.py b/src/mml_composemusic_mcp/ast_nodes.py index dc85e79..5eedbbc 100644 --- a/src/mml_composemusic_mcp/ast_nodes.py +++ b/src/mml_composemusic_mcp/ast_nodes.py @@ -160,6 +160,13 @@ class RepeatStartStmt(ASTNode): pass +@dataclass +class RepeatBreakStmt(ASTNode): + """Last-pass break marker (|) inside a ppmck repeat.""" + + pass + + @dataclass class RepeatEndStmt(ASTNode): """End of repeat section (] with optional count).""" @@ -333,6 +340,7 @@ Statement = ( | DetuneCmdStmt | RelativeVolumeStmt | RepeatStartStmt + | RepeatBreakStmt | RepeatEndStmt | BarStmt | ExtCmdStmt diff --git a/src/mml_composemusic_mcp/lexer.py b/src/mml_composemusic_mcp/lexer.py index ed0a26e..dbc55ce 100644 --- a/src/mml_composemusic_mcp/lexer.py +++ b/src/mml_composemusic_mcp/lexer.py @@ -208,10 +208,6 @@ 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, "-", "-") @@ -295,9 +291,8 @@ class Lexer: return cmd = self._advance() - lower = cmd.lower() - if lower in "olvt": - if lower == "v" and self._peek() in "+-": + if cmd in "olvt": + if cmd == "v" and self._peek() in "+-": sign = self._advance() num = self._read_number() raw = cmd + sign + num @@ -308,16 +303,16 @@ class Lexer: return num = self._read_number() raw = cmd + num - if lower == "o": + if cmd == "o": self._emit(TokenType.OCTAVE, num, raw) - elif lower == "l": + elif cmd == "l": self._emit(TokenType.LENGTH, num, raw) - elif lower == "v": + elif cmd == "v": self._emit(TokenType.VOLUME, num, raw) - elif lower == "t": + elif cmd == "t": self._emit(TokenType.TEMPO, num, raw) return - if lower == "q": + if cmd == "q": num = self._read_number() self._emit(TokenType.QUANTIZE, num, "q" + num) return @@ -325,20 +320,20 @@ class Lexer: num = self._read_signed_number() self._emit(TokenType.DETUNE, num, "D" + num) return - if lower == "s": + if cmd == "s": + raw_start = self.pos - 1 num0 = self._read_number() if self._peek() == ",": self._advance() num1 = self._read_signed_number() - raw = f"s{num0},{num1}" + raw = self.source[raw_start : self.pos] self._emit(TokenType.SWEEP, f"{num0},{num1}", raw) return - note = lower - if note in "cdefgabr": - if note == "r": - self._emit(TokenType.REST, note, note) + if cmd in "cdefgabr": + if cmd == "r": + self._emit(TokenType.REST, cmd, cmd) else: - self._emit(TokenType.NOTE, note, note) + self._emit(TokenType.NOTE, cmd, cmd) return # Unknown command: emit as invalid token for parser to report self._emit(TokenType.INVALID, cmd, cmd) @@ -402,8 +397,8 @@ class Lexer: return if ch == "@": self._advance() - if self.source[self.pos : self.pos + 3].upper() in ("ENV", "VIB", "GLI"): - ext = self.source[self.pos : self.pos + 3].upper() + if self.source[self.pos : self.pos + 3] in ("ENV", "VIB", "GLI"): + ext = self.source[self.pos : self.pos + 3] self.pos += 3 self.column += 3 raw = "@" + ext @@ -432,12 +427,11 @@ class Lexer: elif cmd == "Y": self._emit(TokenType.DETUNE, num, raw) return - note = cmd.upper() - if note in "CDEFGABR": - if note == "R": - self._emit(TokenType.REST, note, note) + if cmd in "CDEFGABR": + if cmd == "R": + self._emit(TokenType.REST, cmd, cmd) else: - self._emit(TokenType.NOTE, note, note) + self._emit(TokenType.NOTE, cmd, cmd) return self._emit(TokenType.INVALID, cmd, cmd) return diff --git a/src/mml_composemusic_mcp/parser_base.py b/src/mml_composemusic_mcp/parser_base.py index 506a33e..2625c5d 100644 --- a/src/mml_composemusic_mcp/parser_base.py +++ b/src/mml_composemusic_mcp/parser_base.py @@ -33,6 +33,11 @@ class ParserContext: def expect(self, *types: TokenType) -> Token | None: return self.match(*types) + def synchronize(self, *types: TokenType) -> None: + """Advance to a recovery token without ever consuming past EOF.""" + while self.peek().type not in (*types, TokenType.EOF): + self.advance() + def add_error( self, code: ErrorCode, diff --git a/src/mml_composemusic_mcp/parser_ppmck.py b/src/mml_composemusic_mcp/parser_ppmck.py index bd3b06b..5b8de40 100644 --- a/src/mml_composemusic_mcp/parser_ppmck.py +++ b/src/mml_composemusic_mcp/parser_ppmck.py @@ -23,6 +23,7 @@ from .ast_nodes import ( Program, QuantizeStmt, RelativeVolumeStmt, + RepeatBreakStmt, RepeatEndStmt, RepeatStartStmt, RestStmt, @@ -65,6 +66,9 @@ class PpmckParser: self.ctx = ParserContext(tokens) self.tracks_seen: set[str] = set() self.track_ids_seen: set[str] = set() + self.repeat_stack: list[Token] = [] + self.repeat_break_seen: list[bool] = [] + self.seen_global_definition = False def parse(self) -> tuple[Program, list[ErrorDetail]]: program = Program(line=1, column=1, tracks=[]) @@ -73,7 +77,7 @@ class PpmckParser: while self.ctx.peek().type != TokenType.EOF: token = self.ctx.peek() if token.type == TokenType.HEADER: - if program.tracks: + if program.tracks or self.seen_global_definition: self.ctx.add_error( code=ErrorCode.SYNTAX_UNEXPECTED_TOKEN, line=token.line, @@ -116,9 +120,10 @@ class PpmckParser: ), ): program.global_statements.append(statement) + self.seen_global_definition = True else: self.ctx.add_error( - code=ErrorCode.SEMANTIC_OUTSIDE_TRACK, + code=ErrorCode.SYNTAX_UNEXPECTED_TOKEN, line=token.line, column=token.column, message="トラック外ではエフェクト定義だけを使用できます。", @@ -128,7 +133,7 @@ class PpmckParser: ) continue # Anything else before a track header is outside a track. - self._warn_outside_track(token) + self._error_outside_track(token) self.ctx.advance() # Attach headers to the first track if no track exists yet @@ -195,7 +200,7 @@ class PpmckParser: def _parse_track(self) -> Track | None: token = self.ctx.advance() - ch = token.value.upper() + ch = token.value if ch in self.track_ids_seen: self.ctx.add_error( code=ErrorCode.SYNTAX_DUPLICATE_TRACK, @@ -207,16 +212,7 @@ class PpmckParser: context=context_line(self.source, token), ) self.track_ids_seen.add(ch) - if ch == "L": - return Track( - line=token.line, - column=token.column, - track_id="L", - channel="Loop", - mode="ppmck", - statements=[], - ) - if ch not in CHANNEL_MAP: + if ch not in (*CHANNEL_MAP, "L"): self.ctx.add_error( code=ErrorCode.SYNTAX_INVALID_TRACK_HEADER, line=token.line, @@ -227,7 +223,7 @@ class PpmckParser: context=context_line(self.source, token), ) return None - channel = CHANNEL_MAP[ch] + channel = "Loop" if ch == "L" else CHANNEL_MAP[ch] self.tracks_seen.add(channel) track = Track( line=token.line, @@ -237,13 +233,48 @@ class PpmckParser: mode="ppmck", statements=[], ) + self.repeat_stack = [] + self.repeat_break_seen = [] 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) + if channel == "Loop" and not isinstance( + stmt, + ( + LengthStmt, + RestStmt, + RepeatStartStmt, + RepeatBreakStmt, + RepeatEndStmt, + BarStmt, + ), + ): + self.ctx.add_error( + code=ErrorCode.SYNTAX_UNEXPECTED_TOKEN, + line=stmt.line, + column=stmt.column, + message="Lトラックでは時間指定以外のコマンドを使用できません。", + severity="error", + hint="l、休符、リピート、小節線だけを使用してください。", + context=context_line(self.source, stmt), + ) + else: + track.statements.append(stmt) + for start in self.repeat_stack: + self.ctx.add_error( + code=ErrorCode.SYNTAX_UNTERMINATED_REPEAT, + line=start.line, + column=start.column, + message="リピート '[' に対応する ']' が見つかりません。", + severity="error", + hint="] を追加してリピートを閉じてください。", + context=context_line(self.source, start), + ) + self.repeat_stack = [] + self.repeat_break_seen = [] return track def _parse_statement(self) -> ASTNode | None: @@ -284,6 +315,19 @@ class PpmckParser: return self._parse_repeat_end() if token.type == TokenType.BAR: self.ctx.advance() + if self.repeat_stack: + if self.repeat_break_seen[-1]: + self.ctx.add_error( + code=ErrorCode.SYNTAX_UNEXPECTED_TOKEN, + line=token.line, + column=token.column, + message="1つのリピート内に終端分岐 '|' は1つだけ指定できます。", + severity="error", + hint="余分な '|' を削除してください。", + context=context_line(self.source, token), + ) + self.repeat_break_seen[-1] = True + return RepeatBreakStmt(line=token.line, column=token.column) return BarStmt(line=token.line, column=token.column) if token.type == TokenType.DETUNE: return self._parse_detune() @@ -448,7 +492,7 @@ class PpmckParser: ) def _require_value(self, token: Token, command: str) -> None: - if not token.value: + if token.value in ("", "+", "-"): self.ctx.add_missing_number_error( token, command, context_line(self.source, token) ) @@ -503,15 +547,38 @@ class PpmckParser: column=token.column, target=self._parse_note(), ) - # ^ without target extends previous note duration + self.ctx.add_error( + code=ErrorCode.SYNTAX_UNTERMINATED_TIE, + line=token.line, + column=token.column, + message="タイ '^' の後に音符または音長が必要です。", + severity="error", + hint="^16 または ^c4 のように対象を続けてください。", + context=context_line(self.source, token), + ) return TieCmdStmt(line=token.line, column=token.column, target=None) def _parse_repeat_start(self) -> RepeatStartStmt: token = self.ctx.advance() + self.repeat_stack.append(token) + self.repeat_break_seen.append(False) return RepeatStartStmt(line=token.line, column=token.column) def _parse_repeat_end(self) -> RepeatEndStmt: token = self.ctx.advance() + if not self.repeat_stack: + self.ctx.add_error( + code=ErrorCode.SYNTAX_UNMATCHED_REPEAT_END, + line=token.line, + column=token.column, + message="']' に対応する '[' が見つかりません。", + severity="error", + hint="余分な ']' を削除してください。", + context=context_line(self.source, token), + ) + else: + self.repeat_stack.pop() + self.repeat_break_seen.pop() count: int | None = None if token.value: count = int(token.value) @@ -524,7 +591,7 @@ class PpmckParser: def _parse_detune(self) -> DetuneCmdStmt: token = self.ctx.advance() self._require_value(token, "D") - value = int(token.value) if token.value else 0 + value = int(token.value) if token.value not in ("", "+", "-") else 0 return DetuneCmdStmt( line=token.line, column=token.column, @@ -534,8 +601,15 @@ class PpmckParser: def _parse_sweep(self) -> SweepStmt: token = self.ctx.advance() parts = token.value.split(",") - speed = int(parts[0]) if len(parts) > 0 and parts[0] else 0 - depth = int(parts[1]) if len(parts) > 1 and parts[1] else 0 + valid_parts = ( + len(parts) == 2 and parts[0].isdigit() and parts[1].lstrip("+-").isdigit() + ) + if "," not in token.raw or not valid_parts: + self.ctx.add_missing_number_error( + token, "s,", context_line(self.source, token) + ) + speed = int(parts[0]) if valid_parts else 0 + depth = int(parts[1]) if valid_parts else 0 return SweepStmt( line=token.line, column=token.column, @@ -601,24 +675,22 @@ class PpmckParser: token = self.ctx.advance() self._require_value(token, "@MP") slot = int(token.value) if token.value else 0 - self.ctx.match(TokenType.EQUAL) - self.ctx.match(TokenType.BRACE_OPEN) - p1 = int(self.ctx.advance().value) - self.ctx.match(TokenType.COMMA) - p2 = int(self.ctx.advance().value) - self.ctx.match(TokenType.COMMA) - p3 = int(self.ctx.advance().value) - self.ctx.match(TokenType.COMMA) - p4 = int(self.ctx.advance().value) - self.ctx.match(TokenType.BRACE_CLOSE) + self._expect_token(TokenType.EQUAL, "'='") + self._expect_token(TokenType.BRACE_OPEN, "'{'") + values: list[int] = [] + for index in range(4): + values.append(self._expect_number("@MPのパラメータ")) + if index < 3: + self._expect_token(TokenType.COMMA, "','") + self._expect_token(TokenType.BRACE_CLOSE, "'}'") return LfoDefStmt( line=token.line, column=token.column, slot=slot, - delay=p1, - speed=p2, - depth=p3, - transition=p4, + delay=values[0], + speed=values[1], + depth=values[2], + transition=values[3], ) def _parse_lfo_use(self) -> LfoUseStmt: @@ -640,7 +712,7 @@ class PpmckParser: slot = int(token.value) if token.value else 0 if self.ctx.peek().type == TokenType.EQUAL: self.ctx.advance() - points, loop_points = self._parse_envelope_body() + points, loop_points = self._parse_envelope_body(signed=True) return PitchEnvDefStmt( line=token.line, column=token.column, @@ -673,7 +745,7 @@ class PpmckParser: slot = int(token.value) if token.value else 0 if self.ctx.peek().type == TokenType.EQUAL: self.ctx.advance() - points, loop_points = self._parse_envelope_body() + points, loop_points = self._parse_envelope_body(signed=True) return NoteEnvDefStmt( line=token.line, column=token.column, @@ -700,16 +772,34 @@ class PpmckParser: token = self.ctx.advance() return NoteEnvOffStmt(line=token.line, column=token.column) - def _parse_envelope_body(self) -> tuple[list[int], list[int]]: - """Parse { num_list | num_list? } for envelope definitions.""" - self.ctx.match(TokenType.BRACE_OPEN) + def _parse_envelope_body( + self, *, signed: bool = False + ) -> tuple[list[int], list[int]]: + """Parse a non-empty envelope body with an optional loop divider.""" + if self._expect_token(TokenType.BRACE_OPEN, "'{'") is None: + self.ctx.synchronize(TokenType.BRACE_CLOSE, TokenType.TRACK_HEADER) + self.ctx.match(TokenType.BRACE_CLOSE) + return [], [] points: list[int] = [] loop_points: list[int] = [] in_loop = False - while self.ctx.peek().type != TokenType.BRACE_CLOSE: + while self.ctx.peek().type not in ( + TokenType.BRACE_CLOSE, + TokenType.TRACK_HEADER, + TokenType.EOF, + ): token = self.ctx.peek() if token.type == TokenType.BAR: self.ctx.advance() + if in_loop: + self.ctx.add_error( + code=ErrorCode.SYNTAX_UNEXPECTED_TOKEN, + line=token.line, + column=token.column, + message="エンベロープのループ区切り '|' は1つだけ指定できます。", + severity="error", + context=context_line(self.source, token), + ) in_loop = True continue if token.type == TokenType.COMMA: @@ -723,6 +813,18 @@ class PpmckParser: else: points.append(val) continue + if signed and token.type in (TokenType.SHARP, TokenType.FLAT): + sign = token.raw + self.ctx.advance() + number = self.ctx.match(TokenType.NUMBER) + if sign not in ("+", "-") or number is None: + self.ctx.add_missing_number_error( + token, "符号付き値", context_line(self.source, token) + ) + continue + val = int(number.value) * (-1 if sign == "-" else 1) + (loop_points if in_loop else points).append(val) + continue # Unexpected token inside envelope body self.ctx.add_error( code=ErrorCode.SYNTAX_UNEXPECTED_TOKEN, @@ -734,22 +836,60 @@ class PpmckParser: context=context_line(self.source, token), ) self.ctx.advance() - self.ctx.match(TokenType.BRACE_CLOSE) + if not points: + token = self.ctx.peek() + self.ctx.add_error( + code=ErrorCode.SYNTAX_UNEXPECTED_TOKEN, + line=token.line, + column=token.column, + message="エンベロープ定義には1つ以上の値が必要です。", + severity="error", + context=context_line(self.source, token), + ) + self._expect_token(TokenType.BRACE_CLOSE, "'}'") return points, loop_points + def _expect_token(self, token_type: TokenType, label: str) -> Token | None: + token = self.ctx.match(token_type) + if token is not None: + return token + actual = self.ctx.peek() + self.ctx.add_error( + code=ErrorCode.SYNTAX_UNEXPECTED_TOKEN, + line=actual.line, + column=actual.column, + message=f"{label} が必要ですが '{actual.raw}' が見つかりました。", + severity="error", + hint=f"{label} を正しい位置に追加してください。", + context=context_line(self.source, actual), + ) + return None + + def _expect_number(self, label: str) -> int: + token = self.ctx.match(TokenType.NUMBER) + if token is not None: + return int(token.value) + actual = self.ctx.peek() + self.ctx.add_missing_number_error( + actual, label, context_line(self.source, actual) + ) + if actual.type not in (TokenType.EOF, TokenType.TRACK_HEADER): + self.ctx.advance() + return 0 + 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: + def _error_outside_track(self, token: Token) -> None: if token.type in (TokenType.EOF, TokenType.COMMENT): return self.ctx.add_error( - code=ErrorCode.SEMANTIC_OUTSIDE_TRACK, + code=ErrorCode.SYNTAX_UNEXPECTED_TOKEN, line=token.line, column=token.column, message=f"'{token.raw}' はトラック外に書かれています。", - severity="warning", + severity="error", hint="コマンドはトラックヘッダー(A, B, T, N)の後に記述してください。", context=context_line(self.source, token), ) diff --git a/src/mml_composemusic_mcp/parser_pyxel.py b/src/mml_composemusic_mcp/parser_pyxel.py index 06cb613..a4fa7e1 100644 --- a/src/mml_composemusic_mcp/parser_pyxel.py +++ b/src/mml_composemusic_mcp/parser_pyxel.py @@ -37,6 +37,7 @@ class PyxelParser: self.source = source self.ctx = ParserContext(tokens) self.tracks_seen: set[str] = set() + self.repeat_stack: list[Token] = [] def parse(self) -> tuple[Program, list[ErrorDetail]]: program = Program(line=1, column=1, tracks=[]) @@ -51,7 +52,7 @@ class PyxelParser: if token.type == TokenType.INVALID: self._handle_invalid(token) continue - self._warn_outside_track(token) + self._error_outside_track(token) self.ctx.advance() return program, self.ctx.errors @@ -90,6 +91,7 @@ class PyxelParser: mode="pyxel", statements=[], ) + self.repeat_stack = [] while self.ctx.peek().type not in ( TokenType.TRACK_HEADER, TokenType.EOF, @@ -97,6 +99,17 @@ class PyxelParser: stmt = self._parse_statement() if stmt is not None: track.statements.append(stmt) + for start in self.repeat_stack: + self.ctx.add_error( + code=ErrorCode.SYNTAX_UNTERMINATED_REPEAT, + line=start.line, + column=start.column, + message="リピート '[' に対応する ']' が見つかりません。", + severity="error", + hint="] を追加してリピートを閉じてください。", + context=context_line(self.source, start), + ) + self.repeat_stack = [] return track def _parse_statement(self) -> ASTNode | None: @@ -331,10 +344,23 @@ class PyxelParser: def _parse_repeat_start(self) -> RepeatStartStmt: token = self.ctx.advance() + self.repeat_stack.append(token) return RepeatStartStmt(line=token.line, column=token.column) def _parse_repeat_end(self) -> RepeatEndStmt: token = self.ctx.advance() + if not self.repeat_stack: + self.ctx.add_error( + code=ErrorCode.SYNTAX_UNMATCHED_REPEAT_END, + line=token.line, + column=token.column, + message="']' に対応する '[' が見つかりません。", + severity="error", + hint="余分な ']' を削除してください。", + context=context_line(self.source, token), + ) + else: + self.repeat_stack.pop() count: int | None = None if token.value: count = int(token.value) @@ -351,6 +377,10 @@ class PyxelParser: slot = 0 if slot_token is not None: slot = int(slot_token.value) + else: + self.ctx.add_missing_number_error( + token, f"@{cmd}", context_line(self.source, token) + ) params: list[int] = [] while True: num = self.ctx.match(TokenType.NUMBER) @@ -369,15 +399,15 @@ class PyxelParser: self.ctx.add_invalid_token_error(token, context_line(self.source, token)) self.ctx.advance() - def _warn_outside_track(self, token: Token) -> None: + def _error_outside_track(self, token: Token) -> None: if token.type == TokenType.EOF: return self.ctx.add_error( - code=ErrorCode.SEMANTIC_OUTSIDE_TRACK, + code=ErrorCode.SYNTAX_UNEXPECTED_TOKEN, line=token.line, column=token.column, message=f"'{token.raw}' はトラック外に書かれています。", - severity="warning", + severity="error", hint="コマンドはトラックヘッダー(0:, 1:, 2:, 3:)の後に記述してください。", context=context_line(self.source, token), ) diff --git a/src/mml_composemusic_mcp/semantic_base.py b/src/mml_composemusic_mcp/semantic_base.py index 0a6acdb..b55a221 100644 --- a/src/mml_composemusic_mcp/semantic_base.py +++ b/src/mml_composemusic_mcp/semantic_base.py @@ -27,6 +27,7 @@ from .ast_nodes import ( Program, QuantizeStmt, RelativeVolumeStmt, + RepeatBreakStmt, RepeatEndStmt, RepeatStartStmt, RestStmt, @@ -47,10 +48,13 @@ from .ir import ( ErrorPhase, NoteEvent, NoteSequence, + RepeatEvent, RestEvent, ) from .parser_base import length_value_to_ticks +MAX_EXPANDED_EVENTS = 100_000 + @dataclass class SemanticContext: @@ -95,6 +99,7 @@ class SemanticAnalyzer: self.gate_time = 1.0 self.duty = 2 self.tick_position = 0 + self.repeat_tick_stack: list[int] = [] def analyze(self) -> tuple[NoteSequence, list[ErrorDetail]]: self.current_channel = None @@ -111,7 +116,10 @@ class SemanticAnalyzer: if track.channel == "Loop": self._handle_loop_track(track) return - for stmt in track.statements: + statements = self._expand_repeats(track.statements) + if statements is None: + return + for stmt in statements: self._analyze_statement(stmt) def _reset_state(self, track: Track) -> None: @@ -121,6 +129,7 @@ class SemanticAnalyzer: self.gate_time = 1.0 self.duty = 2 self.tick_position = 0 + self.repeat_tick_stack = [] def _handle_loop_track(self, track: Track) -> None: # Loop track is handled after all tracks are processed. @@ -166,6 +175,8 @@ class SemanticAnalyzer: self._analyze_relative_volume(stmt) elif isinstance(stmt, RepeatStartStmt): self._analyze_repeat_start(stmt) + elif isinstance(stmt, RepeatBreakStmt): + pass elif isinstance(stmt, RepeatEndStmt): self._analyze_repeat_end(stmt) elif isinstance(stmt, BarStmt): @@ -203,10 +214,52 @@ class SemanticAnalyzer: else: self._analyze_stage2(stmt) - def _ticks_for(self, stmt: NoteStmt | RestStmt) -> int: + def _ticks_for(self, stmt: NoteStmt | RestStmt) -> int | None: length = stmt.length if stmt.length is not None else self.default_length + if not 1 <= length <= 192: + self.ctx.add_error( + code=ErrorCode.SEMANTIC_VALUE_OUT_OF_RANGE, + line=stmt.line, + column=stmt.column, + message=f"音長 {length} は範囲外です。有効範囲: 1〜192。", + severity="error", + hint="1〜192の音長を指定してください。", + context=self._context_line(stmt), + ) + return None return length_value_to_ticks(length, stmt.dots) + def _ticks_for_value(self, value: int, stmt: ASTNode) -> int | None: + if not 1 <= value <= 192: + self.ctx.add_error( + code=ErrorCode.SEMANTIC_VALUE_OUT_OF_RANGE, + line=stmt.line, + column=stmt.column, + message=f"タイ音長 {value} は範囲外です。有効範囲: 1〜192。", + severity="error", + hint="1〜192の音長を指定してください。", + context=self._context_line(stmt), + ) + return None + return length_value_to_ticks(value, 0) + + def _validate_range( + self, stmt: ASTNode, value: int, minimum: int, maximum: int, label: str + ) -> bool: + if minimum <= value <= maximum: + return True + self.ctx.add_error( + code=ErrorCode.SEMANTIC_VALUE_OUT_OF_RANGE, + line=stmt.line, + column=stmt.column, + message=( + f"{label}の値 {value} は範囲外です。有効範囲: {minimum}〜{maximum}。" + ), + severity="error", + context=self._context_line(stmt), + ) + return False + def _analyze_note(self, stmt: NoteStmt) -> None: raise NotImplementedError @@ -247,10 +300,20 @@ class SemanticAnalyzer: raise NotImplementedError def _analyze_repeat_start(self, stmt: RepeatStartStmt) -> None: - raise NotImplementedError + self.repeat_tick_stack.append(self.tick_position) def _analyze_repeat_end(self, stmt: RepeatEndStmt) -> None: - raise NotImplementedError + if not self.repeat_tick_stack: + return + start_tick = self.repeat_tick_stack.pop() + self._add_event( + RepeatEvent( + tick_position=self.tick_position, + start_tick=start_tick, + end_tick=self.tick_position, + repeat_count=0 if stmt.count is None else stmt.count, + ) + ) def _analyze_ext_cmd(self, stmt: ExtCmdStmt) -> None: raise NotImplementedError @@ -331,3 +394,122 @@ class SemanticAnalyzer: if 1 <= node.line <= len(lines): return lines[node.line - 1] return "" + + def _expand_repeats(self, statements: list[Statement]) -> list[Statement] | None: + """Expand nested repeats transactionally before semantic state updates.""" + initial_error_count = sum(e.severity == "error" for e in self.ctx.errors) + + def segment(index: int) -> tuple[list[Statement], int, str | None]: + result: list[Statement] = [] + while index < len(statements): + stmt = statements[index] + if isinstance(stmt, RepeatBreakStmt): + return result, index + 1, "break" + if isinstance(stmt, RepeatEndStmt): + return result, index + 1, "end" + if not isinstance(stmt, RepeatStartStmt): + result.append(stmt) + index += 1 + continue + + prefix, index, terminator = segment(index + 1) + suffix: list[Statement] = [] + if terminator == "break": + suffix, index, terminator = segment(index) + if terminator != "end": + return result, index, terminator + + end_stmt = statements[index - 1] + assert isinstance(end_stmt, RepeatEndStmt) + count = end_stmt.count + if count is not None and count < 1: + self.ctx.add_error( + code=ErrorCode.SEMANTIC_VALUE_OUT_OF_RANGE, + line=end_stmt.line, + column=end_stmt.column, + message="リピート回数は1以上で指定してください。", + severity="error", + context=self._context_line(end_stmt), + ) + return [], len(statements), None + + full = prefix + suffix + prefix_cost = sum(self._event_cost(item) for item in prefix) + full_cost = sum(self._event_cost(item) for item in full) + if count is None: + body_cost = full_cost * 2 + elif suffix: + body_cost = full_cost * (count - 1) + prefix_cost + else: + body_cost = prefix_cost * count + projected = ( + sum(self._event_cost(item) for item in result) + + body_cost + + self._event_cost(end_stmt) + ) + if projected > MAX_EXPANDED_EVENTS: + self._add_repeat_limit_error(end_stmt, projected) + return [], len(statements), None + expanded_body: list[Statement] = [] + if count is None: + expanded_body = full * 2 + self.ctx.add_error( + code=ErrorCode.SEMANTIC_UNSUPPORTED_FEATURE, + line=end_stmt.line, + column=end_stmt.column, + message="無限リピートはWAV生成用IRで2回に展開されます。", + severity="warning", + hint="IRのRepeatEventにはrepeat_count=0を保持します。", + context=self._context_line(end_stmt), + ) + elif suffix: + expanded_body = full * (count - 1) + prefix + else: + expanded_body = prefix * count + result.extend([stmt, *expanded_body, end_stmt]) + return result, index, None + + expanded, _, _ = segment(0) + if sum(e.severity == "error" for e in self.ctx.errors) > initial_error_count: + return None + projected = sum(self._event_cost(stmt) for stmt in expanded) + if projected > MAX_EXPANDED_EVENTS: + node = next( + ( + stmt + for stmt in reversed(statements) + if isinstance(stmt, RepeatEndStmt) + ), + statements[-1] if statements else ASTNode(line=1, column=1), + ) + self._add_repeat_limit_error(node, projected) + return None + return expanded + + def _add_repeat_limit_error(self, node: ASTNode, projected: int) -> None: + self.ctx.add_error( + code=ErrorCode.SEMANTIC_VALUE_OUT_OF_RANGE, + line=node.line, + column=node.column, + message=( + f"リピート展開後のイベント数 {projected} が上限 " + f"{MAX_EXPANDED_EVENTS} を超えます。" + ), + severity="error", + hint="リピート回数またはリピート内のイベント数を減らしてください。", + context=self._context_line(node), + ) + + def _event_cost(self, stmt: Statement) -> int: + no_event = ( + BarStmt, + LengthStmt, + OctaveStmt, + GateTimeStmt, + TransposeStmt, + TieStmt, + TieCmdStmt, + RepeatStartStmt, + RepeatBreakStmt, + ) + return 0 if isinstance(stmt, no_event) else 1 diff --git a/src/mml_composemusic_mcp/semantic_ppmck.py b/src/mml_composemusic_mcp/semantic_ppmck.py index 4171a4b..d736a53 100644 --- a/src/mml_composemusic_mcp/semantic_ppmck.py +++ b/src/mml_composemusic_mcp/semantic_ppmck.py @@ -54,7 +54,7 @@ from .ir import ( VolumeEnvelopeEvent, VolumeEvent, ) -from .parser_base import clamp, length_value_to_ticks, note_to_midi +from .parser_base import clamp, note_to_midi from .semantic_base import SemanticAnalyzer PULSE_CHANNELS = {"Pulse1", "Pulse2"} @@ -67,51 +67,75 @@ class PpmckSemanticAnalyzer(SemanticAnalyzer): self.loop_start: int | None = None self.detune_cents = 0.0 - def _define(self, kind: str, slot: int, value: dict) -> None: + def _define(self, kind: str, slot: int, value: dict, stmt) -> bool: # type: ignore[no-untyped-def] + if not self._validate_range(stmt, slot, 0, 255, "エフェクトスロット"): + return False table = self.note_sequence.definitions[kind] if slot in table: self.ctx.add_error( ErrorCode.SEMANTIC_DUPLICATE_DEFINITION, - 0, - 0, + stmt.line, + stmt.column, f"エフェクトスロット {slot} は既に定義されています。", "error", "別のスロット番号を使用してください。", + self._context_line(stmt), ) - return + return False table[slot] = value + return True + + def _validate_values( + self, + stmt, + values: list[int], + minimum: int, + maximum: int, + label: str, # type: ignore[no-untyped-def] + ) -> bool: + return all( + self._validate_range(stmt, value, minimum, maximum, label) + for value in values + ) + + def _use_defined(self, stmt, kind: str, slot: int) -> bool: # type: ignore[no-untyped-def] + if not self._validate_range(stmt, slot, 0, 255, "エフェクトスロット"): + return False + if slot in self.note_sequence.definitions[kind]: + return True + self.ctx.add_error( + ErrorCode.SEMANTIC_UNDEFINED_REFERENCE, + stmt.line, + stmt.column, + f"未定義のエフェクトスロット {slot} です。", + "error", + "先に同じ番号の定義を追加してください。", + self._context_line(stmt), + ) + return False def analyze(self) -> tuple[NoteSequence, list[ErrorDetail]]: ns, errors = super().analyze() - references = ( - (VolumeEnvelopeEvent, "volume_envelopes"), - (DutyEnvelopeEvent, "duty_envelopes"), - (LfoEvent, "lfos"), - (PitchEnvEvent, "pitch_envelopes"), - (NoteEnvEvent, "note_envelopes"), - ) - for channel in ns.channels.values(): - for event in channel.events: - for event_type, table_name in references: - if ( - isinstance(event, event_type) - and not event.is_definition - and not getattr(event, "is_off", False) - and event.slot not in ns.definitions[table_name] - ): - self.ctx.add_error( - ErrorCode.SEMANTIC_UNDEFINED_REFERENCE, - 0, - 0, - f"未定義のエフェクトスロット {event.slot} です。", - "error", - "先に同じ番号の定義を追加してください。", - ) if self.loop_start is not None: max_tick = max(ch.total_ticks for ch in ns.channels.values()) + if self.loop_start >= max_tick: + loop_track = next( + track for track in self.program.tracks if track.channel == "Loop" + ) + self.ctx.add_error( + ErrorCode.SEMANTIC_VALUE_OUT_OF_RANGE, + loop_track.line, + loop_track.column, + f"Lトラックのloop_start {self.loop_start} は曲長 {max_tick} 以上です。", + "error", + "Lトラックの時間を音声トラックの曲長より短くしてください。", + self._context_line(loop_track), + ) + return ns, errors for ch in ns.channels.values(): ch.events.append( RepeatEvent( + tick_position=max_tick, start_tick=self.loop_start, end_tick=max_tick, repeat_count=0, @@ -134,14 +158,23 @@ class PpmckSemanticAnalyzer(SemanticAnalyzer): self.tick_position = 0 self.detune_cents = 0.0 - def _analyze_track(self, track: Track) -> None: - if track.channel == "Loop": - self.loop_start = 0 + def _handle_loop_track(self, track: Track) -> None: + statements = self._expand_repeats(track.statements) + if statements is None: return - super()._analyze_track(track) + for stmt in statements: + if isinstance(stmt, LengthStmt): + self._analyze_length(stmt) + elif isinstance(stmt, RestStmt): + ticks = self._ticks_for(stmt) + if ticks is not None: + self.tick_position += ticks + self.loop_start = self.tick_position def _analyze_note(self, stmt: NoteStmt) -> None: ticks = self._ticks_for(stmt) + if ticks is None: + return midi = note_to_midi(stmt.note_name, self.octave, stmt.accidental) if self.current_channel == "Noise": self.ctx.add_error( @@ -179,6 +212,8 @@ class PpmckSemanticAnalyzer(SemanticAnalyzer): def _analyze_rest(self, stmt: RestStmt) -> None: ticks = self._ticks_for(stmt) + if ticks is None: + return event = RestEvent(tick_position=self.tick_position, duration=ticks) self._add_event(event) self.tick_position += ticks @@ -187,10 +222,14 @@ class PpmckSemanticAnalyzer(SemanticAnalyzer): if stmt.direction == "up": if self.octave < 7: self.octave += 1 + else: + self._validate_range(stmt, self.octave + 1, 0, 7, "オクターブ") return if stmt.direction == "down": if self.octave > 0: self.octave -= 1 + else: + self._validate_range(stmt, self.octave - 1, 0, 7, "オクターブ") return if stmt.value is None: return @@ -222,9 +261,6 @@ class PpmckSemanticAnalyzer(SemanticAnalyzer): 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, @@ -236,29 +272,22 @@ class PpmckSemanticAnalyzer(SemanticAnalyzer): context=self._context_line(stmt), ) return + if self.current_channel == "Triangle": + 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 not self._validate_range(stmt, stmt.value, 0, 3, "デューティ比"): + return 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}' で '@' は使用できません。", - severity="warning", - hint="Triangleチャンネルはデューティ比を持ちません。@ コマンドを削除してください。", - 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"'@' の値 {stmt.value} は範囲外です。有効範囲: 0〜3。", severity="error", - hint="例: @2 のように指定してください。", + hint="Triangleチャンネルはデューティ比を持ちません。@ コマンドを削除してください。", context=self._context_line(stmt), ) return @@ -286,21 +315,29 @@ class PpmckSemanticAnalyzer(SemanticAnalyzer): if stmt.target is None: return if isinstance(stmt.target, int): - ticks = length_value_to_ticks(stmt.target, 0) + ticks = self._ticks_for_value(stmt.target, stmt) + if ticks is None: + return self._extend_last_note(ticks) self.tick_position += ticks return if isinstance(stmt.target, NoteStmt): target_ticks = self._ticks_for(stmt.target) + if target_ticks is None: + return self._extend_last_note(target_ticks) self.tick_position += target_ticks return def _analyze_detune_cmd(self, stmt: DetuneCmdStmt) -> None: + if not self._validate_range(stmt, stmt.value, -127, 126, "ディチューン"): + return self.detune_cents = float(stmt.value) self._add_event(DetuneEvent(tick_position=self.tick_position, value=stmt.value)) def _analyze_relative_volume(self, stmt: RelativeVolumeStmt) -> None: + if not self._validate_range(stmt, abs(stmt.delta), 1, 15, "相対音量"): + return self.velocity = clamp(self.velocity + stmt.delta, 0, 15) self._add_event( RelativeVolumeEvent(tick_position=self.tick_position, delta=stmt.delta) @@ -337,16 +374,24 @@ class PpmckSemanticAnalyzer(SemanticAnalyzer): ) def _analyze_vol_env_use(self, stmt: VolumeEnvelopeUseStmt) -> None: + if not self._use_defined(stmt, "volume_envelopes", stmt.slot): + return self._add_event( VolumeEnvelopeEvent(tick_position=self.tick_position, slot=stmt.slot) ) def _analyze_vol_env_def(self, stmt: VolumeEnvelopeDefStmt) -> None: - self._define( + if not self._validate_values( + stmt, stmt.points + stmt.loop_points, 0, 15, "音量エンベロープ" + ): + return + if not self._define( "volume_envelopes", stmt.slot, {"points": stmt.points, "loop_points": stmt.loop_points}, - ) + stmt, + ): + return if self.current_channel is not None: self._add_event( VolumeEnvelopeEvent( @@ -359,11 +404,17 @@ class PpmckSemanticAnalyzer(SemanticAnalyzer): ) def _analyze_duty_env_def(self, stmt: DutyEnvelopeDefStmt) -> None: - self._define( + if not self._validate_values( + stmt, stmt.points + stmt.loop_points, 0, 3, "デューティエンベロープ" + ): + return + if not self._define( "duty_envelopes", stmt.slot, {"points": stmt.points, "loop_points": stmt.loop_points}, - ) + stmt, + ): + return if self.current_channel is not None: self._add_event( DutyEnvelopeEvent( @@ -376,13 +427,22 @@ class PpmckSemanticAnalyzer(SemanticAnalyzer): ) def _analyze_duty_env_use(self, stmt: DutyEnvelopeUseStmt) -> None: + if not self._use_defined(stmt, "duty_envelopes", stmt.slot): + return if self.current_channel is not None: self._add_event( DutyEnvelopeEvent(tick_position=self.tick_position, slot=stmt.slot) ) def _analyze_lfo_def(self, stmt: LfoDefStmt) -> None: - if stmt.transition != 0 or stmt.speed <= 0: + if not ( + self._validate_range(stmt, stmt.delay, 0, 255, "LFO delay") + and self._validate_range(stmt, stmt.speed, 1, 255, "LFO period") + and self._validate_range(stmt, stmt.depth, 0, 255, "LFO depth") + and stmt.transition == 0 + ): + if stmt.transition == 0: + return self.ctx.add_error( ErrorCode.SEMANTIC_VALUE_OUT_OF_RANGE, stmt.line, @@ -392,7 +452,7 @@ class PpmckSemanticAnalyzer(SemanticAnalyzer): context=self._context_line(stmt), ) return - self._define( + if not self._define( "lfos", stmt.slot, { @@ -401,7 +461,9 @@ class PpmckSemanticAnalyzer(SemanticAnalyzer): "depth": stmt.depth, "transition": stmt.transition, }, - ) + stmt, + ): + return if self.current_channel is not None: self._add_event( LfoEvent( @@ -416,17 +478,25 @@ class PpmckSemanticAnalyzer(SemanticAnalyzer): ) def _analyze_lfo_use(self, stmt: LfoUseStmt) -> None: + if not self._use_defined(stmt, "lfos", stmt.slot): + return self._add_event(LfoEvent(tick_position=self.tick_position, slot=stmt.slot)) def _analyze_lfo_off(self, stmt: LfoOffStmt) -> None: self._add_event(LfoEvent(tick_position=self.tick_position, slot=0, is_off=True)) def _analyze_pitch_env_def(self, stmt: PitchEnvDefStmt) -> None: - self._define( + if not self._validate_values( + stmt, stmt.points + stmt.loop_points, -127, 126, "ピッチエンベロープ" + ): + return + if not self._define( "pitch_envelopes", stmt.slot, {"points": stmt.points, "loop_points": stmt.loop_points}, - ) + stmt, + ): + return if self.current_channel is not None: self._add_event( PitchEnvEvent( @@ -439,6 +509,8 @@ class PpmckSemanticAnalyzer(SemanticAnalyzer): ) def _analyze_pitch_env_use(self, stmt: PitchEnvUseStmt) -> None: + if not self._use_defined(stmt, "pitch_envelopes", stmt.slot): + return self._add_event(PitchEnvEvent(tick_position=self.tick_position, slot=stmt.slot)) def _analyze_pitch_env_off(self, stmt: PitchEnvOffStmt) -> None: @@ -447,11 +519,17 @@ class PpmckSemanticAnalyzer(SemanticAnalyzer): ) def _analyze_note_env_def(self, stmt: NoteEnvDefStmt) -> None: - self._define( + if not self._validate_values( + stmt, stmt.points + stmt.loop_points, -127, 126, "ノートエンベロープ" + ): + return + if not self._define( "note_envelopes", stmt.slot, {"points": stmt.points, "loop_points": stmt.loop_points}, - ) + stmt, + ): + return if self.current_channel is not None: self._add_event( NoteEnvEvent( @@ -464,6 +542,8 @@ class PpmckSemanticAnalyzer(SemanticAnalyzer): ) def _analyze_note_env_use(self, stmt: NoteEnvUseStmt) -> None: + if not self._use_defined(stmt, "note_envelopes", stmt.slot): + return self._add_event(NoteEnvEvent(tick_position=self.tick_position, slot=stmt.slot)) def _analyze_note_env_off(self, stmt: NoteEnvOffStmt) -> None: @@ -495,28 +575,32 @@ class PpmckSemanticAnalyzer(SemanticAnalyzer): if stmt.target is None: return if isinstance(stmt.target, int): - ticks = length_value_to_ticks(stmt.target, 0) + ticks = self._ticks_for_value(stmt.target, stmt) + if ticks is None: + return self._extend_last_note(ticks) self.tick_position += ticks return if isinstance(stmt.target, NoteStmt): target_ticks = self._ticks_for(stmt.target) + if target_ticks is None: + return self._extend_last_note(target_ticks) self.tick_position += target_ticks return if isinstance(stmt.target, RestStmt): target_ticks = self._ticks_for(stmt.target) + if target_ticks is None: + return 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) + super()._analyze_repeat_start(stmt) def _analyze_repeat_end(self, stmt: RepeatEndStmt) -> None: - # Stage 2 feature - self._analyze_stage2(stmt) + super()._analyze_repeat_end(stmt) def _analyze_ext_cmd(self, stmt: ExtCmdStmt) -> None: # ppmck does not use pyxel ext commands diff --git a/src/mml_composemusic_mcp/semantic_pyxel.py b/src/mml_composemusic_mcp/semantic_pyxel.py index 74a6c29..3cb34f6 100644 --- a/src/mml_composemusic_mcp/semantic_pyxel.py +++ b/src/mml_composemusic_mcp/semantic_pyxel.py @@ -1,7 +1,5 @@ """Pyxel semantic analyzer: AST -> NoteSequence IR.""" -import copy - from .ast_nodes import ( DutyStmt, ExtCmdStmt, @@ -27,13 +25,12 @@ from .ir import ( GlideEvent, NoteEvent, NoteSequence, - RepeatEvent, RestEvent, TempoEvent, VibratoEvent, VolumeEvent, ) -from .parser_base import clamp, length_value_to_ticks, note_to_midi +from .parser_base import clamp, note_to_midi from .semantic_base import SemanticAnalyzer PULSE_CHANNELS = {"Pulse1", "Pulse2"} @@ -43,77 +40,24 @@ PYXEL_DUTY = { } -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.note_sequence.mode = "pyxel" self.transpose = 0 self.detune_cents = 0.0 - self.repeat_stack: list[RepeatFrame] = [] - - def analyze(self) -> tuple[NoteSequence, list[ErrorDetail]]: - ns, errors = super().analyze() - references = ( - (EnvelopeEvent, "envelopes"), - (VibratoEvent, "vibratos"), - (GlideEvent, "glides"), - ) - for channel in ns.channels.values(): - for event in channel.events: - for event_type, table_name in references: - if ( - isinstance(event, event_type) - and event.slot != 0 - and event.slot not in ns.definitions[table_name] - ): - self.ctx.add_error( - ErrorCode.SEMANTIC_UNDEFINED_REFERENCE, - 0, - 0, - f"未定義の@{table_name}スロット {event.slot} です。", - "error", - "パラメータ付きで定義してから選択してください。", - ) - return ns, errors def _reset_state(self, track: Track) -> None: - self.octave = 4 - self.default_length = 4 + super()._reset_state(track) 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) + if ticks is None: + return midi = note_to_midi(stmt.note_name, self.octave, stmt.accidental) if self.current_channel != "Noise": midi += self.transpose @@ -142,6 +86,8 @@ class PyxelSemanticAnalyzer(SemanticAnalyzer): def _analyze_rest(self, stmt: RestStmt) -> None: ticks = self._ticks_for(stmt) + if ticks is None: + return event = RestEvent(tick_position=self.tick_position, duration=ticks) self._add_event(event) self.tick_position += ticks @@ -150,10 +96,14 @@ class PyxelSemanticAnalyzer(SemanticAnalyzer): if stmt.direction == "up": if self.octave < 7: self.octave += 1 + else: + self._validate_range(stmt, self.octave + 1, 0, 7, "オクターブ") return if stmt.direction == "down": if self.octave > 0: self.octave -= 1 + else: + self._validate_range(stmt, self.octave - 1, 0, 7, "オクターブ") return if stmt.value is None: return @@ -222,6 +172,8 @@ class PyxelSemanticAnalyzer(SemanticAnalyzer): self.gate_time = stmt.value / 100.0 def _analyze_duty(self, stmt: DutyStmt) -> None: + if not self._validate_range(stmt, stmt.value, 0, 3, "音色"): + return if stmt.value in PYXEL_DUTY: if self.current_channel not in PULSE_CHANNELS: self.ctx.add_error( @@ -229,7 +181,7 @@ class PyxelSemanticAnalyzer(SemanticAnalyzer): line=stmt.line, column=stmt.column, message=f"チャンネル '{self.current_channel}' で '@{stmt.value}' は使用できません。", - severity="warning", + severity="error", hint="トラック番号がチャンネルを決定します。@ コマンドを削除するか、Pulseチャンネル(0: または 1:)に移動してください。", context=self._context_line(stmt), ) @@ -239,34 +191,28 @@ class PyxelSemanticAnalyzer(SemanticAnalyzer): DutyEvent(tick_position=self.tick_position, value=self.duty) ) return + if stmt.value == 0 and self.current_channel == "Triangle": + return + if stmt.value == 3 and self.current_channel == "Noise": + 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", + severity="error", hint="トラック番号がチャンネルを決定します。@ コマンドを削除するか、適切なチャンネルに移動してください。", context=self._context_line(stmt), ) - elif stmt.value == 3: + else: 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 のように指定してください。", + hint="トラック番号がチャンネルを決定します。@ コマンドを削除するか、適切なチャンネルに移動してください。", context=self._context_line(stmt), ) @@ -287,95 +233,58 @@ class PyxelSemanticAnalyzer(SemanticAnalyzer): self._add_event(TempoEvent(tick_position=self.tick_position, bpm=stmt.value)) def _analyze_transpose(self, stmt: TransposeStmt) -> None: + if not self._validate_range(stmt, stmt.value, -127, 127, "トランスポーズ"): + return self.transpose = stmt.value def _analyze_detune(self, stmt) -> None: # type: ignore[no-untyped-def] + if not self._validate_range(stmt, stmt.value, -127, 127, "ディチューン"): + return 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) + ticks = self._ticks_for_value(stmt.target, stmt) + if ticks is None: + return self._extend_last_note(ticks) self.tick_position += ticks return if isinstance(stmt.target, NoteStmt): target_ticks = self._ticks_for(stmt.target) + if target_ticks is None: + return self._extend_last_note(target_ticks) self.tick_position += target_ticks return if isinstance(stmt.target, RestStmt): target_ticks = self._ticks_for(stmt.target) + if target_ticks is None: + return 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) + super()._analyze_repeat_start(stmt) 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 + super()._analyze_repeat_end(stmt) def _analyze_ext_cmd(self, stmt: ExtCmdStmt) -> None: if stmt.slot == 0: + if stmt.params: + self.ctx.add_error( + ErrorCode.SEMANTIC_VALUE_OUT_OF_RANGE, + stmt.line, + stmt.column, + f"@{stmt.cmd}0 は解除専用で、パラメータを指定できません。", + "error", + context=self._context_line(stmt), + ) + return if stmt.cmd == "ENV": ev = EnvelopeEvent(tick_position=self.tick_position, slot=0) elif stmt.cmd == "VIB": @@ -402,7 +311,7 @@ class PyxelSemanticAnalyzer(SemanticAnalyzer): ) return if stmt.cmd == "ENV": - if len(stmt.params) % 2: + if len(stmt.params) < 2 or len(stmt.params) % 2: self.ctx.add_error( ErrorCode.SEMANTIC_VALUE_OUT_OF_RANGE, stmt.line, @@ -430,14 +339,21 @@ class PyxelSemanticAnalyzer(SemanticAnalyzer): "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 = {} + if len(stmt.params) != 3: + self.ctx.add_error( + ErrorCode.SEMANTIC_VALUE_OUT_OF_RANGE, + stmt.line, + stmt.column, + "@VIBのパラメータは3値で指定してください。", + "error", + context=self._context_line(stmt), + ) + return + params = { + "delay_ticks": stmt.params[0], + "period_ticks": stmt.params[1], + "depth_cents": stmt.params[2], + } ev = VibratoEvent( tick_position=self.tick_position, slot=stmt.slot, @@ -445,13 +361,20 @@ class PyxelSemanticAnalyzer(SemanticAnalyzer): ) self.note_sequence.definitions["vibratos"][stmt.slot] = params else: - if len(stmt.params) >= 2: - params = { - "initial_offset_cents": stmt.params[0], - "duration_ticks": stmt.params[1], - } - else: - params = {} + if len(stmt.params) != 2: + self.ctx.add_error( + ErrorCode.SEMANTIC_VALUE_OUT_OF_RANGE, + stmt.line, + stmt.column, + "@GLIのパラメータは2値で指定してください。", + "error", + context=self._context_line(stmt), + ) + return + params = { + "initial_offset_cents": stmt.params[0], + "duration_ticks": stmt.params[1], + } ev = GlideEvent( tick_position=self.tick_position, slot=stmt.slot, @@ -460,6 +383,22 @@ class PyxelSemanticAnalyzer(SemanticAnalyzer): self.note_sequence.definitions["glides"][stmt.slot] = params self._add_event(ev) else: + table_name = { + "ENV": "envelopes", + "VIB": "vibratos", + "GLI": "glides", + }[stmt.cmd] + if stmt.slot not in self.note_sequence.definitions[table_name]: + self.ctx.add_error( + ErrorCode.SEMANTIC_UNDEFINED_REFERENCE, + stmt.line, + stmt.column, + f"未定義の@{stmt.cmd}スロット {stmt.slot} が参照されました。", + "error", + "先に同じスロットをパラメータ付きで定義してください。", + self._context_line(stmt), + ) + return if stmt.cmd == "ENV": ev = EnvelopeEvent(tick_position=self.tick_position, slot=stmt.slot) elif stmt.cmd == "VIB": diff --git a/tests/test_compose_mml_tool.py b/tests/test_compose_mml_tool.py index 944711f..c33e737 100644 --- a/tests/test_compose_mml_tool.py +++ b/tests/test_compose_mml_tool.py @@ -137,9 +137,7 @@ def test_compose_with_syntax_error(compose_mml): def test_compose_with_warning(compose_mml, tmp_output_dir): - mml = """2: T120 L4 O3 V60 @1 - C4 -""" + mml = "0: T120 L4 O3 V60 [C4]" result = compose_mml(action="compose", mml=mml, mode="pyxel") assert result["success"] is True assert result["validation"]["warnings"] diff --git a/tests/test_error_codes.py b/tests/test_error_codes.py index 538056f..22c9592 100644 --- a/tests/test_error_codes.py +++ b/tests/test_error_codes.py @@ -82,17 +82,17 @@ def test_syntax_unexpected_token(compose_mml): assert result["valid"] is True -# --- SEMANTIC_OUTSIDE_TRACK --- -def test_semantic_outside_track_ppmck(compose_mml): +# --- commands outside tracks are syntax errors --- +def test_command_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.SEMANTIC_OUTSIDE_TRACK.value, "warnings") + assert result["valid"] is False + assert _find_code(result, ErrorCode.SYNTAX_UNEXPECTED_TOKEN.value, "errors") -def test_semantic_outside_track_pyxel(compose_mml): +def test_command_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.SEMANTIC_OUTSIDE_TRACK.value, "warnings") + assert result["valid"] is False + assert _find_code(result, ErrorCode.SYNTAX_UNEXPECTED_TOKEN.value, "errors") # --- SYNTAX_UNTERMINATED_REPEAT --- @@ -193,7 +193,7 @@ def test_semantic_note_out_of_range_pyxel(compose_mml): # --- SEMANTIC_CHANNEL_MISMATCH --- def test_semantic_channel_mismatch_ppmck_triangle_duty(compose_mml): result = compose_mml(action="validate", mml="T t120 l4 @2\n c", mode="ppmck") - assert _find_code(result, ErrorCode.SEMANTIC_CHANNEL_MISMATCH.value, "warnings") + assert _find_code(result, ErrorCode.SEMANTIC_CHANNEL_MISMATCH.value, "errors") def test_semantic_channel_mismatch_ppmck_noise_pitch(compose_mml): @@ -203,7 +203,7 @@ def test_semantic_channel_mismatch_ppmck_noise_pitch(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.SEMANTIC_CHANNEL_MISMATCH.value, "warnings") + assert _find_code(result, ErrorCode.SEMANTIC_CHANNEL_MISMATCH.value, "errors") # --- SYNTAX_UNTERMINATED_HEADER --- diff --git a/tests/test_mml_conformance.py b/tests/test_mml_conformance.py index 442be81..82ec807 100644 --- a/tests/test_mml_conformance.py +++ b/tests/test_mml_conformance.py @@ -4,8 +4,38 @@ 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 DutyEvent, ErrorCode, NoteEvent +from mml_composemusic_mcp.ast_nodes import ( + BarStmt, + DetuneCmdStmt, + DetuneStmt, + DutyStmt, + ExtCmdStmt, + GateTimeStmt, + Header, + LengthStmt, + NoteStmt, + OctaveStmt, + QuantizeStmt, + RelativeVolumeStmt, + RepeatBreakStmt, + RepeatEndStmt, + RepeatStartStmt, + RestStmt, + SweepStmt, + TempoStmt, + TieCmdStmt, + TieStmt, + TransposeStmt, + VolumeEnvelopeDefStmt, + VolumeStmt, +) +from mml_composemusic_mcp.ir import ( + DutyEvent, + ErrorCode, + ErrorPhase, + NoteEvent, + RepeatEvent, +) 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 @@ -29,6 +59,18 @@ def error_codes(errors): return {error.code for error in errors} +def parser_errors(source: str, mode: str): + tokens = tokenize(source, mode) + parser = parse_ppmck if mode == "ppmck" else parse_pyxel + return parser(source, tokens) + + +def first_statement(source: str, mode: str): + program, errors = parser_errors(source, mode) + assert not any(error.severity == "error" for error in errors) + return program.tracks[0].statements[0] + + @pytest.mark.parametrize( ("mode", "source", "accidental", "length", "dots"), [ @@ -268,3 +310,716 @@ def test_generated_single_token_corruption_is_not_silent(mode, bad, index): 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) + + +@pytest.mark.parametrize( + ("mode", "source", "expected"), + [ + pytest.param( + "ppmck", + "A c+4. r8 > < | ; comment", + [ + TokenType.TRACK_HEADER, + TokenType.NOTE, + TokenType.SHARP, + TokenType.NUMBER, + TokenType.DOT, + TokenType.REST, + TokenType.NUMBER, + TokenType.OCTAVE_UP, + TokenType.OCTAVE_DOWN, + TokenType.BAR, + TokenType.COMMENT, + TokenType.EOF, + ], + id="BNF-COMMON-LEX-ppmck", + ), + pytest.param( + "pyxel", + "0: C#4. R8 > < |", + [ + TokenType.TRACK_HEADER, + TokenType.NOTE, + TokenType.SHARP, + TokenType.NUMBER, + TokenType.DOT, + TokenType.REST, + TokenType.NUMBER, + TokenType.OCTAVE_UP, + TokenType.OCTAVE_DOWN, + TokenType.BAR, + TokenType.EOF, + ], + id="BNF-COMMON-LEX-pyxel", + ), + ], +) +def test_bnf_common_lexer_token_contract(mode, source, expected): + assert [token.type for token in tokenize(source, mode)] == expected + + +@pytest.mark.parametrize( + ("source", "token_type", "value"), + [ + pytest.param("A o7", TokenType.OCTAVE, "7", id="BNF-PPMCK-OCTAVE"), + pytest.param("A l192", TokenType.LENGTH, "192", id="BNF-PPMCK-LENGTH"), + pytest.param("A v15", TokenType.VOLUME, "15", id="BNF-PPMCK-VOLUME"), + pytest.param("A @3", TokenType.DUTY, "3", id="BNF-PPMCK-DUTY"), + pytest.param("A q8", TokenType.QUANTIZE, "8", id="BNF-PPMCK-QUANTIZE"), + pytest.param("A t120", TokenType.TEMPO, "120", id="BNF-PPMCK-TEMPO"), + pytest.param("A D-127", TokenType.DETUNE, "-127", id="BNF-PPMCK-DETUNE"), + pytest.param("A s7,-7", TokenType.SWEEP, "7,-7", id="BNF-PPMCK-SWEEP"), + pytest.param("A v+", TokenType.REL_VOL_UP, "", id="BNF-PPMCK-REL-VOL"), + pytest.param("A ^16", TokenType.TIE_CMD, "^", id="BNF-PPMCK-TIE"), + ], +) +def test_bnf_ppmck_command_lexer_contract(source, token_type, value): + token = next( + token for token in tokenize(source, "ppmck") if token.type == token_type + ) + assert token.value == value + + +@pytest.mark.parametrize( + ("source", "token_type", "value"), + [ + pytest.param("0: O7", TokenType.OCTAVE, "7", id="BNF-PYXEL-OCTAVE"), + pytest.param("0: L192", TokenType.LENGTH, "192", id="BNF-PYXEL-LENGTH"), + pytest.param("0: V127", TokenType.VOLUME, "127", id="BNF-PYXEL-VOLUME"), + pytest.param("0: Q100", TokenType.GATE_TIME, "100", id="BNF-PYXEL-GATE"), + pytest.param("0: @3", TokenType.AT, "3", id="BNF-PYXEL-TONE"), + pytest.param("0: T120", TokenType.TEMPO, "120", id="BNF-PYXEL-TEMPO"), + pytest.param("0: K-127", TokenType.TRANSPOSE, "-127", id="BNF-PYXEL-TRANSPOSE"), + pytest.param("0: Y+127", TokenType.DETUNE, "+127", id="BNF-PYXEL-DETUNE"), + pytest.param("0: @ENV1 100 10", TokenType.EXT_CMD, "ENV", id="BNF-PYXEL-EXT"), + ], +) +def test_bnf_pyxel_command_lexer_contract(source, token_type, value): + token = next( + token for token in tokenize(source, "pyxel") if token.type == token_type + ) + assert token.value == value + + +@pytest.mark.parametrize( + ("source", "node_type", "attributes"), + [ + pytest.param("A o7", OctaveStmt, {"value": 7}, id="BNF-PPMCK-AST-o"), + pytest.param("A >", OctaveStmt, {"direction": "up"}, id="BNF-PPMCK-AST-up"), + pytest.param("A l192", LengthStmt, {"value": 192}, id="BNF-PPMCK-AST-l"), + pytest.param("A v15", VolumeStmt, {"value": 15}, id="BNF-PPMCK-AST-v"), + pytest.param("A @3", DutyStmt, {"value": 3}, id="BNF-PPMCK-AST-duty"), + pytest.param("A q8", QuantizeStmt, {"value": 8}, id="BNF-PPMCK-AST-q"), + pytest.param("A t1", TempoStmt, {"value": 1}, id="BNF-PPMCK-AST-t"), + pytest.param("A D-127", DetuneCmdStmt, {"value": -127}, id="BNF-PPMCK-AST-D"), + pytest.param( + "A s7,-7", SweepStmt, {"speed": 7, "depth": -7}, id="BNF-PPMCK-AST-s" + ), + pytest.param( + "A v-15", RelativeVolumeStmt, {"delta": -15}, id="BNF-PPMCK-AST-rel-v" + ), + pytest.param("A ^16", TieCmdStmt, {"target": 16}, id="BNF-PPMCK-AST-tie"), + pytest.param("A &r8", TieStmt, {}, id="BNF-PPMCK-AST-slur"), + pytest.param("A |", BarStmt, {}, id="BNF-PPMCK-AST-bar"), + pytest.param( + "A @v1={15,12|8}", + VolumeEnvelopeDefStmt, + {"slot": 1, "points": [15, 12], "loop_points": [8]}, + id="BNF-PPMCK-AST-envelope", + ), + ], +) +def test_bnf_ppmck_parser_ast_contract(source, node_type, attributes): + statement = first_statement(source, "ppmck") + assert isinstance(statement, node_type) + for name, value in attributes.items(): + assert getattr(statement, name) == value + if source == "A &r8": + assert isinstance(statement.target, RestStmt) + assert statement.target.length == 8 + + +@pytest.mark.parametrize( + ("source", "node_type", "attributes"), + [ + pytest.param("0: O7", OctaveStmt, {"value": 7}, id="BNF-PYXEL-AST-O"), + pytest.param( + "0: <", OctaveStmt, {"direction": "down"}, id="BNF-PYXEL-AST-down" + ), + pytest.param("0: L192", LengthStmt, {"value": 192}, id="BNF-PYXEL-AST-L"), + pytest.param("0: V127", VolumeStmt, {"value": 127}, id="BNF-PYXEL-AST-V"), + pytest.param("0: Q100", GateTimeStmt, {"value": 100}, id="BNF-PYXEL-AST-Q"), + pytest.param("0: @2", DutyStmt, {"value": 2}, id="BNF-PYXEL-AST-tone"), + pytest.param("0: T1", TempoStmt, {"value": 1}, id="BNF-PYXEL-AST-T"), + pytest.param("0: K-127", TransposeStmt, {"value": -127}, id="BNF-PYXEL-AST-K"), + pytest.param("0: Y127", DetuneStmt, {"value": 127}, id="BNF-PYXEL-AST-Y"), + pytest.param("0: &16", TieStmt, {"target": 16}, id="BNF-PYXEL-AST-tie"), + pytest.param( + "0: @VIB1 24 12 100", + ExtCmdStmt, + {"cmd": "VIB", "slot": 1, "params": [24, 12, 100]}, + id="BNF-PYXEL-AST-extension", + ), + ], +) +def test_bnf_pyxel_parser_ast_contract(source, node_type, attributes): + statement = first_statement(source, "pyxel") + assert isinstance(statement, node_type) + for name, value in attributes.items(): + assert getattr(statement, name) == value + + +@pytest.mark.parametrize( + ("mode", "source", "count"), + [ + pytest.param("ppmck", "A [c]2", 2, id="BNF-PPMCK-AST-repeat"), + pytest.param("pyxel", "0: [C]3", 3, id="BNF-PYXEL-AST-repeat"), + ], +) +def test_bnf_repeat_ast_requires_a_complete_structure(mode, source, count): + program, errors = parser_errors(source, mode) + assert not any(error.severity == "error" for error in errors) + statements = program.tracks[0].statements + assert isinstance(statements[0], RepeatStartStmt) + assert isinstance(statements[-1], RepeatEndStmt) + assert statements[-1].count == count + + +@pytest.mark.parametrize( + ("mode", "source", "track_ids", "channels"), + [ + pytest.param( + "ppmck", + "A c\nB d\nT e\nN r\nL", + ["A", "B", "T", "N", "L"], + ["Pulse1", "Pulse2", "Triangle", "Noise", "Loop"], + id="BNF-PPMCK-TRACKS", + ), + pytest.param( + "pyxel", + "0: C\n1: D\n2: E\n3: R", + ["0", "1", "2", "3"], + ["Pulse1", "Pulse2", "Triangle", "Noise"], + id="BNF-PYXEL-TRACKS", + ), + ], +) +def test_bnf_track_mapping(mode, source, track_ids, channels): + program, errors = parser_errors(source, mode) + assert not any(error.severity == "error" for error in errors) + assert [track.track_id for track in program.tracks] == track_ids + assert [track.channel for track in program.tracks] == channels + + +def test_bnf_ppmck_header_ast_and_placement(): + source = '#TITLE "Song"\n#COMPOSER "Composer"\nA c' + program, errors = parser_errors(source, "ppmck") + assert not errors + headers = program.tracks[0].headers + assert all(isinstance(header, Header) for header in headers) + assert [(header.key, header.value) for header in headers] == [ + ("#TITLE", "Song"), + ("#COMPOSER", "Composer"), + ] + + +@pytest.mark.parametrize( + ("mode", "source"), + [ + pytest.param("ppmck", "A K12 c", id="BNF-MODE-ppmck-rejects-pyxel"), + pytest.param("pyxel", "0: v15 C", id="BNF-MODE-pyxel-rejects-ppmck"), + ], +) +def test_bnf_cross_mode_command_is_not_silent(mode, source): + _, _, _, errors = parse_pipeline(source, mode) + assert any(error.severity == "error" for error in errors) + + +@pytest.mark.parametrize( + ("mode", "source"), + [ + pytest.param("ppmck", "A c", id="BNF-DEFAULTS-ppmck"), + pytest.param("pyxel", "0: C", id="BNF-DEFAULTS-pyxel"), + ], +) +def test_bnf_default_note_state_reaches_ir(mode, source): + _, _, sequence, errors = parse_pipeline(source, mode) + 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.note_number == 60 + assert note.duration == 192 + assert note.gate_time == (1.0 if mode == "ppmck" else 0.8) + + +@pytest.mark.parametrize( + ("mode", "source"), + [ + pytest.param( + "ppmck", + "A o0 o7 l1 l192 v0 v15 @0 @3 q1 q8 t1 c", + id="BNF-RANGE-ppmck-valid", + ), + pytest.param("ppmck", "A s0,1 s7,-7 c", id="BNF-RANGE-ppmck-sweep-valid"), + pytest.param( + "pyxel", + "0: O0 O7 L1 L192 V0 V127 Q0 Q100 @1 @2 T1 C", + id="BNF-RANGE-pyxel-valid", + ), + pytest.param( + "pyxel", "0: K-127 K127 Y-127 Y127 C", id="BNF-RANGE-pyxel-signed-valid" + ), + ], +) +def test_bnf_semantic_boundary_values_are_accepted(mode, source): + _, _, _, errors = parse_pipeline(source, mode) + assert not any(error.severity == "error" for error in errors) + + +@pytest.mark.parametrize( + ("mode", "source"), + [ + pytest.param("ppmck", "A o8 c", id="BNF-RANGE-ppmck-o"), + pytest.param("ppmck", "A l0 c", id="BNF-RANGE-ppmck-l-low"), + pytest.param("ppmck", "A l193 c", id="BNF-RANGE-ppmck-l-high"), + pytest.param("ppmck", "A v16 c", id="BNF-RANGE-ppmck-v"), + pytest.param("ppmck", "A @4 c", id="BNF-RANGE-ppmck-duty"), + pytest.param("ppmck", "A q0 c", id="BNF-RANGE-ppmck-q-low"), + pytest.param("ppmck", "A q9 c", id="BNF-RANGE-ppmck-q-high"), + pytest.param("ppmck", "A t0 c", id="BNF-RANGE-ppmck-t"), + pytest.param("ppmck", "A s8,1 c", id="BNF-RANGE-ppmck-s-speed"), + pytest.param("ppmck", "A s0,0 c", id="BNF-RANGE-ppmck-s-depth"), + pytest.param("pyxel", "0: O8 C", id="BNF-RANGE-pyxel-O"), + pytest.param("pyxel", "0: L0 C", id="BNF-RANGE-pyxel-L-low"), + pytest.param("pyxel", "0: L193 C", id="BNF-RANGE-pyxel-L-high"), + pytest.param("pyxel", "0: V128 C", id="BNF-RANGE-pyxel-V"), + pytest.param("pyxel", "0: Q101 C", id="BNF-RANGE-pyxel-Q"), + pytest.param("pyxel", "0: @4 C", id="BNF-RANGE-pyxel-tone"), + pytest.param("pyxel", "0: T0 C", id="BNF-RANGE-pyxel-T"), + ], +) +def test_bnf_semantic_out_of_range_reports_phase(mode, source): + _, _, _, errors = parse_pipeline(source, mode) + matching = [ + error for error in errors if error.code == ErrorCode.SEMANTIC_VALUE_OUT_OF_RANGE + ] + assert matching + assert all(error.phase == ErrorPhase.SEMANTIC for error in matching) + + +@pytest.mark.parametrize( + ("mode", "source"), + [ + pytest.param("ppmck", "T @2 c", id="BNF-CHANNEL-ppmck-duty"), + pytest.param("ppmck", "T s1,1 c", id="BNF-CHANNEL-ppmck-sweep"), + pytest.param("pyxel", "2: @1 C", id="BNF-CHANNEL-pyxel-tone"), + ], +) +def test_bnf_channel_mismatch_is_semantic(mode, source): + _, _, _, errors = parse_pipeline(source, mode) + mismatch = [ + error for error in errors if error.code == ErrorCode.SEMANTIC_CHANNEL_MISMATCH + ] + assert mismatch + assert all(error.phase == ErrorPhase.SEMANTIC for error in mismatch) + + +def test_bnf_pyxel_nested_repeat_reaches_ir(): + _, _, sequence, errors = parse_pipeline("0: [C [D]2]3", "pyxel") + assert not any(error.severity == "error" for error in errors) + notes = [ + event + for event in sequence.channels["Pulse1"].events + if isinstance(event, NoteEvent) + ] + assert len(notes) == 9 + + +@pytest.mark.parametrize( + ("source", "event_key"), + [ + pytest.param("0: @ENV1 100 10 C", "envelopes", id="BNF-PYXEL-IR-ENV"), + pytest.param("0: @VIB1 24 12 100 C", "vibratos", id="BNF-PYXEL-IR-VIB"), + pytest.param("0: @GLI1 100 24 C", "glides", id="BNF-PYXEL-IR-GLI"), + ], +) +def test_bnf_pyxel_extension_definition_reaches_ir(source, event_key): + _, _, sequence, errors = parse_pipeline(source, "pyxel") + assert not any(error.severity == "error" for error in errors) + assert 1 in sequence.definitions[event_key] + + +def test_parser_error_stops_pipeline_before_semantic_ir(): + _, _, sequence, errors = parse_pipeline("0: C ? D", "pyxel") + assert sequence is None + assert ErrorCode.SYNTAX_INVALID_TOKEN in error_codes(errors) + assert all(error.phase == ErrorPhase.SYNTAX for error in errors) + + +def test_semantic_error_is_distinguishable_from_parser_error(): + _, program, sequence, errors = parse_pipeline("0: O8 C", "pyxel") + assert program.tracks + assert sequence is not None + assert any(error.phase == ErrorPhase.SEMANTIC for error in errors) + + +@pytest.mark.parametrize( + ("mode", "source"), + [ + pytest.param("ppmck", "A C", id="STRICT-CASE-ppmck-note"), + pytest.param("ppmck", "A O4 c", id="STRICT-CASE-ppmck-command"), + pytest.param("pyxel", "0: c", id="STRICT-CASE-pyxel-note"), + pytest.param("pyxel", "0: o4 C", id="STRICT-CASE-pyxel-command"), + pytest.param("pyxel", "0: @env1 1 1 C", id="STRICT-CASE-pyxel-ext"), + ], +) +def test_case_rules_are_enforced_by_the_lexer(mode, source): + tokens, _, sequence, errors = parse_pipeline(source, mode) + assert any(token.type == TokenType.INVALID for token in tokens) + assert sequence is None + assert ErrorCode.SYNTAX_INVALID_TOKEN in error_codes(errors) + + +@pytest.mark.parametrize( + ("source", "raw"), + [ + pytest.param("A D+", "D+", id="RAW-sign-only"), + pytest.param("A s1,-", "s1,-", id="RAW-sweep-sign-only"), + pytest.param("A s1 2", "s1", id="RAW-missing-comma"), + ], +) +def test_malformed_compound_tokens_preserve_source_raw_and_report(source, raw): + tokens, _, sequence, errors = parse_pipeline(source, "ppmck") + assert raw in [token.raw for token in tokens] + assert sequence is None + assert ErrorCode.SYNTAX_INVALID_NUMBER in error_codes(errors) + + +def test_ppmck_top_level_order_and_global_definitions(): + valid = '#TITLE "Song"\n@v1={15,8}\n@EP2={-12,+12}\nA @v1 EP2 c' + _, program, sequence, errors = parse_pipeline(valid, "ppmck") + assert not any(error.severity == "error" for error in errors) + assert len(program.global_statements) == 2 + assert 1 in sequence.definitions["volume_envelopes"] + assert 2 in sequence.definitions["pitch_envelopes"] + + for invalid in ('@v1={15}\n#TITLE "Late"\nA c', "c\nA c", "@v1\nA c"): + _, _, sequence, errors = parse_pipeline(invalid, "ppmck") + assert sequence is None + assert ErrorCode.SYNTAX_UNEXPECTED_TOKEN in error_codes(errors) + + +def test_ppmck_repeat_break_ast_and_expansion(): + source = "A l4 [c|d]3 e" + _, program, sequence, errors = parse_pipeline(source, "ppmck") + assert not any(error.severity == "error" for error in errors) + assert isinstance(program.tracks[0].statements[3], RepeatBreakStmt) + notes = [ + event + for event in sequence.channels["Pulse1"].events + if isinstance(event, NoteEvent) + ] + assert [note.note_number for note in notes] == [60, 62, 60, 62, 60, 64] + assert [note.tick_position for note in notes] == [0, 192, 384, 576, 768, 960] + + +@pytest.mark.parametrize( + ("source", "expected_notes"), + [ + pytest.param("A [c d]1", [60, 62], id="REPEAT-normal-N1"), + pytest.param("A [c|d]1", [60], id="REPEAT-break-N1"), + pytest.param( + "A [c [d|e]2|f]2", + [60, 62, 64, 62, 65, 60, 62, 64, 62], + id="REPEAT-nested-breaks", + ), + ], +) +def test_ppmck_repeat_variants(source, expected_notes): + _, _, sequence, errors = parse_pipeline(source, "ppmck") + assert not any(error.severity == "error" for error in errors) + notes = [ + event.note_number + for event in sequence.channels["Pulse1"].events + if isinstance(event, NoteEvent) + ] + assert notes == expected_notes + + +@pytest.mark.parametrize( + "source", + [ + pytest.param("A | c", id="REPEAT-outside-bar-valid"), + pytest.param("A [c|d|e]2", id="REPEAT-duplicate-break"), + pytest.param("A [c", id="REPEAT-unterminated"), + pytest.param("A c]2", id="REPEAT-extra-end"), + ], +) +def test_ppmck_repeat_structure_is_checked_in_parser(source): + program, errors = parser_errors(source, "ppmck") + if source == "A | c": + assert not any(error.severity == "error" for error in errors) + assert isinstance(program.tracks[0].statements[0], BarStmt) + else: + assert any(error.severity == "error" for error in errors) + + +def test_infinite_repeat_has_two_cycle_preview_and_repeat_count_zero(): + _, _, sequence, errors = parse_pipeline("A [c d]", "ppmck") + assert not any(error.severity == "error" for error in errors) + assert any(error.severity == "warning" for error in errors) + events = sequence.channels["Pulse1"].events + assert len([event for event in events if isinstance(event, NoteEvent)]) == 4 + repeat = next(event for event in events if isinstance(event, RepeatEvent)) + assert repeat.repeat_count == 0 + assert (repeat.start_tick, repeat.end_tick) == (0, 768) + + +@pytest.mark.parametrize( + ("count", "accepted"), + [ + pytest.param(99_999, True, id="REPEAT-LIMIT-exact-100000-events"), + pytest.param(100_000, False, id="REPEAT-LIMIT-100001-events"), + ], +) +def test_repeat_expansion_event_limit_is_transactional(count, accepted): + _, _, sequence, errors = parse_pipeline(f"A [c]{count}", "ppmck") + range_errors = [ + error for error in errors if error.code == ErrorCode.SEMANTIC_VALUE_OUT_OF_RANGE + ] + if accepted: + assert not range_errors + assert len(sequence.channels["Pulse1"].events) == 100_000 + else: + assert range_errors + assert sequence.channels["Pulse1"].events == [] + + +def test_nested_repeat_limit_is_preflighted_without_partial_events(): + _, _, sequence, errors = parse_pipeline("A [[c]500]201", "ppmck") + assert ErrorCode.SEMANTIC_VALUE_OUT_OF_RANGE in error_codes(errors) + assert sequence.channels["Pulse1"].events == [] + + +@pytest.mark.parametrize( + ("loop_body", "expected_start"), + [ + pytest.param("", 0, id="LOOP-empty"), + pytest.param("l8 r", 96, id="LOOP-default-length"), + pytest.param("l8 r.", 144, id="LOOP-dotted-rest"), + pytest.param("l8 [r]3", 288, id="LOOP-finite-repeat"), + pytest.param("l8 [r|r]2", 288, id="LOOP-repeat-break"), + pytest.param("l8 [r]", 192, id="LOOP-infinite-preview"), + ], +) +def test_loop_track_calculates_global_loop_start(loop_body, expected_start): + _, _, sequence, errors = parse_pipeline(f"A l1 c\nL {loop_body}", "ppmck") + assert not any(error.severity == "error" for error in errors) + for channel in sequence.channels.values(): + repeats = [event for event in channel.events if isinstance(event, RepeatEvent)] + assert repeats[-1].repeat_count == 0 + assert repeats[-1].start_tick == expected_start + assert repeats[-1].end_tick == 768 + + +@pytest.mark.parametrize( + "source", + [ + pytest.param("A c\nL c", id="LOOP-note-forbidden"), + pytest.param("A c\nL v1", id="LOOP-volume-forbidden"), + pytest.param("A c\nL r\nL", id="LOOP-duplicate"), + ], +) +def test_loop_track_rejects_forbidden_or_duplicate_content(source): + _, _, sequence, errors = parse_pipeline(source, "ppmck") + assert sequence is None + assert any(error.severity == "error" for error in errors) + + +def test_loop_start_must_be_before_the_longest_audio_track_end(): + _, _, sequence, errors = parse_pipeline("A l4 c\nL l4 r", "ppmck") + assert sequence is not None + assert ErrorCode.SEMANTIC_VALUE_OUT_OF_RANGE in error_codes(errors) + assert not any( + isinstance(event, RepeatEvent) + for channel in sequence.channels.values() + for event in channel.events + ) + + +@pytest.mark.parametrize( + ("source", "accepted"), + [ + pytest.param("@v0={0,15}\nA c", True, id="ENV-volume-boundaries"), + pytest.param("@1={0,3}\nA c", True, id="ENV-duty-boundaries"), + pytest.param("@EP255={-127,+126}\nA c", True, id="ENV-pitch-signed"), + pytest.param("@EN255={-127,126}\nA c", True, id="ENV-note-signed"), + pytest.param("@v256={1}\nA c", False, id="ENV-slot-high"), + pytest.param("@v1={16}\nA c", False, id="ENV-volume-high"), + pytest.param("@1={4}\nA c", False, id="ENV-duty-high"), + pytest.param("@EP1={127}\nA c", False, id="ENV-pitch-high"), + pytest.param("@EN1={-128}\nA c", False, id="ENV-note-low"), + ], +) +def test_ppmck_definition_ranges(source, accepted): + _, _, _, errors = parse_pipeline(source, "ppmck") + range_errors = [ + error for error in errors if error.code == ErrorCode.SEMANTIC_VALUE_OUT_OF_RANGE + ] + assert bool(range_errors) is not accepted + + +@pytest.mark.parametrize( + "source", + [ + pytest.param("@v1={}\nA c", id="DEF-empty"), + pytest.param("@v1={1\nA c", id="DEF-unterminated"), + pytest.param("@MP1={0,1,2}\nA c", id="DEF-lfo-missing-value"), + pytest.param("@MP1={0 1,2,0}\nA c", id="DEF-lfo-missing-comma"), + ], +) +def test_ppmck_broken_definitions_stop_before_semantics(source): + _, _, sequence, errors = parse_pipeline(source, "ppmck") + assert sequence is None + assert any(error.phase == ErrorPhase.SYNTAX for error in errors) + + +@pytest.mark.parametrize( + ("cmd", "params", "accepted"), + [ + pytest.param("ENV", "1 2", True, id="PYXEL-ENV-two"), + pytest.param("ENV", "1 2 3 4", True, id="PYXEL-ENV-four"), + pytest.param("ENV", "1", False, id="PYXEL-ENV-one"), + pytest.param("ENV", "1 2 3", False, id="PYXEL-ENV-odd"), + pytest.param("VIB", "1 2 3", True, id="PYXEL-VIB-three"), + pytest.param("VIB", "1 2", False, id="PYXEL-VIB-two"), + pytest.param("VIB", "1 2 3 4", False, id="PYXEL-VIB-four"), + pytest.param("GLI", "1 2", True, id="PYXEL-GLI-two"), + pytest.param("GLI", "1", False, id="PYXEL-GLI-one"), + pytest.param("GLI", "1 2 3", False, id="PYXEL-GLI-three"), + ], +) +def test_pyxel_extension_arity(cmd, params, accepted): + _, _, _, errors = parse_pipeline(f"0: @{cmd}1 {params} C", "pyxel") + range_errors = [ + error for error in errors if error.code == ErrorCode.SEMANTIC_VALUE_OUT_OF_RANGE + ] + assert bool(range_errors) is not accepted + + +def test_pyxel_extension_selection_and_definition_errors_use_source_position(): + source = "0: @VIB1 1 2 3 C @VIB1 D" + _, _, sequence, errors = parse_pipeline(source, "pyxel") + assert not any(error.severity == "error" for error in errors) + assert 1 in sequence.definitions["vibratos"] + + for invalid, code in ( + ("0: @VIB1 C", ErrorCode.SEMANTIC_UNDEFINED_REFERENCE), + ("0: @VIB0 1 2 3 C", ErrorCode.SEMANTIC_VALUE_OUT_OF_RANGE), + ("0: @VIB1 1 2 3 @VIB1 4 5 6 C", ErrorCode.SEMANTIC_DUPLICATE_DEFINITION), + ): + _, _, _, errors = parse_pipeline(invalid, "pyxel") + matching = [error for error in errors if error.code == code] + assert matching and matching[0].line == 1 and matching[0].column > 0 + + +@pytest.mark.parametrize( + ("mode", "source"), + [ + pytest.param("ppmck", "A c0", id="LENGTH-ppmck-note-zero"), + pytest.param("ppmck", "A c193", id="LENGTH-ppmck-note-high"), + pytest.param("ppmck", "A c ^0", id="LENGTH-ppmck-tie-zero"), + pytest.param("ppmck", "A c ^193", id="LENGTH-ppmck-tie-high"), + pytest.param("pyxel", "0: C0", id="LENGTH-pyxel-note-zero"), + pytest.param("pyxel", "0: C193", id="LENGTH-pyxel-note-high"), + pytest.param("pyxel", "0: C &0", id="LENGTH-pyxel-tie-zero"), + pytest.param("pyxel", "0: C &193", id="LENGTH-pyxel-tie-high"), + ], +) +def test_explicit_and_tie_lengths_are_checked_before_division(mode, source): + _, _, _, errors = parse_pipeline(source, mode) + assert ErrorCode.SEMANTIC_VALUE_OUT_OF_RANGE in error_codes(errors) + + +def test_invalid_note_length_does_not_advance_state_or_add_partial_event(): + _, _, sequence, errors = parse_pipeline("A l4 c c0 d", "ppmck") + assert ErrorCode.SEMANTIC_VALUE_OUT_OF_RANGE in error_codes(errors) + notes = [ + event + for event in sequence.channels["Pulse1"].events + if isinstance(event, NoteEvent) + ] + assert len(notes) == 2 + assert notes[1].tick_position == 192 + + +def test_compose_does_not_call_synthesizer_after_syntax_or_semantic_error(monkeypatch): + import mml_composemusic_mcp.server as server + + called = False + + def fail_if_called(*args, **kwargs): # type: ignore[no-untyped-def] + nonlocal called + called = True + raise AssertionError("synthesizer must not be called") + + monkeypatch.setattr(server, "synthesize", fail_if_called) + for mode, source in (("ppmck", "A C"), ("pyxel", "0: O8 C")): + result = compose_mml(action="compose", mml=source, mode=mode) + assert result["success"] is False + assert called is False + + +@pytest.mark.parametrize( + ("mode", "source"), + [ + pytest.param("ppmck", "A [c]0", id="REPEAT-ZERO-ppmck"), + pytest.param("pyxel", "0: [C]0", id="REPEAT-ZERO-pyxel"), + ], +) +def test_repeat_count_zero_is_rejected_without_partial_expansion(mode, source): + _, _, sequence, errors = parse_pipeline(source, mode) + assert ErrorCode.SEMANTIC_VALUE_OUT_OF_RANGE in error_codes(errors) + assert sequence.channels["Pulse1"].events == [] + + +def test_ppmck_effect_reference_errors_keep_ast_source_position(): + source = "A c @v7" + _, _, _, errors = parse_pipeline(source, "ppmck") + undefined = [ + error + for error in errors + if error.code == ErrorCode.SEMANTIC_UNDEFINED_REFERENCE + ] + assert undefined and (undefined[0].line, undefined[0].column) == (1, 5) + + duplicate_source = "@v1={1}\n@v1={2}\nA c" + _, _, _, errors = parse_pipeline(duplicate_source, "ppmck") + duplicate = [ + error + for error in errors + if error.code == ErrorCode.SEMANTIC_DUPLICATE_DEFINITION + ] + assert duplicate and (duplicate[0].line, duplicate[0].column) == (2, 1) + + +@pytest.mark.parametrize( + "source", + [ + pytest.param("2: @0 C", id="PYXEL-TONE-triangle"), + pytest.param("3: @3 C", id="PYXEL-TONE-noise"), + ], +) +def test_pyxel_non_pulse_tones_match_their_track(source): + _, _, _, errors = parse_pipeline(source, "pyxel") + assert not any(error.severity == "error" for error in errors) + + +def test_giant_numeric_value_is_diagnosed_without_parser_failure(): + source = "A l" + ("9" * 1_000) + " c" + _, _, sequence, errors = parse_pipeline(source, "ppmck") + assert sequence is not None + assert ErrorCode.SEMANTIC_VALUE_OUT_OF_RANGE in error_codes(errors) -- cgit v1.2.3