Skip to content

IR Layer Specification

Date: 2025-11-08 Status: Production-ready Version: 0.1.0

Table of Contents

  1. Overview
  2. Design Goals
  3. Data Structures
  4. Event Types
  5. Compilation Process
  6. Query Methods
  7. Usage Examples
  8. Implementation Details
  9. Testing

Overview

The Intermediate Representation (IR) layer sits between the Abstract Syntax Tree (AST) and output generation. It provides a normalized, executable representation of MIDI events that enables:

  • Multiple outputs: MIDI files, JSON, CSV, diagnostic tables
  • Real-time playback: Event scheduling with tempo tracking
  • REPL mode (future): Interactive evaluation and inspection
  • Event queries: Search and analyze events by time, type, channel

The IR layer was introduced in Phase 0 of the implementation to decouple parsing from output generation.


Design Goals

1. Unified Event Representation

All MIDI events (notes, CC, PC, tempo, etc.) use a single MIDIEvent structure with a common timing model.

2. Absolute Timing

All events store absolute time in ticks, computed during expansion. This simplifies output generation and playback.

3. Tempo Awareness

Events include both tick time and computed time_seconds, enabling accurate real-time playback with tempo changes.

4. Query-able Structure

The IRProgram provides query methods to search events by time, type, or channel without re-parsing.

5. Metadata Preservation

Each event can carry metadata (source file, line number, track name) for error reporting and debugging.

6. Immutable Design

IR structures are immutable dataclasses. Transformations create new structures.


Data Structures

MIDIEvent

The core event structure representing a single MIDI message:

@dataclass
class MIDIEvent:
    """Represents a single MIDI event.

    Attributes:
        time: Absolute time in ticks
        type: Event type (NOTE_ON, CC, PC, etc.)
        channel: MIDI channel (1-16)
        data1: First data byte (note number, controller, etc.)
        data2: Second data byte (velocity, value, etc.)
        time_seconds: Absolute time in seconds (computed from tempo map)
        metadata: Source location and other info for error reporting
    """
    time: int
    type: EventType
    channel: int
    data1: int = 0
    data2: int = 0
    time_seconds: float | None = None
    metadata: dict[str, Any] | None = None

Field Details:

Field Type Range Description
time int 0+ Absolute time in ticks (PPQ-relative)
type EventType enum Event type (see EventType section)
channel int 1-16 MIDI channel (1-indexed)
data1 int 0-127 First data byte (note, CC number, etc.)
data2 int 0-127 Second data byte (velocity, value, etc.)
time_seconds float|None 0+ Computed time in seconds (for playback)
metadata dict|None - Source location, track info, etc.

Example:

# Middle C note-on at 0.5 seconds, velocity 100
event = MIDIEvent(
    time=240,              # 240 ticks (0.5 beats at 480 PPQ)
    type=EventType.NOTE_ON,
    channel=1,
    data1=60,              # Middle C
    data2=100,             # Velocity
    time_seconds=0.5,      # Computed from tempo
    metadata={
        "source_file": "song.mmd",
        "source_line": 12,
        "track": "Main"
    }
)


EventType

Enum defining all supported MIDI event types:

class EventType(Enum):
    """MIDI event types."""

    # Channel Voice Messages
    NOTE_ON = auto()
    NOTE_OFF = auto()
    CONTROL_CHANGE = auto()
    PROGRAM_CHANGE = auto()
    PITCH_BEND = auto()
    CHANNEL_PRESSURE = auto()
    POLY_PRESSURE = auto()

    # System Common Messages
    SYSEX = auto()
    MTC_QUARTER_FRAME = auto()
    SONG_POSITION = auto()
    SONG_SELECT = auto()

    # Meta Events (for MIDI files)
    TEMPO = auto()
    TIME_SIGNATURE = auto()
    KEY_SIGNATURE = auto()
    MARKER = auto()
    TEXT = auto()

Category Breakdown:

Channel Voice Messages (most common): - NOTE_ON: Note on (data1=note, data2=velocity) - NOTE_OFF: Note off (data1=note, data2=release velocity) - CONTROL_CHANGE: CC message (data1=controller, data2=value) - PROGRAM_CHANGE: PC message (data1=program, data2=unused) - PITCH_BEND: Pitch bend (data1=LSB, data2=MSB, combined -8192 to +8191) - CHANNEL_PRESSURE: Aftertouch (data1=pressure, data2=unused) - POLY_PRESSURE: Polyphonic aftertouch (data1=note, data2=pressure)

