summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authortanaka takashi <isazaka@gmail.com>2026-07-15 20:00:24 +0900
committertanaka takashi <isazaka@gmail.com>2026-07-15 20:00:24 +0900
commit7249ca3a6fdf51f6b7ea66ac6854edd5e02ae5ec (patch)
tree37a42621228a6f7a87ef02ae08d4613b2151a1a5
parent9cbb60cf68ab2a8bd9ccc0dca4fbd46f2ae9b928 (diff)
feat: MMLパーサーと意味解析を厳格化
- ppmckの終端分岐付きリピートとL制御トラックを実装 - case、構文構造、音長、エフェクト値域の検証を強化 - Pyxel拡張コマンドのarityと参照検証を追加 - リピート展開上限と構文・意味エラー時の合成停止を追加 - BNF、設計書、適合監査、回帰テストを更新
-rw-r--r--doc/Design.md56
-rw-r--r--doc/MML_BNF.md102
-rw-r--r--src/mml_composemusic_mcp/ast_nodes.py8
-rw-r--r--src/mml_composemusic_mcp/lexer.py46
-rw-r--r--src/mml_composemusic_mcp/parser_base.py5
-rw-r--r--src/mml_composemusic_mcp/parser_ppmck.py230
-rw-r--r--src/mml_composemusic_mcp/parser_pyxel.py38
-rw-r--r--src/mml_composemusic_mcp/semantic_base.py190
-rw-r--r--src/mml_composemusic_mcp/semantic_ppmck.py214
-rw-r--r--src/mml_composemusic_mcp/semantic_pyxel.py239
-rw-r--r--tests/test_compose_mml_tool.py4
-rw-r--r--tests/test_error_codes.py18
-rw-r--r--tests/test_mml_conformance.py759
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ノード
@@ -515,6 +517,10 @@ 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
-<ppmck_mml> ::= <ppmck_header>* <ppmck_track>*
-<ppmck_track> ::= <track_header> <ppmck_statement>*
-<track_header>::= "A" | "B" | "T" | "N" | "L"
+<ppmck_mml> ::= <ppmck_header>* <global_definition>* <ppmck_track>*
+<ppmck_track> ::= <audio_track> | <loop_track>
+<audio_track> ::= ("A" | "B" | "T" | "N") <ppmck_statement>*
+<loop_track> ::= "L" (<length_cmd> | <rest> | <loop_repeat> | <bar>)*
<ppmck_statement>
::= <note> | <rest> | <octave_cmd> | <length_cmd>
| <volume_cmd> | <relative_volume_cmd> | <duty_cmd>
@@ -608,7 +615,10 @@ class ErrorPhase(Enum):
| <lfo_def> | <lfo_use> | <lfo_off>
| <pitch_env_def> | <pitch_env_use> | <pitch_env_off>
| <note_env_def> | <note_env_use> | <note_env_off>
- | <repeat_start> | <repeat_end> | <bar>
+ | <ppmck_repeat> | <bar>
+<ppmck_repeat>::= "[" <ppmck_statement>* ("|" <ppmck_statement>*)? "]" <number>?
+<loop_repeat> ::= "[" (<length_cmd> | <rest> | <loop_repeat>)*
+ ("|" (<length_cmd> | <rest> | <loop_repeat>)*)? "]" <number>?
```
### 8.3 pyxelモード
@@ -617,12 +627,13 @@ class ErrorPhase(Enum):
<pyxel_mml> ::= <pyxel_track>*
<pyxel_track> ::= <pyxel_track_header> <pyxel_statement>*
<pyxel_track_header>
- ::= <number> ":"
+ ::= "0:" | "1:" | "2:" | "3:"
<pyxel_statement>
::= <note> | <rest> | <octave_cmd> | <length_cmd>
| <volume_cmd> | <gate_cmd> | <tone_cmd> | <tempo_cmd>
| <transpose_cmd> | <detune_cmd> | <tie_cmd>
- | <repeat_start> | <repeat_end> | <bar> | <ext_cmd>
+ | <pyxel_repeat> | <bar> | <ext_cmd>
+<pyxel_repeat>::= "[" <pyxel_statement>* "]" <number>?
```
---
@@ -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 @@
<digit> ::= "0" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9"
<number> ::= <digit>+
-/* 音符名(Lexer で正規化: ppmck は小文字、pyxel は大文字 → 内部は小文字) */
-<note_name> ::= "c" | "d" | "e" | "f" | "g" | "a" | "b"
+/* 音符名(case-sensitive。受理後のASTでは小文字へ正規化) */
+<ppmck_note_name> ::= "c" | "d" | "e" | "f" | "g" | "a" | "b"
+<pyxel_note_name> ::= "C" | "D" | "E" | "F" | "G" | "A" | "B"
/* 変化記号 */
<sharp> ::= "+" | "#"
@@ -43,6 +44,8 @@
<whitespace> ::= " " | "\t" | "\r" | "\n"
```
+`<whitespace>`は字句間の区切りであり、statementやASTノードではない。`#`はppmckの行頭ヘッダー開始、または両モードで音符名の直後に置くsharpとしてのみ有効である。
+
---
## 2. ppmck モード
@@ -50,7 +53,7 @@
### 2.1 全体構造
```bnf
-<ppmck_mml> ::= <ppmck_header>* <ppmck_track>*
+<ppmck_mml> ::= <ppmck_header>* <ppmck_global_definition>* <ppmck_track>*
<ppmck_header> ::= "#" <header_key> <header_value>?
<header_key> ::= "TITLE" | "COMPOSER" | "PROGRAMER" | "OCTAVE-REV"
@@ -58,15 +61,30 @@
| "EFFECT-INCLUDE"
<header_value> ::= "\"" <string> "\""
-<ppmck_track> ::= <track_header> <ppmck_statement>*
-<track_header> ::= "A" | "B" | "T" | "N" | "L"
+<ppmck_global_definition>
+ ::= <vol_envelope_def> | <duty_envelope_def> | <lfo_def>
+ | <pitch_env_def> | <note_env_def>
+
+<ppmck_track> ::= <audio_track> | <loop_track>
+<audio_track> ::= <audio_track_header> <ppmck_statement>*
+<audio_track_header>
+ ::= "A" | "B" | "T" | "N"
+<loop_track> ::= "L" <loop_statement>*
+<loop_statement> ::= <length_cmd> | <ppmck_rest> | <loop_repeat> | <bar>
+<loop_repeat_item>::= <length_cmd> | <ppmck_rest> | <loop_repeat>
+<loop_repeat> ::= "[" <loop_repeat_item>* ("|" <loop_repeat_item>*)? "]" <number>?
```
+グローバル定義は全トラック共通である。既存入力との互換性のため、同じ定義を音声トラック内の`<ppmck_ext_cmd>`として置くこともできる。`L`は音声を生成しない制御トラックで、0回または1回だけ記述できる。
+
### 2.2 ステートメント
```bnf
-<ppmck_statement> ::= <note>
- | <rest>
+<ppmck_statement> ::= <ppmck_simple_statement> | <ppmck_repeat> | <bar>
+
+<ppmck_simple_statement>
+ ::= <ppmck_note>
+ | <ppmck_rest>
| <octave_cmd>
| <length_cmd>
| <volume_cmd>
@@ -76,14 +94,10 @@
| <tie_cmd>
| <slur_cmd>
| <ppmck_ext_cmd>
- | <repeat_start>
- | <repeat_end>
- | <bar>
| <comment>
- | <newline>
-<note> ::= <note_name> <accidental>? <length>
-<rest> ::= "r" <length>
+<ppmck_note> ::= <ppmck_note_name> <accidental>? <length>
+<ppmck_rest> ::= "r" <length>
<octave_cmd> ::= "o" <number> /* o0 〜 o7 */
| <octave_up>
@@ -96,12 +110,18 @@
<quantize_cmd> ::= "q" <number> /* q1 〜 q8 */
<tempo_cmd> ::= "t" <number> /* t1 〜 */
-<tie_cmd> ::= "^" (<note> | <number>)
-<slur_cmd> ::= "&" (<note> | <rest> | <number>)
+<tie_cmd> ::= "^" (<ppmck_note> | <number>)
+<slur_cmd> ::= "&" (<ppmck_note> | <ppmck_rest> | <number>)
<comment> ::= ";" <string_to_eol>
+
+<ppmck_repeat_item>
+ ::= <ppmck_simple_statement> | <ppmck_repeat>
+<ppmck_repeat> ::= "[" <ppmck_repeat_item>* ("|" <ppmck_repeat_item>*)? "]" <number>?
```
+`[本体]N`は本体をN回再生する。`[前半|後半]N`は`前半+後半`をN−1回、最後に前半だけを再生する。`N=1`では前半だけとなる。`|`は現在のリピート階層で高々1個で、ネストした`|`は内側に所属する。リピート外の`|`は小節線である。回数省略は無限を表し、IRプレビューでは`前半+後半`を2回展開して`repeat_count=0`を保持する。
+
### 2.3 合成拡張コマンド
```bnf
@@ -123,27 +143,27 @@
| <note_env_off>
<relative_volume_cmd>
- ::= "v+" <number>? /* 1 〜 15 */
- | "v-" <number>? /* 1 〜 15 */
+ ::= "v+" <number>? /* 省略時1、1 〜 15 */
+ | "v-" <number>? /* 省略時1、1 〜 15 */
<detune_cmd> ::= "D" <signed_number> /* -127 〜 126 */
<sweep_cmd> ::= "s" <number> "," <signed_number>
-<vol_envelope_def>::= "@v" <number> "=" "{" <number_list> "|" <number_list>? "}"
+<vol_envelope_def>::= "@v" <number> "=" "{" <number_list> ("|" <number_list>?)? "}"
<vol_envelope_use>::= "@v" <number>
-<duty_envelope_def>::= "@" <number> "=" "{" <number_list> "|" <number_list>? "}"
+<duty_envelope_def>::= "@" <number> "=" "{" <number_list> ("|" <number_list>?)? "}"
<duty_envelope_use>::= "@@" <number>
<lfo_def> ::= "@MP" <number> "=" "{" <number> "," <number> "," <number> "," <number> "}"
<lfo_use> ::= "MP" <number>
<lfo_off> ::= "MPOF"
-<pitch_env_def> ::= "@EP" <number> "=" "{" <signed_number_list> "|" <signed_number_list>? "}"
+<pitch_env_def> ::= "@EP" <number> "=" "{" <signed_number_list> ("|" <signed_number_list>?)? "}"
<pitch_env_use> ::= "EP" <number>
<pitch_env_off> ::= "EPOF"
-<note_env_def> ::= "@EN" <number> "=" "{" <signed_number_list> "|" <signed_number_list>? "}"
+<note_env_def> ::= "@EN" <number> "=" "{" <signed_number_list> ("|" <signed_number_list>?)? "}"
<note_env_use> ::= "EN" <number>
<note_env_off> ::= "ENOF"
@@ -158,13 +178,10 @@
```bnf
<ppmck_stage2> ::= <count_length>
- | <section_repeat>
| <transpose_cmd>
| <noise_mode_cmd>
-<count_length> ::= <note_name> <accidental>? "%" <number>
-
-<section_repeat> ::= "[" <ppmck_statement>+ "]" <number>?
+<count_length> ::= <ppmck_note_name> <accidental>? "%" <number>
<transpose_cmd> ::= "K" <number>
<noise_mode_cmd> ::= "m" <number>
@@ -182,14 +199,14 @@
<pyxel_track> ::= <pyxel_track_header> <pyxel_statement>*
<pyxel_track_header>
- ::= <number> ":" /* 0: 1: 2: 3: */
+ ::= "0:" | "1:" | "2:" | "3:"
```
### 3.2 ステートメント
```bnf
-<pyxel_statement> ::= <note>
- | <rest>
+<pyxel_statement> ::= <pyxel_note>
+ | <pyxel_rest>
| <octave_cmd>
| <length_cmd>
| <volume_cmd>
@@ -199,14 +216,12 @@
| <transpose_cmd>
| <detune_cmd>
| <tie_cmd>
- | <repeat_start>
- | <repeat_end>
+ | <pyxel_repeat>
| <bar>
| <ext_cmd>
- | <newline>
-<note> ::= <note_name> <accidental>? <length>
-<rest> ::= "R" <length>
+<pyxel_note> ::= <pyxel_note_name> <accidental>? <length>
+<pyxel_rest> ::= "R" <length>
<octave_cmd> ::= "O" <number> /* O0 〜 O7 */
| <octave_up>
@@ -221,24 +236,29 @@
<transpose_cmd> ::= "K" <signed_number> /* 半音単位 */
<detune_cmd> ::= "Y" <signed_number> /* セント単位 */
-<tie_cmd> ::= <tie> (<note> | <rest> | <number>)
+<tie_cmd> ::= <tie> (<pyxel_note> | <pyxel_rest> | <number>)
+<pyxel_repeat> ::= "[" <pyxel_statement>* "]" <number>?
```
+Pyxelリピートはネストできる。指定回数は1以上で、回数省略は無限を表す。IRプレビューでは本体を2回展開し、`repeat_count=0`を保持する。Pyxelの`|`は常に小節線であり、終端分岐ではない。
+
### 3.3 合成拡張コマンド
```bnf
<ext_cmd> ::= <env_cmd> | <vib_cmd> | <gli_cmd>
<env_cmd> ::= "@ENV" <number> <env_def>?
-<env_def> ::= <number>+
+<env_def> ::= <number> <number> (<number> <number>)*
<vib_cmd> ::= "@VIB" <number> <vib_def>?
-<vib_def> ::= <number>+
+<vib_def> ::= <number> <number> <number>
<gli_cmd> ::= "@GLI" <number> <gli_def>?
-<gli_def> ::= <number>+
+<gli_def> ::= <number> <number>
```
+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
@@ -161,6 +161,13 @@ class RepeatStartStmt(ASTNode):
@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<speed>,<depth>", 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)