Skip to content

API Reference

This page provides auto-generated API documentation for the MIDI Markdown implementation.

Overview

The MMD codebase is organized into several key packages:

  • Parser - Lark-based parsing and AST generation
  • Core/IR - Intermediate Representation layer
  • Codegen - Output format generation (MIDI, CSV, JSON)
  • Runtime - Real-time MIDI playback
  • Validation - Document and value validation
  • Alias - Alias resolution and device libraries
  • Expansion - Command expansion (variables, loops, sweeps)
  • CLI - Command-line interface

Parser

MMDParser

MMDParser

MMDParser(grammar_file: str | None = None)

Main parser class for MIDI Markup Language.

Usage

parser = MMDParser() document = parser.parse_file('song.mmd')

or

document = parser.parse_string(mml_content)

Initialize the parser with the grammar.

Parameters:

Name Type Description Default
grammar_file str | None

Path to the .lark grammar file. If None, uses the grammar from the parser package.

None
Source code in src/midi_markdown/parser/parser.py
def __init__(self, grammar_file: str | None = None):
    """
    Initialize the parser with the grammar.

    Args:
        grammar_file: Path to the .lark grammar file.
                     If None, uses the grammar from the parser package.
    """
    if grammar_file:
        with open(grammar_file) as f:
            grammar = f.read()
    else:
        # Use grammar from the parser package
        grammar_path = Path(__file__).parent / "mmd.lark"
        with open(grammar_path) as f:
            grammar = f.read()

    self.parser = Lark(
        grammar,
        parser="lalr",  # LALR parser for speed
        transformer=MMDTransformer(),
        start="document",
        propagate_positions=True,  # Track line/column numbers
        maybe_placeholders=False,
    )

Functions

parse_file
parse_file(filepath: str | Path) -> MMDDocument

Parse an MMD file.

Parameters:

Name Type Description Default
filepath str | Path

Path to the .mmd file

required

Returns:

Type Description
MMDDocument

MMDDocument object containing the parsed content

Source code in src/midi_markdown/parser/parser.py
def parse_file(self, filepath: str | Path) -> MMDDocument:
    """
    Parse an MMD file.

    Args:
        filepath: Path to the .mmd file

    Returns:
        MMDDocument object containing the parsed content
    """
    with open(filepath, encoding="utf-8") as f:
        content = f.read()

    return self.parse_string(content, str(filepath))
parse_string
parse_string(content: str, filename: str = '<string>') -> MMDDocument

Parse MML content from a string.

Parameters:

Name Type Description Default
content str

MMD markup content

required
filename str

Name for error reporting

'<string>'

Returns:

Type Description
MMDDocument

MMDDocument object

Source code in src/midi_markdown/parser/parser.py
def parse_string(self, content: str, filename: str = "<string>") -> MMDDocument:
    """
    Parse MML content from a string.

    Args:
        content: MMD markup content
        filename: Name for error reporting

    Returns:
        MMDDocument object
    """
    try:
        result = self.parser.parse(content)
        # The transformer should return an MMDDocument
        if isinstance(result, MMDDocument):
            return result
        msg = f"Parser did not return MMDDocument, got {type(result)}"
        raise ValueError(msg)
    except Exception as e:
        self._format_parse_error(e, content, filename)
        raise
parse_interactive
parse_interactive(text: str) -> tuple[bool, MMDDocument | Exception | None]

Parse MML text for REPL, handling incomplete input.

This method supports interactive parsing where input may be incomplete (e.g., user is still typing). It distinguishes between: - Incomplete input: Need more text (returns False, None) - Invalid but complete: Syntax error (returns True, Exception) - Valid and complete: Success (returns True, MMDDocument)

Parameters:

Name Type Description Default
text str

MML source text (may be incomplete)

required

Returns:

Type Description
bool

Tuple of (complete, result):

MMDDocument | Exception | None
  • (False, None): Input incomplete, need more
tuple[bool, MMDDocument | Exception | None]
  • (True, Exception): Input complete but invalid
tuple[bool, MMDDocument | Exception | None]
  • (True, MMDDocument): Input complete and valid
Example

parser = MMDParser() complete, result = parser.parse_interactive("[00:01.0") assert not complete # Incomplete timing marker complete, result = parser.parse_interactive("[00:01.000]\n- cc 1.7.64") assert complete and isinstance(result, MMDDocument)

Source code in src/midi_markdown/parser/parser.py
def parse_interactive(self, text: str) -> tuple[bool, MMDDocument | Exception | None]:
    """Parse MML text for REPL, handling incomplete input.

    This method supports interactive parsing where input may be incomplete
    (e.g., user is still typing). It distinguishes between:
    - Incomplete input: Need more text (returns False, None)
    - Invalid but complete: Syntax error (returns True, Exception)
    - Valid and complete: Success (returns True, MMDDocument)

    Args:
        text: MML source text (may be incomplete)

    Returns:
        Tuple of (complete, result):
        - (False, None): Input incomplete, need more
        - (True, Exception): Input complete but invalid
        - (True, MMDDocument): Input complete and valid

    Example:
        >>> parser = MMDParser()
        >>> complete, result = parser.parse_interactive("[00:01.0")
        >>> assert not complete  # Incomplete timing marker
        >>> complete, result = parser.parse_interactive("[00:01.000]\\n- cc 1.7.64")
        >>> assert complete and isinstance(result, MMDDocument)
    """
    from lark import UnexpectedEOF, UnexpectedInput, UnexpectedToken

    try:
        doc = self.parse_string(text)
        return True, doc
    except UnexpectedEOF:
        # Need more input
        return False, None
    except UnexpectedToken as e:
        # Check if this is incomplete input (unexpected end of input)
        # Lark signals end-of-input with token type '$END' or empty token
        if e.token is None or e.token.type in {"$END", ""}:
            return False, None
        # Otherwise it's a complete but invalid input
        return True, e
    except UnexpectedInput as e:
        # Complete but invalid (other Lark parse errors)
        return True, e
    except Exception as e:
        # Other errors (file not found, etc.)
        return True, e

AST Nodes

ast_nodes

MIDI Markup Language (MML) AST Node Definitions

This module provides the data classes that represent the Abstract Syntax Tree (AST) for parsed MML documents. These are simple, flat data structures designed for ease of use and efficient processing.

Classes

Track dataclass
Track(name: str, channel: int | None = None, events: list[Any] = list())

Represents a track in multi-track mode.

MML supports multi-track MIDI files where each track can be defined separately and merged during compilation.

Timing dataclass
Timing(type: str, value: Any, raw: str)

Represents a timing specification in MML.

Timing can be one of four types: - absolute: [mm:ss.mmm] format (e.g., [01:23.250]) - musical: [bars.beats.ticks] format (e.g., [8.4.120]) - relative: [+value unit] format (e.g., [+1b], [+500ms]) - simultaneous: [@] - execute at same time as previous event

MIDICommand dataclass
MIDICommand(type: str, channel: int | None = None, data1: int | None = None, data2: int | None = None, params: dict[str, Any] = dict(), timing: Timing | None = None, source_line: int = 0)

Represents a MIDI command in the AST.

This is a flexible structure that can represent any MIDI command type: - Channel voice: note_on, note_off, cc, pc, pitch_bend, etc. - System: sysex - Meta: tempo, marker, time_signature, etc. - Alias: calls to user-defined aliases

SweepStatement dataclass
SweepStatement(start_time: Timing | None, end_time: Timing | None, interval: str, commands: list[str | MIDICommand], source_line: int = 0)

Represents a @sweep statement within an alias.

Used for generating automated parameter ramps with timing.

Example

@sweep from [0.0.0] to [+1000ms] every 50ms - cc {ch}.1.ramp(0, 127) @end

DefineStatement dataclass
DefineStatement(name: str, value: Any, source_line: int = 0)

Represents a @define statement within an alias.

Used for creating alias-local variables that can be referenced with ${VAR_NAME} syntax within the same alias.

Example

@define MIDI_VAL 100 - cc {ch}.7.${MIDI_VAL}

AliasDefinition dataclass
AliasDefinition(name: str, parameters: list[dict[str, Any]], commands: list[str | MIDICommand | Timing | DefineStatement | SweepStatement], description: str | None = None, computed_values: dict[str, str] = dict(), conditional_branches: list[ConditionalBranch] | None = None, is_macro: bool = False, has_conditionals: bool = False)

Represents an alias definition in MML.

Aliases can be: - Simple: Single command template with parameter substitution - Macro: Multiple commands with full command blocks - Conditional: Macro with @if/@elif/@else branches (Stage 7) - Advanced: With @define and @sweep statements


Core / Intermediate Representation

IR Data Structures

ir

Intermediate Representation (IR) data structures.

The IR layer sits between the AST and output formats, enabling: - REPL: Interactive evaluation and inspection - Live playback: Real-time MIDI output - Diagnostics: Timing analysis, event queries - Multiple outputs: MIDI files, JSON, CSV, etc.

Classes

EventType

Bases: Enum

MIDI event types for Intermediate Representation.

This enum covers all MIDI event types supported by MML: - Channel Voice Messages (NOTE_ON, NOTE_OFF, CC, PC, etc.) - System Common Messages (SYSEX, MTC, Song Position/Select) - Meta Events for MIDI files (TEMPO, TIME_SIGNATURE, MARKER, TEXT)

See Also
  • MIDI 1.0 Specification: https://www.midi.org/specifications
  • Standard MIDI Files Specification (SMF)
MIDIEvent dataclass
MIDIEvent(time: int, type: EventType, channel: int, data1: int = 0, data2: int = 0, time_seconds: float | None = None, metadata: dict[str, Any] | None = None)

Represents a single MIDI event in the Intermediate Representation.

The MIDIEvent is the core data structure in the IR layer, representing a single MIDI message with absolute timing. Events are created during compilation from AST and can be queried, filtered, and converted to various output formats.

Attributes:

Name Type Description
time int

Absolute time in ticks (PPQ-based, e.g., 480 ticks = 1 quarter note at PPQ=480)