System Common Messages: - SYSEX: System Exclusive message - MTC_QUARTER_FRAME: MIDI Time Code quarter frame - SONG_POSITION: Song position pointer - SONG_SELECT: Song select

Meta Events (MIDI file only, not sent to devices): - TEMPO: Tempo change (data1=BPM) - TIME_SIGNATURE: Time signature change - KEY_SIGNATURE: Key signature - MARKER: Marker text - TEXT: Text annotation


IRProgram

The complete compiled program containing all events and metadata:

@dataclass
class IRProgram:
    """Intermediate representation of compiled MMD program.

    Attributes:
        resolution: PPQ (ticks per quarter note)
        initial_tempo: Starting tempo in BPM
        events: Sorted list of MIDI events (by time)
        metadata: Document frontmatter + computed information
    """
    resolution: int
    initial_tempo: int
    events: list[MIDIEvent]
    metadata: dict[str, Any]

    # Computed properties
    @property
    def duration_ticks(self) -> int:
        """Total duration in ticks."""
        return max((e.time for e in self.events), default=0)

    @property
    def duration_seconds(self) -> float:
        """Total duration in seconds."""
        return max((e.time_seconds for e in self.events if e.time_seconds), default=0.0)

    @property
    def track_count(self) -> int:
        """Number of unique tracks."""
        tracks = {e.metadata.get("track", 0) for e in self.events if e.metadata}
        return len(tracks) if tracks else 1

    @property
    def event_count(self) -> int:
        """Total number of events."""
        return len(self.events)

    # Query methods (see Query Methods section)
    def events_at_time(self, seconds: float, tolerance: float = 0.01) -> list[MIDIEvent]
    def events_in_range(self, start: float, end: float) -> list[MIDIEvent]
    def events_by_type(self, event_type: EventType) -> list[MIDIEvent]
    def events_by_channel(self, channel: int) -> list[MIDIEvent]

Metadata Fields:

Field Type Description
title str Song title (from frontmatter)
author str Author name (from frontmatter)
description str Description (from frontmatter)
version str MMD version (from frontmatter)
tempo int Initial tempo in BPM
time_signature tuple Time signature (numerator, denominator)

Example:

ir_program = IRProgram(
    resolution=480,          # 480 PPQ
    initial_tempo=120,       # 120 BPM
    events=[...],            # List of MIDIEvent objects
    metadata={
        "title": "My Song",
        "author": "Composer Name",
        "description": "A test composition",
        "version": "1.0"
    }
)

# Access properties
print(f"Duration: {ir_program.duration_seconds}s")
print(f"Events: {ir_program.event_count}")
print(f"Tracks: {ir_program.track_count}")


Event Types

String to EventType Conversion

The IR compiler converts string event types (from the expander) to EventType enums:

def string_to_event_type(type_str: str) -> EventType:
    """Convert string type to EventType enum.

    Supports both full names and abbreviations:
    - "note_on" or "NOTE_ON" → EventType.NOTE_ON
    - "cc" or "control_change" → EventType.CONTROL_CHANGE
    - "pc" or "program_change" → EventType.PROGRAM_CHANGE
    - "pb" or "pitch_bend" → EventType.PITCH_BEND
    - etc.

    Raises:
        ValueError: If type_str is not recognized
    """

Supported Mappings:

String EventType
"note_on" NOTE_ON
"note_off" NOTE_OFF
"cc", "control_change" CONTROL_CHANGE
"pc", "program_change" PROGRAM_CHANGE
"pb", "pitch_bend" PITCH_BEND
"cp", "channel_pressure" CHANNEL_PRESSURE
"pp", "poly_pressure" POLY_PRESSURE
"sysex" SYSEX
"tempo" TEMPO
"time_signature" TIME_SIGNATURE
"key_signature" KEY_SIGNATURE
"marker" MARKER
"text" TEXT
"mtc_quarter_frame" MTC_QUARTER_FRAME
"song_position" SONG_POSITION
"song_select" SONG_SELECT

Compilation Process

The compile_ast_to_ir() function orchestrates the conversion from AST to IR:

Pipeline

MMLDocument (AST)
[1] Extract frontmatter
    ↓ tempo, time_signature
[2] CommandExpander.process_ast()
    ↓ list[dict] (event dictionaries)
[3] Convert dicts → MIDIEvent objects
    ↓ list[MIDIEvent]
[4] Build tempo map
    ↓ list[(tick, bpm)]
[5] Compute time_seconds for all events
    ↓ MIDIEvent.time_seconds = ...
