summaryrefslogtreecommitdiff
path: root/src
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 /src
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 チェック通過
Diffstat (limited to 'src')
-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
9 files changed, 1165 insertions, 58 deletions
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",