summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authortanaka takashi <isazaka@gmail.com>2026-07-12 17:58:19 +0900
committertanaka takashi <isazaka@gmail.com>2026-07-12 17:59:16 +0900
commitbf36a773918959eeb6ffe7dae2fb8b0874438eb4 (patch)
treeda7ff0037ea923fea255d2ed69ed2fb51395d9b9
parentf1cd4d7d16f6e75a50bb0c587c0f759f7cf710cd (diff)
feat(ppmck): mckc準拠の構文拡張とIR保持コマンド追加
- @をデューティ比、qをクオンタイズに修正(mckc原本準拠) - ^タイ、Dディチューン、sスイープ、v+/v-相対音量を追加 - @v/@@/@MP/@EP/@EN エンベロープ・LFO系を構文解析してIR保持 - 上記IR保持コマンドは合成未対応を SEMANTIC_UNSUPPORTED_FEATURE で警告 - Design.md / MML_BNF.md を更新 - 全200テスト + ruff チェック通過
-rw-r--r--doc/Design.md141
-rw-r--r--doc/MML_BNF.md113
-rw-r--r--src/mml_composemusic_mcp/ast_nodes.py159
-rw-r--r--src/mml_composemusic_mcp/ir.py117
-rw-r--r--src/mml_composemusic_mcp/lexer.py140
-rw-r--r--src/mml_composemusic_mcp/parser_ppmck.py305
-rw-r--r--src/mml_composemusic_mcp/semantic_base.py111
-rw-r--r--src/mml_composemusic_mcp/semantic_ppmck.py348
-rw-r--r--src/mml_composemusic_mcp/semantic_pyxel.py6
-rw-r--r--src/mml_composemusic_mcp/server.py27
-rw-r--r--src/mml_composemusic_mcp/templates.py10
-rw-r--r--tests/test_error_codes.py2
-rw-r--r--tests/test_lexer.py74
-rw-r--r--tests/test_mml_conformance.py53
-rw-r--r--tests/test_parser.py10
-rw-r--r--tests/test_parser_detailed.py74
16 files changed, 1573 insertions, 117 deletions
diff --git a/doc/Design.md b/doc/Design.md
index 1ce8aeb..525a22e 100644
--- a/doc/Design.md
+++ b/doc/Design.md
@@ -25,7 +25,7 @@ LLMがMML(Music Macro Language)を記述することで、レトロチップ
| 対象 | 内容 |
|---|---|
| 実装範囲 | Pulse1 / Pulse2 / Triangle / Noiseの4ch MML解析、WAV合成、バリデーション、テンプレート生成 |
-| 構文拡張 | Pyxel `@ENV` / `@VIB` / `@GLI`をIRへ保持し、合成未対応のwarningを返す |
+| 構文拡張 | Pyxel `@ENV` / `@VIB` / `@GLI`、ppmck `@v` / `@@` / `@MP` / `@EP` / `@EN` / `D` / `s` / `v+` / `v-` / `^` をIRへ保持し、合成未対応のwarningを返す |
| 対象外 | GUI / Webフロントエンド、DPCM、NSF出力、未定義のppmck拡張構文 |
### 1.4 技術スタック
@@ -241,7 +241,10 @@ ChannelSequence = {
}
Event = NoteEvent | RestEvent | VolumeEvent | DutyEvent
- | TempoEvent | RepeatEvent
+ | TempoEvent | RepeatEvent | QuantizeEvent | DetuneEvent
+ | SweepEvent | RelativeVolumeEvent
+ | VolumeEnvelopeEvent | DutyEnvelopeEvent | LfoEvent
+ | PitchEnvEvent | NoteEnvEvent
| EnvelopeEvent | VibratoEvent | GlideEvent
```
@@ -281,13 +284,16 @@ MML文字列
| オクターブ | `o`/`O` + 数値, `>`, `<` | |
| 音長 | `l`/`L` + 数値, 音符直後の数値, `.` | |
| 音量 | `v`/`V` + 数値 | |
-| デューティ/音色 | `q`+数値(ppmck), `@`+数値(pyxel) | |
+| デューティ/音色 | `@`+数値(ppmck/pyxel), `q`+数値(ppmck クオンタイズ) | |
| テンポ | `t`/`T` + 数値 | |
-| ゲートタイム | `Q` + 数値 (pyxel) | |
+| ゲートタイム | `q`+数値(ppmck 1-8), `Q` + 数値 (pyxel 0-100) | |
| トランスポーズ | `K` + 符号付き整数 (pyxel) | 例: `K-12`, `K+7` |
-| ディチューン | `Y` + 符号付き整数 (pyxel) | 例: `Y-25`, `Y+10` |
-| タイ/スラー | `&` | |
+| ディチューン | `D` + 符号付き整数 (ppmck), `Y` + 符号付き整数 (pyxel) | 例: `D-10`, `Y-25` |
+| 相対音量 | `v+` / `v-` (ppmck) | IR保持のみ |
+| スイープ | `s`+数値,数値 (ppmck) | IR保持のみ |
+| タイ | `^` (ppmck), `&` (両モード) | `^`はタイ、`&`はスラー |
| リピート | `[`, `]` + 数値 | ppmckはASTへ保持してwarning、Pyxelは展開 |
+| エンベロープ/LFO | `@v`/`@@`/`@MP`/`@EP`/`@EN` (ppmck), `@ENV`/`@VIB`/`@GLI` (pyxel) | IR保持のみ、合成未対応warning |
| 小節線 | `\|` | 視覚用、再生に影響しない |
| トラック識別 | ppmck: `A`/`B`/`T`/`N`/`L`, pyxel: `数字:` | |
| ヘッダー | `#` で始まる行 (ppmck) | |
@@ -305,7 +311,8 @@ Parserの責務は**トークン列からASTを生成するのみ**。値範囲ã
- `#` ヘッダーを認識
- 各コマンドをASTノードに変換
- リピートの開始・終了をAST文として保持し、未対応warningを返す
-- タイ `&` の結合対象をASTで保持
+- タイ `^` / スラー `&` の結合対象をASTで保持
+- `@v`/`@@`/`@MP`/`@EP`/`@EN` エンベロープ定義 `{...|...}` と使用をASTで保持
#### pyxelパーサ
@@ -332,6 +339,9 @@ SemanticAnalyzerの責務は**ASTを検証・解釈してNoteSequence IRを生æˆ
- Triangle音量無視
- Noise音高無視
- 全体ループ `L` の処理
+- `@` = デューティ比、`q` = クオンタイズ(gate_time = value / 8)として解釈
+- `^` タイを前の音符の音長延長として解釈
+- `D`/`s`/`v+`/`v-`、各種エンベロープ/LFOをIRへ保持し `SEMANTIC_UNSUPPORTED_FEATURE` warningを返す
- リピートなど合成未対応のASTに対するwarning
#### pyxel固有
@@ -341,6 +351,7 @@ SemanticAnalyzerの責務は**ASTを検証・解釈してNoteSequence IRを生æˆ
- `@` コマンドのチャンネル適合性チェック
- `@ENV`/`@VIB`/`@GLI` のIR保持と合成未対応warning
- 無限リピートの2回打ち切りwarning
+- `&` スラーを前の音符の音長延長として解釈
---
@@ -417,6 +428,89 @@ class TieStmt(ASTNode):
target: NoteStmt | RestStmt | int | None
@dataclass
+class TieCmdStmt(ASTNode):
+ target: NoteStmt | int | None
+
+@dataclass
+class QuantizeStmt(ASTNode):
+ value: int
+
+@dataclass
+class DetuneCmdStmt(ASTNode):
+ value: int
+
+@dataclass
+class RelativeVolumeStmt(ASTNode):
+ up: bool
+ value: int | None
+
+@dataclass
+class SweepStmt(ASTNode):
+ x: int
+ y: int
+
+@dataclass
+class VolumeEnvelopeUseStmt(ASTNode):
+ slot: int
+
+@dataclass
+class VolumeEnvelopeDefStmt(ASTNode):
+ slot: int
+ values: list[int]
+ loop: list[int] | None
+
+@dataclass
+class DutyEnvelopeUseStmt(ASTNode):
+ slot: int
+
+@dataclass
+class DutyEnvelopeDefStmt(ASTNode):
+ slot: int
+ values: list[int]
+ loop: list[int] | None
+
+@dataclass
+class LfoUseStmt(ASTNode):
+ slot: int
+
+@dataclass
+class LfoDefStmt(ASTNode):
+ slot: int
+ params: list[int]
+
+@dataclass
+class LfoOffStmt(ASTNode):
+ pass
+
+@dataclass
+class PitchEnvUseStmt(ASTNode):
+ slot: int
+
+@dataclass
+class PitchEnvDefStmt(ASTNode):
+ slot: int
+ values: list[int]
+ loop: list[int] | None
+
+@dataclass
+class PitchEnvOffStmt(ASTNode):
+ pass
+
+@dataclass
+class NoteEnvUseStmt(ASTNode):
+ slot: int
+
+@dataclass
+class NoteEnvDefStmt(ASTNode):
+ slot: int
+ values: list[int]
+ loop: list[int] | None
+
+@dataclass
+class NoteEnvOffStmt(ASTNode):
+ pass
+
+@dataclass
class RepeatStartStmt(ASTNode):
pass
@@ -506,7 +600,14 @@ class ErrorPhase(Enum):
<track_header>::= "A" | "B" | "T" | "N" | "L"
<ppmck_statement>
::= <note> | <rest> | <octave_cmd> | <length_cmd>
- | <volume_cmd> | <duty_cmd> | <tempo_cmd> | <tie_cmd>
+ | <volume_cmd> | <relative_volume_cmd> | <duty_cmd>
+ | <quantize_cmd> | <tempo_cmd> | <tie_cmd> | <slur_cmd>
+ | <detune_cmd> | <sweep_cmd>
+ | <vol_envelope_def> | <vol_envelope_use>
+ | <duty_envelope_def> | <duty_envelope_use>
+ | <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>
```
@@ -530,7 +631,7 @@ class ErrorPhase(Enum):
BNFの各規則は Lexer → AST → IR/診断の一貫経路で検証する。`tests/test_mml_conformance.py`は、音符・休符・コマンド・トラック・ヘッダー・拡張コマンドについて正例、負例、境界値、位置情報を検査する。Hypothesisによる固定seedの生成テストで、有効音列のAST/IR保存と単一トークン破壊の非黙殺も確認する。
-ppmckの将来予約構文は現在の受理言語に含めない。Pyxel拡張は空白区切り引数だけを受理し、AST/IRへ保持したうえで`SEMANTIC_UNSUPPORTED_FEATURE`をwarningとして返す。
+ppmckの将来予約構文は現在の受理言語に含めない。ppmckの `@v`/`@@`/`@MP`/`@EP`/`@EN` および `D`/`s`/`v+`/`v-` は構文解析してIRへ保持し、合成未対応を `SEMANTIC_UNSUPPORTED_FEATURE` でwarningとして返す。Pyxel拡張も同様に空白区切り引数を受理し、AST/IRへ保持したうえで `SEMANTIC_UNSUPPORTED_FEATURE` warningを返す。
## 9. APU音声合成エンジン
@@ -548,13 +649,17 @@ NoteSequence IRを入力としてPulse1、Pulse2、Triangle、Noiseを合成しã
| 項目 | ppmckモード | pyxelモード |
|---|---|---|
-| **大文字/小文字** | 小文字 `c d e` | 大文字 `C D E` |
-| **コマンド** | `o` `l` `v` `t` `q` | `O` `L` `V` `T` `Q` `@` `K` `Y` |
+| **大文字/小文字** | 小文字 `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:`(番号) |
| **Parser** | `parser_ppmck.py` | `parser_pyxel.py` |
| **SemanticAnalyzer** | `semantic_ppmck.py` | `semantic_pyxel.py` |
| **音量範囲** | 0-15(クラシックAPU準拠) | 0-127 |
-| **デューティ比** | `q0`〜`q3` で4種 | `@1`(50%), `@2`(25%) の2種のみ |
+| **デューティ比** | `@0`〜`@3` で4種(Pulse系のみ) | `@0`〜`@3`(Pulse系のみ有効) |
+| **クオンタイズ/ゲート** | `q1`〜`q8`(gate_time = value / 8) | `Q0`〜`Q100`(%) |
+| **タイ/スラー** | `^` = タイ、`&` = スラー | `&` = スラー(タイなし) |
+| **ディチューン** | `D-127`〜`D126`(IR保持のみ) | `Y-127`〜`Y127`(セント) |
+| **エンベロープ/LFO** | `@v`/`@@`/`@MP`/`@EP`/`@EN`(IR保持のみ) | `@ENV`/`@VIB`/`@GLI`(IR保持のみ) |
| **リピート** | `L`(構文上のループトラック)、`[...]`(AST保持・warning、合成未反映) | `[...]`(回数指定、省略時は2回で打ち切り、ネスト対応) |
---
@@ -578,13 +683,13 @@ NoteSequence IRを入力としてPulse1、Pulse2、Triangle、Noiseを合成しã
| カテゴリ | 実装内容 |
|---|---|
| MCPツール | `compose_mml`(compose/validate/template) |
-| Lexer | ppmck/pyxel 両モード対応、共通トークン定義、INVALIDトークン |
-| Parser | ppmck/pyxel 別Parser、AST生成 |
-| SemanticAnalyzer | ppmck/pyxel 別Analyzer、AST→IR変換 |
-| IR | NoteSequence、各種Event、ErrorDetail(phase付き) |
+| 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 |
+| 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モード |
-| テスト | pytestの固定・統合テスト、Hypothesisの生成テスト、ruffによるリント/フォーマット |
+| Templates | basic/melody/chord/drum/empty の5種 × 2モード。ppmckテンプレートは `@` でデューティ比を指定 |
+| テスト | pytestの固定・統合テスト、Hypothesisの生成テスト、ruffによるリント/フォーマット。全200テスト通過 |
---
diff --git a/doc/MML_BNF.md b/doc/MML_BNF.md
index 599fd24..83a1281 100644
--- a/doc/MML_BNF.md
+++ b/doc/MML_BNF.md
@@ -71,8 +71,11 @@
| <length_cmd>
| <volume_cmd>
| <duty_cmd>
+ | <quantize_cmd>
| <tempo_cmd>
| <tie_cmd>
+ | <slur_cmd>
+ | <ppmck_ext_cmd>
| <repeat_start>
| <repeat_end>
| <bar>
@@ -88,45 +91,86 @@
<length_cmd> ::= "l" <number> /* l1 〜 l192 */
<volume_cmd> ::= "v" <number> /* v0 〜 v15 */
-<duty_cmd> ::= "q" <number> /* q0 〜 q3, Pulse系のみ */
+
+<duty_cmd> ::= "@" <number> /* @0 〜 @3, Pulse系のみ */
+<quantize_cmd> ::= "q" <number> /* q1 〜 q8 */
<tempo_cmd> ::= "t" <number> /* t1 〜 */
-<tie_cmd> ::= <tie> (<note> | <rest> | <number>)
+<tie_cmd> ::= "^" (<note> | <number>)
+<slur_cmd> ::= "&" (<note> | <rest> | <number>)
<comment> ::= ";" <string_to_eol>
```
-### 2.3 将来予定コマンド(現在の受理文法には含めない)
+### 2.3 拡張コマンド(第1段階: IR 保持のみ、合成無視)
+
+```bnf
+<ppmck_ext_cmd> ::= <relative_volume_cmd>
+ | <detune_cmd>
+ | <sweep_cmd>
+ | <vol_envelope_def>
+ | <vol_envelope_use>
+ | <duty_envelope_def>
+ | <duty_envelope_use>
+ | <lfo_def>
+ | <lfo_use>
+ | <lfo_off>
+ | <pitch_env_def>
+ | <pitch_env_use>
+ | <pitch_env_off>
+ | <note_env_def>
+ | <note_env_use>
+ | <note_env_off>
+
+<relative_volume_cmd>
+ ::= "v+" <number>? /* 1 〜 15 */
+ | "v-" <number>? /* 1 〜 15 */
+
+<detune_cmd> ::= "D" <signed_number> /* -127 〜 126 */
+<sweep_cmd> ::= "s" <number> "," <number>
+
+<vol_envelope_def>::= "@v" <number> "=" "{" <number_list> "|" <number_list>? "}"
+<vol_envelope_use>::= "@v" <number>
+
+<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_use> ::= "EP" <number>
+<pitch_env_off> ::= "EPOF"
+
+<note_env_def> ::= "@EN" <number> "=" "{" <signed_number_list> "|" <signed_number_list>? "}"
+<note_env_use> ::= "EN" <number>
+<note_env_off> ::= "ENOF"
+
+<signed_number> ::= ("+" | "-")? <number>
+<number_list> ::= <number> (","? <number>)*
+<signed_number_list> ::= <signed_number> (","? <signed_number>)*
+```
+
+### 2.4 将来予定コマンド(現在の受理文法には含めない)
以下は設計予約であり、現在は構文エラーになる。実装時に受理文法へ移す。
```bnf
<ppmck_stage2> ::= <count_length>
- | <envelope_def>
- | <envelope_use>
- | <duty_envelope_def>
- | <sweep_cmd>
| <section_repeat>
| <transpose_cmd>
| <noise_mode_cmd>
<count_length> ::= <note_name> <accidental>? "%" <number>
-<envelope_def> ::= "@v" <number> "=" "{" <number_list> "|" <number_list>? "}"
-<envelope_use> ::= "@v" <number>
-
-<duty_envelope_def>::= "@" <number> "=" "{" <number_list> "|" <number_list>? "}"
-
-<sweep_cmd> ::= "s" <number> "," <number>
-
<section_repeat> ::= "[" <ppmck_statement>+ "]" <number>?
<transpose_cmd> ::= "K" <number>
<noise_mode_cmd> ::= "m" <number>
-
-<number_list> ::= <number> (","? <number>)*
```
+
---
## 3. pyxel モード
@@ -211,16 +255,27 @@
## 5. 値範囲
-| コマンド | 有効範囲 | デフォルト |
-|---|---|---|
-| `o` / `O` | 0 〜 7 | 4 |
-| `l` / `L` | 1 〜 192 | 4 |
-| `v` (ppmck) | 0 〜 15 | 15 |
-| `V` (pyxel) | 0 〜 127 | 100 |
-| `q` (ppmck) | 0 〜 3 | 2 |
-| `@` (pyxel) | 0 〜 3 | — |
-| `Q` (pyxel) | 0 〜 100 | 80 |
-| `t` / `T` | 1 〜 | 120 |
+| コマンド | モード | 有効範囲 | デフォルト | 備考 |
+|---|---|---|---|---|
+| `o` / `O` | 両方 | 0 〜 7 | 4 | |
+| `l` / `L` | 両方 | 1 〜 192 | 4 | |
+| `v` | ppmck | 0 〜 15 | 15 | |
+| `V` | pyxel | 0 〜 127 | 100 | |
+| `v+` / `v-` | ppmck | 1 〜 15 | 1 | 相対音量(IR保持のみ) |
+| `@` | ppmck | 0 〜 3 | 2 | デューティ比(Pulse系のみ) |
+| `@` | pyxel | 0 〜 3 | — | デューティ比(Pulse系のみ) |
+| `q` | ppmck | 1 〜 8 | 8 | クオンタイズ(gate_time = value / 8) |
+| `Q` | pyxel | 0 〜 100 | 80 | ゲートタイム(%) |
+| `t` / `T` | 両方 | 1 〜 | 120 | |
+| `D` | ppmck | -127 〜 126 | 0 | ディチューン(IR保持のみ) |
+| `s` | ppmck | 0 〜 15, 0 〜 7 | — | スイープ `s<x>,<y>`(IR保持のみ) |
+| `@v` | ppmck | 0 〜 255 | — | 音量エンベロープ(IR保持のみ) |
+| `@@` | ppmck | 0 〜 255 | — | デューティエンベロープ(IR保持のみ) |
+| `@MP` / `MP` / `MPOF` | ppmck | 0 〜 255 | — | LFO(IR保持のみ) |
+| `@EP` / `EP` / `EPOF` | ppmck | 0 〜 255 | — | ピッチエンベロープ(IR保持のみ) |
+| `@EN` / `EN` / `ENOF` | ppmck | 0 〜 255 | — | ノートエンベロープ(IR保持のみ) |
+| `K` | pyxel | -127 〜 127 | 0 | トランスポーズ(半音) |
+| `Y` | pyxel | -127 〜 127 | 0 | ディチューン(セント) |
---
@@ -228,6 +283,8 @@
- 本 BNF は `mml-composemusic-mcp` の**サポート範囲**を定義するものであり、オリジナルの PPMCK や Pyxel の全機能を網羅するものではない。
- 第2段階予定コマンドは設計予約として記載するが、現在の受理文法には含めない。
-- `@ENV` / `@VIB` / `@GLI` は構文解析してIRへ保持し、合成未対応を `SEMANTIC_UNSUPPORTED_FEATURE` で警告する。
-- コマンド文字は ppmck では小文字、pyxel では大文字を使用する。音符・休符も同じ規則に従う。
+- ppmck モードの `@v` / `@@` / `@MP` / `@EP` / `@EN`、および `D` / `s` / `v+` / `v-` は構文解析して IR へ保持し、合成には反映しない。未対応を `SEMANTIC_UNSUPPORTED_FEATURE` で警告する。
+- pyxel モードの `@ENV` / `@VIB` / `@GLI` も同様に IR 保持のみで、`SEMANTIC_UNSUPPORTED_FEATURE` 警告を返す。
+- コマンド文字は ppmck では小文字、pyxel では大文字を使用する。音符・休符も同じ規則に従う。ただし ppmck の `D`(ディチューン)は大文字のみ受理する(小文字 `d` は音符)。
+- `^` は ppmck モード専用のタイ、`&` は両モード共通のスラー(slur)として扱う。
- 詳細な実装アーキテクチャは [Design.md](Design.md) を参照。
diff --git a/src/mml_composemusic_mcp/ast_nodes.py b/src/mml_composemusic_mcp/ast_nodes.py
index bc4fdc4..3341510 100644
--- a/src/mml_composemusic_mcp/ast_nodes.py
+++ b/src/mml_composemusic_mcp/ast_nodes.py
@@ -118,6 +118,13 @@ class DetuneStmt(ASTNode):
@dataclass
+class QuantizeStmt(ASTNode):
+ """ppmck quantize command (q1-q8)."""
+
+ value: int
+
+
+@dataclass
class TieStmt(ASTNode):
"""Tie/slur command (&)."""
@@ -125,6 +132,27 @@ class TieStmt(ASTNode):
@dataclass
+class TieCmdStmt(ASTNode):
+ """ppmck tie command (^). Extends duration, pitch from previous note."""
+
+ target: NoteStmt | int | None = None
+
+
+@dataclass
+class DetuneCmdStmt(ASTNode):
+ """ppmck detune command (D<num>)."""
+
+ value: int
+
+
+@dataclass
+class RelativeVolumeStmt(ASTNode):
+ """ppmck relative volume (v+num / v-num)."""
+
+ delta: int # positive=up, negative=down
+
+
+@dataclass
class RepeatStartStmt(ASTNode):
"""Start of repeat section ([)."""
@@ -154,21 +182,92 @@ class ExtCmdStmt(ASTNode):
params: list[int] = field(default_factory=list)
-# --- Stage 2 statements (kept in AST but ignored by semantic analyzer) ---
+# --- Envelope / LFO / vibrato statements (IR kept, synthesis warning) ---
@dataclass
-class CountLengthStmt(ASTNode):
- """ppmck count length note (c%48)."""
+class VolumeEnvelopeUseStmt(ASTNode):
+ """ppmck volume envelope use (@v<num>)."""
- note_name: str
- accidental: int
- count: int
+ slot: int
+
+
+@dataclass
+class VolumeEnvelopeDefStmt(ASTNode):
+ """ppmck volume envelope definition (@v<num> = {...|...})."""
+
+ slot: int
+ points: list[int] = field(default_factory=list)
+ loop_points: list[int] = field(default_factory=list)
+
+
+@dataclass
+class DutyEnvelopeDefStmt(ASTNode):
+ """ppmck duty envelope definition (@<num> = {...|...})."""
+
+ slot: int
+ points: list[int] = field(default_factory=list)
+ loop_points: list[int] = field(default_factory=list)
+
+
+@dataclass
+class DutyEnvelopeUseStmt(ASTNode):
+ """ppmck duty envelope use (@@<num>)."""
+
+ slot: int
+
+
+@dataclass
+class LfoDefStmt(ASTNode):
+ """ppmck LFO definition (@MP<num> = {p1,p2,p3,p4})."""
+
+ slot: int
+ delay: int
+ speed: int
+ depth: int
+ transition: int
+
+
+@dataclass
+class LfoUseStmt(ASTNode):
+ """ppmck LFO use (MP<num>)."""
+
+ slot: int
+
+
+@dataclass
+class LfoOffStmt(ASTNode):
+ """ppmck LFO off (MPOF)."""
+
+ pass
+
+
+@dataclass
+class PitchEnvDefStmt(ASTNode):
+ """ppmck pitch envelope definition (@EP<num> = {...|...})."""
+
+ slot: int
+ points: list[int] = field(default_factory=list)
+ loop_points: list[int] = field(default_factory=list)
+
+
+@dataclass
+class PitchEnvUseStmt(ASTNode):
+ """ppmck pitch envelope use (EP<num>)."""
+
+ slot: int
+
+
+@dataclass
+class PitchEnvOffStmt(ASTNode):
+ """ppmck pitch envelope off (EPOF)."""
+
+ pass
@dataclass
-class EnvelopeDefStmt(ASTNode):
- """ppmck envelope definition (@v0 = { ... })."""
+class NoteEnvDefStmt(ASTNode):
+ """ppmck note envelope definition (@EN<num> = {...|...})."""
slot: int
points: list[int] = field(default_factory=list)
@@ -176,6 +275,32 @@ class EnvelopeDefStmt(ASTNode):
@dataclass
+class NoteEnvUseStmt(ASTNode):
+ """ppmck note envelope use (EN<num>)."""
+
+ slot: int
+
+
+@dataclass
+class NoteEnvOffStmt(ASTNode):
+ """ppmck note envelope off (ENOF)."""
+
+ pass
+
+
+# --- Stage 2 statements (kept in AST but ignored by semantic analyzer) ---
+
+
+@dataclass
+class CountLengthStmt(ASTNode):
+ """ppmck count length note (c%48)."""
+
+ note_name: str
+ accidental: int
+ count: int
+
+
+@dataclass
class SweepStmt(ASTNode):
"""ppmck sweep command (s1,2)."""
@@ -201,13 +326,29 @@ Statement = (
| GateTimeStmt
| TransposeStmt
| DetuneStmt
+ | QuantizeStmt
| TieStmt
+ | TieCmdStmt
+ | DetuneCmdStmt
+ | RelativeVolumeStmt
| RepeatStartStmt
| RepeatEndStmt
| BarStmt
| ExtCmdStmt
+ | VolumeEnvelopeUseStmt
+ | VolumeEnvelopeDefStmt
+ | DutyEnvelopeDefStmt
+ | DutyEnvelopeUseStmt
+ | LfoDefStmt
+ | LfoUseStmt
+ | LfoOffStmt
+ | PitchEnvDefStmt
+ | PitchEnvUseStmt
+ | PitchEnvOffStmt
+ | NoteEnvDefStmt
+ | NoteEnvUseStmt
+ | NoteEnvOffStmt
| CountLengthStmt
- | EnvelopeDefStmt
| SweepStmt
| NoiseModeStmt
)
diff --git a/src/mml_composemusic_mcp/ir.py b/src/mml_composemusic_mcp/ir.py
index 8d1bc25..4dd7978 100644
--- a/src/mml_composemusic_mcp/ir.py
+++ b/src/mml_composemusic_mcp/ir.py
@@ -134,6 +134,35 @@ class RepeatEvent:
@dataclass
+class QuantizeEvent:
+ type: str = "quantize"
+ tick_position: int = 0
+ value: int = 8 # 1-8
+
+
+@dataclass
+class DetuneEvent:
+ type: str = "detune"
+ tick_position: int = 0
+ value: int = 0 # -127 to 126
+
+
+@dataclass
+class SweepEvent:
+ type: str = "sweep"
+ tick_position: int = 0
+ speed: int = 0
+ depth: int = 0
+
+
+@dataclass
+class RelativeVolumeEvent:
+ type: str = "rel_volume"
+ tick_position: int = 0
+ delta: int = 0
+
+
+@dataclass
class EnvelopeEvent:
type: str = "envelope"
tick_position: int = 0
@@ -142,6 +171,61 @@ class EnvelopeEvent:
@dataclass
+class VolumeEnvelopeEvent:
+ type: str = "vol_envelope"
+ tick_position: int = 0
+ slot: int = 0
+ points: list[int] = field(default_factory=list)
+ loop_points: list[int] = field(default_factory=list)
+ is_definition: bool = False
+
+
+@dataclass
+class DutyEnvelopeEvent:
+ type: str = "duty_envelope"
+ tick_position: int = 0
+ slot: int = 0
+ points: list[int] = field(default_factory=list)
+ loop_points: list[int] = field(default_factory=list)
+ is_definition: bool = False
+
+
+@dataclass
+class LfoEvent:
+ type: str = "lfo"
+ tick_position: int = 0
+ slot: int = 0
+ delay: int = 0
+ speed: int = 0
+ depth: int = 0
+ transition: int = 0
+ is_definition: bool = False
+ is_off: bool = False
+
+
+@dataclass
+class PitchEnvEvent:
+ type: str = "pitch_envelope"
+ tick_position: int = 0
+ slot: int = 0
+ points: list[int] = field(default_factory=list)
+ loop_points: list[int] = field(default_factory=list)
+ is_definition: bool = False
+ is_off: bool = False
+
+
+@dataclass
+class NoteEnvEvent:
+ type: str = "note_envelope"
+ tick_position: int = 0
+ slot: int = 0
+ points: list[int] = field(default_factory=list)
+ loop_points: list[int] = field(default_factory=list)
+ is_definition: bool = False
+ is_off: bool = False
+
+
+@dataclass
class VibratoEvent:
type: str = "vibrato"
tick_position: int = 0
@@ -164,7 +248,16 @@ Event = (
| DutyEvent
| TempoEvent
| RepeatEvent
+ | QuantizeEvent
+ | DetuneEvent
+ | SweepEvent
+ | RelativeVolumeEvent
| EnvelopeEvent
+ | VolumeEnvelopeEvent
+ | DutyEnvelopeEvent
+ | LfoEvent
+ | PitchEnvEvent
+ | NoteEnvEvent
| VibratoEvent
| GlideEvent
)
@@ -209,9 +302,27 @@ class NoteSequence:
def event_to_dict(event: Event) -> dict[str, Any]:
if isinstance(
- event, (NoteEvent, RestEvent, VolumeEvent, DutyEvent, TempoEvent, RepeatEvent)
+ event,
+ (
+ NoteEvent,
+ RestEvent,
+ VolumeEvent,
+ DutyEvent,
+ TempoEvent,
+ RepeatEvent,
+ QuantizeEvent,
+ DetuneEvent,
+ SweepEvent,
+ RelativeVolumeEvent,
+ EnvelopeEvent,
+ VolumeEnvelopeEvent,
+ DutyEnvelopeEvent,
+ LfoEvent,
+ PitchEnvEvent,
+ NoteEnvEvent,
+ VibratoEvent,
+ GlideEvent,
+ ),
):
return event.__dict__
- if isinstance(event, (EnvelopeEvent, VibratoEvent, GlideEvent)):
- return event.__dict__
raise TypeError(f"Unsupported event type: {type(event)}")
diff --git a/src/mml_composemusic_mcp/lexer.py b/src/mml_composemusic_mcp/lexer.py
index 94876bf..e5f08a8 100644
--- a/src/mml_composemusic_mcp/lexer.py
+++ b/src/mml_composemusic_mcp/lexer.py
@@ -17,6 +17,7 @@ class TokenType(Enum):
TEMPO = auto()
GATE_TIME = auto()
TIE = auto()
+ TIE_CMD = auto() # ^ (ppmck tie)
REPEAT_START = auto()
REPEAT_END = auto()
BAR = auto()
@@ -29,7 +30,26 @@ class TokenType(Enum):
DOT = auto()
AT = auto()
TRANSPOSE = auto()
- DETUNE = auto()
+ DETUNE = auto() # D (ppmck detune)
+ SWEEP = auto() # s (ppmck sweep)
+ QUANTIZE = auto() # q (ppmck quantize)
+ REL_VOL_UP = auto() # v+ (ppmck)
+ REL_VOL_DOWN = auto() # v- (ppmck)
+ DUTY_ENV_USE = auto() # @@ (ppmck)
+ VOL_ENV = auto() # @v (ppmck)
+ LFO_DEF = auto() # @MP (ppmck)
+ LFO_USE = auto() # MP (ppmck)
+ LFO_OFF = auto() # MPOF (ppmck)
+ PITCH_ENV_DEF = auto() # @EP (ppmck)
+ PITCH_ENV_USE = auto() # EP (ppmck)
+ PITCH_ENV_OFF = auto() # EPOF (ppmck)
+ NOTE_ENV_DEF = auto() # @EN (ppmck)
+ NOTE_ENV_USE = auto() # EN (ppmck)
+ NOTE_ENV_OFF = auto() # ENOF (ppmck)
+ BRACE_OPEN = auto() # { (ppmck envelope def)
+ BRACE_CLOSE = auto() # } (ppmck envelope def)
+ EQUAL = auto() # = (ppmck envelope def)
+ COMMA = auto() # , (ppmck sweep/envelope def)
EXT_CMD = auto() # @ENV, @VIB, @GLI
INVALID = auto() # unrecognised character
EOF = auto()
@@ -151,6 +171,10 @@ class Lexer:
self._advance()
self._emit(TokenType.TIE, "&", "&")
return
+ if ch == "^":
+ self._advance()
+ self._emit(TokenType.TIE_CMD, "^", "^")
+ return
if ch == "[":
self._advance()
self._emit(TokenType.REPEAT_START, "[", "[")
@@ -160,6 +184,22 @@ class Lexer:
count = self._read_number()
self._emit(TokenType.REPEAT_END, count, "]" + count)
return
+ if ch == "{":
+ self._advance()
+ self._emit(TokenType.BRACE_OPEN, "{", "{")
+ return
+ if ch == "}":
+ self._advance()
+ self._emit(TokenType.BRACE_CLOSE, "}", "}")
+ return
+ if ch == "=":
+ self._advance()
+ self._emit(TokenType.EQUAL, "=", "=")
+ return
+ if ch == ",":
+ self._advance()
+ self._emit(TokenType.COMMA, ",", ",")
+ return
if ch == ".":
self._advance()
self._emit(TokenType.DOT, ".", ".")
@@ -176,6 +216,40 @@ class Lexer:
self._advance()
self._emit(TokenType.FLAT, "-", "-")
return
+ if ch == "@":
+ self._advance()
+ next_ch = self._peek()
+ if next_ch == "@":
+ self._advance()
+ num = self._read_number()
+ self._emit(TokenType.DUTY_ENV_USE, num, "@@" + num)
+ return
+ if next_ch == "v":
+ self._advance()
+ num = self._read_number()
+ self._emit(TokenType.VOL_ENV, num, "@v" + num)
+ return
+ if next_ch == "M" and self._peek(1) == "P":
+ self._advance()
+ self._advance()
+ num = self._read_number()
+ self._emit(TokenType.LFO_DEF, num, "@MP" + num)
+ return
+ if next_ch == "E" and self._peek(1) == "P":
+ self._advance()
+ self._advance()
+ num = self._read_number()
+ self._emit(TokenType.PITCH_ENV_DEF, num, "@EP" + num)
+ return
+ if next_ch == "E" and self._peek(1) == "N":
+ self._advance()
+ self._advance()
+ num = self._read_number()
+ self._emit(TokenType.NOTE_ENV_DEF, num, "@EN" + num)
+ return
+ num = self._read_number()
+ self._emit(TokenType.DUTY, num, "@" + num)
+ return
if ch.isdigit():
num = self._read_number()
self._emit(TokenType.NUMBER, num, num)
@@ -185,9 +259,53 @@ class Lexer:
self._emit(TokenType.TRACK_HEADER, raw.upper(), raw)
return
if ch.isalpha():
+ # 2-character commands: MP, EP, EN and their OFF variants
+ if ch == "M" and self._peek(1) == "P":
+ self._advance()
+ self._advance()
+ if self._peek() == "O" and self._peek(1) == "F":
+ self._advance()
+ self._advance()
+ self._emit(TokenType.LFO_OFF, "MPOF", "MPOF")
+ else:
+ num = self._read_number()
+ self._emit(TokenType.LFO_USE, num, "MP" + num)
+ return
+ if ch == "E" and self._peek(1) == "P":
+ self._advance()
+ self._advance()
+ if self._peek() == "O" and self._peek(1) == "F":
+ self._advance()
+ self._advance()
+ self._emit(TokenType.PITCH_ENV_OFF, "EPOF", "EPOF")
+ else:
+ num = self._read_number()
+ self._emit(TokenType.PITCH_ENV_USE, num, "EP" + num)
+ return
+ if ch == "E" and self._peek(1) == "N":
+ self._advance()
+ self._advance()
+ if self._peek() == "O" and self._peek(1) == "F":
+ self._advance()
+ self._advance()
+ self._emit(TokenType.NOTE_ENV_OFF, "ENOF", "ENOF")
+ else:
+ num = self._read_number()
+ self._emit(TokenType.NOTE_ENV_USE, num, "EN" + num)
+ return
+
cmd = self._advance()
lower = cmd.lower()
- if lower in "olvtq":
+ if lower in "olvt":
+ if lower == "v" and self._peek() in "+-":
+ sign = self._advance()
+ num = self._read_number()
+ raw = cmd + sign + num
+ if sign == "+":
+ self._emit(TokenType.REL_VOL_UP, num, raw)
+ else:
+ self._emit(TokenType.REL_VOL_DOWN, num, raw)
+ return
num = self._read_number()
raw = cmd + num
if lower == "o":
@@ -198,8 +316,22 @@ class Lexer:
self._emit(TokenType.VOLUME, num, raw)
elif lower == "t":
self._emit(TokenType.TEMPO, num, raw)
- elif lower == "q":
- self._emit(TokenType.DUTY, num, raw)
+ return
+ if lower == "q":
+ num = self._read_number()
+ self._emit(TokenType.QUANTIZE, num, "q" + num)
+ return
+ if cmd == "D":
+ num = self._read_signed_number()
+ self._emit(TokenType.DETUNE, num, "D" + num)
+ return
+ if lower == "s":
+ num0 = self._read_number()
+ if self._peek() == ",":
+ self._advance()
+ num1 = self._read_number()
+ raw = f"s{num0},{num1}"
+ self._emit(TokenType.SWEEP, f"{num0},{num1}", raw)
return
note = lower
if note in "cdefgabr":
diff --git a/src/mml_composemusic_mcp/parser_ppmck.py b/src/mml_composemusic_mcp/parser_ppmck.py
index 892c167..47a0412 100644
--- a/src/mml_composemusic_mcp/parser_ppmck.py
+++ b/src/mml_composemusic_mcp/parser_ppmck.py
@@ -3,18 +3,36 @@
from .ast_nodes import (
ASTNode,
BarStmt,
+ DetuneCmdStmt,
+ DutyEnvelopeDefStmt,
+ DutyEnvelopeUseStmt,
DutyStmt,
Header,
LengthStmt,
+ LfoDefStmt,
+ LfoOffStmt,
+ LfoUseStmt,
+ NoteEnvDefStmt,
+ NoteEnvOffStmt,
+ NoteEnvUseStmt,
NoteStmt,
OctaveStmt,
+ PitchEnvDefStmt,
+ PitchEnvOffStmt,
+ PitchEnvUseStmt,
Program,
+ QuantizeStmt,
+ RelativeVolumeStmt,
RepeatEndStmt,
RepeatStartStmt,
RestStmt,
+ SweepStmt,
TempoStmt,
+ TieCmdStmt,
TieStmt,
Track,
+ VolumeEnvelopeDefStmt,
+ VolumeEnvelopeUseStmt,
VolumeStmt,
)
from .ir import ErrorCode, ErrorDetail
@@ -222,10 +240,14 @@ class PpmckParser:
return self._parse_volume()
if token.type == TokenType.DUTY:
return self._parse_duty()
+ if token.type == TokenType.QUANTIZE:
+ return self._parse_quantize()
if token.type == TokenType.TEMPO:
return self._parse_tempo()
if token.type == TokenType.TIE:
return self._parse_tie()
+ if token.type == TokenType.TIE_CMD:
+ return self._parse_tie_cmd()
if token.type == TokenType.REPEAT_START:
return self._parse_repeat_start()
if token.type == TokenType.REPEAT_END:
@@ -233,6 +255,36 @@ class PpmckParser:
if token.type == TokenType.BAR:
self.ctx.advance()
return BarStmt(line=token.line, column=token.column)
+ if token.type == TokenType.DETUNE:
+ return self._parse_detune()
+ if token.type == TokenType.SWEEP:
+ return self._parse_sweep()
+ if token.type == TokenType.REL_VOL_UP:
+ return self._parse_rel_vol(up=True)
+ if token.type == TokenType.REL_VOL_DOWN:
+ return self._parse_rel_vol(up=False)
+ if token.type == TokenType.VOL_ENV:
+ return self._parse_vol_env()
+ if token.type == TokenType.DUTY_ENV_USE:
+ return self._parse_duty_env_use()
+ if token.type == TokenType.LFO_DEF:
+ return self._parse_lfo_def()
+ if token.type == TokenType.LFO_USE:
+ return self._parse_lfo_use()
+ if token.type == TokenType.LFO_OFF:
+ return self._parse_lfo_off()
+ if token.type == TokenType.PITCH_ENV_DEF:
+ return self._parse_pitch_env_def()
+ if token.type == TokenType.PITCH_ENV_USE:
+ return self._parse_pitch_env_use()
+ if token.type == TokenType.PITCH_ENV_OFF:
+ return self._parse_pitch_env_off()
+ if token.type == TokenType.NOTE_ENV_DEF:
+ return self._parse_note_env_def()
+ if token.type == TokenType.NOTE_ENV_USE:
+ return self._parse_note_env_use()
+ if token.type == TokenType.NOTE_ENV_OFF:
+ return self._parse_note_env_off()
# Unknown token in track context
self.ctx.add_error(
code=ErrorCode.SYNTAX_UNEXPECTED_TOKEN,
@@ -334,14 +386,27 @@ class PpmckParser:
def _parse_duty(self) -> DutyStmt:
token = self.ctx.advance()
- self._require_value(token, "q")
- value = int(token.value) if token.value else 0
+ self._require_value(token, "@")
+ value = int(token.value) if token.value else 2
+ # @<num> = { ... | ... } → duty envelope definition
+ if self.ctx.peek().type == TokenType.EQUAL:
+ return self._parse_duty_env_def_rest(token, value)
return DutyStmt(
line=token.line,
column=token.column,
value=value,
)
+ def _parse_quantize(self) -> QuantizeStmt:
+ token = self.ctx.advance()
+ self._require_value(token, "q")
+ value = int(token.value) if token.value else 8
+ return QuantizeStmt(
+ line=token.line,
+ column=token.column,
+ value=value,
+ )
+
def _parse_tempo(self) -> TempoStmt:
token = self.ctx.advance()
self._require_value(token, "t")
@@ -391,6 +456,26 @@ class PpmckParser:
)
return TieStmt(line=token.line, column=token.column, target=None)
+ def _parse_tie_cmd(self) -> TieCmdStmt:
+ """Parse ^ (tie) command."""
+ token = self.ctx.advance()
+ next_token = self.ctx.peek()
+ if next_token.type == TokenType.NUMBER:
+ self.ctx.advance()
+ return TieCmdStmt(
+ line=token.line,
+ column=token.column,
+ target=int(next_token.value),
+ )
+ if next_token.type == TokenType.NOTE:
+ return TieCmdStmt(
+ line=token.line,
+ column=token.column,
+ target=self._parse_note(),
+ )
+ # ^ without target extends previous note duration
+ return TieCmdStmt(line=token.line, column=token.column, target=None)
+
def _parse_repeat_start(self) -> RepeatStartStmt:
token = self.ctx.advance()
return RepeatStartStmt(line=token.line, column=token.column)
@@ -406,6 +491,222 @@ class PpmckParser:
count=count,
)
+ def _parse_detune(self) -> DetuneCmdStmt:
+ token = self.ctx.advance()
+ self._require_value(token, "D")
+ value = int(token.value) if token.value else 0
+ return DetuneCmdStmt(
+ line=token.line,
+ column=token.column,
+ value=value,
+ )
+
+ 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
+ return SweepStmt(
+ line=token.line,
+ column=token.column,
+ speed=speed,
+ depth=depth,
+ )
+
+ def _parse_rel_vol(self, up: bool) -> RelativeVolumeStmt:
+ token = self.ctx.advance()
+ value = int(token.value) if token.value else 1
+ delta = value if up else -value
+ return RelativeVolumeStmt(
+ line=token.line,
+ column=token.column,
+ delta=delta,
+ )
+
+ def _parse_vol_env(self) -> ASTNode:
+ """@v<num> → VolumeEnvelopeUseStmt or VolumeEnvelopeDefStmt."""
+ token = self.ctx.advance()
+ self._require_value(token, "@v")
+ slot = int(token.value) if token.value else 0
+ if self.ctx.peek().type == TokenType.EQUAL:
+ self.ctx.advance() # consume =
+ points, loop_points = self._parse_envelope_body()
+ return VolumeEnvelopeDefStmt(
+ line=token.line,
+ column=token.column,
+ slot=slot,
+ points=points,
+ loop_points=loop_points,
+ )
+ return VolumeEnvelopeUseStmt(
+ line=token.line,
+ column=token.column,
+ slot=slot,
+ )
+
+ def _parse_duty_env_use(self) -> DutyEnvelopeUseStmt:
+ token = self.ctx.advance()
+ self._require_value(token, "@@")
+ slot = int(token.value) if token.value else 0
+ return DutyEnvelopeUseStmt(
+ line=token.line,
+ column=token.column,
+ slot=slot,
+ )
+
+ def _parse_duty_env_def_rest(self, token: Token, slot: int) -> DutyEnvelopeDefStmt:
+ """@<num> = { ... | ... } → DutyEnvelopeDefStmt."""
+ self.ctx.advance() # consume =
+ points, loop_points = self._parse_envelope_body()
+ return DutyEnvelopeDefStmt(
+ line=token.line,
+ column=token.column,
+ slot=slot,
+ points=points,
+ loop_points=loop_points,
+ )
+
+ def _parse_lfo_def(self) -> LfoDefStmt:
+ """@MP<num> = { p1, p2, p3, p4 } → LfoDefStmt."""
+ 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)
+ return LfoDefStmt(
+ line=token.line,
+ column=token.column,
+ slot=slot,
+ delay=p1,
+ speed=p2,
+ depth=p3,
+ transition=p4,
+ )
+
+ def _parse_lfo_use(self) -> LfoUseStmt:
+ token = self.ctx.advance()
+ slot = int(token.value) if token.value else 0
+ return LfoUseStmt(
+ line=token.line,
+ column=token.column,
+ slot=slot,
+ )
+
+ def _parse_lfo_off(self) -> LfoOffStmt:
+ token = self.ctx.advance()
+ return LfoOffStmt(line=token.line, column=token.column)
+
+ def _parse_pitch_env_def(self) -> PitchEnvDefStmt:
+ token = self.ctx.advance()
+ self._require_value(token, "@EP")
+ 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()
+ return PitchEnvDefStmt(
+ line=token.line,
+ column=token.column,
+ slot=slot,
+ points=points,
+ loop_points=loop_points,
+ )
+ return PitchEnvDefStmt(
+ line=token.line,
+ column=token.column,
+ slot=slot,
+ )
+
+ def _parse_pitch_env_use(self) -> PitchEnvUseStmt:
+ token = self.ctx.advance()
+ slot = int(token.value) if token.value else 0
+ return PitchEnvUseStmt(
+ line=token.line,
+ column=token.column,
+ slot=slot,
+ )
+
+ def _parse_pitch_env_off(self) -> PitchEnvOffStmt:
+ token = self.ctx.advance()
+ return PitchEnvOffStmt(line=token.line, column=token.column)
+
+ def _parse_note_env_def(self) -> NoteEnvDefStmt:
+ token = self.ctx.advance()
+ self._require_value(token, "@EN")
+ 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()
+ return NoteEnvDefStmt(
+ line=token.line,
+ column=token.column,
+ slot=slot,
+ points=points,
+ loop_points=loop_points,
+ )
+ return NoteEnvDefStmt(
+ line=token.line,
+ column=token.column,
+ slot=slot,
+ )
+
+ def _parse_note_env_use(self) -> NoteEnvUseStmt:
+ token = self.ctx.advance()
+ slot = int(token.value) if token.value else 0
+ return NoteEnvUseStmt(
+ line=token.line,
+ column=token.column,
+ slot=slot,
+ )
+
+ def _parse_note_env_off(self) -> NoteEnvOffStmt:
+ 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)
+ points: list[int] = []
+ loop_points: list[int] = []
+ in_loop = False
+ while self.ctx.peek().type != TokenType.BRACE_CLOSE:
+ token = self.ctx.peek()
+ if token.type == TokenType.BAR:
+ self.ctx.advance()
+ in_loop = True
+ continue
+ if token.type == TokenType.COMMA:
+ self.ctx.advance()
+ continue
+ if token.type == TokenType.NUMBER:
+ self.ctx.advance()
+ val = int(token.value)
+ if in_loop:
+ loop_points.append(val)
+ else:
+ points.append(val)
+ continue
+ # Unexpected token inside envelope body
+ self.ctx.add_error(
+ code=ErrorCode.SYNTAX_UNEXPECTED_TOKEN,
+ line=token.line,
+ column=token.column,
+ message=f"エンベロープ定義内で '{token.raw}' は使用できません。",
+ severity="error",
+ hint="数値、カンマ、'|'、'}' を使用してください。",
+ context=context_line(self.source, token),
+ )
+ self.ctx.advance()
+ self.ctx.match(TokenType.BRACE_CLOSE)
+ return points, loop_points
+
def _handle_invalid(self, token: Token) -> None:
self.ctx.add_invalid_token_error(token, context_line(self.source, token))
self.ctx.advance()
diff --git a/src/mml_composemusic_mcp/semantic_base.py b/src/mml_composemusic_mcp/semantic_base.py
index 0318a84..8b27e89 100644
--- a/src/mml_composemusic_mcp/semantic_base.py
+++ b/src/mml_composemusic_mcp/semantic_base.py
@@ -5,23 +5,40 @@ from dataclasses import dataclass, field
from .ast_nodes import (
ASTNode,
BarStmt,
+ DetuneCmdStmt,
DetuneStmt,
+ DutyEnvelopeDefStmt,
+ DutyEnvelopeUseStmt,
DutyStmt,
- EnvelopeDefStmt,
ExtCmdStmt,
GateTimeStmt,
LengthStmt,
+ LfoDefStmt,
+ LfoOffStmt,
+ LfoUseStmt,
+ NoteEnvDefStmt,
+ NoteEnvOffStmt,
+ NoteEnvUseStmt,
NoteStmt,
OctaveStmt,
+ PitchEnvDefStmt,
+ PitchEnvOffStmt,
+ PitchEnvUseStmt,
Program,
+ QuantizeStmt,
+ RelativeVolumeStmt,
RepeatEndStmt,
RepeatStartStmt,
RestStmt,
Statement,
+ SweepStmt,
TempoStmt,
+ TieCmdStmt,
TieStmt,
Track,
TransposeStmt,
+ VolumeEnvelopeDefStmt,
+ VolumeEnvelopeUseStmt,
VolumeStmt,
)
from .ir import (
@@ -134,8 +151,16 @@ class SemanticAnalyzer:
self._analyze_transpose(stmt)
elif isinstance(stmt, DetuneStmt):
self._analyze_detune(stmt)
+ elif isinstance(stmt, QuantizeStmt):
+ self._analyze_quantize(stmt)
elif isinstance(stmt, TieStmt):
self._analyze_tie(stmt)
+ elif isinstance(stmt, TieCmdStmt):
+ self._analyze_tie_cmd(stmt)
+ elif isinstance(stmt, DetuneCmdStmt):
+ self._analyze_detune_cmd(stmt)
+ elif isinstance(stmt, RelativeVolumeStmt):
+ self._analyze_relative_volume(stmt)
elif isinstance(stmt, RepeatStartStmt):
self._analyze_repeat_start(stmt)
elif isinstance(stmt, RepeatEndStmt):
@@ -144,8 +169,34 @@ class SemanticAnalyzer:
pass
elif isinstance(stmt, ExtCmdStmt):
self._analyze_ext_cmd(stmt)
- elif isinstance(stmt, EnvelopeDefStmt):
- self._analyze_stage2(stmt)
+ elif isinstance(stmt, VolumeEnvelopeUseStmt):
+ self._analyze_vol_env_use(stmt)
+ elif isinstance(stmt, VolumeEnvelopeDefStmt):
+ self._analyze_vol_env_def(stmt)
+ elif isinstance(stmt, DutyEnvelopeDefStmt):
+ self._analyze_duty_env_def(stmt)
+ elif isinstance(stmt, DutyEnvelopeUseStmt):
+ self._analyze_duty_env_use(stmt)
+ elif isinstance(stmt, LfoDefStmt):
+ self._analyze_lfo_def(stmt)
+ elif isinstance(stmt, LfoUseStmt):
+ self._analyze_lfo_use(stmt)
+ elif isinstance(stmt, LfoOffStmt):
+ self._analyze_lfo_off(stmt)
+ elif isinstance(stmt, PitchEnvDefStmt):
+ self._analyze_pitch_env_def(stmt)
+ elif isinstance(stmt, PitchEnvUseStmt):
+ self._analyze_pitch_env_use(stmt)
+ elif isinstance(stmt, PitchEnvOffStmt):
+ self._analyze_pitch_env_off(stmt)
+ elif isinstance(stmt, NoteEnvDefStmt):
+ self._analyze_note_env_def(stmt)
+ elif isinstance(stmt, NoteEnvUseStmt):
+ self._analyze_note_env_use(stmt)
+ elif isinstance(stmt, NoteEnvOffStmt):
+ self._analyze_note_env_off(stmt)
+ elif isinstance(stmt, SweepStmt):
+ self._analyze_sweep(stmt)
else:
self._analyze_stage2(stmt)
@@ -177,9 +228,21 @@ class SemanticAnalyzer:
def _analyze_gate_time(self, stmt: GateTimeStmt) -> None:
raise NotImplementedError
+ def _analyze_quantize(self, stmt: QuantizeStmt) -> None:
+ raise NotImplementedError
+
def _analyze_tie(self, stmt: TieStmt) -> None:
raise NotImplementedError
+ def _analyze_tie_cmd(self, stmt: TieCmdStmt) -> None:
+ raise NotImplementedError
+
+ def _analyze_detune_cmd(self, stmt: DetuneCmdStmt) -> None:
+ raise NotImplementedError
+
+ def _analyze_relative_volume(self, stmt: RelativeVolumeStmt) -> None:
+ raise NotImplementedError
+
def _analyze_repeat_start(self, stmt: RepeatStartStmt) -> None:
raise NotImplementedError
@@ -189,6 +252,48 @@ class SemanticAnalyzer:
def _analyze_ext_cmd(self, stmt: ExtCmdStmt) -> None:
raise NotImplementedError
+ def _analyze_vol_env_use(self, stmt: VolumeEnvelopeUseStmt) -> None:
+ raise NotImplementedError
+
+ def _analyze_vol_env_def(self, stmt: VolumeEnvelopeDefStmt) -> None:
+ raise NotImplementedError
+
+ def _analyze_duty_env_def(self, stmt: DutyEnvelopeDefStmt) -> None:
+ raise NotImplementedError
+
+ def _analyze_duty_env_use(self, stmt: DutyEnvelopeUseStmt) -> None:
+ raise NotImplementedError
+
+ def _analyze_lfo_def(self, stmt: LfoDefStmt) -> None:
+ raise NotImplementedError
+
+ def _analyze_lfo_use(self, stmt: LfoUseStmt) -> None:
+ raise NotImplementedError
+
+ def _analyze_lfo_off(self, stmt: LfoOffStmt) -> None:
+ raise NotImplementedError
+
+ def _analyze_pitch_env_def(self, stmt: PitchEnvDefStmt) -> None:
+ raise NotImplementedError
+
+ def _analyze_pitch_env_use(self, stmt: PitchEnvUseStmt) -> None:
+ raise NotImplementedError
+
+ def _analyze_pitch_env_off(self, stmt: PitchEnvOffStmt) -> None:
+ raise NotImplementedError
+
+ def _analyze_note_env_def(self, stmt: NoteEnvDefStmt) -> None:
+ raise NotImplementedError
+
+ def _analyze_note_env_use(self, stmt: NoteEnvUseStmt) -> None:
+ raise NotImplementedError
+
+ def _analyze_note_env_off(self, stmt: NoteEnvOffStmt) -> None:
+ raise NotImplementedError
+
+ def _analyze_sweep(self, stmt: SweepStmt) -> None:
+ raise NotImplementedError
+
def _analyze_stage2(self, stmt: ASTNode) -> None:
self.ctx.add_error(
code=ErrorCode.SEMANTIC_UNSUPPORTED_FEATURE,
diff --git a/src/mml_composemusic_mcp/semantic_ppmck.py b/src/mml_composemusic_mcp/semantic_ppmck.py
index b4393c0..e2cabf9 100644
--- a/src/mml_composemusic_mcp/semantic_ppmck.py
+++ b/src/mml_composemusic_mcp/semantic_ppmck.py
@@ -1,33 +1,57 @@
"""PPMCK semantic analyzer: AST -> NoteSequence IR."""
-
from .ast_nodes import (
+ DetuneCmdStmt,
+ DutyEnvelopeDefStmt,
+ DutyEnvelopeUseStmt,
DutyStmt,
- EnvelopeDefStmt,
ExtCmdStmt,
GateTimeStmt,
LengthStmt,
+ LfoDefStmt,
+ LfoOffStmt,
+ LfoUseStmt,
+ NoteEnvDefStmt,
+ NoteEnvOffStmt,
+ NoteEnvUseStmt,
NoteStmt,
OctaveStmt,
+ PitchEnvDefStmt,
+ PitchEnvOffStmt,
+ PitchEnvUseStmt,
Program,
+ QuantizeStmt,
+ RelativeVolumeStmt,
RepeatEndStmt,
RepeatStartStmt,
RestStmt,
SweepStmt,
TempoStmt,
+ TieCmdStmt,
TieStmt,
Track,
+ VolumeEnvelopeDefStmt,
+ VolumeEnvelopeUseStmt,
VolumeStmt,
)
from .ir import (
+ DetuneEvent,
+ DutyEnvelopeEvent,
DutyEvent,
ErrorCode,
ErrorDetail,
+ LfoEvent,
+ NoteEnvEvent,
NoteEvent,
NoteSequence,
+ PitchEnvEvent,
+ QuantizeEvent,
+ RelativeVolumeEvent,
RepeatEvent,
RestEvent,
+ SweepEvent,
TempoEvent,
+ VolumeEnvelopeEvent,
VolumeEvent,
)
from .parser_base import clamp, length_value_to_ticks, note_to_midi
@@ -44,9 +68,7 @@ class PpmckSemanticAnalyzer(SemanticAnalyzer):
def analyze(self) -> tuple[NoteSequence, list[ErrorDetail]]:
ns, errors = super().analyze()
if self.loop_start is not None:
- max_tick = max(
- ch.total_ticks for ch in ns.channels.values()
- )
+ max_tick = max(ch.total_ticks for ch in ns.channels.values())
for ch in ns.channels.values():
ch.events.append(
RepeatEvent(
@@ -181,9 +203,9 @@ class PpmckSemanticAnalyzer(SemanticAnalyzer):
code=ErrorCode.SEMANTIC_CHANNEL_MISMATCH,
line=stmt.line,
column=stmt.column,
- message=f"チャンネル '{self.current_channel}' で 'q' は使用できません。",
+ message=f"チャンネル '{self.current_channel}' で '@' は使用できません。",
severity="warning",
- hint="Triangleチャンネルはデューティ比を持ちません。q コマンドを削除してください。",
+ hint="Triangleチャンネルはデューティ比を持ちません。@ コマンドを削除してください。",
context=self._context_line(stmt),
)
return
@@ -192,15 +214,297 @@ class PpmckSemanticAnalyzer(SemanticAnalyzer):
code=ErrorCode.SEMANTIC_VALUE_OUT_OF_RANGE,
line=stmt.line,
column=stmt.column,
- message=f"'q' の値 {stmt.value} は範囲外です。有効範囲: 0〜3。",
+ message=f"'@' の値 {stmt.value} は範囲外です。有効範囲: 0〜3。",
severity="error",
- hint="例: q2 のように指定してください。",
+ hint="例: @2 のように指定してください。",
context=self._context_line(stmt),
)
return
self.duty = stmt.value
self._add_event(DutyEvent(tick_position=self.tick_position, value=stmt.value))
+ def _analyze_quantize(self, stmt: QuantizeStmt) -> None:
+ if not 1 <= stmt.value <= 8:
+ self.ctx.add_error(
+ code=ErrorCode.SEMANTIC_VALUE_OUT_OF_RANGE,
+ line=stmt.line,
+ column=stmt.column,
+ message=f"'q' の値 {stmt.value} は範囲外です。有効範囲: 1〜8。",
+ severity="error",
+ hint="例: q4 のように指定してください。",
+ context=self._context_line(stmt),
+ )
+ return
+ self.gate_time = stmt.value / 8.0
+ self._add_event(
+ QuantizeEvent(tick_position=self.tick_position, value=stmt.value)
+ )
+
+ def _analyze_tie_cmd(self, stmt: TieCmdStmt) -> None:
+ if stmt.target is None:
+ return
+ if isinstance(stmt.target, int):
+ ticks = length_value_to_ticks(stmt.target, 0)
+ self._extend_last_note(ticks)
+ self.tick_position += ticks
+ return
+ if isinstance(stmt.target, NoteStmt):
+ target_ticks = self._ticks_for(stmt.target)
+ self._extend_last_note(target_ticks)
+ self.tick_position += target_ticks
+ return
+
+ def _analyze_detune_cmd(self, stmt: DetuneCmdStmt) -> None:
+ self._add_event(DetuneEvent(tick_position=self.tick_position, value=stmt.value))
+ self.ctx.add_error(
+ code=ErrorCode.SEMANTIC_UNSUPPORTED_FEATURE,
+ line=stmt.line,
+ column=stmt.column,
+ message=f"ディチューン 'D{stmt.value}' はIR保持のみで合成に反映されません。",
+ severity="warning",
+ hint="現時点では無視されます。",
+ context=self._context_line(stmt),
+ )
+
+ def _analyze_relative_volume(self, stmt: RelativeVolumeStmt) -> None:
+ self._add_event(
+ RelativeVolumeEvent(tick_position=self.tick_position, delta=stmt.delta)
+ )
+ sign = "+" if stmt.delta > 0 else "-"
+ self.ctx.add_error(
+ code=ErrorCode.SEMANTIC_UNSUPPORTED_FEATURE,
+ line=stmt.line,
+ column=stmt.column,
+ message=f"相対音量 'v{sign}{abs(stmt.delta)}' はIR保持のみです。",
+ severity="warning",
+ hint="現時点では無視されます。",
+ context=self._context_line(stmt),
+ )
+
+ def _analyze_sweep(self, stmt: SweepStmt) -> None:
+ self._add_event(
+ SweepEvent(
+ tick_position=self.tick_position,
+ speed=stmt.speed,
+ depth=stmt.depth,
+ )
+ )
+ self.ctx.add_error(
+ code=ErrorCode.SEMANTIC_UNSUPPORTED_FEATURE,
+ line=stmt.line,
+ column=stmt.column,
+ message=f"スイープ 's{stmt.speed},{stmt.depth}' はIR保持のみです。",
+ severity="warning",
+ hint="現時点では無視されます。",
+ context=self._context_line(stmt),
+ )
+
+ def _analyze_vol_env_use(self, stmt: VolumeEnvelopeUseStmt) -> None:
+ self._add_event(
+ VolumeEnvelopeEvent(tick_position=self.tick_position, slot=stmt.slot)
+ )
+ self.ctx.add_error(
+ code=ErrorCode.SEMANTIC_UNSUPPORTED_FEATURE,
+ line=stmt.line,
+ column=stmt.column,
+ message=f"音量エンベロープ '@v{stmt.slot}' はIR保持のみです。",
+ severity="warning",
+ hint="現時点では無視されます。",
+ context=self._context_line(stmt),
+ )
+
+ def _analyze_vol_env_def(self, stmt: VolumeEnvelopeDefStmt) -> None:
+ self._add_event(
+ VolumeEnvelopeEvent(
+ tick_position=self.tick_position,
+ slot=stmt.slot,
+ points=stmt.points,
+ loop_points=stmt.loop_points,
+ is_definition=True,
+ )
+ )
+ self.ctx.add_error(
+ code=ErrorCode.SEMANTIC_UNSUPPORTED_FEATURE,
+ line=stmt.line,
+ column=stmt.column,
+ message=f"音量エンベロープ定義 '@v{stmt.slot}={{...}}' はIR保持のみです。",
+ severity="warning",
+ hint="現時点では無視されます。",
+ context=self._context_line(stmt),
+ )
+
+ def _analyze_duty_env_def(self, stmt: DutyEnvelopeDefStmt) -> None:
+ self._add_event(
+ DutyEnvelopeEvent(
+ tick_position=self.tick_position,
+ slot=stmt.slot,
+ points=stmt.points,
+ loop_points=stmt.loop_points,
+ is_definition=True,
+ )
+ )
+ self.ctx.add_error(
+ code=ErrorCode.SEMANTIC_UNSUPPORTED_FEATURE,
+ line=stmt.line,
+ column=stmt.column,
+ message=f"デューティエンベロープ定義 '@{stmt.slot}={{...}}' はIR保持のみです。",
+ severity="warning",
+ hint="現時点では無視されます。",
+ context=self._context_line(stmt),
+ )
+
+ def _analyze_duty_env_use(self, stmt: DutyEnvelopeUseStmt) -> None:
+ self._add_event(
+ DutyEnvelopeEvent(tick_position=self.tick_position, slot=stmt.slot)
+ )
+ self.ctx.add_error(
+ code=ErrorCode.SEMANTIC_UNSUPPORTED_FEATURE,
+ line=stmt.line,
+ column=stmt.column,
+ message=f"デューティエンベロープ使用 '@@{stmt.slot}' はIR保持のみです。",
+ severity="warning",
+ hint="現時点では無視されます。",
+ context=self._context_line(stmt),
+ )
+
+ def _analyze_lfo_def(self, stmt: LfoDefStmt) -> None:
+ self._add_event(
+ LfoEvent(
+ tick_position=self.tick_position,
+ slot=stmt.slot,
+ delay=stmt.delay,
+ speed=stmt.speed,
+ depth=stmt.depth,
+ transition=stmt.transition,
+ is_definition=True,
+ )
+ )
+ self.ctx.add_error(
+ code=ErrorCode.SEMANTIC_UNSUPPORTED_FEATURE,
+ line=stmt.line,
+ column=stmt.column,
+ message=f"LFO定義 '@MP{stmt.slot}={{...}}' はIR保持のみです。",
+ severity="warning",
+ hint="現時点では無視されます。",
+ context=self._context_line(stmt),
+ )
+
+ def _analyze_lfo_use(self, stmt: LfoUseStmt) -> None:
+ self._add_event(LfoEvent(tick_position=self.tick_position, slot=stmt.slot))
+ self.ctx.add_error(
+ code=ErrorCode.SEMANTIC_UNSUPPORTED_FEATURE,
+ line=stmt.line,
+ column=stmt.column,
+ message=f"LFO使用 'MP{stmt.slot}' はIR保持のみです。",
+ severity="warning",
+ hint="現時点では無視されます。",
+ context=self._context_line(stmt),
+ )
+
+ def _analyze_lfo_off(self, stmt: LfoOffStmt) -> None:
+ self._add_event(LfoEvent(tick_position=self.tick_position, slot=0, is_off=True))
+ self.ctx.add_error(
+ code=ErrorCode.SEMANTIC_UNSUPPORTED_FEATURE,
+ line=stmt.line,
+ column=stmt.column,
+ message="LFO解除 'MPOF' はIR保持のみです。",
+ severity="warning",
+ hint="現時点では無視されます。",
+ context=self._context_line(stmt),
+ )
+
+ def _analyze_pitch_env_def(self, stmt: PitchEnvDefStmt) -> None:
+ self._add_event(
+ PitchEnvEvent(
+ tick_position=self.tick_position,
+ slot=stmt.slot,
+ points=stmt.points,
+ loop_points=stmt.loop_points,
+ is_definition=True,
+ )
+ )
+ self.ctx.add_error(
+ code=ErrorCode.SEMANTIC_UNSUPPORTED_FEATURE,
+ line=stmt.line,
+ column=stmt.column,
+ message=f"ピッチエンベロープ定義 '@EP{stmt.slot}={{...}}' はIR保持のみです。",
+ severity="warning",
+ hint="現時点では無視されます。",
+ context=self._context_line(stmt),
+ )
+
+ def _analyze_pitch_env_use(self, stmt: PitchEnvUseStmt) -> None:
+ self._add_event(PitchEnvEvent(tick_position=self.tick_position, slot=stmt.slot))
+ self.ctx.add_error(
+ code=ErrorCode.SEMANTIC_UNSUPPORTED_FEATURE,
+ line=stmt.line,
+ column=stmt.column,
+ message=f"ピッチエンベロープ使用 'EP{stmt.slot}' はIR保持のみです。",
+ severity="warning",
+ hint="現時点では無視されます。",
+ context=self._context_line(stmt),
+ )
+
+ def _analyze_pitch_env_off(self, stmt: PitchEnvOffStmt) -> None:
+ self._add_event(
+ PitchEnvEvent(tick_position=self.tick_position, slot=0, is_off=True)
+ )
+ self.ctx.add_error(
+ code=ErrorCode.SEMANTIC_UNSUPPORTED_FEATURE,
+ line=stmt.line,
+ column=stmt.column,
+ message="ピッチエンベロープ解除 'EPOF' はIR保持のみです。",
+ severity="warning",
+ hint="現時点では無視されます。",
+ context=self._context_line(stmt),
+ )
+
+ def _analyze_note_env_def(self, stmt: NoteEnvDefStmt) -> None:
+ self._add_event(
+ NoteEnvEvent(
+ tick_position=self.tick_position,
+ slot=stmt.slot,
+ points=stmt.points,
+ loop_points=stmt.loop_points,
+ is_definition=True,
+ )
+ )
+ self.ctx.add_error(
+ code=ErrorCode.SEMANTIC_UNSUPPORTED_FEATURE,
+ line=stmt.line,
+ column=stmt.column,
+ message=f"ノートエンベロープ定義 '@EN{stmt.slot}={{...}}' はIR保持のみです。",
+ severity="warning",
+ hint="現時点では無視されます。",
+ context=self._context_line(stmt),
+ )
+
+ def _analyze_note_env_use(self, stmt: NoteEnvUseStmt) -> None:
+ self._add_event(NoteEnvEvent(tick_position=self.tick_position, slot=stmt.slot))
+ self.ctx.add_error(
+ code=ErrorCode.SEMANTIC_UNSUPPORTED_FEATURE,
+ line=stmt.line,
+ column=stmt.column,
+ message=f"ノートエンベロープ使用 'EN{stmt.slot}' はIR保持のみです。",
+ severity="warning",
+ hint="現時点では無視されます。",
+ context=self._context_line(stmt),
+ )
+
+ def _analyze_note_env_off(self, stmt: NoteEnvOffStmt) -> None:
+ self._add_event(
+ NoteEnvEvent(tick_position=self.tick_position, slot=0, is_off=True)
+ )
+ self.ctx.add_error(
+ code=ErrorCode.SEMANTIC_UNSUPPORTED_FEATURE,
+ line=stmt.line,
+ column=stmt.column,
+ message="ノートエンベロープ解除 'ENOF' はIR保持のみです。",
+ severity="warning",
+ hint="現時点では無視されます。",
+ context=self._context_line(stmt),
+ )
+
def _analyze_tempo(self, stmt: TempoStmt) -> None:
if stmt.value < 1:
self.ctx.add_error(
@@ -252,28 +556,10 @@ class PpmckSemanticAnalyzer(SemanticAnalyzer):
self._analyze_stage2(stmt)
def _analyze_stage2(self, stmt) -> None: # type: ignore[no-untyped-def]
- if isinstance(stmt, EnvelopeDefStmt):
- self.ctx.add_error(
- code=ErrorCode.SEMANTIC_UNSUPPORTED_FEATURE,
- line=stmt.line,
- column=stmt.column,
- message="エンベロープ定義 '@v' は第2段階で実装されます。",
- severity="warning",
- hint="現時点では無視されます。",
- context=self._context_line(stmt),
- )
- elif isinstance(stmt, SweepStmt):
- self.ctx.add_error(
- code=ErrorCode.SEMANTIC_UNSUPPORTED_FEATURE,
- line=stmt.line,
- column=stmt.column,
- message="スイープ 's' は第2段階で実装されます。",
- severity="warning",
- hint="現時点では無視されます。",
- context=self._context_line(stmt),
- )
- else:
- super()._analyze_stage2(stmt)
+ # VolumeEnvelopeDefStmt is handled by _analyze_vol_env_def.
+ # SweepStmt is handled by _analyze_sweep.
+ # Remaining stage2 items: count-length notes, noise mode, pyxel ext.
+ super()._analyze_stage2(stmt)
def analyze_ppmck(
diff --git a/src/mml_composemusic_mcp/semantic_pyxel.py b/src/mml_composemusic_mcp/semantic_pyxel.py
index 32808d1..7bf94ba 100644
--- a/src/mml_composemusic_mcp/semantic_pyxel.py
+++ b/src/mml_composemusic_mcp/semantic_pyxel.py
@@ -362,7 +362,11 @@ class PyxelSemanticAnalyzer(SemanticAnalyzer):
points = []
for i in range(1, len(stmt.params), 2):
duration = stmt.params[i]
- target = stmt.params[i - 1] if i - 1 < len(stmt.params) else stmt.params[-1]
+ target = (
+ stmt.params[i - 1]
+ if i - 1 < len(stmt.params)
+ else stmt.params[-1]
+ )
points.append({"target_volume": target, "duration_ticks": duration})
ev = EnvelopeEvent(
tick_position=self.tick_position,
diff --git a/src/mml_composemusic_mcp/server.py b/src/mml_composemusic_mcp/server.py
index de0ea82..9e9ac67 100644
--- a/src/mml_composemusic_mcp/server.py
+++ b/src/mml_composemusic_mcp/server.py
@@ -228,15 +228,24 @@ def compose_mml(
def _dict_to_note_sequence(data: dict) -> NoteSequence:
from .ir import (
ChannelSequence,
+ DetuneEvent,
+ DutyEnvelopeEvent,
DutyEvent,
EnvelopeEvent,
GlideEvent,
+ LfoEvent,
+ NoteEnvEvent,
NoteEvent,
NoteSequence,
+ PitchEnvEvent,
+ QuantizeEvent,
+ RelativeVolumeEvent,
RepeatEvent,
RestEvent,
+ SweepEvent,
TempoEvent,
VibratoEvent,
+ VolumeEnvelopeEvent,
VolumeEvent,
)
@@ -268,6 +277,24 @@ def _dict_to_note_sequence(data: dict) -> NoteSequence:
ch.events.append(RepeatEvent(**ev))
elif ev_type == "envelope":
ch.events.append(EnvelopeEvent(**ev))
+ elif ev_type == "quantize":
+ ch.events.append(QuantizeEvent(**ev))
+ elif ev_type == "detune":
+ ch.events.append(DetuneEvent(**ev))
+ elif ev_type == "sweep":
+ ch.events.append(SweepEvent(**ev))
+ elif ev_type == "rel_volume":
+ ch.events.append(RelativeVolumeEvent(**ev))
+ elif ev_type == "vol_envelope":
+ ch.events.append(VolumeEnvelopeEvent(**ev))
+ elif ev_type == "duty_envelope":
+ ch.events.append(DutyEnvelopeEvent(**ev))
+ elif ev_type == "lfo":
+ ch.events.append(LfoEvent(**ev))
+ elif ev_type == "pitch_envelope":
+ ch.events.append(PitchEnvEvent(**ev))
+ elif ev_type == "note_envelope":
+ ch.events.append(NoteEnvEvent(**ev))
elif ev_type == "vibrato":
ch.events.append(VibratoEvent(**ev))
elif ev_type == "glide":
diff --git a/src/mml_composemusic_mcp/templates.py b/src/mml_composemusic_mcp/templates.py
index 1a644fc..9872e44 100644
--- a/src/mml_composemusic_mcp/templates.py
+++ b/src/mml_composemusic_mcp/templates.py
@@ -2,11 +2,11 @@
TEMPLATES = {
"ppmck": {
- "basic": '#TITLE "Basic Song"\n#COMPOSER "LLM"\n\nA t120 l8 o4 v15 q2\n c d e f | g a b > c\n\nB l8 o3 v12 q1\n e g b r | e g b r\n\nT l4 o3 v7\n c2 g2 e2 c2\n\nN l8 v10\n c r c r | c r c r\n',
- "melody": '#TITLE "Melody Lead"\n#COMPOSER "LLM"\n\nA t120 l8 o5 v15 q2\n c e g e | d f a f | e g b g | c4 r4\n\nB l8 o4 v10 q1\n c e g e | c e g e | d f a f | c4 r4\n\nT l4 o3 v7\n c2 g2 | a2 f2 | g2 d2 | c2 r2\n\nN l8 v8\n c r c r | c r c r | c r c r | c r c r\n',
- "chord": '#TITLE "Chord Backing"\n#COMPOSER "LLM"\n\nA t120 l4 o4 v12 q2\n c e g c | f a > c f | g b > d g | c e g c\n\nB l4 o4 v12 q1\n e g b e | f a > c f | d g b d | e g b e\n\nT l2 o3 v7\n c2 | f2 | g2 | c2\n\nN l8 v8\n r c r c | r c r c | r c r c | r c r c\n',
- "drum": '#TITLE "Drum Beat"\n#COMPOSER "LLM"\n\nA t120 l8 o4 v15 q2\n c r e r | g r e r | c r e r | g r c r\n\nB l8 o3 v12 q1\n e r g r | e r g r | e r g r | e r g r\n\nT l4 o2 v7\n c2 c2 | c2 c2 | c2 c2 | c2 c2\n\nN l16 v10\n c r c r c r c r | c r c r c r c r\n c r c r c r c r | c r c r c r c r\n',
- "empty": '#TITLE "Empty Template"\n#COMPOSER "LLM"\n\nA t120 l4 o4 v15 q2\n r1\n\nB l4 o3 v12 q1\n r1\n\nT l4 o3 v7\n r1\n\nN l4 v10\n r1\n',
+ "basic": '#TITLE "Basic Song"\n#COMPOSER "LLM"\n\nA t120 l8 o4 v15 @2\n c d e f | g a b > c\n\nB l8 o3 v12 @1\n e g b r | e g b r\n\nT l4 o3 v7\n c2 g2 e2 c2\n\nN l8 v10\n c r c r | c r c r\n',
+ "melody": '#TITLE "Melody Lead"\n#COMPOSER "LLM"\n\nA t120 l8 o5 v15 @2\n c e g e | d f a f | e g b g | c4 r4\n\nB l8 o4 v10 @1\n c e g e | c e g e | d f a f | c4 r4\n\nT l4 o3 v7\n c2 g2 | a2 f2 | g2 d2 | c2 r2\n\nN l8 v8\n c r c r | c r c r | c r c r | c r c r\n',
+ "chord": '#TITLE "Chord Backing"\n#COMPOSER "LLM"\n\nA t120 l4 o4 v12 @2\n c e g c | f a > c f | g b > d g | c e g c\n\nB l4 o4 v12 @1\n e g b e | f a > c f | d g b d | e g b e\n\nT l2 o3 v7\n c2 | f2 | g2 | c2\n\nN l8 v8\n r c r c | r c r c | r c r c | r c r c\n',
+ "drum": '#TITLE "Drum Beat"\n#COMPOSER "LLM"\n\nA t120 l8 o4 v15 @2\n c r e r | g r e r | c r e r | g r c r\n\nB l8 o3 v12 @1\n e r g r | e r g r | e r g r | e r g r\n\nT l4 o2 v7\n c2 c2 | c2 c2 | c2 c2 | c2 c2\n\nN l16 v10\n c r c r c r c r | c r c r c r c r\n c r c r c r c r | c r c r c r c r\n',
+ "empty": '#TITLE "Empty Template"\n#COMPOSER "LLM"\n\nA t120 l4 o4 v15 @2\n r1\n\nB l4 o3 v12 @1\n r1\n\nT l4 o3 v7\n r1\n\nN l4 v10\n r1\n',
},
"pyxel": {
"basic": "0: T120 L8 O4 V100 @1\n C D E F | G A B >C\n\n1: L8 O3 V80 @2\n E G B R | E G B R\n\n2: L4 O3 V60\n C2 G2 E2 C2\n\n3: L8 V80\n C R C R | C R C R\n",
diff --git a/tests/test_error_codes.py b/tests/test_error_codes.py
index f9bd5bb..538056f 100644
--- a/tests/test_error_codes.py
+++ b/tests/test_error_codes.py
@@ -192,7 +192,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 q2\n c", mode="ppmck")
+ result = compose_mml(action="validate", mml="T t120 l4 @2\n c", mode="ppmck")
assert _find_code(result, ErrorCode.SEMANTIC_CHANNEL_MISMATCH.value, "warnings")
diff --git a/tests/test_lexer.py b/tests/test_lexer.py
index 64fa17c..1d7f8e7 100644
--- a/tests/test_lexer.py
+++ b/tests/test_lexer.py
@@ -4,12 +4,13 @@ from mml_composemusic_mcp.lexer import TokenType, tokenize
def test_ppmck_tokens():
- source = "A t150 l8 o4 v15 q2\nc d e f"
+ source = "A t150 l8 o4 v15 @2\nc d e f"
tokens = tokenize(source, "ppmck")
types = [t.type for t in tokens]
assert TokenType.TRACK_HEADER in types
assert TokenType.TEMPO in types
assert TokenType.NOTE in types
+ assert TokenType.DUTY in types
def test_pyxel_tokens():
@@ -35,3 +36,74 @@ def test_ppmck_invalid_token():
def test_pyxel_invalid_token():
tokens = tokenize("0: T120 L4 O4 X C", "pyxel")
assert any(t.type == TokenType.INVALID for t in tokens)
+
+
+def test_ppmck_at_duty():
+ tokens = tokenize("A @2 c", "ppmck")
+ assert any(t.type == TokenType.DUTY for t in tokens)
+
+
+def test_ppmck_quantize():
+ tokens = tokenize("A q4 c", "ppmck")
+ assert any(t.type == TokenType.QUANTIZE for t in tokens)
+
+
+def test_ppmck_tie_cmd():
+ tokens = tokenize("A c4 ^4", "ppmck")
+ assert any(t.type == TokenType.TIE_CMD for t in tokens)
+
+
+def test_ppmck_detune():
+ tokens = tokenize("A D10 c", "ppmck")
+ assert any(t.type == TokenType.DETUNE for t in tokens)
+
+
+def test_ppmck_sweep():
+ tokens = tokenize("A s1,2 c", "ppmck")
+ assert any(t.type == TokenType.SWEEP for t in tokens)
+
+
+def test_ppmck_rel_vol():
+ tokens = tokenize("A v+5 v-3 c", "ppmck")
+ assert any(t.type == TokenType.REL_VOL_UP for t in tokens)
+ assert any(t.type == TokenType.REL_VOL_DOWN for t in tokens)
+
+
+def test_ppmck_vol_env():
+ tokens = tokenize("A @v0 c", "ppmck")
+ assert any(t.type == TokenType.VOL_ENV for t in tokens)
+
+
+def test_ppmck_vol_env_def():
+ tokens = tokenize("@v0 = { 15, 10, | 5 }", "ppmck")
+ assert any(t.type == TokenType.VOL_ENV for t in tokens)
+ assert any(t.type == TokenType.BRACE_OPEN for t in tokens)
+ assert any(t.type == TokenType.BRACE_CLOSE for t in tokens)
+
+
+def test_ppmck_lfo_def():
+ tokens = tokenize("@MP0 = { 0, 10, 5, 0 }", "ppmck")
+ assert any(t.type == TokenType.LFO_DEF for t in tokens)
+
+
+def test_ppmck_lfo_use_off():
+ tokens = tokenize("A MP1 MPOF c", "ppmck")
+ assert any(t.type == TokenType.LFO_USE for t in tokens)
+ assert any(t.type == TokenType.LFO_OFF for t in tokens)
+
+
+def test_ppmck_pitch_env():
+ tokens = tokenize("A EP1 EPOF c", "ppmck")
+ assert any(t.type == TokenType.PITCH_ENV_USE for t in tokens)
+ assert any(t.type == TokenType.PITCH_ENV_OFF for t in tokens)
+
+
+def test_ppmck_note_env():
+ tokens = tokenize("A EN1 ENOF c", "ppmck")
+ assert any(t.type == TokenType.NOTE_ENV_USE for t in tokens)
+ assert any(t.type == TokenType.NOTE_ENV_OFF for t in tokens)
+
+
+def test_ppmck_duty_env_use():
+ tokens = tokenize("A @@0 c", "ppmck")
+ assert any(t.type == TokenType.DUTY_ENV_USE for t in tokens)
diff --git a/tests/test_mml_conformance.py b/tests/test_mml_conformance.py
index 0e38945..f611f6e 100644
--- a/tests/test_mml_conformance.py
+++ b/tests/test_mml_conformance.py
@@ -5,7 +5,7 @@ from hypothesis import given, seed, settings
from hypothesis import strategies as st
from mml_composemusic_mcp.ast_nodes import NoteStmt, TransposeStmt
-from mml_composemusic_mcp.ir import ErrorCode, NoteEvent
+from mml_composemusic_mcp.ir import DutyEvent, ErrorCode, NoteEvent
from mml_composemusic_mcp.lexer import TokenType, tokenize
from mml_composemusic_mcp.parser_ppmck import parse_ppmck
from mml_composemusic_mcp.parser_pyxel import parse_pyxel
@@ -58,7 +58,7 @@ def test_note_follows_bnf_accidental_before_length(
@pytest.mark.parametrize(
("mode", "source"),
[
- ("ppmck", "A o l v q t c"),
+ ("ppmck", "A o l v @ t c"),
("pyxel", "0: O L V Q @ T K Y C"),
("pyxel", "0: K+ Y- C"),
],
@@ -149,6 +149,55 @@ def test_validate_full_pyxel_path_preserves_warning_and_summary():
)
+@pytest.mark.parametrize(
+ ("source", "expected_warning_code"),
+ [
+ ("A D10 c", ErrorCode.SEMANTIC_UNSUPPORTED_FEATURE),
+ ("A s1,2 c", ErrorCode.SEMANTIC_UNSUPPORTED_FEATURE),
+ ("A v+5 c", ErrorCode.SEMANTIC_UNSUPPORTED_FEATURE),
+ ("A @v0 c", ErrorCode.SEMANTIC_UNSUPPORTED_FEATURE),
+ ("A @@0 c", ErrorCode.SEMANTIC_UNSUPPORTED_FEATURE),
+ ("A MP1 c", ErrorCode.SEMANTIC_UNSUPPORTED_FEATURE),
+ ("A MPOF c", ErrorCode.SEMANTIC_UNSUPPORTED_FEATURE),
+ ("A EP1 c", ErrorCode.SEMANTIC_UNSUPPORTED_FEATURE),
+ ("A EPOF c", ErrorCode.SEMANTIC_UNSUPPORTED_FEATURE),
+ ("A EN1 c", ErrorCode.SEMANTIC_UNSUPPORTED_FEATURE),
+ ("A ENOF c", ErrorCode.SEMANTIC_UNSUPPORTED_FEATURE),
+ ],
+)
+def test_ppmck_new_commands_produce_warning(source, expected_warning_code):
+ _, _, _, errors = parse_pipeline(source, "ppmck")
+ assert not any(e.severity == "error" for e in errors)
+ assert expected_warning_code in error_codes(errors)
+
+
+def test_ppmck_at_duty_in_pulse_channel():
+ _, _, ns, errors = parse_pipeline("A @2 c", "ppmck")
+ assert not any(e.severity == "error" for e in errors)
+ duties = [e for e in ns.channels["Pulse1"].events if isinstance(e, DutyEvent)]
+ assert duties and duties[0].value == 2
+
+
+def test_ppmck_at_duty_in_triangle_channel_warns():
+ _, _, _, errors = parse_pipeline("T @2 c", "ppmck")
+ assert ErrorCode.SEMANTIC_CHANNEL_MISMATCH in error_codes(errors)
+
+
+def test_ppmck_quantize_sets_gate_time():
+ _, _, ns, errors = parse_pipeline("A q4 c", "ppmck")
+ assert not any(e.severity == "error" for e in errors)
+ notes = [e for e in ns.channels["Pulse1"].events if isinstance(e, NoteEvent)]
+ assert notes and notes[0].gate_time == 0.5
+
+
+def test_ppmck_tie_cmd_extends_duration():
+ _, _, ns, errors = parse_pipeline("A c4 ^4", "ppmck")
+ assert not any(e.severity == "error" for e in errors)
+ notes = [e for e in ns.channels["Pulse1"].events if isinstance(e, NoteEvent)]
+ assert len(notes) == 1
+ assert notes[0].duration == 384
+
+
note_names = st.sampled_from(tuple("cdefgab"))
accidentals = st.sampled_from(("", "+", "#", "-"))
lengths = st.one_of(st.just(""), st.integers(1, 192).map(str))
diff --git a/tests/test_parser.py b/tests/test_parser.py
index a0f11c7..291eb9b 100644
--- a/tests/test_parser.py
+++ b/tests/test_parser.py
@@ -6,7 +6,7 @@ from mml_composemusic_mcp.parser_ppmck import parse_ppmck
from mml_composemusic_mcp.parser_pyxel import parse_pyxel
PPMCK_EXAMPLE = """#TITLE "Test"
-A t150 l8 o4 v15 q2
+A t150 l8 o4 v15 @2
c d e f
B o3
c e g
@@ -27,9 +27,7 @@ def test_ppmck_parse():
assert len(program.tracks) == 2
assert program.tracks[0].channel == "Pulse1"
assert program.tracks[1].channel == "Pulse2"
- notes = [
- s for s in program.tracks[0].statements if isinstance(s, NoteStmt)
- ]
+ notes = [s for s in program.tracks[0].statements if isinstance(s, NoteStmt)]
assert notes[0].note_name == "c"
@@ -40,9 +38,7 @@ def test_pyxel_parse():
assert not any(e.severity == "error" for e in errors)
assert len(program.tracks) == 2
assert program.tracks[0].channel == "Pulse1"
- notes = [
- s for s in program.tracks[0].statements if isinstance(s, NoteStmt)
- ]
+ notes = [s for s in program.tracks[0].statements if isinstance(s, NoteStmt)]
assert notes[0].note_name == "c"
diff --git a/tests/test_parser_detailed.py b/tests/test_parser_detailed.py
index 7033d54..f1f4b6a 100644
--- a/tests/test_parser_detailed.py
+++ b/tests/test_parser_detailed.py
@@ -3,6 +3,7 @@
from mml_composemusic_mcp.ir import (
DutyEvent,
EnvelopeEvent,
+ ErrorCode,
NoteEvent,
)
from mml_composemusic_mcp.lexer import tokenize
@@ -54,13 +55,82 @@ def test_pyxel_channel_assignment():
# --- Duty ---
-def test_ppmck_duty_q():
- source = "A t120 l4 o4 q3\n c"
+def test_ppmck_duty_at():
+ source = "A t120 l4 o4 @3\n c"
ns, errors = _analyze_ppmck(source)
duties = [e for e in ns.channels["Pulse1"].events if isinstance(e, DutyEvent)]
assert duties and duties[0].value == 3
+def test_ppmck_quantize():
+ source = "A t120 l4 o4 q4\n c"
+ ns, errors = _analyze_ppmck(source)
+ notes = [e for e in ns.channels["Pulse1"].events if isinstance(e, NoteEvent)]
+ assert notes and notes[0].gate_time == 0.5
+
+
+def test_ppmck_tie_cmd():
+ source = "A t120 l4 o4\n c4 ^4"
+ ns, errors = _analyze_ppmck(source)
+ notes = [e for e in ns.channels["Pulse1"].events if isinstance(e, NoteEvent)]
+ assert len(notes) == 1
+ assert notes[0].duration == 384
+
+
+def test_ppmck_detune_warning():
+ source = "A t120 l4 o4 D10\n c"
+ ns, errors = _analyze_ppmck(source)
+ assert any(e.code == ErrorCode.SEMANTIC_UNSUPPORTED_FEATURE for e in errors)
+
+
+def test_ppmck_sweep_warning():
+ source = "A t120 l4 o4 s1,2\n c"
+ ns, errors = _analyze_ppmck(source)
+ assert any(e.code == ErrorCode.SEMANTIC_UNSUPPORTED_FEATURE for e in errors)
+
+
+def test_ppmck_rel_vol_warning():
+ source = "A t120 l4 o4 v+5\n c"
+ ns, errors = _analyze_ppmck(source)
+ assert any(e.code == ErrorCode.SEMANTIC_UNSUPPORTED_FEATURE for e in errors)
+
+
+def test_ppmck_vol_env_use_warning():
+ source = "A t120 l4 o4 @v0\n c"
+ ns, errors = _analyze_ppmck(source)
+ assert any(e.code == ErrorCode.SEMANTIC_UNSUPPORTED_FEATURE for e in errors)
+
+
+def test_ppmck_vol_env_def_warning():
+ source = "A t120 l4 o4\n@v0 = { 15, 10, | 5 }\n c"
+ ns, errors = _analyze_ppmck(source)
+ assert any(e.code == ErrorCode.SEMANTIC_UNSUPPORTED_FEATURE for e in errors)
+
+
+def test_ppmck_lfo_def_warning():
+ source = "A t120 l4 o4\n@MP0 = { 0, 10, 5, 0 }\n c"
+ ns, errors = _analyze_ppmck(source)
+ assert any(e.code == ErrorCode.SEMANTIC_UNSUPPORTED_FEATURE for e in errors)
+
+
+def test_ppmck_duty_env_use_warning():
+ source = "A t120 l4 o4 @@0\n c"
+ ns, errors = _analyze_ppmck(source)
+ assert any(e.code == ErrorCode.SEMANTIC_UNSUPPORTED_FEATURE for e in errors)
+
+
+def test_ppmck_duty_triangle_error():
+ source = "T t120 l4 o3 @2\n c"
+ ns, errors = _analyze_ppmck(source)
+ assert any(e.code == ErrorCode.SEMANTIC_CHANNEL_MISMATCH for e in errors)
+
+
+def test_ppmck_quantize_range_error():
+ source = "A t120 l4 o4 q9\n c"
+ ns, errors = _analyze_ppmck(source)
+ assert any(e.code == ErrorCode.SEMANTIC_VALUE_OUT_OF_RANGE for e in errors)
+
+
def test_pyxel_at_duty():
source = "0: T120 L4 O4 @1\n C"
ns, errors = _analyze_pyxel(source)