[6] Create IRProgram
IRProgram

Implementation

def compile_ast_to_ir(
    document: MMLDocument,
    ppq: int = 480,
) -> IRProgram:
    """Compile MMD 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 MMD document AST
        ppq: Pulses per quarter note (MIDI resolution)

    Returns:
        IRProgram ready for output or execution
    """
    # Step 1: Extract frontmatter
    tempo = document.frontmatter.get("tempo", 120)
    time_signature = document.frontmatter.get("time_signature", (4, 4))

    # Step 2: Expand AST to event dictionaries
    expander = CommandExpander(ppq=ppq, tempo=tempo, time_signature=time_signature)
    expanded_dicts = expander.process_ast(document.events)

    # Step 3: Convert event dicts to MIDIEvent objects
    events = []
    for event_dict in expanded_dicts:
        # Skip special meta events (trackname, instrumentname, end_of_track)
        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)

    # Steps 4-6: Wrap in IRProgram (computes time_seconds)
    return create_ir_program(
        events=events,
        ppq=ppq,
        initial_tempo=tempo,
        frontmatter=document.frontmatter,
    )

Helper: create_ir_program()

def create_ir_program(
    events: list[MIDIEvent],
    ppq: int,
    initial_tempo: int,
    frontmatter: dict[str, Any] | None = None,
) -> IRProgram:
    """Create IR program from events and metadata.

    This helper function:
    1. Sorts events by time
    2. Builds tempo map from TEMPO events
    3. Computes time_seconds for all events
    4. Wraps in IRProgram structure

    Args:
        events: List of MIDI events (unsorted)
        ppq: Pulses per quarter note
        initial_tempo: Starting tempo in BPM
        frontmatter: Document frontmatter metadata

    Returns:
        IRProgram with computed time_seconds
    """
    # Step 1: Sort events by time
    events.sort(key=lambda e: e.time)

    # Step 2: Build tempo map
    tempo_map: list[tuple[int, int]] = [(0, initial_tempo)]
    for event in events:
        if event.type == EventType.TEMPO and event.data1:
            tempo_map.append((event.time, event.data1))
    tempo_map.sort(key=lambda x: x[0])

    # Step 3: Compute time_seconds for each event
    for event in events:
        event.time_seconds = _ticks_to_seconds(event.time, tempo_map, ppq)

    # Step 4: Create metadata dict
    metadata = {
        "title": frontmatter.get("title", "Untitled") if frontmatter else "Untitled",
        "author": frontmatter.get("author", "") if frontmatter else "",
        "description": frontmatter.get("description", "") if frontmatter else "",
        "version": frontmatter.get("version", "1.0") if frontmatter else "1.0",
    }

    # Step 5: Return IRProgram
    return IRProgram(
        resolution=ppq,
        initial_tempo=initial_tempo,
        events=events,
        metadata=metadata,
    )

Tempo Map and Time Conversion

The tempo map tracks tempo changes over time:

tempo_map = [
    (0, 120),      # Start at 120 BPM
    (960, 140),    # Change to 140 BPM at tick 960
    (1920, 100),   # Change to 100 BPM at tick 1920
]

Time conversion algorithm:

def _ticks_to_seconds(ticks: int, tempo_map: list[tuple[int, int]], ppq: int) -> float:
    """Convert tick position to seconds using tempo map.

    Algorithm:
    1. For each tempo segment [prev_tick, tempo_tick):
       - Calculate duration in ticks: delta_ticks = tempo_tick - prev_tick
       - Convert to beats: beats = delta_ticks / ppq
       - Convert to seconds: seconds = beats * (60 / tempo_bpm)
    2. Sum all segment durations up to target tick

    Args:
        ticks: Absolute tick position
        tempo_map: List of (tick, bpm) tuples (sorted)
        ppq: Pulses per quarter note

    Returns:
        Time in seconds
    """
    seconds = 0.0
    prev_tick = 0
    prev_tempo = tempo_map[0][1]

    for tempo_tick, tempo_bpm in tempo_map:
        if tempo_tick > ticks:
            break

        # Add time for segment at previous tempo
        if tempo_tick > prev_tick:
            tick_delta = tempo_tick - prev_tick
            seconds += (tick_delta / ppq) * (60.0 / prev_tempo)

        prev_tick = tempo_tick
        prev_tempo = tempo_bpm

    # Add remaining time at final tempo
    if ticks > prev_tick:
        tick_delta = ticks - prev_tick
        seconds += (tick_delta / ppq) * (60.0 / prev_tempo)

    return seconds

