diff --git a/main.py b/main.py index 1ecf2a2..22d14e3 100644 --- a/main.py +++ b/main.py @@ -39,4 +39,4 @@ if __name__ == "__main__": print("Parsed: " + parse_expression(expressions[ex]).text) except Exception as e: print(f"[red]Failed on {ex}[/red]") - # print(f"[red]Failed on {ex}[/red]: {str(e)[0:40]}...") + # print(f"[red]Failed on {ex}[/red]: {str(e)[0:40]}...") diff --git a/tests/test_parser.py b/tests/test_parser.py index ed8126b..e1b1620 100644 --- a/tests/test_parser.py +++ b/tests/test_parser.py @@ -46,8 +46,8 @@ def test_parsing_text(pattern: str): ("q2 eq3", [2, 3]), ], ) -def test_pcs(pattern: str, expected: list): - assert parse_expression(pattern).pcs() == expected +def test_pitch_classes(pattern: str, expected: list): + assert parse_expression(pattern).pitch_classes() == expected # TODO: Add tests for octaves # ("__6 _0 _1 _2 _3 _4 _5 _6 0 1 2 3 4 5 6 ^0 ^1 ^2 ^3 ^4 ^5 ^6 ^^0", [-2, -1, -1, -1, -1, -1, -1, -1, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 2]), diff --git a/tests/test_scale.py b/tests/test_scale.py index 23f765b..bc2c7af 100644 --- a/tests/test_scale.py +++ b/tests/test_scale.py @@ -15,11 +15,11 @@ from ziffers import scale ], ) def test_notenames(name: str, expected: int): - assert scale.note_to_midi(name) == expected + assert scale.note_name_to_midi(name) == expected @pytest.mark.parametrize( - "pcs,expected", + "pitch_classes,expected", [ ( list(range(-9, 10)), @@ -47,7 +47,7 @@ def test_notenames(name: str, expected: int): ), ], ) -def test_note_to_midi(pcs: str, expected: int): +def test_note_to_midi(pitch_classes: str, expected: int): assert [ - scale.note_from_pc(root=60, pitch_class=val, intervals="Ionian") for val in pcs + scale.note_from_pc(root=60, pitch_class=val, intervals="Ionian") for val in pitch_classes ] == expected diff --git a/ziffers/classes.py b/ziffers/classes.py index 1a241a4..da55d21 100644 --- a/ziffers/classes.py +++ b/ziffers/classes.py @@ -1,5 +1,5 @@ """ Ziffers classes for the parsed notation """ -from dataclasses import dataclass, field +from dataclasses import dataclass, field, replace import itertools import operator import random @@ -7,10 +7,17 @@ from .defaults import DEFAULT_OPTIONS from .scale import note_from_pc, midi_to_pitch_class -@dataclass +@dataclass(kw_only=True) class Meta: """Abstract class for all Ziffers items""" + kwargs: dict = field(default=None, repr=False) + + def __post_init__(self): + if self.kwargs: + for key, val in self.kwargs.items(): + setattr(self, key, val) + def update(self, new_values): """Update attributes from dict""" for key, value in new_values.items(): @@ -25,21 +32,30 @@ class Meta: setattr(self, key, value) -@dataclass +@dataclass(kw_only=True) class Item(Meta): """Class for all Ziffers text based items""" - text: str + text: str = field(default=None) + + def get_item(self): + """Return the item""" + return self -@dataclass -class Whitespace(Item): +@dataclass(kw_only=True) +class Whitespace: """Class for whitespace""" + text: str item_type: str = field(default=None, repr=False, init=False) + def get_item(self): + """Returns None. Used in filtering""" + return None -@dataclass + +@dataclass(kw_only=True) class DurationChange(Item): """Class for changing duration""" @@ -57,7 +73,7 @@ class OctaveChange(Item): item_type: str = field(default="change", repr=False, init=False) -@dataclass +@dataclass(kw_only=True) class OctaveAdd(Item): """Class for modifying octave""" @@ -66,42 +82,94 @@ class OctaveAdd(Item): item_type: str = field(default="add", repr=False, init=False) -@dataclass +@dataclass(kw_only=True) class Event(Item): """Abstract class for events with duration""" duration: float = field(default=None) -@dataclass +@dataclass(kw_only=True) class Pitch(Event): """Class for pitch in time""" - pitch_class: int = field(default=None) + pitch_class: int octave: int = field(default=None) modifier: int = field(default=0) note: int = field(default=None) + key: str = field(default=None) + scale: str | list = field(default=None) - def set_note(self, note: int): + def __post_init__(self): + super().__post_init__() + if self.text is None: + self.text = str(self.pitch_class) + self.update_note() + + def update_note(self): + """Update note if Key, Scale and Pitch-class is present + """ + if ( + (self.key is not None) + and (self.scale is not None) + and (self.pitch_class is not None) + and (self.note is None) + ): + note = note_from_pc( + root=self.key, + pitch_class=self.pitch_class, + intervals=self.scale, + modifier=self.modifier if self.modifier is not None else 0, + octave=self.octave if self.octave is not None else 0, + ) + self.set_note(note) + + def set_note(self, note: int) -> int: + """Sets a note for the pitch and returns the note. + + Args: + note (int): Midi note + + Returns: + int: Returns the saved note + """ self.note = note return note + # pylint: disable=locally-disabled, unused-argument + def get_value(self, re_eval=False) -> int: + """Returns the pitch class -@dataclass + Returns: + int: Integer value for the pitch + """ + return self.pitch_class + + +@dataclass(kw_only=True) class RandomPitch(Event): """Class for random pitch""" pitch_class: int = field(default=None) + # pylint: disable=locally-disabled, unused-argument + def get_value(self, re_eval=False) -> int: + """Return random value -@dataclass + Returns: + int: Returns random pitch + """ + return self.pitch_class + + +@dataclass(kw_only=True) class RandomPercent(Item): """Class for random percent""" percent: float = field(default=None) -@dataclass +@dataclass(kw_only=True) class Chord(Event): """Class for chords""" @@ -113,7 +181,7 @@ class Chord(Event): self.notes = notes -@dataclass +@dataclass(kw_only=True) class RomanNumeral(Event): """Class for roman numbers""" @@ -123,46 +191,49 @@ class RomanNumeral(Event): pitch_classes: list = None def set_notes(self, chord_notes: list[int]): + """Set notes to roman numeral + + Args: + chord_notes (list[int]): List of notes in midi to be added + """ self.notes = chord_notes def set_pitch_classes(self, pitches: list[tuple]): - if self.pitch_classes == None: + """Set pitch classes to roman numeral + + Args: + pitches (list[tuple]): Pitch classes to be added + """ + if self.pitch_classes is None: self.pitch_classes = [] for pitch in pitches: self.pitch_classes.append(Pitch(**pitch)) -@dataclass +@dataclass(kw_only=True) class Function(Event): """Class for functions""" run: str = field(default=None) -@dataclass +@dataclass(kw_only=True) class Sequence(Meta): """Class for sequences of items""" - values: list[Item] + values: list text: str = field(default=None) wrap_start: str = field(default=None, repr=False) wrap_end: str = field(default=None, repr=False) local_index: int = field(default=0, init=False) + evaluation: bool = field(default=False, init=False) def __post_init__(self): + super().__post_init__() self.text = self.__collect_text() - def __iter__(self): - return self - - def __next__(self): - if self.local_index < len(self.values): - next_item = self.values[self.local_index] - self.local_index += 1 - return next_item - - self.local_index = 0 - raise StopIteration + def __getitem__(self, index): + return self.values[index] def update_values(self, new_values): """Update value attributes from dict""" @@ -180,77 +251,183 @@ class Sequence(Meta): text = text + self.wrap_end return text - def flatten_values(self): - """Flattens the Ziffers object tree""" + def evaluate_tree(self, options=None, re_eval=False): + """Evaluates and flattens the Ziffers object tree""" for item in self.values: if isinstance(item, Sequence): - yield from item.flatten_values() + if item.evaluation: + yield from item.evaluate(options, re_eval) + else: + yield from item.evaluate_tree(options, re_eval) else: - yield item + # Get value / generated value from the item + current = item.get_item() + # Ignore items that returns None + if current is not None: + if isinstance(current, (DurationChange, OctaveChange, OctaveAdd)): + options = self.__update_options(current, options) + else: + if set(("key", "scale")) <= options.keys(): + if isinstance(current, (Pitch, RandomPitch, RandomInteger)): + current = self.__update_pitch(current, options, re_eval) + elif isinstance(current, Chord): + current = self.__update_chord(current, options) + elif isinstance(current, RomanNumeral): + current = self.__create_chord_from_roman( + current, options + ) + current.update_new(options) + yield current + + def filter(self, keep: tuple): + """Filter out items from sequence. + + Args: + keep (tuple): Tuple describing classes to keep + + Returns: + Sequence: Copy of the sequence with filtered values. + """ + return replace( + self, values=[item for item in self.values if isinstance(item, keep)] + ) + + def __update_options(self, current: Item, options: dict) -> dict: + """Update options based on current item + + Args: + current (Item): Current item like Duration change, Octave change etc. + options (dict): Current options + + Returns: + dict: Updated options + """ + if current.item_type == "change": # Change options + options[current.key] = current.value + elif current.item_type == "add": + if current.key in options: # Add to existing value + options[current.key] += current.value + else: # Create value if not existing + options[current.key] = current.value + return options + + def __update_pitch(self, current: Item, options: dict, re_eval: bool = False) -> dict: + """Update pich based on optons + + Args: + current (Item): _description_ + options (dict): _description_ + + Returns: + dict: _description_ + """ + if hasattr(current, "modifier"): + c_modifier = 0 + elif options["modifier"]: + c_modifier = options["modifier"] + else: + c_modifier = 0 + + if hasattr(current, "octave"): + c_octave = 0 + elif options["octave"]: + c_octave = options["octave"] + else: + c_octave = 0 + + note = note_from_pc( + root=options["key"], + pitch_class=current.get_value(re_eval), + intervals=options["scale"], + modifier=c_modifier, + octave=c_octave, + ) + new_pitch = Pitch( + pitch_class=current.get_value(re_eval), + text=str(current.get_value(re_eval)), + note=note, + octave=c_octave, + modifier=c_modifier, + kwargs=options, + ) + return new_pitch + + def __update_chord(self, current: Chord, options: dict) -> Chord: + """Update chord based on options + + Args: + current (Chord): Current chord object + options (dict): Options + re (bool, optional): Re-evaluation flag. Defaults to False. + + Returns: + Chord: Returns updated chord + """ + pcs = current.pitch_classes + notes = [ + pc.set_note(note_from_pc(options["key"], pc.pitch_class, options["scale"])) + for pc in pcs + ] + current.set_notes(notes) + return current + + def __create_chord_from_roman(self, current: RomanNumeral, options: dict) -> Chord: + """Create chord fom roman numeral + + Args: + current (RomanNumeral): Current roman numeral + options (dict): Options + re (bool, optional): Re-evaluation flag. Defaults to False. + + Returns: + Chord: New chord created from Roman numeral + """ + key = options["key"] + scale = options["scale"] + pitches = [midi_to_pitch_class(note, key, scale) for note in current.notes] + chord_notes = [ + note_from_pc( + root=key, + pitch_class=pitch, + intervals=scale, + modifier=current.modifier if hasattr(current, "modifier") else 0, + ) + for pitch in pitches + ] + chord = Chord(text="".join(pitches), pitch_classes=pitches, notes=chord_notes) + return chord -@dataclass +@dataclass(kw_only=True) class Ziffers(Sequence): """Main class for holding options and the current state""" options: dict = field(default_factory=DEFAULT_OPTIONS) loop_i: int = 0 - iterator: iter = field(default=None, repr=False) - current: Whitespace | DurationChange | OctaveChange | OctaveAdd = field( - default=None - ) + iterator = None + current: Item = field(default=None) - def __post_init__(self): - super().__post_init__() - self.iterator = self.flatten_values() + def __iter__(self): + return self def __next__(self): self.current = next(self.iterator) - - # Skip whitespace and collect duration & octave changes - while isinstance( - self.current, (Whitespace, DurationChange, OctaveChange, OctaveAdd) - ): - if self.current.item_type == "change": # Change options - self.options[self.current.key] = self.current.value - elif self.current.item_type == "add": - if self.current.key in self.options: # Add to existing value - self.options[self.current.key] += self.current.value - else: # Create value if not existing - self.options[self.current.key] = self.current.value - - self.current = next(self.iterator) # Skip item - - # Update collected options & default options - self.current.update_new(self.options) - - # Resolve note(s) from scale - if set(("key", "scale")) <= self.options.keys(): - key = self.options["key"] - scale = self.options["scale"] - if isinstance(self.current, (Pitch, RandomPitch)): - note = note_from_pc( - root=key, - pitch_class=self.current.pitch_class, - intervals=scale, - modifier=self.current.modifier, - ) - self.current.set_note(note) - elif isinstance(self.current, Chord): - pcs = self.current.pitch_classes - notes = [ - pc.set_note(note_from_pc(key, pc.pitch_class, scale)) for pc in pcs - ] - self.current.set_notes(notes) - elif isinstance(self.current, RomanNumeral): - pitch_classes = [ - midi_to_pitch_class(note, key, scale) for note in self.current.notes - ] - self.current.set_pitch_classes(pitch_classes) - self.loop_i += 1 return self.current + def init_opts(self, options): + """Evaluate the Ziffers tree using the options""" + self.options = options + self.iterator = iter(self.evaluate_tree(self.options)) + + def re_eval(self, options): + """Re-evaluate the iterator""" + self.iterator = iter(self.evaluate_tree(options, True)) + + def get_list(self): + """Return list""" + return list(self) + def take(self, num: int) -> list[Pitch]: """Take number of pitch classes from the parsed sequence. Cycles from the beginning. @@ -263,7 +440,8 @@ class Ziffers(Sequence): return list(itertools.islice(itertools.cycle(self), num)) def loop(self) -> iter: - return itertools.cycle(self.iterator) + """Return cyclic loop""" + return itertools.cycle(iter(self)) def set_defaults(self, options: dict): """Sets options for the parser @@ -293,7 +471,7 @@ class Ziffers(Sequence): return [val.octave for val in self.values if isinstance(val, Pitch)] -@dataclass +@dataclass(kw_only=True) class ListSequence(Sequence): """Class for Ziffers list sequences""" @@ -301,14 +479,19 @@ class ListSequence(Sequence): wrap_end: str = field(default=")", repr=False) -@dataclass +@dataclass(kw_only=True) class Integer(Item): """Class for integers""" value: int + # pylint: disable=locally-disabled, unused-argument + def get_value(self, re_eval=False): + """Return value of the integer""" + return self.value -@dataclass + +@dataclass(kw_only=True) class RandomInteger(Item): """Class for random integer""" @@ -316,17 +499,19 @@ class RandomInteger(Item): max: int def __post_init__(self): + super().__post_init__() if self.min > self.max: new_max = self.min self.min = self.max self.max = new_max - def value(self): + # pylint: disable=locally-disabled, unused-argument + def get_value(self, re_eval=False): """Evaluate the random value for the generator""" return random.randint(self.min, self.max) -@dataclass +@dataclass(kw_only=True) class RepeatedListSequence(Sequence): """Class for Ziffers list sequences""" @@ -335,32 +520,47 @@ class RepeatedListSequence(Sequence): wrap_end: str = field(default=":)", repr=False) -@dataclass +@dataclass(kw_only=True) class Subdivision(Item): """Class for subdivisions""" values: list[Event] -@dataclass -class Cyclic(Sequence): +@dataclass(kw_only=True) +class Cyclic(Item): """Class for cyclic sequences""" + values: list cycle: int = 0 wrap_start: str = field(default="<", repr=False) wrap_end: str = field(default=">", repr=False) - def __next__(self): - yield self.values[self.cycle % len(self.cycle)] - self.cycle += 1 - raise StopIteration + def __post_init__(self): + super().__post_init__() + self.text = self.__collect_text() + self.values = [val for val in self.values if not isinstance(val, Whitespace)] - def value(self): + def __collect_text(self) -> str: + """Collect text value from values""" + text = "".join([val.text for val in self.values]) + if self.wrap_start is not None: + text = self.wrap_start + text + if self.wrap_end is not None: + text = text + self.wrap_end + return text + + def get_value(self, re_eval=False): """Get the value for the current cycle""" - return self.values[self.cycle % len(self.cycle)] + value = self.values[self.cycle % len(self.values)] + if re_eval: # If re-evaluated + self.cycle = 0 + else: + self.cycle += 1 + return value -@dataclass +@dataclass(kw_only=True) class Range(Item): """Class for range""" @@ -368,35 +568,62 @@ class Range(Item): end: int = field(default=None) -ops = { - "+": operator.add, - "-": operator.sub, - "*": operator.mul, - "/": operator.truediv, - "%": operator.mod, -} - - -@dataclass +@dataclass(kw_only=True) class Operator(Item): """Class for math operators""" - value: ... = field(init=False, repr=False) - - def __post_init__(self): - self.value = ops[self.text] + value: ... -@dataclass +@dataclass(kw_only=True) class ListOperation(Sequence): """Class for list operations""" - def run(self): - """Run operations""" - pass + def __post_init__(self): + super().__post_init__() + self.evaluation = True + + def filter_operation(self, values): + """Filtering for the operation elements""" + keep = (Sequence, Event, RandomInteger, Integer, Cyclic) + for item in values: + if isinstance(item, Sequence): + yield item.filter(keep) + elif isinstance(item, keep): + yield item + + def evaluate(self, options: dict, re_eval=False): + """Evaluates the operation""" + operators = self.values[1::2] # Fetch every second operator element + values = self.values[::2] # Fetch every second list element + values = list(self.filter_operation(values)) # Filter out crap + result = values[0] # Start results with the first array + for i, operand in enumerate(operators): + operation = operand.value + right_value = values[i + 1] + if isinstance(right_value, Sequence): + result = [ + Pitch( + pitch_class=operation(x.get_value(re_eval), y.get_value(re_eval)), + kwargs=options, + ) + for x in result + for y in right_value + ] + else: + result = [ + Pitch( + pitch_class=operation( + x.get_value(re_eval), right_value.get_value(re_eval) + ), + kwargs=options, + ) + for x in result + ] + return Sequence(values=result) -@dataclass +@dataclass(kw_only=True) class Operation(Item): """Class for lisp-like operations: (+ 1 2 3) etc.""" @@ -404,7 +631,7 @@ class Operation(Item): operator: operator -@dataclass +@dataclass(kw_only=True) class Eval(Sequence): """Class for evaluation notation""" @@ -417,14 +644,14 @@ class Eval(Sequence): self.result = eval(self.text) -@dataclass +@dataclass(kw_only=True) class Atom(Item): """Class for evaluable atoms""" value: ... -@dataclass +@dataclass(kw_only=True) class Euclid(Item): """Class for euclidean cycles""" @@ -435,7 +662,7 @@ class Euclid(Item): rotate: int = field(default=None) -@dataclass +@dataclass(kw_only=True) class RepeatedSequence(Sequence): """Class for repeats""" diff --git a/ziffers/defaults.py b/ziffers/defaults.py index e4062db..f404421 100644 --- a/ziffers/defaults.py +++ b/ziffers/defaults.py @@ -1,4 +1,5 @@ """ Default options for Ziffers """ +import operator DEFAULT_DURS = { "m": 8.0, # 15360/1920 @@ -82,6 +83,14 @@ MODIFIERS = { ROMANS = {"i": 1, "v": 5, "x": 10, "l": 50, "c": 100, "d": 500, "m": 1000} +OPERATORS = { + "+": operator.add, + "-": operator.sub, + "*": operator.mul, + "/": operator.truediv, + "%": operator.mod, +} + # pylint: disable=locally-disabled, too-many-lines SCALES = { diff --git a/ziffers/mapper.py b/ziffers/mapper.py index 587ca54..2a7dcda 100644 --- a/ziffers/mapper.py +++ b/ziffers/mapper.py @@ -29,7 +29,7 @@ from .classes import ( RepeatedSequence, ) from .common import flatten, sum_dict -from .defaults import DEFAULT_DURS +from .defaults import DEFAULT_DURS, OPERATORS from .scale import parse_roman, chord_from_roman_numeral @@ -43,8 +43,8 @@ class ZiffersTransformer(Transformer): def start(self, items) -> Ziffers: """Root for the rules""" - seq = Sequence(values=items[0]) - return Ziffers(values=seq, options={}) + # seq = Sequence(values=items[0]) + return Ziffers(values=items[0], options={}) def sequence(self, items): """Flatten sequence""" @@ -53,7 +53,7 @@ class ZiffersTransformer(Transformer): def random_integer(self, item) -> RandomInteger: """Parses random integer syntax""" val = item[0][1:-1].split(",") - return RandomInteger(min=val[0], max=val[1], text=item[0].value) + return RandomInteger(min=int(val[0]), max=int(val[1]), text=item[0].value) def range(self, item) -> Range: """Parses range syntax""" @@ -284,14 +284,14 @@ class ZiffersTransformer(Transformer): ) return seq - def SIGNED_NUMBER(self, token): + def NUMBER(self, token): """Parse integer""" val = token.value return Integer(text=val, value=int(val)) def number(self, item): """Return partial number (Integer or RandomInteger)""" - return item + return item[0] def cyclic_number(self, item): """Parse cyclic notation""" @@ -310,7 +310,7 @@ class ZiffersTransformer(Transformer): def operator(self, token): """Parse operator""" val = token[0].value - return Operator(text=val) + return Operator(text=val, value=OPERATORS[val]) def list_items(self, items): """Parse sequence""" @@ -320,6 +320,10 @@ class ZiffersTransformer(Transformer): """Parse list operation""" return ListOperation(values=items) + def right_op(self,items): + """Get right value for the operation""" + return items[0] + def euclid(self, items): """Parse euclid notation""" params = items[1][1:-1].split(",") diff --git a/ziffers/parser.py b/ziffers/parser.py index 683d5e0..4e5c7dd 100644 --- a/ziffers/parser.py +++ b/ziffers/parser.py @@ -41,58 +41,14 @@ def zparse(expr: str, **opts) -> Ziffers: """ parsed = parse_expression(expr) if opts: - parsed.set_defaults(opts) + parsed.init_opts(opts) return parsed # pylint: disable=invalid-name -def z0(expr: str, **opts) -> Ziffers: - """Shortened method name for zparse""" - return zparse(expr, **opts) - - -def z1(expr: str, **opts) -> Ziffers: - """Shortened method name for zparse""" - return zparse(expr, **opts) - - -def z2(expr: str, **opts) -> Ziffers: - """Shortened method name for zparse""" - return zparse(expr, **opts) - - -def z3(expr: str, **opts) -> Ziffers: - """Shortened method name for zparse""" - return zparse(expr, **opts) - - -def z4(expr: str, **opts) -> Ziffers: - """Shortened method name for zparse""" - return zparse(expr, **opts) - - -def z5(expr: str, **opts) -> Ziffers: - """Shortened method name for zparse""" - return zparse(expr, **opts) - - -def z6(expr: str, **opts) -> Ziffers: - """Shortened method name for zparse""" - return zparse(expr, **opts) - - -def z7(expr: str, **opts) -> Ziffers: - """Shortened method name for zparse""" - return zparse(expr, **opts) - - -def z8(expr: str, **opts) -> Ziffers: - """Shortened method name for zparse""" - return zparse(expr, **opts) - - -def z9(expr: str, **opts) -> Ziffers: +def z(expr: str, **opts) -> Ziffers: """Shortened method name for zparse""" return zparse(expr, **opts) + \ No newline at end of file diff --git a/ziffers/scale.py b/ziffers/scale.py index bc24428..2d8f035 100644 --- a/ziffers/scale.py +++ b/ziffers/scale.py @@ -209,7 +209,7 @@ def midi_to_pitch_class(note: int, key: str | int, scale: str) -> dict: scale (str): Used scale Returns: - tuple: Returns tuple containing (pitch class as string, pitch class, octave, optional modifier) + tuple: Returns dict containing pitch-class values """ pitch_class = note % 12 octave = midi_to_octave(note) - 5 diff --git a/ziffers/ziffers.lark b/ziffers/ziffers.lark index fe81493..bccf0e1 100644 --- a/ziffers/ziffers.lark +++ b/ziffers/ziffers.lark @@ -18,7 +18,7 @@ ?roman_number: /iv|v|v?i{1,3}/ // Valid as integer - ?number: NUMBER | random_integer | cyclic_number + number: NUMBER | random_integer | cyclic_number cyclic_number: "<" number (WS number)* ">" // Repeats @@ -29,7 +29,8 @@ repeated_list: prefix* "(:" sequence ":" [number] ")" // Right recursive list operation - list_op: list (operator (list | number))+ + list_op: list (operator right_op)+ + right_op: list | number operator: /([\+\-\*\/%]|<<|>>)/ // Euclidean cycles @@ -64,8 +65,8 @@ random_integer: /\(-?[0-9]+,-?[0-9]+\)/ range: /-?[0-9]\.\.-?[0-9]/ cycle: "<" sequence ">" - random_pitch: "?" - random_percent: "%" + random_pitch: /(\?)(?!\d)/ + random_percent: /(%)(?!\d)/ // Rules for evaluating clauses inside {} // TODO: Support for parenthesis?