mtrack 0.12.0

A multitrack audio and MIDI player for live performances.
Documentation
// Copyright (C) 2026 Michael Wilson <mike@mdwn.dev>
//
// This program is free software: you can redistribute it and/or modify it under
// the terms of the GNU General Public License as published by the Free Software
// Foundation, version 3.
//
// This program is distributed in the hope that it will be useful, but WITHOUT
// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
// FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// this program. If not, see <https://www.gnu.org/licenses/>.
//
// Main file rule - can contain any combination of fixture types, venues, light shows, sequences, and tempo
file = { SOI ~ (fixture_type | venue | light_show | sequence | tempo)* ~ EOI }

// Light show rules
// Allow optional show names so a single unnamed show per file is valid.
// When multiple shows are present, the parser will enforce that each has a name.
light_show = { "show" ~ show_name? ~ show_content }

show_name = { "\"" ~ (!"\"" ~ ANY)* ~ "\"" }

show_content = { "{" ~ (tempo | cue)* ~ "}" }

cue = { (time_string | measure_time) ~ (effect | layer_command | sequence_reference | stop_sequence_command | offset_command | reset_measures_command | inline_loop)* }

// Sequence definition rules
sequence = { "sequence" ~ sequence_name ~ sequence_content }

sequence_name = { "\"" ~ (!"\"" ~ ANY)* ~ "\"" }

sequence_content = { "{" ~ (tempo | sequence_cue)* ~ "}" }

sequence_cue = { (time_string | measure_time) ~ (effect | layer_command | sequence_reference | stop_sequence_command | offset_command | reset_measures_command | inline_loop)* }

// Sequence reference in a cue
sequence_reference = { "sequence" ~ sequence_name ~ (","? ~ sequence_params)? }

sequence_params = { sequence_param ~ ("," ~ sequence_param)* }

sequence_param = { sequence_param_name ~ ":" ~ sequence_param_value }

sequence_param_name = { "loop" }

sequence_param_value = { loop_parameter | number_value }

// Stop sequence command
stop_sequence_command = { "stop" ~ "sequence" ~ sequence_name }

// Inline loop - allows repeating a block of cues inline
inline_loop = { "loop" ~ "{" ~ inline_loop_cue* ~ "}" ~ ","? ~ "repeats" ~ ":" ~ number_value }

// Cues inside an inline loop (timing is relative to loop start)
inline_loop_cue = { (time_string | measure_time) ~ (effect | layer_command | stop_sequence_command | offset_command | reset_measures_command)* }

// Measure offset commands
offset_command = { "offset" ~ number_value ~ "measures" }
reset_measures_command = { "reset_measures" }

// Layer control commands (grandMA-inspired)
layer_command = { layer_command_type ~ "(" ~ layer_command_params? ~ ")" }

layer_command_type = { "release" | "clear" | "freeze" | "unfreeze" | "master" }

layer_command_params = { layer_command_param ~ ("," ~ layer_command_param)* }

layer_command_param = { layer_command_param_name ~ ":" ~ layer_command_param_value }

layer_command_param_name = { "layer" | "time" | "intensity" | "speed" }

layer_command_param_value = { layer_parameter | time_parameter | percentage | number_value }

time_string = @{ "@" ~ (time_mm_ss_mmm | time_ss_mmm) }

measure_time = @{ "@" ~ ASCII_DIGIT+ ~ "/" ~ ASCII_DIGIT+ ~ ("." ~ ASCII_DIGIT+)? }

time_mm_ss_mmm = @{ ASCII_DIGIT+ ~ ":" ~ ASCII_DIGIT+ ~ "." ~ ASCII_DIGIT+ }

time_ss_mmm = @{ ASCII_DIGIT+ ~ "." ~ ASCII_DIGIT+ }

// Parameters are optional; support both `effect_type param: v` and `effect_type, param: v`
effect = { group_list ~ ":" ~ effect_type ~ (","? ~ parameters)? }

group_list = { group_name ~ ("," ~ group_name)* }

group_name = { (ASCII_ALPHANUMERIC | "_" | "-")+ }

effect_type = { "static" | "cycle" | "strobe" | "pulse" | "chase" | "dimmer" | "rainbow" }

parameters = { parameter ~ ("," ~ parameter)* }

parameter = { parameter_name ~ ":" ~ parameter_value }

// Prefer identifiers for names; allow quoted names when needed
parameter_name = { identifier | string }

parameter_value = { 
    percentage |
    time_parameter |
    direction_parameter |
    chase_pattern_parameter |
    loop_parameter |
    step_parameter |
    transition_parameter |
    layer_parameter |
    blend_mode_parameter |
    color_parameter |
    number_value |
    string |
    bare_identifier
}

// Restrictive bare identifier to avoid swallowing typos
// Atomic to prevent backtracking issues when it's the last parameter
bare_identifier = @{ (ASCII_ALPHA | "_") ~ (ASCII_ALPHANUMERIC | "_" | "-")* }

color_parameter = { 
    quoted_hex_color |
    quoted_rgb_color |
    hex_color | 
    rgb_color | 
    named_color
}

hex_color = @{ "#" ~ ASCII_HEX_DIGIT{6} }

// RGB color with optional spaces after commas - make atomic to control whitespace
rgb_color = @{ "rgb(" ~ ASCII_DIGIT+ ~ "," ~ " "? ~ ASCII_DIGIT+ ~ "," ~ " "? ~ ASCII_DIGIT+ ~ ")" }

named_color = { "\"" ~ ("red" | "green" | "blue" | "white" | "black" | "yellow" | "cyan" | "magenta" | "orange" | "purple") ~ "\"" }

quoted_hex_color = @{ "\"" ~ "#" ~ ASCII_HEX_DIGIT{6} ~ "\"" }