type EventType

Event type from EventType enum (NOTE_ON, CC, TEMPO, etc.)

channel int

MIDI channel (1-16, or 0 for meta events with no channel)

data1 int

First data byte (note number for notes, CC number for CC, etc.)

data2 int

Second data byte (velocity for notes, CC value for CC, etc.)

time_seconds float | None

Absolute time in seconds (computed from tempo map during IR creation)

metadata dict[str, Any] | None

Optional metadata dict with source location, track name, etc.

Example
Create a Note On event at 1 second (480 ticks at 120 BPM, PPQ=480)

event = MIDIEvent( ... time=480, ... type=EventType.NOTE_ON, ... channel=1, ... data1=60, # Middle C ... data2=80, # Velocity ... time_seconds=1.0, ... metadata={"source_line": 10, "track": "Main"} ... ) print(f"Note {event.data1} at {event.time_seconds}s") Note 60 at 1.0s

See Also
  • IRProgram: Container for collections of MIDIEvent objects
  • string_to_event_type: Convert string types to EventType enum
IRProgram dataclass
IRProgram(resolution: int, initial_tempo: int, events: list[MIDIEvent], metadata: dict[str, Any])

Intermediate representation of a compiled MML program.

The IRProgram is the central data structure after compilation, containing all MIDI events with computed timing. It sits between the AST (syntactic representation) and output formats (MIDI files, JSON, live playback).

This IR layer enables: - Query operations (events by time, type, channel) - Multiple output formats from single compilation - REPL with interactive inspection - Live playback with real-time scheduling - Diagnostic analysis (timing, event counts, duration)

Attributes:

Name Type Description
resolution int

PPQ (Pulses Per Quarter note), typically 480 or 960

initial_tempo int

Starting tempo in BPM (before any tempo changes)

events list[MIDIEvent]

Sorted list of MIDIEvent objects (sorted by time)

metadata dict[str, Any]

Dictionary with document metadata (title, author, etc.)

Example

from midi_markdown.parser.parser import MMDParser from midi_markdown.core.compiler import compile_ast_to_ir parser = MMDParser() doc = parser.parse_file("examples/00_basics/00_hello_world.mmd") ir = compile_ast_to_ir(doc, ppq=480) print(f"Duration: {ir.duration_seconds:.2f}s") Duration: 2.00s print(f"Events: {ir.event_count}") Events: 4

Query events by type

notes = ir.events_by_type(EventType.NOTE_ON) print(f"Note events: {len(notes)}") Note events: 2

See Also
  • compile_ast_to_ir: Main compilation function
  • create_ir_program: Helper for creating IR from events
  • MIDIEvent: Individual event structure
Attributes
duration_ticks property
duration_ticks: int

Total duration in ticks.

duration_seconds property
duration_seconds: float

Total duration in seconds.

track_count property
track_count: int

Number of unique tracks.

event_count property
event_count: int

Total number of events.

Functions
events_at_time
events_at_time(seconds: float, tolerance: float = 0.01) -> list[MIDIEvent]

Get events at specific time (within tolerance).

Parameters:

Name Type Description Default
seconds float

Time in seconds

required
tolerance float

Time window in seconds (default 10ms)

0.01

Returns:

Type Description
list[MIDIEvent]

List of events within time window

Source code in src/midi_markdown/core/ir.py
def events_at_time(self, seconds: float, tolerance: float = 0.01) -> list[MIDIEvent]:
    """Get events at specific time (within tolerance).

    Args:
        seconds: Time in seconds
        tolerance: Time window in seconds (default 10ms)

    Returns:
        List of events within time window
    """
    return [
        e
        for e in self.events
        if e.time_seconds is not None and abs(e.time_seconds - seconds) <= tolerance
    ]
events_in_range
events_in_range(start: float, end: float) -> list[MIDIEvent]

Get events in time range.

Parameters:

Name Type Description Default
start float

Start time in seconds

required
end float

End time in seconds

required

Returns:

Type Description
list[MIDIEvent]

List of events in range [start, end]

Source code in src/midi_markdown/core/ir.py
def events_in_range(self, start: float, end: float) -> list[MIDIEvent]:
    """Get events in time range.

    Args:
        start: Start time in seconds
        end: End time in seconds

    Returns:
        List of events in range [start, end]
    """
    return [
        e for e in self.events if e.time_seconds is not None and start <= e.time_seconds <= end
    ]
events_by_type
events_by_type(event_type: EventType) -> list[MIDIEvent]

Get all events of specific type.

Source code in src/midi_markdown/core/ir.py
def events_by_type(self, event_type: EventType) -> list[MIDIEvent]:
    """Get all events of specific type."""
    return [e for e in self.events if e.type == event_type]
events_by_channel
events_by_channel(channel: int) -> list[MIDIEvent]

Get all events on specific channel.

Source code in src/midi_markdown/core/ir.py
def events_by_channel(self, channel: int) -> list[MIDIEvent]:
    """Get all events on specific channel."""
    return [e for e in self.events if e.channel == channel]

IR Compiler

compiler

AST to IR compilation.

Converts parsed AST (from parser) into executable IR (intermediate representation). The IR can then be sent to various outputs: MIDI files, JSON, live playback, REPL.

Functions

compile_ast_to_ir
compile_ast_to_ir(document: MMDDocument, ppq: int = 480) -> IRProgram

Compile MML document AST to IR program.

This is the main entry point for compilation. It orchestrates: 1. Event generation from AST commands 2. Timing resolution (absolute, musical, relative) 3. Expansion (loops, sweeps, variables) 4. Validation (ranges, monotonicity) 5. Time computation (ticks → seconds using tempo map)

Parameters:

Name Type Description Default
document MMDDocument

Parsed MML document AST

required
ppq int

Pulses per quarter note (MIDI resolution)

480

Returns:

Type Description
IRProgram

IRProgram ready for output or execution

Example

from midi_markdown.parser.parser import MMDParser from midi_markdown.core import compile_ast_to_ir parser = MMDParser() doc = parser.parse_file("song.mmd") ir = compile_ast_to_ir(doc, ppq=480) print(f"Duration: {ir.duration_seconds}s, Events: {ir.event_count}")

Source code in src/midi_markdown/core/compiler.py
def compile_ast_to_ir(
    document: MMDDocument,
    ppq: int = 480,
) -> IRProgram:
    """Compile MML document AST to IR program.

    This is the main entry point for compilation. It orchestrates:
    1. Event generation from AST commands
    2. Timing resolution (absolute, musical, relative)
    3. Expansion (loops, sweeps, variables)
    4. Validation (ranges, monotonicity)
    5. Time computation (ticks → seconds using tempo map)

    Args:
        document: Parsed MML document AST
        ppq: Pulses per quarter note (MIDI resolution)

    Returns:
        IRProgram ready for output or execution

    Example:
        >>> from midi_markdown.parser.parser import MMDParser
        >>> from midi_markdown.core import compile_ast_to_ir
        >>> parser = MMDParser()
        >>> doc = parser.parse_file("song.mmd")
        >>> ir = compile_ast_to_ir(doc, ppq=480)
        >>> print(f"Duration: {ir.duration_seconds}s, Events: {ir.event_count}")
    """
    # Import here to avoid circular dependency
    from midi_markdown.expansion.expander import CommandExpander

    # Get tempo and time signature from frontmatter
    tempo = document.frontmatter.get("tempo", 120)
    time_signature = document.frontmatter.get("time_signature", (4, 4))

    # Collect all events from both top-level and tracks
    all_events = []

    # Add top-level events (if any)
    if document.events:
        all_events.extend(document.events)

    # Add events from all tracks
    for track in document.tracks:
        if track.events:
            all_events.extend(track.events)

    # Expand AST to event dictionaries
    expander = CommandExpander(ppq=ppq, tempo=tempo, time_signature=time_signature)
    expanded_dicts = expander.process_ast(all_events)

    # Convert event dicts to MIDIEvent objects
    events = []
    for event_dict in expanded_dicts:
        # Skip meta events that are handled specially or not in EventType enum
        # - end_of_track: automatically added by MIDI file writer
        # - trackname/instrumentname: handled separately by MIDI file writer
        event_type = event_dict["type"]
        if event_type in ("end_of_track", "trackname", "instrumentname"):
            continue

        midi_event = MIDIEvent(
            time=event_dict["time"],
            type=string_to_event_type(event_type),
            channel=event_dict.get("channel", 0),
            data1=event_dict.get("data1", 0),
            data2=event_dict.get("data2", 0),
            metadata=event_dict.get("metadata"),
        )
        events.append(midi_event)

    # Wrap in IRProgram (computes time_seconds)
    return create_ir_program(
        events=events,
        ppq=ppq,
        initial_tempo=tempo,
        frontmatter=document.frontmatter,
    )

Code Generation

MIDI File Generation

midi_file

MIDI file generation from IR program.

Generates Standard MIDI Files from compiled IR programs. Pure function approach - returns bytes instead of writing files.

Functions

generate_midi_file
generate_midi_file(ir_program: IRProgram, midi_format: int = MIDI_FORMAT_MULTI_TRACK) -> bytes

Generate Standard MIDI File from IR program.

This is a pure function that converts an IR program to MIDI file bytes. The caller is responsible for writing the bytes to disk.

Parameters:

Name Type Description Default
ir_program IRProgram

Compiled IR program

required
midi_format int

MIDI file format (0, 1, or 2)

MIDI_FORMAT_MULTI_TRACK

Returns:

Type Description
bytes

MIDI file as bytes

Example

from midi_markdown.core import compile_ast_to_ir from midi_markdown.parser.parser import MMDParser parser = MMDParser() doc = parser.parse_file("song.mmd") ir = compile_ast_to_ir(doc) midi_bytes = generate_midi_file(ir) Path("output.mid").write_bytes(midi_bytes)

Source code in src/midi_markdown/codegen/midi_file.py
def generate_midi_file(
    ir_program: IRProgram,
    midi_format: int = MIDI_FORMAT_MULTI_TRACK,
) -> bytes:
    """Generate Standard MIDI File from IR program.

    This is a pure function that converts an IR program to MIDI file bytes.
    The caller is responsible for writing the bytes to disk.

    Args:
        ir_program: Compiled IR program
        midi_format: MIDI file format (0, 1, or 2)

    Returns:
        MIDI file as bytes

    Example:
        >>> from midi_markdown.core import compile_ast_to_ir
        >>> from midi_markdown.parser.parser import MMDParser
        >>> parser = MMDParser()
        >>> doc = parser.parse_file("song.mmd")
        >>> ir = compile_ast_to_ir(doc)
        >>> midi_bytes = generate_midi_file(ir)
        >>> Path("output.mid").write_bytes(midi_bytes)
    """
    # Extract events and resolution from IR program
    events = ir_program.events
    ppq = ir_program.resolution if ir_program.resolution else DEFAULT_PPQ

    # Create MIDI file
    mid = MidiFile(type=midi_format, ticks_per_beat=ppq)

    # Create a single track (for format 1)
    track = MidiTrack()
    mid.tracks.append(track)

    # Convert events to mido messages
    messages = _events_to_messages(events)

    # Add messages to track
    for msg in messages:
        track.append(msg)

    # Add end of track meta message
    track.append(MetaMessage("end_of_track", time=0))

    # Write to BytesIO buffer
    buffer = BytesIO()
    mid.save(file=buffer)

    # Return bytes
    return buffer.getvalue()

CSV Export

csv_export

CSV export for MIDI events in midicsv-compatible format.

This module exports IRProgram data to the midicsv CSV format, which can be imported into spreadsheet programs, databases, or text processing tools.

Format specification: https://www.fourmilab.ch/webtools/midicsv/

Functions

export_to_csv
export_to_csv(ir_program: IRProgram, include_header: bool = True) -> str

Export IRProgram to midicsv-compatible CSV format.

Parameters:

Name Type Description Default
ir_program IRProgram

Compiled IR program to export

required
include_header bool

Include file header and footer records

True

Returns:

Type Description
str

CSV string in midicsv format

Example

from midi_markdown.core import compile_ast_to_ir from midi_markdown.parser.parser import MMDParser parser = MMDParser() doc = parser.parse_file("song.mmd") ir = compile_ast_to_ir(doc) csv = export_to_csv(ir) print(csv) 0, 0, Header, 1, 1, 480 1, 0, Start_track 1, 0, Tempo, 500000 ...

Source code in src/midi_markdown/codegen/csv_export.py
def export_to_csv(ir_program: IRProgram, include_header: bool = True) -> str:
    """Export IRProgram to midicsv-compatible CSV format.

    Args:
        ir_program: Compiled IR program to export
        include_header: Include file header and footer records

    Returns:
        CSV string in midicsv format

    Example:
        >>> from midi_markdown.core import compile_ast_to_ir
        >>> from midi_markdown.parser.parser import MMDParser
        >>> parser = MMDParser()
        >>> doc = parser.parse_file("song.mmd")
        >>> ir = compile_ast_to_ir(doc)
        >>> csv = export_to_csv(ir)
        >>> print(csv)
        0, 0, Header, 1, 1, 480
        1, 0, Start_track
        1, 0, Tempo, 500000
        ...
    """
    lines = []

    # Add file header if requested
    if include_header:
        # Header: track 0, time 0, Header, format, nTracks, division
        # Format 1 = single track, nTracks = 1, division = PPQ
        lines.append(f"0, 0, Header, 1, 1, {ir_program.resolution}")

    # Track start marker
    lines.append("1, 0, Start_track")

    # Convert all events to CSV lines
    for event in ir_program.events:
        csv_line = _format_event_as_csv(event, track=1, ppq=ir_program.resolution)
        if csv_line:  # Skip events that don't export to CSV
            lines.append(csv_line)

    # Track end marker (at max time)
    max_time = ir_program.duration_ticks
    lines.append(f"1, {max_time}, End_track")

    # Add file footer if requested
    if include_header:
        lines.append("0, 0, End_of_file")

    return "\n".join(lines)

JSON Export

json_export

JSON export for MIDI events in complete and simplified formats.

This module exports IRProgram data to JSON in two formats: - Complete: Full MIDI data with exact timing (for programmatic use) - Simplified: Human-readable format with note names and readable times (for analysis)

Functions

export_to_json
export_to_json(ir_program: IRProgram, format: str = 'complete', pretty: bool = True) -> str

Export IRProgram to JSON format.

Parameters:

Name Type Description Default
ir_program IRProgram

Compiled IR program to export

required
format str

"complete" (full MIDI data) or "simplified" (human-readable)

'complete'
pretty bool

Pretty-print with indentation (default: True)

True

Returns:

Type Description
str

JSON string

Example

from midi_markdown.core import compile_ast_to_ir from midi_markdown.parser.parser import MMDParser parser = MMDParser() doc = parser.parse_file("song.mmd") ir = compile_ast_to_ir(doc) json_complete = export_to_json(ir, format="complete") json_simple = export_to_json(ir, format="simplified")

Source code in src/midi_markdown/codegen/json_export.py
def export_to_json(
    ir_program: IRProgram,
    format: str = "complete",
    pretty: bool = True,
) -> str:
    """Export IRProgram to JSON format.

    Args:
        ir_program: Compiled IR program to export
        format: "complete" (full MIDI data) or "simplified" (human-readable)
        pretty: Pretty-print with indentation (default: True)

    Returns:
        JSON string

    Example:
        >>> from midi_markdown.core import compile_ast_to_ir
        >>> from midi_markdown.parser.parser import MMDParser
        >>> parser = MMDParser()
        >>> doc = parser.parse_file("song.mmd")
        >>> ir = compile_ast_to_ir(doc)
        >>> json_complete = export_to_json(ir, format="complete")
        >>> json_simple = export_to_json(ir, format="simplified")
    """
    if format == "simplified":
        data = _build_simplified_format(ir_program)
    else:  # default to complete
        data = _build_complete_format(ir_program)

    # Serialize to JSON
    if pretty:
        return json.dumps(data, indent=2, ensure_ascii=False)
    return json.dumps(data, ensure_ascii=False)

Runtime / Real-time Playback

MIDI I/O

midi_io

MIDI I/O management for real-time MIDI output.

This module provides the MIDIOutputManager class for managing MIDI output port connections and sending MIDI messages using python-rtmidi.

Event Scheduler

scheduler

Event scheduler for precise real-time MIDI playback.

This module provides the EventScheduler class for scheduling and playing MIDI events with sub-5ms timing precision using a hybrid sleep/busy-wait strategy.

Classes

EventScheduler
EventScheduler(midi_output: MIDIOutputManager)

Schedules and plays MIDI events with precise timing.

The EventScheduler uses a hybrid sleep/busy-wait strategy to achieve sub-5ms timing precision: - For delays > 10ms: Use time.sleep() (OS scheduler) - For delays < 10ms: Use busy-wait loop (tight loop)

Example

from midi_markdown.runtime.midi_io import MIDIOutputManager midi = MIDIOutputManager() midi.open_port(0) scheduler = EventScheduler(midi) events = [ ... ScheduledEvent(0.0, [0x90, 60, 80], {}), ... ScheduledEvent(1000.0, [0x80, 60, 0], {}), ... ] scheduler.load_events(events) scheduler.start()

... wait for completion ...

scheduler.stop() midi.close_port()

Initialize event scheduler.

Parameters:

Name Type Description Default
midi_output MIDIOutputManager

MIDIOutputManager instance for sending MIDI messages

required
Source code in src/midi_markdown/runtime/scheduler.py
def __init__(self, midi_output: MIDIOutputManager) -> None:
    """Initialize event scheduler.

    Args:
        midi_output: MIDIOutputManager instance for sending MIDI messages
    """
    self.midi_output = midi_output
    self.event_queue: queue.PriorityQueue[ScheduledEvent] = queue.PriorityQueue()
    self.state = "stopped"  # stopped, playing, paused
    self.scheduler_thread: threading.Thread | None = None
    self.start_time: float | None = None
    self.pause_time: float | None = None
    self.time_offset: float = 0.0
    self.on_event_sent: Callable[[dict], None] | None = None
    self.on_complete: Callable[[], None] | None = None
    self._stop_flag = threading.Event()
Functions
load_events
load_events(events: list[ScheduledEvent]) -> None

Load events into scheduler queue.

Clears any existing events and loads the new event list into the internal PriorityQueue.

Parameters:

Name Type Description Default
events list[ScheduledEvent]

List of ScheduledEvent objects (will be sorted by time_ms)

required
Example

scheduler.load_events([ ... ScheduledEvent(0.0, [0x90, 60, 80], {}), ... ScheduledEvent(500.0, [0x80, 60, 0], {}), ... ])

Source code in src/midi_markdown/runtime/scheduler.py
def load_events(self, events: list[ScheduledEvent]) -> None:
    """Load events into scheduler queue.

    Clears any existing events and loads the new event list into the
    internal PriorityQueue.

    Args:
        events: List of ScheduledEvent objects (will be sorted by time_ms)

    Example:
        >>> scheduler.load_events([
        ...     ScheduledEvent(0.0, [0x90, 60, 80], {}),
        ...     ScheduledEvent(500.0, [0x80, 60, 0], {}),
        ... ])
    """
    # Clear existing queue
    self.event_queue = queue.PriorityQueue()

    # Add all events to queue (PriorityQueue sorts by __lt__)
    for event in events:
        self.event_queue.put(event)
start
start() -> None

Start playback in separate thread.

Creates and starts a daemon thread that processes events from the queue. If already playing, this is a no-op.

Example

scheduler.load_events(events) scheduler.start()

Playback begins immediately in background thread
Source code in src/midi_markdown/runtime/scheduler.py
def start(self) -> None:
    """Start playback in separate thread.

    Creates and starts a daemon thread that processes events from the queue.
    If already playing, this is a no-op.

    Example:
        >>> scheduler.load_events(events)
        >>> scheduler.start()
        >>> # Playback begins immediately in background thread
    """
    if self.state == "playing":
        return  # Already playing

    self.state = "playing"
    self.start_time = time.perf_counter()
    self._stop_flag.clear()

    # Start scheduler thread
    self.scheduler_thread = threading.Thread(
        target=self._scheduler_loop,
        daemon=True,
    )
    self.scheduler_thread.start()
pause
pause() -> None

Pause playback (preserves position).

Records the current time so that resume() can adjust the time offset to account for the pause duration.

Example

scheduler.start()

... playback running ...

scheduler.pause()

Playback paused, position preserved
Source code in src/midi_markdown/runtime/scheduler.py
def pause(self) -> None:
    """Pause playback (preserves position).

    Records the current time so that resume() can adjust the time offset
    to account for the pause duration.

    Example:
        >>> scheduler.start()
        >>> # ... playback running ...
        >>> scheduler.pause()
        >>> # Playback paused, position preserved
    """
    if self.state != "playing":
        return

    self.state = "paused"
    self.pause_time = time.perf_counter()
resume
resume() -> None

Resume from paused position.

Calculates the duration of the pause and adjusts the time offset so that playback continues from where it left off.

Example

scheduler.pause() time.sleep(2.0) # Paused for 2 seconds scheduler.resume()

Playback resumes, no timing drift
Source code in src/midi_markdown/runtime/scheduler.py
def resume(self) -> None:
    """Resume from paused position.

    Calculates the duration of the pause and adjusts the time offset so that
    playback continues from where it left off.

    Example:
        >>> scheduler.pause()
        >>> time.sleep(2.0)  # Paused for 2 seconds
        >>> scheduler.resume()
        >>> # Playback resumes, no timing drift
    """
    if self.state != "paused":
        return

    # Calculate time spent paused and adjust offset
    if self.pause_time is not None:
        paused_duration = time.perf_counter() - self.pause_time
        self.time_offset += paused_duration
    self.state = "playing"
stop
stop() -> None

Stop playback and reset position.

Sets the stop flag and waits for the scheduler thread to finish (with 1-second timeout). Resets all timing state.

Example

scheduler.start()

... playback running ...

scheduler.stop()

Playback stopped, thread cleaned up
Source code in src/midi_markdown/runtime/scheduler.py
def stop(self) -> None:
    """Stop playback and reset position.

    Sets the stop flag and waits for the scheduler thread to finish
    (with 1-second timeout). Resets all timing state.

    Example:
        >>> scheduler.start()
        >>> # ... playback running ...
        >>> scheduler.stop()
        >>> # Playback stopped, thread cleaned up
    """
    self.state = "stopped"
    self._stop_flag.set()

    # Wait for scheduler thread to finish
    if self.scheduler_thread and self.scheduler_thread.is_alive():
        self.scheduler_thread.join(timeout=1.0)

    self.start_time = None
    self.pause_time = None
    self.time_offset = 0.0
seek
seek(target_time_ms: float, all_events: list[ScheduledEvent]) -> None

Seek to a specific time position.

Stops current playback, reloads events from target time onward, and adjusts timing state to resume from the new position.

Parameters:

Name Type Description Default
target_time_ms float

Target time in milliseconds from start

required
all_events list[ScheduledEvent]

Complete list of all events (needed to reload from target time)

required
Example

scheduler.seek(5000.0, all_events) # Seek to 5 seconds

Source code in src/midi_markdown/runtime/scheduler.py
def seek(self, target_time_ms: float, all_events: list[ScheduledEvent]) -> None:
    """Seek to a specific time position.

    Stops current playback, reloads events from target time onward, and
    adjusts timing state to resume from the new position.

    Args:
        target_time_ms: Target time in milliseconds from start
        all_events: Complete list of all events (needed to reload from target time)

    Example:
        >>> scheduler.seek(5000.0, all_events)  # Seek to 5 seconds
    """
    # Store current state
    was_playing = self.state == "playing"

    # Stop current playback
    self._stop_flag.set()
    if self.scheduler_thread and self.scheduler_thread.is_alive():
        self.scheduler_thread.join(timeout=0.5)

    # Clamp target time to valid range
    if target_time_ms < 0:
        target_time_ms = 0.0

    # Reload only events at or after target time
    future_events = [e for e in all_events if e.time_ms >= target_time_ms]
    self.load_events(future_events)

    # Adjust timing offset to account for seek
    # We want elapsed time to match target_time_ms when playback resumes
    self.time_offset = target_time_ms / 1000  # Convert to seconds

    # Resume if was playing, otherwise stay paused/stopped
    if was_playing:
        self.start()
    else:
        self.state = "stopped"

Tempo Tracker

tempo_tracker

Tempo tracking for tick-to-millisecond conversion.

This module provides the TempoTracker class for converting between MIDI tick times and real-world millisecond times, accounting for tempo changes throughout a sequence.

Classes

TempoTracker
TempoTracker(ppq: int, default_tempo: float = 120.0)

Converts tick times to milliseconds using tempo map.

The TempoTracker maintains a tempo map that allows accurate conversion between MIDI tick times and real-world millisecond times, accounting for tempo changes throughout a sequence.

Example

tracker = TempoTracker(ppq=480, default_tempo=120.0) tracker.add_tempo_change(960, 140.0) # Change to 140 BPM at tick 960 tracker.build_tempo_map() print(tracker.ticks_to_ms(1440)) # Convert tick 1440 to milliseconds 1428.57...

Initialize tempo tracker.

Parameters:

Name Type Description Default
ppq int

Pulses per quarter note (ticks per beat)

required
default_tempo float

Initial tempo in BPM

120.0
Source code in src/midi_markdown/runtime/tempo_tracker.py
def __init__(self, ppq: int, default_tempo: float = 120.0) -> None:
    """Initialize tempo tracker.

    Args:
        ppq: Pulses per quarter note (ticks per beat)
        default_tempo: Initial tempo in BPM
    """
    self.ppq = ppq
    self.default_tempo = default_tempo
    self.segments: list[TempoSegment] = []
    self._built = False
Functions
add_tempo_change
add_tempo_change(tick: int, tempo: float) -> None

Register a tempo change at specified tick.

Tempo changes can be added in any order - they will be sorted when build_tempo_map() is called.

Parameters:

Name Type Description Default
tick int

Absolute tick time where tempo changes

required
tempo float

New tempo in BPM

required
Example

tracker = TempoTracker(ppq=480) tracker.add_tempo_change(480, 90.0) tracker.add_tempo_change(960, 140.0)

Source code in src/midi_markdown/runtime/tempo_tracker.py
def add_tempo_change(self, tick: int, tempo: float) -> None:
    """Register a tempo change at specified tick.

    Tempo changes can be added in any order - they will be sorted when
    build_tempo_map() is called.

    Args:
        tick: Absolute tick time where tempo changes
        tempo: New tempo in BPM

    Example:
        >>> tracker = TempoTracker(ppq=480)
        >>> tracker.add_tempo_change(480, 90.0)
        >>> tracker.add_tempo_change(960, 140.0)
    """
    self.segments.append(TempoSegment(tick, tempo, 0.0))
    self._built = False  # Mark as needing rebuild
build_tempo_map
build_tempo_map() -> None

Calculate cumulative milliseconds for each tempo segment.

This method must be called after all tempo changes are added and before ticks_to_ms() or ms_to_ticks() are used. It: 1. Sorts segments by tick 2. Ensures a segment exists at tick 0 (uses default_tempo if needed) 3. Calculates cumulative milliseconds for each segment

Example

tracker = TempoTracker(ppq=480, default_tempo=120.0) tracker.add_tempo_change(960, 140.0) tracker.build_tempo_map()

Source code in src/midi_markdown/runtime/tempo_tracker.py
def build_tempo_map(self) -> None:
    """Calculate cumulative milliseconds for each tempo segment.

    This method must be called after all tempo changes are added and before
    ticks_to_ms() or ms_to_ticks() are used. It:
    1. Sorts segments by tick
    2. Ensures a segment exists at tick 0 (uses default_tempo if needed)
    3. Calculates cumulative milliseconds for each segment

    Example:
        >>> tracker = TempoTracker(ppq=480, default_tempo=120.0)
        >>> tracker.add_tempo_change(960, 140.0)
        >>> tracker.build_tempo_map()
    """
    # Sort segments by tick
    self.segments.sort(key=lambda s: s.start_tick)

    # Ensure segment at tick 0 exists
    if not self.segments or self.segments[0].start_tick > 0:
        self.segments.insert(0, TempoSegment(0, self.default_tempo, 0.0))

    # Calculate cumulative milliseconds for each segment
    for i in range(1, len(self.segments)):
        prev = self.segments[i - 1]
        curr = self.segments[i]

        # Calculate duration of previous segment
        tick_delta = curr.start_tick - prev.start_tick
        ms_delta = self._ticks_to_ms_simple(tick_delta, prev.tempo)

        # Set cumulative time for current segment
        curr.cumulative_ms = prev.cumulative_ms + ms_delta

    self._built = True
ticks_to_ms
ticks_to_ms(ticks: int) -> float

Convert absolute tick time to milliseconds.

Parameters:

Name Type Description Default
ticks int

Absolute tick time

required

Returns:

Type Description
float

Time in milliseconds

Raises:

Type Description
RuntimeError

If tempo map not built (call build_tempo_map() first)

Example

tracker = TempoTracker(ppq=480, default_tempo=120.0) tracker.build_tempo_map() tracker.ticks_to_ms(480) # 1 beat at 120 BPM = 500ms 500.0

Source code in src/midi_markdown/runtime/tempo_tracker.py
def ticks_to_ms(self, ticks: int) -> float:
    """Convert absolute tick time to milliseconds.

    Args:
        ticks: Absolute tick time

    Returns:
        Time in milliseconds

    Raises:
        RuntimeError: If tempo map not built (call build_tempo_map() first)

    Example:
        >>> tracker = TempoTracker(ppq=480, default_tempo=120.0)
        >>> tracker.build_tempo_map()
        >>> tracker.ticks_to_ms(480)  # 1 beat at 120 BPM = 500ms
        500.0
    """
    if not self._built:
        msg = "Tempo map not built - call build_tempo_map() first"
        raise RuntimeError(msg)

    # Find the segment containing this tick
    segment = self._find_segment(ticks)

    # Calculate time within this segment
    tick_offset = ticks - segment.start_tick
    ms_offset = self._ticks_to_ms_simple(tick_offset, segment.tempo)

    return segment.cumulative_ms + ms_offset
ms_to_ticks
ms_to_ticks(ms: float) -> int

Convert milliseconds to absolute tick time.

Parameters:

Name Type Description Default
ms float

Time in milliseconds

required

Returns:

Type Description
int

Absolute tick time

Raises:

Type Description
RuntimeError

If tempo map not built (call build_tempo_map() first)

Example

tracker = TempoTracker(ppq=480, default_tempo=120.0) tracker.build_tempo_map() tracker.ms_to_ticks(500.0) # 500ms at 120 BPM = 1 beat = 480 ticks 480

Source code in src/midi_markdown/runtime/tempo_tracker.py
def ms_to_ticks(self, ms: float) -> int:
    """Convert milliseconds to absolute tick time.

    Args:
        ms: Time in milliseconds

    Returns:
        Absolute tick time

    Raises:
        RuntimeError: If tempo map not built (call build_tempo_map() first)

    Example:
        >>> tracker = TempoTracker(ppq=480, default_tempo=120.0)
        >>> tracker.build_tempo_map()
        >>> tracker.ms_to_ticks(500.0)  # 500ms at 120 BPM = 1 beat = 480 ticks
        480
    """
    if not self._built:
        msg = "Tempo map not built - call build_tempo_map() first"
        raise RuntimeError(msg)

    # Find the segment containing this time
    segment = self._find_segment_by_ms(ms)

    # Calculate ticks within this segment
    ms_offset = ms - segment.cumulative_ms
    tick_offset = self._ms_to_ticks_simple(ms_offset, segment.tempo)

    return segment.start_tick + tick_offset

Realtime Player

player

Real-time MIDI playback engine.

This module provides the RealtimePlayer class for playing compiled MML programs in real-time through MIDI output ports.

Classes

RealtimePlayer
RealtimePlayer(ir_program: IRProgram, port_name: str | int)

High-level real-time MIDI playback from IRProgram.

The RealtimePlayer orchestrates MIDI I/O, tempo tracking, and event scheduling to play compiled MML programs in real-time through MIDI output devices.

Example

from midi_markdown.core.compiler import compile_mml from midi_markdown.runtime.player import RealtimePlayer ir = compile_mml("examples/00_basics/00_hello_world.mmd") player = RealtimePlayer(ir, "IAC Driver Bus 1") player.play()

... wait for completion ...

player.stop()

Initialize real-time player.

Parameters:

Name Type Description Default
ir_program IRProgram

Compiled IR program to play

required
port_name str | int

MIDI output port name or index

required

Raises:

Type Description
MIDIIOError

If MIDI port cannot be opened

Source code in src/midi_markdown/runtime/player.py
def __init__(self, ir_program: IRProgram, port_name: str | int) -> None:
    """Initialize real-time player.

    Args:
        ir_program: Compiled IR program to play
        port_name: MIDI output port name or index

    Raises:
        MIDIIOError: If MIDI port cannot be opened
    """
    self.ir_program = ir_program
    self.midi_output = MIDIOutputManager()
    self.port_name = port_name

    # Build tempo tracker from IR
    self.tempo_tracker = TempoTracker(
        ppq=ir_program.resolution, default_tempo=float(ir_program.initial_tempo)
    )
    self._build_tempo_map()

    # Get time signature for musical navigation
    self.time_signature = ir_program.metadata.get("time_signature", (4, 4))
    self.ppq = ir_program.resolution

    # Create scheduler and store all scheduled events for seeking
    self.scheduler = EventScheduler(self.midi_output)
    self._all_scheduled_events: list[ScheduledEvent] = []
    self._load_events()

    # Open MIDI port
    self.midi_output.open_port(port_name)
Functions
play
play() -> None

Start playback from beginning.

If already playing, this is a no-op.

Source code in src/midi_markdown/runtime/player.py
def play(self) -> None:
    """Start playback from beginning.

    If already playing, this is a no-op.
    """
    self.scheduler.start()
pause
pause() -> None

Pause playback.

Position is preserved for resume(). If not playing, this is a no-op.

Source code in src/midi_markdown/runtime/player.py
def pause(self) -> None:
    """Pause playback.

    Position is preserved for resume(). If not playing, this is a no-op.
    """
    self.scheduler.pause()
resume
resume() -> None

Resume from paused position.

If not paused, this is a no-op. Time offset is adjusted to account for pause duration.

Source code in src/midi_markdown/runtime/player.py
def resume(self) -> None:
    """Resume from paused position.

    If not paused, this is a no-op. Time offset is adjusted to account
    for pause duration.
    """
    self.scheduler.resume()
stop
stop() -> None

Stop playback and send All Notes Off.

Stops the scheduler, sends CC 123 (All Notes Off) on all 16 channels to prevent stuck notes, and resets playback position.

Source code in src/midi_markdown/runtime/player.py
def stop(self) -> None:
    """Stop playback and send All Notes Off.

    Stops the scheduler, sends CC 123 (All Notes Off) on all 16 channels
    to prevent stuck notes, and resets playback position.
    """
    self.scheduler.stop()
    self._all_notes_off()
seek
seek(target_time_ms: float) -> None

Seek to a specific time position.

Stops current playback, sends All Notes Off to prevent stuck notes, and resumes playback from the target time.

Parameters:

Name Type Description Default
target_time_ms float

Target time in milliseconds from start

required
Example

player.seek(5000.0) # Seek to 5 seconds

Source code in src/midi_markdown/runtime/player.py
def seek(self, target_time_ms: float) -> None:
    """Seek to a specific time position.

    Stops current playback, sends All Notes Off to prevent stuck notes,
    and resumes playback from the target time.

    Args:
        target_time_ms: Target time in milliseconds from start

    Example:
        >>> player.seek(5000.0)  # Seek to 5 seconds
    """
    # Send All Notes Off before seeking to prevent stuck notes
    self._all_notes_off()

    # Delegate to scheduler
    self.scheduler.seek(target_time_ms, self._all_scheduled_events)
seek_bars
seek_bars(bar_offset: int, current_position_ms: float) -> float

Seek forward or backward by a number of bars.

Parameters:

Name Type Description Default
bar_offset int

Number of bars to seek (positive=forward, negative=backward)

required
current_position_ms float

Current playback position in milliseconds

required

Returns:

Type Description
float

New position in milliseconds after seeking

Example

new_pos = player.seek_bars(1, 5000.0) # Seek forward 1 bar new_pos = player.seek_bars(-2, 10000.0) # Seek backward 2 bars

Source code in src/midi_markdown/runtime/player.py
def seek_bars(self, bar_offset: int, current_position_ms: float) -> float:
    """Seek forward or backward by a number of bars.

    Args:
        bar_offset: Number of bars to seek (positive=forward, negative=backward)
        current_position_ms: Current playback position in milliseconds

    Returns:
        New position in milliseconds after seeking

    Example:
        >>> new_pos = player.seek_bars(1, 5000.0)  # Seek forward 1 bar
        >>> new_pos = player.seek_bars(-2, 10000.0)  # Seek backward 2 bars
    """
    # Calculate ticks per bar
    beats_per_bar = self.time_signature[0]
    ticks_per_bar = beats_per_bar * self.ppq

    # Convert current position to ticks
    current_ticks = self.tempo_tracker.ms_to_ticks(current_position_ms)

    # Calculate new position in ticks
    new_ticks = current_ticks + (bar_offset * ticks_per_bar)
    new_ticks = max(0, new_ticks)  # Clamp to valid range

    # Convert back to milliseconds
    new_position_ms = self.tempo_tracker.ticks_to_ms(new_ticks)

    # Perform the seek
    self.seek(new_position_ms)

    return new_position_ms
seek_beats
seek_beats(beat_offset: int, current_position_ms: float) -> float

Seek forward or backward by a number of beats.

Parameters:

Name Type Description Default
beat_offset int

Number of beats to seek (positive=forward, negative=backward)

required
current_position_ms float

Current playback position in milliseconds

required

Returns:

Type Description
float

New position in milliseconds after seeking

Example

new_pos = player.seek_beats(4, 5000.0) # Seek forward 4 beats new_pos = player.seek_beats(-1, 10000.0) # Seek backward 1 beat

Source code in src/midi_markdown/runtime/player.py
def seek_beats(self, beat_offset: int, current_position_ms: float) -> float:
    """Seek forward or backward by a number of beats.

    Args:
        beat_offset: Number of beats to seek (positive=forward, negative=backward)
        current_position_ms: Current playback position in milliseconds

    Returns:
        New position in milliseconds after seeking

    Example:
        >>> new_pos = player.seek_beats(4, 5000.0)  # Seek forward 4 beats
        >>> new_pos = player.seek_beats(-1, 10000.0)  # Seek backward 1 beat
    """
    # Calculate ticks per beat
    ticks_per_beat = self.ppq

    # Convert current position to ticks
    current_ticks = self.tempo_tracker.ms_to_ticks(current_position_ms)

    # Calculate new position in ticks
    new_ticks = current_ticks + (beat_offset * ticks_per_beat)
    new_ticks = max(0, new_ticks)  # Clamp to valid range

    # Convert back to milliseconds
    new_position_ms = self.tempo_tracker.ticks_to_ms(new_ticks)

    # Perform the seek
    self.seek(new_position_ms)

    return new_position_ms
get_duration_ms
get_duration_ms() -> float

Get total duration in milliseconds.

Returns:

Type Description
float

Duration in milliseconds, or 0.0 if no events

Source code in src/midi_markdown/runtime/player.py
def get_duration_ms(self) -> float:
    """Get total duration in milliseconds.

    Returns:
        Duration in milliseconds, or 0.0 if no events
    """
    if not self.ir_program.events:
        return 0.0

    # Get last event time
    last_tick = max(event.time for event in self.ir_program.events)
    return self.tempo_tracker.ticks_to_ms(last_tick)
is_complete
is_complete() -> bool

Check if playback is complete.

Returns:

Type Description
bool

True if scheduler is stopped, False otherwise

Source code in src/midi_markdown/runtime/player.py
def is_complete(self) -> bool:
    """Check if playback is complete.

    Returns:
        True if scheduler is stopped, False otherwise
    """
    return self.scheduler.state == "stopped"

TUI Components

state

Thread-safe state management for TUI.

This module provides the TUIState class for managing UI state updates from multiple threads (main UI thread, scheduler thread, keyboard thread).

Classes
TUIState
TUIState(total_duration_ms: float, initial_tempo: float, max_events: int = 20)

Thread-safe state container for TUI updates.

This class manages all state that needs to be shared between threads: - Main UI thread: Reads state for rendering - Scheduler thread: Updates position and events - Keyboard thread: Updates playback state

All public methods are thread-safe using a lock.

Initialize TUI state.

Parameters:

Name Type Description Default
total_duration_ms float

Total duration of playback in milliseconds

required
initial_tempo float

Initial tempo in BPM

required
max_events int

Maximum number of events to keep in history

20
Source code in src/midi_markdown/runtime/tui/state.py
def __init__(self, total_duration_ms: float, initial_tempo: float, max_events: int = 20):
    """Initialize TUI state.

    Args:
        total_duration_ms: Total duration of playback in milliseconds
        initial_tempo: Initial tempo in BPM
        max_events: Maximum number of events to keep in history
    """
    self._lock = threading.Lock()

    # Playback state
    self._position_ms: float = 0.0
    self._position_ticks: int = 0
    self._tempo: float = initial_tempo
    self._state: str = "stopped"  # stopped, playing, paused
    self._total_duration_ms: float = total_duration_ms

    # Event history (fixed-size circular buffer)
    self._event_history: deque[EventInfo] = deque(maxlen=max_events)
Functions
update_position
update_position(position_ms: float, position_ticks: int) -> None

Update current playback position.

Parameters:

Name Type Description Default
position_ms float

Current position in milliseconds

required
position_ticks int

Current position in ticks

required
Source code in src/midi_markdown/runtime/tui/state.py
def update_position(self, position_ms: float, position_ticks: int) -> None:
    """Update current playback position.

    Args:
        position_ms: Current position in milliseconds
        position_ticks: Current position in ticks
    """
    with self._lock:
        self._position_ms = position_ms
        self._position_ticks = position_ticks
update_tempo
update_tempo(tempo: float) -> None

Update current tempo.

Parameters:

Name Type Description Default
tempo float

Tempo in BPM

required
Source code in src/midi_markdown/runtime/tui/state.py
def update_tempo(self, tempo: float) -> None:
    """Update current tempo.

    Args:
        tempo: Tempo in BPM
    """
    with self._lock:
        self._tempo = tempo
set_state
set_state(state: str) -> None

Set playback state.

Parameters:

Name Type Description Default
state str

One of "stopped", "playing", "paused"

required
Source code in src/midi_markdown/runtime/tui/state.py
def set_state(self, state: str) -> None:
    """Set playback state.

    Args:
        state: One of "stopped", "playing", "paused"
    """
    with self._lock:
        self._state = state
add_event
add_event(event_info: EventInfo) -> None

Add event to history buffer.

Parameters:

Name Type Description Default
event_info EventInfo

Event information to add

required
Source code in src/midi_markdown/runtime/tui/state.py
def add_event(self, event_info: EventInfo) -> None:
    """Add event to history buffer.

    Args:
        event_info: Event information to add
    """
    with self._lock:
        self._event_history.append(event_info)
get_state_snapshot
get_state_snapshot() -> dict[str, Any]

Get atomic snapshot of current state.

Returns:

Type Description
dict[str, Any]

Dictionary with all state fields

Source code in src/midi_markdown/runtime/tui/state.py
def get_state_snapshot(self) -> dict[str, Any]:
    """Get atomic snapshot of current state.

    Returns:
        Dictionary with all state fields
    """
    with self._lock:
        return {
            "position_ms": self._position_ms,
            "position_ticks": self._position_ticks,
            "tempo": self._tempo,
            "state": self._state,
            "total_duration_ms": self._total_duration_ms,
            "event_history": list(self._event_history),  # Copy the deque
        }
clear_events
clear_events() -> None

Clear event history buffer.

Source code in src/midi_markdown/runtime/tui/state.py
def clear_events(self) -> None:
    """Clear event history buffer."""
    with self._lock:
        self._event_history.clear()
reset
reset() -> None

Reset state to initial values.

Source code in src/midi_markdown/runtime/tui/state.py
def reset(self) -> None:
    """Reset state to initial values."""
    with self._lock:
        self._position_ms = 0.0
        self._position_ticks = 0
        self._state = "stopped"
        self._event_history.clear()

components

Rich UI components for TUI display.

This module provides reusable Rich components for rendering the TUI: - Header: File name, port, title - Progress bar: Visual playback progress - Event list: Scrolling MIDI event history - Status bar: Time, tempo, state - Controls: Keyboard shortcut help

display

TUI Display Manager with Rich Live integration.

This module provides the TUIDisplayManager class that manages the live terminal display using Rich's Live context for flicker-free updates at 30 FPS.

Classes
TUIDisplayManager
TUIDisplayManager(state: TUIState, file_name: str, port_name: str, title: str | None = None, refresh_rate: int = 30)

Manages live terminal display with Rich Live.

This class coordinates the display of all TUI components and manages the refresh loop at a target rate of 30 FPS (every ~33ms).

The display runs in the main thread while playback happens in a background thread, with state synchronized through TUIState.

Initialize TUI display manager.

Parameters:

Name Type Description Default
state TUIState

TUIState instance for reading display data

required
file_name str

Name of the MML file being played

required
port_name str

MIDI output port name

required
title str | None

Optional song title

None
refresh_rate int

Target refresh rate in FPS (default: 30)

30
Source code in src/midi_markdown/runtime/tui/display.py
def __init__(
    self,
    state: TUIState,
    file_name: str,
    port_name: str,
    title: str | None = None,
    refresh_rate: int = 30,
):
    """Initialize TUI display manager.

    Args:
        state: TUIState instance for reading display data
        file_name: Name of the MML file being played
        port_name: MIDI output port name
        title: Optional song title
        refresh_rate: Target refresh rate in FPS (default: 30)
    """
    self.state = state
    self.file_name = file_name
    self.port_name = port_name
    self.title = title
    self.refresh_rate = refresh_rate
    self.refresh_interval = 1.0 / refresh_rate  # ~33ms for 30 FPS

    self.console = Console()
    self.live: Live | None = None
    self._stop_flag = threading.Event()
Functions
start
start() -> None

Start the live display.

This should be called before starting playback. The display will run until stop() is called.

Source code in src/midi_markdown/runtime/tui/display.py
def start(self) -> None:
    """Start the live display.

    This should be called before starting playback. The display will
    run until stop() is called.
    """
    self._stop_flag.clear()

    # Create Live context with auto-refresh disabled (we control refresh rate)
    self.live = Live(
        self._render_layout(),
        console=self.console,
        refresh_per_second=self.refresh_rate,
        screen=False,  # Don't use alternate screen
    )
    self.live.start()
stop
stop() -> None

Stop the live display and cleanup.

Source code in src/midi_markdown/runtime/tui/display.py
def stop(self) -> None:
    """Stop the live display and cleanup."""
    self._stop_flag.set()

    if self.live:
        self.live.stop()
        self.live = None
update_display
update_display() -> None

Update the display with current state.

This should be called periodically (e.g., every 33ms for 30 FPS).

Source code in src/midi_markdown/runtime/tui/display.py
def update_display(self) -> None:
    """Update the display with current state.

    This should be called periodically (e.g., every 33ms for 30 FPS).
    """
    if self.live and not self._stop_flag.is_set():
        self.live.update(self._render_layout())
run_loop
run_loop() -> None

Run the display update loop until stopped.

This is a blocking call that continuously updates the display at the target refresh rate. Call this in the main thread while playback runs in a background thread.

Source code in src/midi_markdown/runtime/tui/display.py
def run_loop(self) -> None:
    """Run the display update loop until stopped.

    This is a blocking call that continuously updates the display
    at the target refresh rate. Call this in the main thread while
    playback runs in a background thread.
    """
    while not self._stop_flag.is_set():
        self.update_display()
        time.sleep(self.refresh_interval)

Validation

Document Validator

document_validator

Document structure and command validation.

Timing Validator

timing_validator

Timing sequence validation.

Value Validator

value_validator

MIDI value range validation.


Alias System

Alias Resolver

resolver

Alias resolver for expanding device-specific commands.

The alias system is a core feature of MML that allows device-specific shortcuts.

Classes

AliasResolver
AliasResolver(aliases: dict[str, AliasDefinition] | None = None, max_depth: int = 10)

Resolves and expands aliases to MIDI commands.

The resolver: 1. Validates alias usage 2. Binds arguments to parameters 3. Validates parameter ranges and types 4. Evaluates computed values (mathematical expressions) 5. Evaluates conditional branches (if/elif/else) 6. Expands aliases to base MIDI commands

Stage 1: Basic expansion (no nesting, no imports, no computation) Stage 2: Enhanced parameter types (note names, percentages, booleans) Stage 3: Nested aliases (aliases calling other aliases) Stage 6: Computed values (mathematical expressions in parameters) Stage 7: Conditional logic (@if/@elif/@else branches)

Initialize the alias resolver.

Parameters:

Name Type Description Default
aliases dict[str, AliasDefinition] | None

Dictionary of alias definitions (name -> AliasDefinition)

None
max_depth int

Maximum allowed nesting depth (default: 10)

10
Source code in src/midi_markdown/alias/resolver.py
def __init__(
    self, aliases: dict[str, AliasDefinition] | None = None, max_depth: int = 10
) -> None:
    """Initialize the alias resolver.

    Args:
        aliases: Dictionary of alias definitions (name -> AliasDefinition)
        max_depth: Maximum allowed nesting depth (default: 10)
    """
    self.aliases = aliases or {}
    self.max_depth = max_depth
    self.computation_engine = SafeComputationEngine()
Functions
resolve
resolve(alias_name: str, arguments: list[Any], timing: Any = None, source_line: int = 0, _expansion_stack: list[tuple[str, list[Any]]] | None = None, _depth: int = 0) -> list[MIDICommand]

Resolve an alias to MIDI commands with nesting support.

This method now supports nested aliases (aliases calling other aliases) with cycle detection and depth limiting.

Parameters:

Name Type Description Default
alias_name str

The alias name to expand

required
arguments list[Any]

Arguments passed to the alias

required
timing Any

Timing information to apply to expanded commands

None
source_line int

Source line number for error reporting

0
_expansion_stack list[tuple[str, list[Any]]] | None

Internal - tracks current expansion path for cycle detection

None
_depth int

Internal - current expansion depth for depth limiting

0

Returns:

Type Description
list[MIDICommand]

List of expanded MIDI commands (fully resolved, no nested alias calls)

Raises:

Type Description
AliasError

If alias not found or parameters invalid

AliasRecursionError

If circular dependency detected

AliasMaxDepthError

If expansion exceeds max_depth

Note

The _expansion_stack and _depth parameters are for internal use only and should not be passed by external callers.

Source code in src/midi_markdown/alias/resolver.py
def resolve(
    self,
    alias_name: str,
    arguments: list[Any],
    timing: Any = None,
    source_line: int = 0,
    _expansion_stack: list[tuple[str, list[Any]]] | None = None,
    _depth: int = 0,
) -> list[MIDICommand]:
    """Resolve an alias to MIDI commands with nesting support.

    This method now supports nested aliases (aliases calling other aliases)
    with cycle detection and depth limiting.

    Args:
        alias_name: The alias name to expand
        arguments: Arguments passed to the alias
        timing: Timing information to apply to expanded commands
        source_line: Source line number for error reporting
        _expansion_stack: Internal - tracks current expansion path for cycle detection
        _depth: Internal - current expansion depth for depth limiting

    Returns:
        List of expanded MIDI commands (fully resolved, no nested alias calls)

    Raises:
        AliasError: If alias not found or parameters invalid
        AliasRecursionError: If circular dependency detected
        AliasMaxDepthError: If expansion exceeds max_depth

    Note:
        The _expansion_stack and _depth parameters are for internal use only
        and should not be passed by external callers.
    """
    # Initialize tracking on first call
    if _expansion_stack is None:
        _expansion_stack = []

    # Check for max depth exceeded
    if _depth >= self.max_depth:
        raise AliasMaxDepthError(
            alias_name=alias_name,
            current_depth=_depth,
            max_depth=self.max_depth,
            call_chain=_expansion_stack,
        )

    # Check for circular dependency (cycle detection)
    if any(name == alias_name for name, _ in _expansion_stack):
        raise AliasRecursionError(alias_name=alias_name, call_chain=_expansion_stack)

    # Add current alias to stack
    _expansion_stack.append((alias_name, arguments))

    try:
        # Look up alias definition
        if alias_name not in self.aliases:
            msg = (
                f"Undefined alias '{alias_name}' at line {source_line}. "
                f"Available aliases: {', '.join(self.aliases.keys()) or 'none'}"
            )
            raise AliasError(msg)

        alias_def = self.aliases[alias_name]

        # Bind and validate parameters
        param_values = self._bind_parameters(alias_def, arguments, source_line)

        # Evaluate computed values (Stage 6)
        if alias_def.computed_values:
            try:
                for var_name, expr_tree in alias_def.computed_values.items():
                    # Convert Lark tree to Python expression
                    expr_str = self.computation_engine.lark_tree_to_python(expr_tree)

                    # Evaluate expression with current parameter values (read-only)
                    computed_value = self.computation_engine.evaluate_expression(
                        expr_str, param_values
                    )

                    # Add computed value to parameter namespace
                    param_values[var_name] = computed_value

            except ComputationError as e:
                msg = f"Computation error in alias '{alias_name}' at line {source_line}: {e}"
                raise AliasError(msg)

        # Select commands based on conditionals (Stage 7)
        if alias_def.has_conditionals and alias_def.conditional_branches:
            # Conditional alias - evaluate conditions and select branch
            evaluator = ConditionalEvaluator()
            selected_commands = evaluator.select_branch(
                alias_def.conditional_branches, param_values
            )

            if selected_commands is None:
                msg = (
                    f"No conditional branch matched in alias '{alias_name}' "
                    f"at line {source_line}. Parameter values: {param_values}"
                )
                raise AliasError(msg)

            # Use selected commands
            commands_to_expand = selected_commands
        else:
            # Non-conditional alias - use all commands
            commands_to_expand = alias_def.commands

        # Expand commands - track timing for relative offsets
        from midi_markdown.parser.ast_nodes import Timing

        expanded_commands = []
        accumulated_timing = None  # Track relative timing to apply to next command
        current_timing = timing  # Start with timing from call site

        for item in commands_to_expand:
            # Check if this is a timing statement
            if isinstance(item, Timing):
                if item.type == "relative":
                    # Accumulate relative timing for next command
                    if accumulated_timing is None:
                        accumulated_timing = item
                    else:
                        # Combine relative timings (add them)
                        accumulated_timing = self._combine_relative_timing(
                            accumulated_timing, item
                        )
                elif item.type == "simultaneous":
                    # Simultaneous timing means same time as previous command
                    # Store it to apply to next command
                    accumulated_timing = item
                elif item.type == "absolute":
                    # Absolute timing not allowed in aliases
                    msg = (
                        f"Absolute timing (e.g., [mm:ss.mmm]) is not supported in alias '{alias_name}'. "
                        f"Use relative timing (e.g., [+100ms], [+1b]) instead to preserve reusability. "
                        f"See docs/dev-guides/anti-patterns.md for details."
                    )
                    raise AliasError(msg)
                elif item.type == "musical":
                    # Musical timing not allowed in aliases
                    msg = (
                        f"Musical timing (e.g., [bars.beats.ticks]) is not supported in alias '{alias_name}'. "
                        f"Use relative timing (e.g., [+100ms], [+1b]) instead to preserve reusability. "
                        f"See docs/dev-guides/anti-patterns.md for details."
                    )
                    raise AliasError(msg)
                else:
                    # Unknown timing type
                    msg = f"Unknown timing type '{item.type}' in alias '{alias_name}'"
                    raise AliasError(msg)
                continue

            # It's a command (str or MIDICommand)
            command_template = item

            # Determine what timing to apply to this command
            command_timing = accumulated_timing or current_timing

            if isinstance(command_template, str):
                # String template - substitute parameters and parse
                expanded_str = self._substitute_parameters(command_template, param_values)

                # Check if this might be a nested alias call
                # Format: "alias_name arg1 arg2 ..." or "alias_name arg1.arg2.arg3"
                nested_commands = self._try_resolve_nested_alias(
                    expanded_str, command_timing, source_line, _expansion_stack, _depth
                )

                if nested_commands is not None:
                    # Was a nested alias - add all expanded commands
                    expanded_commands.extend(nested_commands)
                else:
                    # Not a nested alias - parse as MIDI command
                    expanded_cmd = self._parse_command_string(
                        expanded_str, command_timing, source_line
                    )
                    expanded_commands.append(expanded_cmd)

                # Reset accumulated timing after applying to command
                accumulated_timing = None

            elif isinstance(command_template, MIDICommand):
                # Already parsed command - substitute in params dict
                expanded_cmd = self._substitute_in_command(
                    command_template, param_values, command_timing, source_line
                )

                # Check if this is an alias_call type that needs recursive resolution
                if expanded_cmd.type == "alias_call":
                    # Extract alias name and args from command
                    nested_alias_name = expanded_cmd.params.get("alias_name")
                    nested_args = expanded_cmd.params.get("arguments", [])

                    if nested_alias_name is None:
                        msg = f"alias_call command missing alias_name at line {source_line}"
                        raise AliasError(msg)

                    # Recursively resolve the nested alias
                    nested_commands = self.resolve(
                        alias_name=str(nested_alias_name),
                        arguments=nested_args,
                        timing=command_timing or expanded_cmd.timing,
                        source_line=expanded_cmd.source_line,
                        _expansion_stack=_expansion_stack,
                        _depth=_depth + 1,
                    )
                    expanded_commands.extend(nested_commands)
                else:
                    expanded_commands.append(expanded_cmd)

                # Reset accumulated timing after applying to command
                accumulated_timing = None

        return expanded_commands

    finally:
        # Always remove from stack, even if error occurred
        _expansion_stack.pop()

Import Resolver

imports

Import manager for loading device libraries and alias definitions.

This module handles: - Resolving import paths (relative/absolute) - Loading and parsing device library files - Detecting circular imports - Merging alias definitions from multiple sources

Alias Models

models

Data models for alias expansion tracking.

This module provides data structures for tracking alias expansion state, including call chains and expansion nodes.

Conditionals

conditionals

Conditional evaluation for alias branching logic (Stage 7).

This module provides the ConditionalEvaluator class that evaluates conditional expressions in alias definitions (@if/@elif/@else) and selects the appropriate branch to execute based on parameter values.

Computation

computation

Safe computation engine for evaluating expressions in alias definitions.

This module provides secure evaluation of mathematical expressions in computed value blocks, with strict security controls to prevent code injection.


Command Expansion

Command Expander

expander

Command Expander - Unified orchestrator for variable expansion, loops, and sweeps.

Phase 4: Coordinates all expansion operations and provides a clean interface for the compilation pipeline.

Classes

CommandExpander
CommandExpander(ppq: int = 480, tempo: float = 120.0, time_signature: tuple[int, int] = (4, 4), source_file: str = '<unknown>')

Unified orchestrator for command expansion.

Coordinates: - Variable definitions (@define) via SymbolTable - Loop expansion via LoopExpander - Sweep expansion via SweepExpander - Variable substitution in commands - Event sorting and validation

Usage

expander = CommandExpander(ppq=480, tempo=120.0) expanded_events = expander.process_ast(ast_nodes)

Initialize command expander.

Parameters:

Name Type Description Default
ppq int

Pulses per quarter note (MIDI resolution)

480
tempo float

Default tempo in BPM

120.0
time_signature tuple[int, int]

Time signature as (numerator, denominator) tuple, e.g., (4, 4) for 4/4 time

(4, 4)
source_file str

Source filename for error reporting

'<unknown>'
Source code in src/midi_markdown/expansion/expander.py
def __init__(
    self,
    ppq: int = 480,
    tempo: float = 120.0,
    time_signature: tuple[int, int] = (4, 4),
    source_file: str = "<unknown>",
):
    """
    Initialize command expander.

    Args:
        ppq: Pulses per quarter note (MIDI resolution)
        tempo: Default tempo in BPM
        time_signature: Time signature as (numerator, denominator) tuple, e.g., (4, 4) for 4/4 time
        source_file: Source filename for error reporting
    """
    self.ppq = ppq
    self.tempo = tempo
    self.time_signature = time_signature
    self.source_file = source_file

    # Core components
    self.symbol_table = SymbolTable()
    self.computation_engine = SafeComputationEngine()
    self.loop_expander = LoopExpander(
        parent_symbols=self.symbol_table, ppq=ppq, tempo=tempo, time_signature=time_signature
    )
    self.sweep_expander = SweepExpander(ppq=ppq)
    self.random_expander = RandomValueExpander()

    # State
    self.current_time = 0  # Track current time in ticks
    self.events: list[dict] = []
    self.stats = ExpansionStats()
Functions
process_ast
process_ast(ast_nodes: list[Any]) -> list[dict]

Process AST nodes and return expanded events.

Uses two-pass processing: 1. Pass 1: Collect all @define statements 2. Pass 2: Expand loops, sweeps, and commands with variable substitution

Parameters:

Name Type Description Default
ast_nodes list[Any]

List of AST nodes from parser

required

Returns:

Type Description
list[dict]

List of expanded event dictionaries sorted by time

Raises:

Type Description
ExpansionError

If expansion fails

Source code in src/midi_markdown/expansion/expander.py
def process_ast(self, ast_nodes: list[Any]) -> list[dict]:
    """
    Process AST nodes and return expanded events.

    Uses two-pass processing:
    1. Pass 1: Collect all @define statements
    2. Pass 2: Expand loops, sweeps, and commands with variable substitution

    Args:
        ast_nodes: List of AST nodes from parser

    Returns:
        List of expanded event dictionaries sorted by time

    Raises:
        ExpansionError: If expansion fails
    """
    self.events = []
    self.stats = ExpansionStats()

    # Pass 1: Process all @define statements
    for node in ast_nodes:
        if self._is_define_node(node):
            self._process_define(node)

    # Pass 2: Expand loops, sweeps, and commands
    for node in ast_nodes:
        if self._is_define_node(node):
            continue  # Already processed in pass 1

        if self._is_loop_node(node):
            self._process_loop(node)
        elif self._is_sweep_node(node):
            self._process_sweep(node)
        elif self._is_timed_event_node(node):
            self._process_timed_event(node)
        elif self._is_command_node(node):
            self._process_command(node)

    # Sort events by time
    self.events = self._sort_events(self.events)

    # Validate events
    self._validate_events(self.events)

    self.stats.events_generated = len(self.events)
    return self.events
get_stats
get_stats() -> ExpansionStats

Get expansion statistics.

Source code in src/midi_markdown/expansion/expander.py
def get_stats(self) -> ExpansionStats:
    """Get expansion statistics."""
    return self.stats
get_symbol_table
get_symbol_table() -> SymbolTable

Get symbol table for inspection.

Source code in src/midi_markdown/expansion/expander.py
def get_symbol_table(self) -> SymbolTable:
    """Get symbol table for inspection."""
    return self.symbol_table

Variables

variables

Symbol table implementation for MML variables. Handles variable definition, lookup, and scoping.

Loops

loops

Loop implementation for MIDI Markup Language.

Provides @loop directive for repeating event sequences with: - Iteration control (count) - Timing intervals (beats, ticks, ms, BBT) - Loop variables (LOOP_INDEX, LOOP_ITERATION, LOOP_COUNT) - Scoped variable resolution

Phase 3 of Variables Implementation.

Sweeps

sweeps

Sweep implementation for MIDI Markup Language.

Provides @sweep directive for value interpolation with: - Multiple interpolation curves (linear, exponential, logarithmic, ease-in/out) - Time-based or step-based interpolation - Support for any MIDI value parameter

Phase 3 of Variables Implementation.


Utilities

Parameter Types

parameter_types

Parameter Type Conversion Utilities

Provides functions to convert various parameter types (note names, percentages, booleans) to MIDI values. Used by both the parser and alias system.

Constants

constants

MIDI Markup Language Constants

Defines all magic numbers and constants used throughout the MML implementation.

Attributes

MIDI_CHANNEL_MIN module-attribute
MIDI_CHANNEL_MIN = 1
MIDI_CHANNEL_MAX module-attribute
MIDI_CHANNEL_MAX = 16
MIDI_NOTE_MIN module-attribute
MIDI_NOTE_MIN = 0
MIDI_NOTE_MAX module-attribute
MIDI_NOTE_MAX = 127
PITCH_BEND_MIN module-attribute
PITCH_BEND_MIN = -8192
PITCH_BEND_MAX module-attribute
PITCH_BEND_MAX = 8191
DEFAULT_PPQ module-attribute
DEFAULT_PPQ = 480
DEFAULT_TEMPO module-attribute
DEFAULT_TEMPO = 120

CLI

Main Application

main

CLI entry point for MIDI Markdown.

This module provides the main command-line interface using Typer. All command implementations are in the commands/ package.

Functions

cli
cli() -> None

Main CLI entry point.

Source code in src/midi_markdown/cli/main.py
def cli() -> None:
    """Main CLI entry point."""
    app()

Error Formatting

errors

Rich error display formatting for the MIDI Markup Language compiler.

This module provides structured error display with: - Source code context with line numbers - Visual indicators (carets, colors) pointing to error locations - Error codes (E1xx parse, E2xx validation, E3xx type, E4xx file/import) - Helpful suggestions and "did you mean?" hints - Accessibility support (--no-color, --no-emoji flags)


Type Reference

Common Types

# Timing modes
TimingMode = Literal["absolute", "musical", "relative", "simultaneous"]

# Event types
EventType = Literal[
    "note_on",
    "note_off",
    "cc",
    "pc",
    "pitch_bend",
    "poly_pressure",
    "channel_pressure",
    "tempo",
    "time_signature",
    "marker",
    "text",
    "sysex"
]

# Command types in AST
CommandType = Literal[
    "note_on",
    "note_off",
    "cc",
    "pc",
    "pitch_bend",
    "poly_pressure",
    "channel_pressure",
    "tempo",
    "time_signature",
    "marker",
    "text",
    "sysex"
]

# Parameter types for aliases
ParameterType = Literal["int", "note", "percent", "bool", "enum"]

Data Structures

# MIDI Event (IR layer)
@dataclass
class MIDIEvent:
    time: int                          # Absolute ticks
    type: EventType                    # Event type
    channel: int | None = None         # 1-16 for channel events
    data1: int | None = None           # Note/CC number, etc.
    data2: int | None = None           # Velocity/value, etc.
    metadata: dict[str, Any] = field(default_factory=dict)

# Timing
@dataclass
class Timing:
    mode: str                          # "absolute", "musical", "relative", "simultaneous"
    value: int | tuple[int, ...]      # Absolute ticks or (bars, beats, ticks)

# MIDI Command (AST)
@dataclass
class MIDICommand:
    type: str                          # Command type
    channel: int                       # 1-16
    data1: int | None = None           # Note/CC number
    data2: int | None = None           # Velocity/value
    duration: int | None = None        # For notes with duration

Implementation Notes

Pipeline Overview

Input (.mmd)
Parser                → Parse to AST (Lark grammar + transformer)
Import Resolver       → Load device library aliases
Alias Resolver        → Expand alias calls to MIDI commands
Command Expander      → Expand variables, loops, sweeps, conditionals
Validator             → Check ranges, timing, values
IR Compiler           → Convert to Intermediate Representation
IRProgram             → Query-able event list with metadata
┌───────────────────┬──────────────────────────┐
│ Codegen           │ Runtime (Phase 3)        │
│ - MIDI file       │ - Real-time MIDI I/O     │
│ - JSON export     │ - Event scheduler        │
│ - CSV export      │ - Tempo tracker          │
│                   │ - TUI player             │
└───────────────────┴──────────────────────────┘
    ↓                          ↓
Output (.mid, .json)    Live MIDI Output

Key Design Patterns

  1. Functional Core: Codegen functions are pure (no side effects)
  2. Immutable AST: AST nodes are frozen dataclasses
  3. Progressive Enhancement: Each pipeline stage adds information
  4. Type Safety: Full type hints with mypy validation
  5. Error Context: All errors track source location

Testing Strategy

  • 1090+ tests (72.53% coverage)
  • Unit tests: Component isolation
  • Integration tests: Multi-component workflows
  • E2E tests: Full compilation pipeline
  • Fixtures: 37 test MMD files (25 valid, 12 invalid)

See Also