diff options
| author | tanaka takashi <isazaka@gmail.com> | 2026-07-11 19:02:14 +0900 |
|---|---|---|
| committer | tanaka takashi <isazaka@gmail.com> | 2026-07-11 19:02:14 +0900 |
| commit | ca35520338057512a71acd42bb116ffbb82cc192 (patch) | |
| tree | 9faa2428aeb71b62497f21ceec93b99ef7f2a5ac /src/mml_composemusic_mcp/ir.py | |
| parent | 7dca1616b8c891f5e140c7267323d1fcdde6741a (diff) | |
feat: implement Famicom-style MML composer MCP server
- Add FastMCP server with compose_mml tool
- Support ppmck and pyxel MML syntax
- Add lexer and parsers for both formats
- Add NES APU-style synthesizer (pulse/triangle/noise) to WAV
- Add MML templates (basic/melody/chord/drum/empty) for both modes
- Add tests for lexer, parser, and server
- Support stdio, http, sse, streamable-http transports
- Update README, pyproject.toml, and .gitignore
Diffstat (limited to 'src/mml_composemusic_mcp/ir.py')
| -rw-r--r-- | src/mml_composemusic_mcp/ir.py | 185 |
1 files changed, 185 insertions, 0 deletions
diff --git a/src/mml_composemusic_mcp/ir.py b/src/mml_composemusic_mcp/ir.py new file mode 100644 index 0000000..05ef37a --- /dev/null +++ b/src/mml_composemusic_mcp/ir.py @@ -0,0 +1,185 @@ +"""Intermediate representation for MML NoteSequence and errors.""" + +from dataclasses import dataclass, field +from enum import Enum +from typing import Any + + +class ErrorCode(Enum): + """Machine-readable error codes.""" + + SYNTAX_INVALID_TOKEN = "SYNTAX_INVALID_TOKEN" + SYNTAX_INVALID_NUMBER = "SYNTAX_INVALID_NUMBER" + SYNTAX_VALUE_OUT_OF_RANGE = "SYNTAX_VALUE_OUT_OF_RANGE" + SYNTAX_UNEXPECTED_TOKEN = "SYNTAX_UNEXPECTED_TOKEN" + SYNTAX_UNTERMINATED_REPEAT = "SYNTAX_UNTERMINATED_REPEAT" + SYNTAX_UNMATCHED_REPEAT_END = "SYNTAX_UNMATCHED_REPEAT_END" + SYNTAX_UNTERMINATED_TIE = "SYNTAX_UNTERMINATED_TIE" + SYNTAX_INVALID_TRACK_HEADER = "SYNTAX_INVALID_TRACK_HEADER" + SYNTAX_DUPLICATE_TRACK = "SYNTAX_DUPLICATE_TRACK" + SYNTAX_EMPTY_TRACK = "SYNTAX_EMPTY_TRACK" + SYNTAX_NOTE_OUT_OF_RANGE = "SYNTAX_NOTE_OUT_OF_RANGE" + SYNTAX_CHANNEL_MISMATCH = "SYNTAX_CHANNEL_MISMATCH" + SYNTAX_UNTERMINATED_HEADER = "SYNTAX_UNTERMINATED_HEADER" + SYNTAX_UNDEFINED_REFERENCE = "SYNTAX_UNDEFINED_REFERENCE" + + SYSTEM_SYNTHESIS_FAILED = "SYSTEM_SYNTHESIS_FAILED" + SYSTEM_WAV_WRITE_FAILED = "SYSTEM_WAV_WRITE_FAILED" + SYSTEM_INTERNAL_ERROR = "SYSTEM_INTERNAL_ERROR" + + +@dataclass +class ErrorDetail: + """Structured error/warning detail.""" + + code: ErrorCode + line: int + column: int + message: str + severity: str # "error" | "warning" + hint: str = "" + context: str = "" + + def to_dict(self) -> dict[str, Any]: + return { + "code": self.code.value, + "line": self.line, + "column": self.column, + "message": self.message, + "severity": self.severity, + "hint": self.hint, + "context": self.context, + } + + +@dataclass +class NoteEvent: + type: str = "note" + tick_position: int = 0 + duration: int = 0 + note_number: int = 69 + velocity: int = 15 + duty: int = 2 + gate_time: float = 1.0 + + +@dataclass +class RestEvent: + type: str = "rest" + tick_position: int = 0 + duration: int = 0 + + +@dataclass +class VolumeEvent: + type: str = "volume" + tick_position: int = 0 + value: int = 15 + + +@dataclass +class DutyEvent: + type: str = "duty" + tick_position: int = 0 + value: int = 2 + + +@dataclass +class TempoEvent: + type: str = "tempo" + tick_position: int = 0 + bpm: int = 120 + + +@dataclass +class RepeatEvent: + type: str = "repeat" + tick_position: int = 0 + start_tick: int = 0 + end_tick: int = 0 + repeat_count: int = 1 + duration: int = 0 + + +@dataclass +class EnvelopeEvent: + type: str = "envelope" + tick_position: int = 0 + slot: int = 0 + points: list[dict[str, int]] = field(default_factory=list) + + +@dataclass +class VibratoEvent: + type: str = "vibrato" + tick_position: int = 0 + slot: int = 0 + params: dict[str, int] = field(default_factory=dict) + + +@dataclass +class GlideEvent: + type: str = "glide" + tick_position: int = 0 + slot: int = 0 + params: dict[str, int] = field(default_factory=dict) + + +Event = ( + NoteEvent + | RestEvent + | VolumeEvent + | DutyEvent + | TempoEvent + | RepeatEvent + | EnvelopeEvent + | VibratoEvent + | GlideEvent +) + + +@dataclass +class ChannelSequence: + channel_type: str = "pulse" + events: list[Event] = field(default_factory=list) + total_ticks: int = 0 + + def to_dict(self) -> dict[str, Any]: + return { + "channel_type": self.channel_type, + "events": [event_to_dict(e) for e in self.events], + "total_ticks": self.total_ticks, + } + + +@dataclass +class NoteSequence: + version: str = "1.0" + bpm: int = 120 + ticks_per_quarter: int = 192 + channels: dict[str, ChannelSequence] = field( + default_factory=lambda: { + "Pulse1": ChannelSequence("pulse"), + "Pulse2": ChannelSequence("pulse"), + "Triangle": ChannelSequence("triangle"), + "Noise": ChannelSequence("noise"), + } + ) + + def to_dict(self) -> dict[str, Any]: + return { + "version": self.version, + "bpm": self.bpm, + "ticks_per_quarter": self.ticks_per_quarter, + "channels": {k: v.to_dict() for k, v in self.channels.items()}, + } + + +def event_to_dict(event: Event) -> dict[str, Any]: + if isinstance( + event, (NoteEvent, RestEvent, VolumeEvent, DutyEvent, TempoEvent, RepeatEvent) + ): + return event.__dict__ + if isinstance(event, (EnvelopeEvent, VibratoEvent, GlideEvent)): + return event.__dict__ + raise TypeError(f"Unsupported event type: {type(event)}") |