Example:

# At 480 PPQ, 120 BPM:
# 480 ticks = 1 beat = 0.5 seconds
# 960 ticks = 2 beats = 1.0 seconds

ticks = 960
tempo_map = [(0, 120)]
ppq = 480

seconds = _ticks_to_seconds(ticks, tempo_map, ppq)
print(seconds)  # 1.0


Query Methods

The IRProgram class provides query methods for event analysis:

1. events_at_time()

Find events at a specific time (with tolerance):

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 [seconds - tolerance, seconds + tolerance]
    """
    return [
        e
        for e in self.events
        if e.time_seconds is not None and abs(e.time_seconds - seconds) <= tolerance
    ]

Example:

# Find all events at 1.0 seconds (±10ms)
events = ir_program.events_at_time(1.0)
for event in events:
    print(f"{event.type.name}: channel {event.channel}")

2. events_in_range()

Find events in a time range:

def events_in_range(self, start: float, end: float) -> list[MIDIEvent]:
    """Get events in time range.

    Args:
        start: Start time in seconds (inclusive)
        end: End time in seconds (inclusive)

    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
    ]

Example:

# Find all events between 0.5s and 2.0s
events = ir_program.events_in_range(0.5, 2.0)
print(f"Found {len(events)} events in range")

3. events_by_type()

Find all events of a specific type:

def events_by_type(self, event_type: EventType) -> list[MIDIEvent]:
    """Get all events of specific type.

    Args:
        event_type: EventType enum value

    Returns:
        List of events matching type
    """
    return [e for e in self.events if e.type == event_type]

Example:

# Find all note-on events
note_ons = ir_program.events_by_type(EventType.NOTE_ON)
print(f"Total notes: {len(note_ons)}")

# Find all tempo changes
tempos = ir_program.events_by_type(EventType.TEMPO)
for event in tempos:
    print(f"Tempo change to {event.data1} BPM at {event.time_seconds}s")

4. events_by_channel()

Find all events on a specific channel:

def events_by_channel(self, channel: int) -> list[MIDIEvent]:
    """Get all events on specific channel.

    Args:
        channel: MIDI channel (1-16)

    Returns:
        List of events on channel
    """
    return [e for e in self.events if e.channel == channel]

Example:

# Find all events on channel 1
ch1_events = ir_program.events_by_channel(1)
print(f"Channel 1: {len(ch1_events)} events")

# Count events per channel
for channel in range(1, 17):
    count = len(ir_program.events_by_channel(channel))
    if count > 0:
        print(f"Channel {channel}: {count} events")


Usage Examples

Complete Compilation Example

from midi_markdown.parser.parser import MMLParser
from midi_markdown.core import compile_ast_to_ir
from midi_markdown.codegen.midi_file import generate_midi_file
from pathlib import Path

# Step 1: Parse MMD file
parser = MMLParser()
document = parser.parse_file("song.mmd")

# Step 2: Compile to IR
ir_program = compile_ast_to_ir(document, ppq=480)

# Step 3: Inspect IR
print(f"Title: {ir_program.metadata['title']}")
print(f"Duration: {ir_program.duration_seconds}s")
print(f"Events: {ir_program.event_count}")
print(f"Tracks: {ir_program.track_count}")

# Step 4: Generate MIDI file
midi_bytes = generate_midi_file(ir_program, midi_format=1)
Path("output.mid").write_bytes(midi_bytes)

Event Analysis Example

# Find all notes
notes = ir_program.events_by_type(EventType.NOTE_ON)

# Compute pitch histogram
from collections import Counter
pitch_counts = Counter(event.data1 for event in notes)

print("Most common pitches:")
for pitch, count in pitch_counts.most_common(5):
    print(f"  {pitch} (MIDI): {count} times")

# Find longest note
note_offs = {e.data1: e.time for e in ir_program.events_by_type(EventType.NOTE_OFF)}
longest_duration = 0
longest_note = None

for note_on in notes:
    note_off_time = note_offs.get(note_on.data1)
    if note_off_time:
        duration = note_off_time - note_on.time
        if duration > longest_duration:
            longest_duration = duration
            longest_note = note_on.data1

print(f"Longest note: {longest_note} (duration: {longest_duration} ticks)")

Real-time Playback Example

from midi_markdown.runtime.player import RealtimePlayer

# Create player from IR program
player = RealtimePlayer(ir_program, port_number=0)

# Play with TUI
player.play()  # Blocks until complete