quoted_rgb_color = @{ "\"" ~ "rgb(" ~ ASCII_DIGIT+ ~ "," ~ " "? ~ ASCII_DIGIT+ ~ "," ~ " "? ~ ASCII_DIGIT+ ~ ")" ~ "\"" }

// Time-based parameters (duration, fade, etc.) all use the same format
// Supports absolute time (ms, s) and musical time (beats, measures)
time_parameter = @{ time_value ~ time_unit }

direction_parameter = {
    "forward" | "backward" | "random" | "pingpong" |
    "left_to_right" | "right_to_left" |
    "top_to_bottom" | "bottom_to_top" |
    "clockwise" | "counter_clockwise"
}

chase_pattern_parameter = { "linear" | "snake" | "random" }

loop_parameter = { "once" | "loop" | "pingpong" | "random" }

step_parameter = { "[" ~ step_list ~ "]" }

step_list = { step ~ ("," ~ step)* }

step = { "{" ~ step_content ~ "}" }

step_content = { step_param ~ ("," ~ step_param)* }

step_param = { parameter_name ~ ":" ~ parameter_value }

transition_parameter = { "snap" | "fade" | "crossfade" }

layer_parameter = { "background" | "midground" | "foreground" }

blend_mode_parameter = { "replace" | "multiply" | "add" | "overlay" | "screen" }

percentage = @{ ASCII_DIGIT+ ~ "%" }

// Canonical number form reused across numeric parameters
number_value = { ASCII_DIGIT+ ~ ("." ~ ASCII_DIGIT+)? }

time_value = { number_value }

time_unit = @{ "measures" | "measure" | "beats" | "beat" | "ms" | "s" }

string = { "\"" ~ (!"\"" ~ ANY)* ~ "\"" }

identifier = { (ASCII_ALPHA | "_") ~ (ASCII_ALPHANUMERIC | "_" | "-")* }

// Tempo section rules
tempo = { "tempo" ~ "{" ~ tempo_content ~ "}" }

tempo_content = { (tempo_start | tempo_bpm | tempo_time_signature | tempo_changes)* }

tempo_start = { "start" ~ ":" ~ time_parameter }

tempo_bpm = { "bpm" ~ ":" ~ number_value }

tempo_time_signature = { "time_signature" ~ ":" ~ time_sig_value }

time_sig_value = @{ ASCII_DIGIT+ ~ "/" ~ ASCII_DIGIT+ }

tempo_changes = { "changes" ~ ":" ~ "[" ~ tempo_change_list? ~ "]" }

tempo_change_list = { tempo_change ~ ("," ~ tempo_change)* }

tempo_change = { (time_string | measure_time) ~ "{" ~ tempo_change_content ~ "}" }

tempo_change_content = { tempo_change_param ~ ("," ~ tempo_change_param)* }

tempo_change_param = { 
    tempo_change_bpm | tempo_change_time_signature | tempo_change_transition
}

tempo_change_bpm = { "bpm" ~ ":" ~ number_value }

tempo_change_time_signature = { "time_signature" ~ ":" ~ time_sig_value }

tempo_change_transition = { "transition" ~ ":" ~ tempo_transition_duration }

tempo_transition_duration = { tempo_transition_measures | tempo_transition_beats | tempo_transition_snap }

tempo_transition_measures = @{ ASCII_DIGIT+ ~ ("." ~ ASCII_DIGIT+)? ~ "m" }

tempo_transition_beats = { number_value }

tempo_transition_snap = { "snap" }

COMMENT = _{ ("//" | "#") ~ (!"\n" ~ ANY)* }

// Fixture type rules
fixture_type = { "fixture_type" ~ fixture_type_name ~ "{" ~ fixture_type_content ~ "}" }

fixture_type_name = { "\"" ~ (!"\"" ~ ANY)* ~ "\"" }

fixture_type_content = {
    (channels | channel_map | max_strobe_frequency | min_strobe_frequency | strobe_dmx_offset | special_cases)*
}

channels = { "channels" ~ ":" ~ number_value }

max_strobe_frequency = { "max_strobe_frequency" ~ ":" ~ number_value }

min_strobe_frequency = { "min_strobe_frequency" ~ ":" ~ number_value }

strobe_dmx_offset = { "strobe_dmx_offset" ~ ":" ~ number_value }

channel_map = { "channel_map" ~ ":" ~ "{" ~ channel_mapping_list ~ "}" }

channel_mapping_list = { channel_mapping ~ ("," ~ channel_mapping)* }

channel_mapping = { channel_name ~ ":" ~ channel_number }

channel_name = { "\"" ~ (!"\"" ~ ANY)* ~ "\"" }

channel_number = { number_value }

special_cases = { "special_cases" ~ ":" ~ "[" ~ special_case_list ~ "]" }

special_case_list = { special_case ~ ("," ~ special_case)* }

special_case = { "\"" ~ (!"\"" ~ ANY)* ~ "\"" }

// Venue rules - support both simple and complex formats
venue = { "venue" ~ string ~ "{" ~ venue_content ~ "}" }

venue_content = { (fixture | group)* }

fixture = { "fixture" ~ string ~ identifier ~ "@" ~ universe_num ~ ":" ~ address_num ~ tags? }

universe_num = { ASCII_DIGIT+ }
address_num = { ASCII_DIGIT+ }

tags = { "tags" ~ tag_list }
tag_list = { "[" ~ string ~ ("," ~ string)* ~ "]" }

group = { "group" ~ string ~ "=" ~ identifier_list }

identifier_list = { identifier ~ ("," ~ identifier)* }

WHITESPACE = _{ " " | "\t" | "\r" | "\n" | COMMENT }