From bf36a773918959eeb6ffe7dae2fb8b0874438eb4 Mon Sep 17 00:00:00 2001 From: tanaka takashi Date: Sun, 12 Jul 2026 17:58:19 +0900 Subject: feat(ppmck): mckc準拠の構文拡張とIR保持コマンド追加 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - @をデューティ比、qをクオンタイズに修正(mckc原本準拠) - ^タイ、Dディチューン、sスイープ、v+/v-相対音量を追加 - @v/@@/@MP/@EP/@EN エンベロープ・LFO系を構文解析してIR保持 - 上記IR保持コマンドは合成未対応を SEMANTIC_UNSUPPORTED_FEATURE で警告 - Design.md / MML_BNF.md を更新 - 全200テスト + ruff チェック通過 --- doc/Design.md | 141 ++++++++++-- doc/MML_BNF.md | 113 +++++++--- src/mml_composemusic_mcp/ast_nodes.py | 159 ++++++++++++- src/mml_composemusic_mcp/ir.py | 117 +++++++++- src/mml_composemusic_mcp/lexer.py | 140 +++++++++++- src/mml_composemusic_mcp/parser_ppmck.py | 305 ++++++++++++++++++++++++- src/mml_composemusic_mcp/semantic_base.py | 111 ++++++++- src/mml_composemusic_mcp/semantic_ppmck.py | 348 ++++++++++++++++++++++++++--- src/mml_composemusic_mcp/semantic_pyxel.py | 6 +- src/mml_composemusic_mcp/server.py | 27 +++ src/mml_composemusic_mcp/templates.py | 10 +- tests/test_error_codes.py | 2 +- tests/test_lexer.py | 74 +++++- tests/test_mml_conformance.py | 53 ++++- tests/test_parser.py | 10 +- tests/test_parser_detailed.py | 74 +++++- 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 +- `&` スラーを前の音符の音長延長として解釈 --- @@ -416,6 +427,89 @@ class DetuneStmt(ASTNode): 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): ::= "A" | "B" | "T" | "N" | "L" ::= | | | - | | | | + | | | + | | | | + | | + | | + | | + | | | + | | | + | | | | | | ``` @@ -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 @@ | | | + | | | + | + | | | | @@ -88,45 +91,86 @@ ::= "l" /* l1 〜 l192 */ ::= "v" /* v0 〜 v15 */ - ::= "q" /* q0 〜 q3, Pulse系のみ */ + + ::= "@" /* @0 〜 @3, Pulse系のみ */ + ::= "q" /* q1 〜 q8 */ ::= "t" /* t1 〜 */ - ::= ( | | ) + ::= "^" ( | ) + ::= "&" ( | | ) ::= ";" ``` -### 2.3 将来予定コマンド(現在の受理文法には含めない) +### 2.3 拡張コマンド(第1段階: IR 保持のみ、合成無視) + +```bnf + ::= + | + | + | + | + | + | + | + | + | + | + | + | + | + | + | + + + ::= "v+" ? /* 1 〜 15 */ + | "v-" ? /* 1 〜 15 */ + + ::= "D" /* -127 〜 126 */ + ::= "s" "," + +::= "@v" "=" "{" "|" ? "}" +::= "@v" + +::= "@" "=" "{" "|" ? "}" +::= "@@" + + ::= "@MP" "=" "{" "," "," "," "}" + ::= "MP" + ::= "MPOF" + + ::= "@EP" "=" "{" "|" ? "}" + ::= "EP" + ::= "EPOF" + + ::= "@EN" "=" "{" "|" ? "}" + ::= "EN" + ::= "ENOF" + + ::= ("+" | "-")? + ::= (","? )* + ::= (","? )* +``` + +### 2.4 将来予定コマンド(現在の受理文法には含めない) 以下は設計予約であり、現在は構文エラーになる。実装時に受理文法へ移す。 ```bnf ::= - | - | - | - | | | | ::= ? "%" - ::= "@v" "=" "{" "|" ? "}" - ::= "@v" - -::= "@" "=" "{" "|" ? "}" - - ::= "s" "," - ::= "[" + "]" ? ::= "K" ::= "m" - - ::= (","? )* ``` + --- ## 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,`(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 @@ -117,6 +117,13 @@ class DetuneStmt(ASTNode): value: int +@dataclass +class QuantizeStmt(ASTNode): + """ppmck quantize command (q1-q8).""" + + value: int + + @dataclass class TieStmt(ASTNode): """Tie/slur command (&).""" @@ -124,6 +131,27 @@ class TieStmt(ASTNode): target: NoteStmt | RestStmt | int | None = None +@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).""" + + 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,27 +182,124 @@ 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).""" - note_name: str - accidental: int - count: int + slot: int + + +@dataclass +class VolumeEnvelopeDefStmt(ASTNode): + """ppmck volume envelope definition (@v = {...|...}).""" + + 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 (@ = {...|...}).""" + + 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 (@@).""" + + slot: int + + +@dataclass +class LfoDefStmt(ASTNode): + """ppmck LFO definition (@MP = {p1,p2,p3,p4}).""" + + slot: int + delay: int + speed: int + depth: int + transition: int + + +@dataclass +class LfoUseStmt(ASTNode): + """ppmck LFO use (MP).""" + + slot: int + + +@dataclass +class LfoOffStmt(ASTNode): + """ppmck LFO off (MPOF).""" + + pass + + +@dataclass +class PitchEnvDefStmt(ASTNode): + """ppmck pitch envelope definition (@EP = {...|...}).""" + + 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).""" + + 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 = {...|...}).""" slot: int points: list[int] = field(default_factory=list) loop_points: list[int] = field(default_factory=list) +@dataclass +class NoteEnvUseStmt(ASTNode): + """ppmck note envelope use (EN).""" + + 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 @@ -133,6 +133,35 @@ class RepeatEvent: duration: int = 0 +@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" @@ -141,6 +170,61 @@ class EnvelopeEvent: points: list[dict[str, int]] = field(default_factory=list) +@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" @@ -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 + # @ = { ... | ... } → 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 → 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: + """@ = { ... | ... } → 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 = { 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) -- cgit v1.2.3