summaryrefslogtreecommitdiff
path: root/src/mml_composemusic_mcp/ast_nodes.py
diff options
context:
space:
mode:
Diffstat (limited to 'src/mml_composemusic_mcp/ast_nodes.py')
-rw-r--r--src/mml_composemusic_mcp/ast_nodes.py230
1 files changed, 230 insertions, 0 deletions
diff --git a/src/mml_composemusic_mcp/ast_nodes.py b/src/mml_composemusic_mcp/ast_nodes.py
new file mode 100644
index 0000000..bc4fdc4
--- /dev/null
+++ b/src/mml_composemusic_mcp/ast_nodes.py
@@ -0,0 +1,230 @@
+"""Common AST node definitions for ppmck and pyxel MML."""
+
+from dataclasses import dataclass, field
+from typing import Any
+
+
+@dataclass
+class ASTNode:
+ """Base class for all AST nodes."""
+
+ line: int
+ column: int
+
+
+@dataclass
+class Program(ASTNode):
+ """Root program node."""
+
+ tracks: list[Track] = field(default_factory=list)
+
+
+@dataclass
+class Track(ASTNode):
+ """A single track definition."""
+
+ track_id: str # "A" / "B" / "T" / "N" / "L" (ppmck) or "0"-"3" (pyxel)
+ channel: str # "Pulse1" / "Pulse2" / "Triangle" / "Noise" / "Loop"
+ mode: str # "ppmck" / "pyxel"
+ statements: list[Statement] = field(default_factory=list)
+ headers: list[Header] = field(default_factory=list)
+
+
+@dataclass
+class Header(ASTNode):
+ """ppmck # header line."""
+
+ key: str
+ value: str
+ unterminated: bool = False
+
+
+# --- Statements ---
+
+
+@dataclass
+class NoteStmt(ASTNode):
+ """A note command (c, d, e, ...)."""
+
+ note_name: str # normalized to lowercase: "c"-"b"
+ accidental: int = 0 # +1 sharp, -1 flat, 0 none
+ length: int | None = None # None means use default length
+ dots: int = 0
+
+
+@dataclass
+class RestStmt(ASTNode):
+ """A rest command (r / R)."""
+
+ length: int | None = None
+ dots: int = 0
+
+
+@dataclass
+class OctaveStmt(ASTNode):
+ """Octave command or relative octave shift."""
+
+ value: int | None = None # absolute octave number
+ direction: str | None = None # "up" or "down" for relative shifts
+
+
+@dataclass
+class LengthStmt(ASTNode):
+ """Default length command (l / L)."""
+
+ value: int
+
+
+@dataclass
+class VolumeStmt(ASTNode):
+ """Volume command (v / V)."""
+
+ value: int
+
+
+@dataclass
+class DutyStmt(ASTNode):
+ """Duty/tone command (q / @)."""
+
+ value: int
+
+
+@dataclass
+class TempoStmt(ASTNode):
+ """Tempo command (t / T)."""
+
+ value: int
+
+
+@dataclass
+class GateTimeStmt(ASTNode):
+ """Gate time command (Q, pyxel only)."""
+
+ value: int
+
+
+@dataclass
+class TransposeStmt(ASTNode):
+ """Transpose command (K, pyxel only)."""
+
+ value: int
+
+
+@dataclass
+class DetuneStmt(ASTNode):
+ """Detune command (Y, pyxel only)."""
+
+ value: int
+
+
+@dataclass
+class TieStmt(ASTNode):
+ """Tie/slur command (&)."""
+
+ target: NoteStmt | RestStmt | int | None = None
+
+
+@dataclass
+class RepeatStartStmt(ASTNode):
+ """Start of repeat section ([)."""
+
+ pass
+
+
+@dataclass
+class RepeatEndStmt(ASTNode):
+ """End of repeat section (] with optional count)."""
+
+ count: int | None = None # None means infinite
+
+
+@dataclass
+class BarStmt(ASTNode):
+ """Bar line (|), visual only."""
+
+ pass
+
+
+@dataclass
+class ExtCmdStmt(ASTNode):
+ """pyxel @ENV / @VIB / @GLI command."""
+
+ cmd: str # "ENV" / "VIB" / "GLI"
+ slot: int
+ params: list[int] = field(default_factory=list)
+
+
+# --- 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 EnvelopeDefStmt(ASTNode):
+ """ppmck envelope definition (@v0 = { ... })."""
+
+ slot: int
+ points: list[int] = field(default_factory=list)
+ loop_points: list[int] = field(default_factory=list)
+
+
+@dataclass
+class SweepStmt(ASTNode):
+ """ppmck sweep command (s1,2)."""
+
+ speed: int
+ depth: int
+
+
+@dataclass
+class NoiseModeStmt(ASTNode):
+ """ppmck noise mode command (m0 / m1)."""
+
+ mode: int
+
+
+Statement = (
+ NoteStmt
+ | RestStmt
+ | OctaveStmt
+ | LengthStmt
+ | VolumeStmt
+ | DutyStmt
+ | TempoStmt
+ | GateTimeStmt
+ | TransposeStmt
+ | DetuneStmt
+ | TieStmt
+ | RepeatStartStmt
+ | RepeatEndStmt
+ | BarStmt
+ | ExtCmdStmt
+ | CountLengthStmt
+ | EnvelopeDefStmt
+ | SweepStmt
+ | NoiseModeStmt
+)
+
+
+def _node_to_dict(node: Any) -> Any:
+ """Recursively convert an AST node to a JSON-compatible dict."""
+ if isinstance(node, list):
+ return [_node_to_dict(item) for item in node]
+ if not isinstance(node, ASTNode):
+ return node
+ data: dict[str, Any] = {"__type__": node.__class__.__name__}
+ for key, value in node.__dict__.items():
+ data[key] = _node_to_dict(value)
+ return data
+
+
+def ast_to_dict(node: ASTNode) -> dict[str, Any]:
+ """Convert an AST node to a JSON-compatible dict."""
+ return _node_to_dict(node)