diff --git a/ziffers/classes/__init__.py b/ziffers/classes/__init__.py new file mode 100644 index 0000000..a250516 --- /dev/null +++ b/ziffers/classes/__init__.py @@ -0,0 +1,3 @@ +from .items import * +from .sequences import * +from .root import * diff --git a/ziffers/classes/items.py b/ziffers/classes/items.py new file mode 100644 index 0000000..72d7730 --- /dev/null +++ b/ziffers/classes/items.py @@ -0,0 +1,592 @@ +""" Ziffers item classes """ +from dataclasses import dataclass, field, asdict +import operator +import random +from ..scale import ( + note_from_pc, + midi_to_pitch_class, + midi_to_freq, + get_scale_length, + chord_from_degree, +) +from ..common import repeat_text + + +@dataclass(kw_only=True) +class Meta: + """Abstract class for all Ziffers items""" + + kwargs: dict = field(default=None, repr=False) + local_options: dict = field(default_factory=dict) + + def __post_init__(self): + if self.kwargs: + self.update_options(self.kwargs) + + def replace_options(self, new_values): + """Replaces attribute values from dict""" + for key, value in new_values.items(): + if hasattr(self, key): + setattr(self, key, value) + + def update_options(self, options): + """Updates attribute values only if value is None""" + merged_options = self.local_options | options + for key, value in merged_options.items(): + if hasattr(self, key): + if key == "octave": + local_value = self.local_options.get("octave", False) + oct_change = self.local_options.get("octave_change", False) + if oct_change: + setattr(self, key, oct_change) + elif local_value: + setattr(self, key, value + local_value) + elif getattr(self, key) is None: + setattr(self, key, value) + elif getattr(self, key) is None: + local_value = self.local_options.get(key, False) + if local_value: + setattr(self, key, local_value) + else: + setattr(self, key, value) + + def dict(self): + """Returns safe dict from the dataclass""" + return {k: str(v) for k, v in asdict(self).items()} + + +@dataclass(kw_only=True) +class Item(Meta): + """Class for all Ziffers text based items""" + + text: str = field(default=None) + measure: int = field(default=0, init=False) + + def get_updated_item(self, options: dict): + """Get updated item with replaced options + + Args: + options (dict): Options as a dict + + Returns: + Item: Returns updated item + """ + self.replace_options(options) + return self + + def get_options(self) -> dict: + """Return local options from item + + Returns: + dict: Options as a dict + """ + keys = ["octave", "modifier", "key", "scale", "duration"] + return {key: getattr(self, key) for key in keys if hasattr(self, key)} + + +@dataclass(kw_only=True) +class Whitespace: + """Class for whitespace""" + + text: str + + +@dataclass(kw_only=True) +class Modification(Item): + """Superclass for pitch modifications""" + + key: str + value: ... + + def as_options(self): + """Return modification as a dict""" + return {self.key: self.value} + + +@dataclass(kw_only=True) +class DurationChange(Modification): + """Class for changing duration""" + + value: float + key: str = field(default="duration", repr=False, init=False) + + +@dataclass +class OctaveChange(Modification): + """Class for changing octave""" + + value: int + key: str = field(default="octave", repr=False, init=False) + + +@dataclass(kw_only=True) +class OctaveAdd(Modification): + """Class for modifying octave""" + + value: int + key: str = field(default="octave", repr=False, init=False) + + +@dataclass(kw_only=True) +class Event(Item): + """Abstract class for events with duration""" + + duration: float = field(default=None) + + +@dataclass +class Rest(Event): + """Class for rests""" + + +@dataclass +class Measure(Item): + """Class for measures/bars. Used to reset default options.""" + + text: str = field(default="|", init=False) + + def reset_options(self, options: dict): + """Reset options when measure changes""" + next_measure = options.get("measure", 0) + 1 + start_options = options["start_options"].copy() + options.clear() + options.update(start_options) + options["measure"] = next_measure + options["start_options"] = start_options.copy() + self.measure = next_measure + + +@dataclass(kw_only=True) +class Pitch(Event): + """Class for pitch in time""" + + pitch_class: int + octave: int = field(default=None) + modifier: int = field(default=None) + note: int = field(default=None) + key: str = field(default=None) + scale: str | list = field(default=None) + freq: float = field(default=None) + beat: float = field(default=None) + + def __post_init__(self): + super().__post_init__() + if self.text is None: + self.text = str(self.pitch_class) + self.update_note() + # self._update_text() + + def _update_text(self): + if self.octave is not None: + self.text = repeat_text("^", "_", self.octave) + self.text + if self.modifier is not None: + self.text = repeat_text("#", "b", self.modifier) + self.text + + def get_note(self): + """Getter for note""" + return self.note + + def get_freq(self): + """Getter for freq""" + return self.freq + + def get_octave(self): + """Getter for octave""" + return self.octave + + def get_beat(self): + """Getter for beat""" + return self.beat + + def get_pitch_class(self): + """Getter for pitche""" + return self.pitch_class + + def get_duration(self): + """Getter for duration""" + return self.duration + + def update_note(self, force: bool = False): + """Update note if Key, Scale and Pitch-class are 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 or force) + ): + 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.freq = midi_to_freq(note) + self.note = note + if self.duration is not None: + self.beat = self.duration * 4 + + def check_note(self, options: dict): + """Check for note modification""" + if "key" in options and self.key is not options["key"]: + self.key = options["key"] + edit = True + if "scale" in options and self.scale is not options["scale"]: + self.scale = options["scale"] + edit = True + if edit: + self.update_note(True) + + 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 + + def set_freq(self, freq: float): + """Set frequency for the pitch object""" + self.freq = freq + + # pylint: disable=locally-disabled, unused-argument + def get_value(self, options) -> int: + """Returns the pitch class + + 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) + + def get_value(self, options: dict) -> int: + """Return random value + + Returns: + int: Returns random pitch + """ + return random.randint( + 0, get_scale_length(options.get("scale", "Major")) if options else 9 + ) + + +@dataclass(kw_only=True) +class RandomPercent(Item): + """Class for random percent""" + + percent: float = field(default=None) + + +@dataclass(kw_only=True) +class Chord(Event): + """Class for chords""" + + pitch_classes: list[Pitch] = field(default=None) + notes: list[int] = field(default=None) + inversions: int = field(default=None) + pitches: list[int] = field(default=None, init=False) + freqs: list[float] = field(default=None, init=False) + octaves: list[int] = field(default=None, init=False) + durations: list[float] = field(default=None, init=False) + beats: list[float] = field(default=None, init=False) + + def __post_init__(self): + if self.inversions is not None: + self.invert(self.inversions) + + @property + def note(self): + """Synonym for notes""" + return self.notes + + def set_notes(self, notes: list[int]): + """Set notes to the class""" + self.notes = notes + + def get_note(self): + """Getter for notes""" + return self.notes + + def get_freq(self): + """Getter for freqs""" + return self.freqs + + def get_octave(self): + """Getter for octaves""" + return self.octaves + + def get_beat(self): + """Getter for beats""" + return self.beats + + def get_pitch_class(self): + """Getter for pitches""" + return self.pitches + + def get_duration(self): + """Getter for durations""" + return self.durations + + def invert(self, value: int): + """Chord inversion""" + new_pitches = ( + list(reversed(self.pitch_classes)) if value < 0 else self.pitch_classes + ) + for _ in range(abs(value)): + new_pitch = new_pitches[_ % len(new_pitches)] + if not new_pitch.local_options.get("octave"): + new_pitch.local_options["octave"] = 0 + new_pitch.local_options["octave"] += -1 if value <= 0 else 1 + + self.pitch_classes = new_pitches + + def update_notes(self, options=None): + """Update notes""" + pitches, notes, freqs, octaves, durations, beats = ([] for _ in range(6)) + + # Update notes + for pitch in self.pitch_classes: + if options is not None: + pitch.update_options(options) + pitch.update_note(True) + + # Sort by generated notes + self.pitch_classes = sorted(self.pitch_classes, key=lambda x: x.note) + + # Create helper lists + for pitch in self.pitch_classes: + pitches.append(pitch.pitch_class) + notes.append(pitch.note) + freqs.append(pitch.freq) + octaves.append(pitch.octave) + durations.append(pitch.duration) + beats.append(pitch.beat) + + self.pitches = pitches + self.notes = notes + self.freqs = freqs + self.octaves = octaves + self.durations = durations + self.duration = durations[0] + self.beats = beats + self.text = "".join([val.text for val in self.pitch_classes]) + + +@dataclass(kw_only=True) +class RomanNumeral(Event): + """Class for roman numbers""" + + value: str = field(default=None) + chord_type: str = field(default=None) + notes: list[int] = field(default=None, init=False) + pitch_classes: list = field(default=None, init=False) + inversions: int = field(default=None) + evaluated_chord: Chord = 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]): + """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)) + + def evaluate_chord(self, options: dict) -> Chord: + """Create chord fom roman numeral""" + key = options["key"] + scale = options["scale"] + pitch_text = "" + pitch_classes = [] + self.notes = chord_from_degree( + self.value, self.chord_type, options["scale"], options["key"] + ) + for note in self.notes: + pitch_dict = midi_to_pitch_class(note, key, scale) + pitch_classes.append( + Pitch( + pitch_class=pitch_dict["pitch_class"], + note=note, + freq=midi_to_freq(note), + kwargs=(options | pitch_dict), + ) + ) + pitch_text += pitch_dict["text"] + + chord = Chord( + text=pitch_text, + pitch_classes=pitch_classes, + duration=options["duration"], + notes=self.notes, + kwargs=options, + inversions=self.inversions, + ) + + chord.update_notes(options) + + self.evaluated_chord = chord + + return chord + + +@dataclass(kw_only=True) +class Function(Event): + """Class for functions""" + + run: ... = field(default=None) + + +@dataclass(kw_only=True) +class FunctionList(Event): + """Class for functions""" + + values: list + + +@dataclass(kw_only=True) +class VariableAssignment(Item): + """Class for defining variables""" + + variable: str + value: Item + pre_eval: bool + + +@dataclass(kw_only=True) +class Variable(Event): + """Class for using variables""" + + name: str + + +@dataclass(kw_only=True) +class VariableList(Item): + """Class for using variables""" + + values: list + + +@dataclass(kw_only=True) +class Integer(Item): + """Class for integers""" + + value: int + + # pylint: disable=locally-disabled, unused-argument + def get_value(self, options): + """Return value of the integer""" + return self.value + + +@dataclass(kw_only=True) +class RandomInteger(Item): + """Class for random integer""" + + min: int + 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 + + # pylint: disable=locally-disabled, unused-argument + def get_value(self, options: dict = None): + """Evaluate the random value for the generator""" + return random.randint(self.min, self.max) + + +@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 __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 __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, options=None): + """Get the value for the current cycle""" + value = self.values[self.cycle % len(self.values)] + self.cycle += 1 + return value + + +@dataclass(kw_only=True) +class Range(Item): + """Class for range""" + + start: int = field(default=None) + end: int = field(default=None) + + def evaluate(self, options): + """Evaluates range and generates a generator of Pitches""" + merged_options = options | self.local_options + if options["octave"]: + merged_options["octave"] += options["octave"] + if self.start < self.end: + for i in range(self.start, self.end + 1): + yield Pitch(pitch_class=i, kwargs=merged_options) + elif self.start > self.end: + for i in reversed(range(self.end, self.start + 1)): + yield Pitch(pitch_class=i, kwargs=merged_options) + else: + yield Pitch(pitch_class=self.start, kwargs=merged_options) + + +@dataclass(kw_only=True) +class Operator(Item): + """Class for math operators""" + + value: ... + + +@dataclass(kw_only=True) +class Operation(Item): + """Class for lisp-like operations: (+ 1 2 3) etc.""" + + values: list + operator: operator + + +@dataclass(kw_only=True) +class Atom(Item): + """Class for evaluable atoms""" + + value: ... diff --git a/ziffers/classes/root.py b/ziffers/classes/root.py new file mode 100644 index 0000000..6148de2 --- /dev/null +++ b/ziffers/classes/root.py @@ -0,0 +1,154 @@ +"""Root class for Ziffers object""" + +from dataclasses import dataclass, field +from itertools import islice, cycle +from ..defaults import DEFAULT_OPTIONS +from .items import Item, Pitch, Chord, Event +from .sequences import Sequence, Subdivision + + +@dataclass(kw_only=True) +class Ziffers(Sequence): + """Main class for holding options and the current state""" + + options: dict = field(default_factory=DEFAULT_OPTIONS.copy()) + start_options: dict = None + loop_i: int = field(default=0, init=False) + cycle_i: int = field(default=0, init=False) + iterator = None + current: Item = field(default=None) + cycle_length: int = field(default=0, init=False) + + def __getitem__(self, index): + self.loop_i = index % self.cycle_length + new_cycle = index // self.cycle_length + # Re-evaluate if the prior loop has ended + if new_cycle > self.cycle_i or new_cycle < self.cycle_i: + self.re_eval() + self.cycle_i = new_cycle + self.cycle_length = len(self.evaluated_values) + self.loop_i = index % self.cycle_length + return self.evaluated_values[self.loop_i] + + def __iter__(self): + return self + + def __next__(self): + self.current = next(self.iterator) + self.loop_i += 1 + return self.current + + # pylint: disable=locally-disabled, dangerous-default-value + def init_opts(self, options=None): + """Evaluate the Ziffers tree using the options""" + self.options.update(DEFAULT_OPTIONS.copy()) + if options: + self.options.update(options) + else: + self.options = DEFAULT_OPTIONS.copy() + + self.start_options = self.options.copy() + self.options["start_options"] = self.start_options + self.init_tree(self.options) + + def re_eval(self): + """Re-evaluate the iterator""" + self.options = self.start_options.copy() + self.options["start_options"] = self.start_options + self.init_tree(self.options) + + def init_tree(self, options): + """Initialize evaluated values and perform post-evaluation""" + self.evaluated_values = list(self.evaluate_tree(options)) + self.evaluated_values = list(self.post_evaluation()) + self.iterator = iter(self.evaluated_values) + self.cycle_length = len(self.evaluated_values) + + def post_evaluation(self): + """Post-evaluation performs evaluation that can only be done after initial evaluation""" + for item in self.evaluated_values: + if isinstance(item, Subdivision): + yield from item.evaluate_durations() + else: + yield item + + 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. + + Args: + num (int): Number of pitch classes to take from the sequence + + Returns: + list: List of pitch class items + """ + return list(islice(cycle(self), num)) + + def loop(self) -> iter: + """Return cyclic loop""" + return cycle(iter(self)) + + def set_defaults(self, options: dict): + """Sets options for the parser + + Args: + options (dict): Options as a dict + """ + self.options = DEFAULT_OPTIONS.copy() | options + + def pitch_classes(self) -> list[int]: + """Return list of pitch classes as ints""" + return [ + val.get_pitch_class() + for val in self.evaluated_values + if isinstance(val, (Pitch, Chord)) + ] + + def notes(self) -> list[int]: + """Return list of midi notes""" + return [ + val.get_note() + for val in self.evaluated_values + if isinstance(val, (Pitch, Chord)) + ] + + def durations(self) -> list[float]: + """Return list of pitch durations as floats""" + return [ + val.get_duration() + for val in self.evaluated_values + if isinstance(val, Event) + ] + + def beats(self) -> list[float]: + """Return list of pitch durations as floats""" + return [ + val.get_beat() for val in self.evaluated_values if isinstance(val, Event) + ] + + def pairs(self) -> list[tuple]: + """Return list of pitches and durations""" + return [ + (val.get_pitch_class(), val.get_duration()) + for val in self.evaluated_values + if isinstance(val, Pitch) + ] + + def octaves(self) -> list[int]: + """Return list of octaves""" + return [ + val.get_octave() + for val in self.evaluated_values + if isinstance(val, (Pitch, Chord)) + ] + + def freqs(self) -> list[int]: + """Return list of octaves""" + return [ + val.get_freq() + for val in self.evaluated_values + if isinstance(val, (Pitch, Chord)) + ] diff --git a/ziffers/classes.py b/ziffers/classes/sequences.py similarity index 50% rename from ziffers/classes.py rename to ziffers/classes/sequences.py index 7aea6ac..a1ae302 100644 --- a/ziffers/classes.py +++ b/ziffers/classes/sequences.py @@ -1,481 +1,35 @@ -""" Ziffers classes for the parsed notation """ -from dataclasses import dataclass, field, replace, asdict -from itertools import product, islice, cycle -from math import floor +""" Sequence classes for Ziffers """ +from dataclasses import dataclass, field, replace +from itertools import product from types import LambdaType -import operator -import random from copy import deepcopy -from .defaults import DEFAULT_OPTIONS -from .common import repeat_text, cyclic_zip -from .scale import ( - note_from_pc, - midi_to_pitch_class, - midi_to_freq, - get_scale_length, - chord_from_degree, +from ..defaults import DEFAULT_OPTIONS +from ..common import cyclic_zip, euclidian_rhythm +from ..scale import note_from_pc, midi_to_freq +from .items import ( + Meta, + Item, + Event, + DurationChange, + OctaveChange, + OctaveAdd, + Pitch, + Rest, + RandomPitch, + Chord, + RomanNumeral, + Cyclic, + RandomInteger, + Range, + Integer, + VariableAssignment, + Variable, + VariableList, + Measure, + Function, + Modification, + Whitespace, ) -from .common import euclidian_rhythm - - -@dataclass(kw_only=True) -class Meta: - """Abstract class for all Ziffers items""" - - kwargs: dict = field(default=None, repr=False) - local_options: dict = field(default_factory=dict) - - def __post_init__(self): - if self.kwargs: - self.update_options(self.kwargs) - - def replace_options(self, new_values): - """Replaces attribute values from dict""" - for key, value in new_values.items(): - if hasattr(self, key): - setattr(self, key, value) - - def update_options(self, options): - """Updates attribute values only if value is None""" - merged_options = self.local_options | options - for key, value in merged_options.items(): - if hasattr(self, key): - if key == "octave": - local_value = self.local_options.get("octave", False) - oct_change = self.local_options.get("octave_change", False) - if oct_change: - setattr(self, key, oct_change) - elif local_value: - setattr(self, key, value + local_value) - elif getattr(self, key) is None: - setattr(self, key, value) - elif getattr(self, key) is None: - local_value = self.local_options.get(key, False) - if local_value: - setattr(self, key, local_value) - else: - setattr(self, key, value) - - def dict(self): - """Returns safe dict from the dataclass""" - return {k: str(v) for k, v in asdict(self).items()} - - -@dataclass(kw_only=True) -class Item(Meta): - """Class for all Ziffers text based items""" - - text: str = field(default=None) - measure: int = field(default=0, init=False) - - def get_updated_item(self, options: dict): - """Get updated item with replaced options - - Args: - options (dict): Options as a dict - - Returns: - Item: Returns updated item - """ - self.replace_options(options) - return self - - def get_options(self) -> dict: - """Return local options from item - - Returns: - dict: Options as a dict - """ - keys = ["octave", "modifier", "key", "scale", "duration"] - return {key: getattr(self, key) for key in keys if hasattr(self, key)} - - -@dataclass(kw_only=True) -class Whitespace: - """Class for whitespace""" - - text: str - - -@dataclass(kw_only=True) -class Modification(Item): - """Superclass for pitch modifications""" - - key: str - value: ... - - def as_options(self): - """Return modification as a dict""" - return {self.key: self.value} - - -@dataclass(kw_only=True) -class DurationChange(Modification): - """Class for changing duration""" - - value: float - key: str = field(default="duration", repr=False, init=False) - - -@dataclass -class OctaveChange(Modification): - """Class for changing octave""" - - value: int - key: str = field(default="octave", repr=False, init=False) - - -@dataclass(kw_only=True) -class OctaveAdd(Modification): - """Class for modifying octave""" - - value: int - key: str = field(default="octave", repr=False, init=False) - - -@dataclass(kw_only=True) -class Event(Item): - """Abstract class for events with duration""" - - duration: float = field(default=None) - - -@dataclass -class Rest(Event): - """Class for rests""" - - -@dataclass -class Measure(Item): - """Class for measures/bars. Used to reset default options.""" - - text: str = field(default="|", init=False) - - def reset_options(self, options: dict): - """Reset options when measure changes""" - next_measure = options.get("measure", 0) + 1 - start_options = options["start_options"].copy() - options.clear() - options.update(start_options) - options["measure"] = next_measure - options["start_options"] = start_options.copy() - self.measure = next_measure - - -@dataclass(kw_only=True) -class Pitch(Event): - """Class for pitch in time""" - - pitch_class: int - octave: int = field(default=None) - modifier: int = field(default=None) - note: int = field(default=None) - key: str = field(default=None) - scale: str | list = field(default=None) - freq: float = field(default=None) - beat: float = field(default=None) - - def __post_init__(self): - super().__post_init__() - if self.text is None: - self.text = str(self.pitch_class) - self.update_note() - # self._update_text() - - def _update_text(self): - if self.octave is not None: - self.text = repeat_text("^", "_", self.octave) + self.text - if self.modifier is not None: - self.text = repeat_text("#", "b", self.modifier) + self.text - - def get_note(self): - """Getter for note""" - return self.note - - def get_freq(self): - """Getter for freq""" - return self.freq - - def get_octave(self): - """Getter for octave""" - return self.octave - - def get_beat(self): - """Getter for beat""" - return self.beat - - def get_pitch_class(self): - """Getter for pitche""" - return self.pitch_class - - def get_duration(self): - """Getter for duration""" - return self.duration - - def update_note(self, force: bool = False): - """Update note if Key, Scale and Pitch-class are 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 or force) - ): - 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.freq = midi_to_freq(note) - self.note = note - if self.duration is not None: - self.beat = self.duration * 4 - - def check_note(self, options: dict): - """Check for note modification""" - if "key" in options and self.key is not options["key"]: - self.key = options["key"] - edit = True - if "scale" in options and self.scale is not options["scale"]: - self.scale = options["scale"] - edit = True - if edit: - self.update_note(True) - - 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 - - def set_freq(self, freq: float): - """Set frequency for the pitch object""" - self.freq = freq - - # pylint: disable=locally-disabled, unused-argument - def get_value(self, options) -> int: - """Returns the pitch class - - 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) - - def get_value(self, options: dict) -> int: - """Return random value - - Returns: - int: Returns random pitch - """ - return random.randint( - 0, get_scale_length(options.get("scale", "Major")) if options else 9 - ) - - -@dataclass(kw_only=True) -class RandomPercent(Item): - """Class for random percent""" - - percent: float = field(default=None) - - -@dataclass(kw_only=True) -class Chord(Event): - """Class for chords""" - - pitch_classes: list[Pitch] = field(default=None) - notes: list[int] = field(default=None) - inversions: int = field(default=None) - pitches: list[int] = field(default=None, init=False) - freqs: list[float] = field(default=None, init=False) - octaves: list[int] = field(default=None, init=False) - durations: list[float] = field(default=None, init=False) - beats: list[float] = field(default=None, init=False) - - def __post_init__(self): - if self.inversions is not None: - self.invert(self.inversions) - - @property - def note(self): - """Synonym for notes""" - return self.notes - - def set_notes(self, notes: list[int]): - """Set notes to the class""" - self.notes = notes - - def get_note(self): - """Getter for notes""" - return self.notes - - def get_freq(self): - """Getter for freqs""" - return self.freqs - - def get_octave(self): - """Getter for octaves""" - return self.octaves - - def get_beat(self): - """Getter for beats""" - return self.beats - - def get_pitch_class(self): - """Getter for pitches""" - return self.pitches - - def get_duration(self): - """Getter for durations""" - return self.durations - - def invert(self, value: int): - """Chord inversion""" - new_pitches = ( - list(reversed(self.pitch_classes)) if value < 0 else self.pitch_classes - ) - for _ in range(abs(value)): - new_pitch = new_pitches[_ % len(new_pitches)] - if not new_pitch.local_options.get("octave"): - new_pitch.local_options["octave"] = 0 - new_pitch.local_options["octave"] += -1 if value <= 0 else 1 - - self.pitch_classes = new_pitches - - def update_notes(self, options=None): - """Update notes""" - pitches, notes, freqs, octaves, durations, beats = ([] for _ in range(6)) - - # Update notes - for pitch in self.pitch_classes: - if options is not None: - pitch.update_options(options) - pitch.update_note(True) - - # Sort by generated notes - self.pitch_classes = sorted(self.pitch_classes, key=lambda x: x.note) - - # Create helper lists - for pitch in self.pitch_classes: - pitches.append(pitch.pitch_class) - notes.append(pitch.note) - freqs.append(pitch.freq) - octaves.append(pitch.octave) - durations.append(pitch.duration) - beats.append(pitch.beat) - - self.pitches = pitches - self.notes = notes - self.freqs = freqs - self.octaves = octaves - self.durations = durations - self.duration = durations[0] - self.beats = beats - self.text = "".join([val.text for val in self.pitch_classes]) - - -@dataclass(kw_only=True) -class RomanNumeral(Event): - """Class for roman numbers""" - - value: str = field(default=None) - chord_type: str = field(default=None) - notes: list[int] = field(default=None, init=False) - pitch_classes: list = field(default=None, init=False) - inversions: int = field(default=None) - evaluated_chord: Chord = 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]): - """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)) - - def evaluate_chord(self, options: dict) -> Chord: - """Create chord fom roman numeral""" - key = options["key"] - scale = options["scale"] - pitch_text = "" - pitch_classes = [] - self.notes = chord_from_degree( - self.value, self.chord_type, options["scale"], options["key"] - ) - for note in self.notes: - pitch_dict = midi_to_pitch_class(note, key, scale) - pitch_classes.append( - Pitch( - pitch_class=pitch_dict["pitch_class"], - note=note, - freq=midi_to_freq(note), - kwargs=(options | pitch_dict), - ) - ) - pitch_text += pitch_dict["text"] - - chord = Chord( - text=pitch_text, - pitch_classes=pitch_classes, - duration=options["duration"], - notes=self.notes, - kwargs=options, - inversions=self.inversions, - ) - - chord.update_notes(options) - - self.evaluated_chord = chord - - return chord - - -@dataclass(kw_only=True) -class Function(Event): - """Class for functions""" - - run: ... = field(default=None) - - -@dataclass(kw_only=True) -class VariableAssignment(Item): - """Class for defining variables""" - - variable: str - value: Item - pre_eval: bool - - -@dataclass(kw_only=True) -class Variable(Item): - """Class for using variables""" - - name: str @dataclass(kw_only=True) @@ -561,11 +115,25 @@ class Sequence(Meta): options[item.variable.name] = item.value elif isinstance(item, Variable): if options[item.name]: - opt_item = options[item.name] - if isinstance(opt_item, LambdaType): - yield Function(run=opt_item, text=item.text, kwargs=options) - variable = deepcopy(opt_item) - yield from _resolve_item(variable, options) + if item.name in options: + opt_item = options[item.name] + if isinstance(opt_item, LambdaType): + yield Function(run=opt_item, text=item.text, kwargs=options) + variable = deepcopy(opt_item) + yield from _resolve_item(variable, options) + elif isinstance(item, VariableList): + seqlist = [] + for var in item.values: + if var.name in options: + opt_item = options[var.name] + if isinstance(opt_item, LambdaType): + seqlist.append( + Function(run=opt_item, text=var.text, kwargs=options) + ) + elif isinstance(opt_item, Sequence): + seqlist.append(opt_item) + if len(seqlist) > 0: + yield PolyphonicSequence(values=seqlist) elif isinstance(item, Range): yield from item.evaluate(options) elif isinstance(item, Cyclic): @@ -703,149 +271,8 @@ class Sequence(Meta): @dataclass(kw_only=True) -class Ziffers(Sequence): - """Main class for holding options and the current state""" - - options: dict = field(default_factory=DEFAULT_OPTIONS.copy()) - start_options: dict = None - loop_i: int = field(default=0, init=False) - cycle_i: int = field(default=0, init=False) - iterator = None - current: Item = field(default=None) - cycle_length: int = field(default=0, init=False) - - def __getitem__(self, index): - self.loop_i = index % self.cycle_length - new_cycle = floor(index / self.cycle_length) - if new_cycle > self.cycle_i or new_cycle < self.cycle_i: - self.re_eval() - self.cycle_i = new_cycle - self.cycle_length = len(self.evaluated_values) - self.loop_i = index % self.cycle_length - return self.evaluated_values[self.loop_i] - - def __iter__(self): - return self - - def __next__(self): - self.current = next(self.iterator) - self.loop_i += 1 - return self.current - - # pylint: disable=locally-disabled, dangerous-default-value - def init_opts(self, options=None): - """Evaluate the Ziffers tree using the options""" - self.options.update(DEFAULT_OPTIONS.copy()) - if options: - self.options.update(options) - else: - self.options = DEFAULT_OPTIONS.copy() - - self.start_options = self.options.copy() - self.options["start_options"] = self.start_options - self.init_tree(self.options) - - def re_eval(self): - """Re-evaluate the iterator""" - self.options = self.start_options.copy() - self.options["start_options"] = self.start_options - self.init_tree(self.options) - - def init_tree(self, options): - """Initialize evaluated values and perform post-evaluation""" - self.evaluated_values = list(self.evaluate_tree(options)) - self.evaluated_values = list(self.post_evaluation()) - self.iterator = iter(self.evaluated_values) - self.cycle_length = len(self.evaluated_values) - - def post_evaluation(self): - """Post-evaluation performs evaluation that can only be done after initial evaluation""" - for item in self.evaluated_values: - if isinstance(item, Subdivision): - yield from item.evaluate_durations() - else: - yield item - - 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. - - Args: - num (int): Number of pitch classes to take from the sequence - - Returns: - list: List of pitch class items - """ - return list(islice(cycle(self), num)) - - def loop(self) -> iter: - """Return cyclic loop""" - return cycle(iter(self)) - - def set_defaults(self, options: dict): - """Sets options for the parser - - Args: - options (dict): Options as a dict - """ - self.options = DEFAULT_OPTIONS.copy() | options - - def pitch_classes(self) -> list[int]: - """Return list of pitch classes as ints""" - return [ - val.get_pitch_class() - for val in self.evaluated_values - if isinstance(val, (Pitch, Chord)) - ] - - def notes(self) -> list[int]: - """Return list of midi notes""" - return [ - val.get_note() - for val in self.evaluated_values - if isinstance(val, (Pitch, Chord)) - ] - - def durations(self) -> list[float]: - """Return list of pitch durations as floats""" - return [ - val.get_duration() - for val in self.evaluated_values - if isinstance(val, Event) - ] - - def beats(self) -> list[float]: - """Return list of pitch durations as floats""" - return [ - val.get_beat() for val in self.evaluated_values if isinstance(val, Event) - ] - - def pairs(self) -> list[tuple]: - """Return list of pitches and durations""" - return [ - (val.get_pitch_class(), val.get_duration()) - for val in self.evaluated_values - if isinstance(val, Pitch) - ] - - def octaves(self) -> list[int]: - """Return list of octaves""" - return [ - val.get_octave() - for val in self.evaluated_values - if isinstance(val, (Pitch, Chord)) - ] - - def freqs(self) -> list[int]: - """Return list of octaves""" - return [ - val.get_freq() - for val in self.evaluated_values - if isinstance(val, (Pitch, Chord)) - ] +class PolyphonicSequence: + values: list @dataclass(kw_only=True) @@ -856,38 +283,6 @@ class ListSequence(Sequence): wrap_end: str = field(default=")", repr=False) -@dataclass(kw_only=True) -class Integer(Item): - """Class for integers""" - - value: int - - # pylint: disable=locally-disabled, unused-argument - def get_value(self, options): - """Return value of the integer""" - return self.value - - -@dataclass(kw_only=True) -class RandomInteger(Item): - """Class for random integer""" - - min: int - 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 - - # pylint: disable=locally-disabled, unused-argument - def get_value(self, options: dict = None): - """Evaluate the random value for the generator""" - return random.randint(self.min, self.max) - - @dataclass(kw_only=True) class RepeatedListSequence(Sequence): """Class for Ziffers list sequences""" @@ -922,65 +317,6 @@ class Subdivision(Sequence): yield item -@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 __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 __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, options=None): - """Get the value for the current cycle""" - value = self.values[self.cycle % len(self.values)] - self.cycle += 1 - return value - - -@dataclass(kw_only=True) -class Range(Item): - """Class for range""" - - start: int = field(default=None) - end: int = field(default=None) - - def evaluate(self, options): - """Evaluates range and generates a generator of Pitches""" - merged_options = options | self.local_options - if options["octave"]: - merged_options["octave"] += options["octave"] - if self.start < self.end: - for i in range(self.start, self.end + 1): - yield Pitch(pitch_class=i, kwargs=merged_options) - elif self.start > self.end: - for i in reversed(range(self.end, self.start + 1)): - yield Pitch(pitch_class=i, kwargs=merged_options) - else: - yield Pitch(pitch_class=self.start, kwargs=merged_options) - - -@dataclass(kw_only=True) -class Operator(Item): - """Class for math operators""" - - value: ... - - @dataclass(kw_only=True) class ListOperation(Sequence): """Class for list operations""" @@ -1164,14 +500,6 @@ class ListOperation(Sequence): return left -@dataclass(kw_only=True) -class Operation(Item): - """Class for lisp-like operations: (+ 1 2 3) etc.""" - - values: list - operator: operator - - @dataclass(kw_only=True) class Eval(Sequence): """Class for evaluation notation""" @@ -1185,56 +513,6 @@ class Eval(Sequence): self.result = eval(self.text) -@dataclass(kw_only=True) -class Atom(Item): - """Class for evaluable atoms""" - - value: ... - - -@dataclass(kw_only=True) -class Euclid(Item): - """Class for euclidean cycles""" - - pulses: int - length: int - onset: ListSequence - offset: ListSequence = field(default=None) - rotate: int = field(default=0) - evaluated_values: list = field(default=None) - - def evaluate(self, options): - """Evaluate values using euclidean spread""" - onset_values = [ - val for val in self.onset.values if not isinstance(val, Whitespace) - ] - onset_length = len(onset_values) - booleans = euclidian_rhythm(self.pulses, self.length, self.rotate) - self.evaluated_values = [] - - if self.offset is not None: - offset_values = [ - val for val in self.offset.values if not isinstance(val, Whitespace) - ] - offset_length = len(offset_values) - - on_i = 0 - off_i = 0 - - for i in range(self.length): - if booleans[i]: - value = onset_values[on_i % onset_length] - on_i += 1 - else: - if self.offset is None: - value = Rest(duration=options["duration"]) - else: - value = offset_values[off_i % offset_length] - off_i += 1 - - self.evaluated_values.append(value) - - @dataclass(kw_only=True) class RepeatedSequence(Sequence): """Class for repeats""" @@ -1278,3 +556,46 @@ class RepeatedSequence(Sequence): pitch_class=item.get_value(self.local_options), kwargs=self.local_options, ) + + +@dataclass(kw_only=True) +class Euclid(Item): + """Class for euclidean cycles""" + + pulses: int + length: int + onset: ListSequence + offset: ListSequence = field(default=None) + rotate: int = field(default=0) + evaluated_values: list = field(default=None) + + def evaluate(self, options): + """Evaluate values using euclidean spread""" + onset_values = [ + val for val in self.onset.values if not isinstance(val, Whitespace) + ] + onset_length = len(onset_values) + booleans = euclidian_rhythm(self.pulses, self.length, self.rotate) + self.evaluated_values = [] + + if self.offset is not None: + offset_values = [ + val for val in self.offset.values if not isinstance(val, Whitespace) + ] + offset_length = len(offset_values) + + on_i = 0 + off_i = 0 + + for i in range(self.length): + if booleans[i]: + value = onset_values[on_i % onset_length] + on_i += 1 + else: + if self.offset is None: + value = Rest(duration=options["duration"]) + else: + value = offset_values[off_i % offset_length] + off_i += 1 + + self.evaluated_values.append(value) diff --git a/ziffers/mapper.py b/ziffers/mapper.py index c5263b2..4a92cb8 100644 --- a/ziffers/mapper.py +++ b/ziffers/mapper.py @@ -1,7 +1,17 @@ """ Lark transformer for mapping Lark tokens to Ziffers objects """ from lark import Transformer, Token -from .classes import ( - Ziffers, +from .classes.root import Ziffers +from .classes.sequences import ( + Sequence, + ListSequence, + RepeatedListSequence, + ListOperation, + RepeatedSequence, + Euclid, + Subdivision, + Eval, +) +from .classes.items import ( Whitespace, DurationChange, OctaveChange, @@ -12,24 +22,17 @@ from .classes import ( RandomPercent, Chord, RomanNumeral, - Sequence, - ListSequence, - RepeatedListSequence, - Subdivision, Cyclic, RandomInteger, Range, Operator, - ListOperation, Operation, - Eval, Atom, Integer, - Euclid, - RepeatedSequence, VariableAssignment, Variable, - Measure + VariableList, + Measure, ) from .common import flatten, sum_dict from .defaults import DEFAULT_DURS, OPERATORS @@ -337,6 +340,10 @@ class ZiffersTransformer(Transformer): """Return parsed variable name""" return Variable(name=items[0].value, text=items[0].value) + def variablelist(self, items): + """Return list of variables""" + return VariableList(values=items, text="".join([item.text for item in items])) + # List rules def list(self, items): @@ -459,4 +466,4 @@ class ZiffersTransformer(Transformer): repeats=items[1], wrap_start="", wrap_end=":" + items[1].text, - ) \ No newline at end of file + ) diff --git a/ziffers/spec/ziffers.lark b/ziffers/spec/ziffers.lark index 048a8a2..3132377 100644 --- a/ziffers/spec/ziffers.lark +++ b/ziffers/spec/ziffers.lark @@ -1,6 +1,6 @@ // Root for the rules ?root: sequence -> start - sequence: (pitch_class | repeat_item | assignment | variable | rest | dur_change | oct_mod | oct_change | WS | measure | chord | named_roman | cycle | random_integer | random_pitch | random_percent | range | list | repeated_list | lisp_operation | list_op | subdivision | eval | euclid | repeat)* + sequence: (pitch_class | repeat_item | assignment | variable | variablelist | rest | dur_change | oct_mod | oct_change | WS | measure | chord | named_roman | cycle | random_integer | random_pitch | random_percent | range | list | repeated_list | lisp_operation | list_op | subdivision | eval | euclid | repeat)* // Pitch classes pitch_class: prefix* pitch @@ -18,6 +18,8 @@ ass_op: /[=~]/ variable: /[A-Z]/ + variablelist: variable variable+ + // Durations duration_chars: dotted_dur+ dotted_dur: dchar dot*