Export to JSON Example

from midi_markdown.codegen.json_export import export_json

# Export complete format
json_str = export_json(ir_program, simplified=False)
Path("events.json").write_text(json_str)

# Export simplified format (minimal info)
json_simple = export_json(ir_program, simplified=True)
Path("events_simple.json").write_text(json_simple)

Implementation Details

Location

The IR layer is implemented in: - src/midi_markdown/core/ir.py (276 lines) - src/midi_markdown/core/compiler.py (83 lines)

Dependencies

Internal: - parser/ast_nodes.py: MMLDocument AST structure - expansion/expander.py: CommandExpander for expansion - alias/computation.py: SafeComputationEngine for expressions

External: - Python 3.12+ standard library (dataclasses, enum) - Type hints (typing.TYPE_CHECKING for circular imports)

Performance Characteristics

Time Complexity: - compile_ast_to_ir(): O(n) where n = number of events - create_ir_program(): O(n log n) due to sorting - _ticks_to_seconds(): O(t) where t = number of tempo changes - Query methods: O(n) linear scan (could be optimized with indexing)

Space Complexity: - O(n) for event list - O(t) for tempo map - O(1) for metadata

Scalability: - Tested with 1000+ event files - Typical compilation: < 100ms for 100-event files - Memory efficient (events stored as dataclasses, not dicts)


Testing

Test Coverage

The IR layer has comprehensive test coverage:

Unit Tests (tests/unit/test_ir.py): - MIDIEvent creation - EventType enum values - string_to_event_type() conversions - IRProgram properties (duration, counts) - Query methods (at_time, in_range, by_type, by_channel) - Tempo map construction - Time conversion (_ticks_to_seconds)

Integration Tests (tests/integration/test_end_to_end.py): - Full compilation pipeline (MML → IR → MIDI) - Timing accuracy verification - All event types (tempo, PC, CC, notes, pitch bend, pressure) - MIDI file formats (0, 1, 2)

Test Examples:

def test_ir_event_creation():
    """Test MIDIEvent dataclass."""
    event = MIDIEvent(
        time=480,
        type=EventType.NOTE_ON,
        channel=1,
        data1=60,
        data2=100,
        time_seconds=0.5,
    )
    assert event.time == 480
    assert event.type == EventType.NOTE_ON
    assert event.channel == 1
    assert event.data1 == 60
    assert event.data2 == 100

def test_ir_program_properties():
    """Test IRProgram computed properties."""
    events = [
        MIDIEvent(time=0, type=EventType.NOTE_ON, channel=1, time_seconds=0.0),
        MIDIEvent(time=480, type=EventType.NOTE_OFF, channel=1, time_seconds=0.5),
        MIDIEvent(time=960, type=EventType.NOTE_ON, channel=2, time_seconds=1.0),
    ]
    ir_program = IRProgram(resolution=480, initial_tempo=120, events=events, metadata={})

    assert ir_program.duration_ticks == 960
    assert ir_program.duration_seconds == 1.0
    assert ir_program.event_count == 3

def test_events_by_type():
    """Test event type queries."""
    events = [
        MIDIEvent(time=0, type=EventType.NOTE_ON, channel=1),
        MIDIEvent(time=0, type=EventType.CONTROL_CHANGE, channel=1),
        MIDIEvent(time=100, type=EventType.NOTE_ON, channel=1),
    ]
    ir_program = IRProgram(resolution=480, initial_tempo=120, events=events, metadata={})

    note_ons = ir_program.events_by_type(EventType.NOTE_ON)
    assert len(note_ons) == 2

    ccs = ir_program.events_by_type(EventType.CONTROL_CHANGE)
    assert len(ccs) == 1

Summary

The IR layer provides:

Unified representation: Single MIDIEvent structure for all event types ✅ Absolute timing: Tick-based timing with computed seconds ✅ Tempo awareness: Accurate time conversion with tempo changes ✅ Query methods: Search events by time, type, channel ✅ Metadata preservation: Source location for error reporting ✅ Multiple outputs: Enables MIDI files, JSON, CSV, playback ✅ Testable: 72.53% code coverage, comprehensive unit/integration tests

The IR layer is production-ready and serves as the foundation for all output formats and runtime modes.


Next Steps: - Read architecture.md for overall system design - Read architecture/parser.md for parser details - Explore source code: src/midi_markdown/core/ir.py - Run tests: pytest tests/unit/test_ir.py -v

Document Version: 1.0 Last Updated: 2025-11-08