diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/mml_composemusic_mcp/ast_nodes.py | 159 | ||||
| -rw-r--r-- | src/mml_composemusic_mcp/ir.py | 117 | ||||
| -rw-r--r-- | src/mml_composemusic_mcp/lexer.py | 140 | ||||
| -rw-r--r-- | src/mml_composemusic_mcp/parser_ppmck.py | 305 | ||||
| -rw-r--r-- | src/mml_composemusic_mcp/semantic_base.py | 111 | ||||
| -rw-r--r-- | src/mml_composemusic_mcp/semantic_ppmck.py | 348 | ||||
| -rw-r--r-- | src/mml_composemusic_mcp/semantic_pyxel.py | 6 | ||||
| -rw-r--r-- | src/mml_composemusic_mcp/server.py | 27 | ||||
| -rw-r--r-- | src/mml_composemusic_mcp/templates.py | 10 |
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", |
