Source code for pyflp.pattern

# PyFLP - An FL Studio project file (.flp) parser
# Copyright (C) 2022 demberto
#
# 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, either version 3 of the License, or (at your option)
# any later version. 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/>.

"""Contains the types used by patterns, MIDI notes and their automation data."""

from __future__ import annotations

import enum
from collections import defaultdict
from typing import DefaultDict, Iterator, cast

import construct as c

from pyflp._adapters import StdEnum
from pyflp._descriptors import EventProp, FlagProp, StructProp
from pyflp._events import (
    DATA,
    DWORD,
    TEXT,
    WORD,
    BoolEvent,
    ColorEvent,
    EventEnum,
    EventTree,
    I32Event,
    IndexedEvent,
    ListEventBase,
    U16Event,
    U32Event,
)
from pyflp._models import EventModel, ItemModel, ModelCollection, ModelReprMixin, supports_slice
from pyflp.exceptions import ModelNotFound, NoModelsFound
from pyflp.timemarker import TimeMarker, TimeMarkerID
from pyflp.types import RGBA

__all__ = ["Note", "Controller", "Pattern", "Patterns"]


class ControllerEvent(ListEventBase):
    STRUCT = c.GreedyRange(
        c.Struct(
            "position" / c.Int32ul,  # 4, can be delta as well!
            "_u1" / c.Byte,  # 5
            "_u2" / c.Byte,  # 6
            "channel" / c.Int8ul,  # 7
            "_flags" / c.Int8ul,  # 8
            "value" / c.Float32l,  # 12
        )
    )


@enum.unique
class _NoteFlags(enum.IntFlag):
    Slide = 1 << 3


class NotesEvent(ListEventBase):
    STRUCT = c.GreedyRange(
        c.Struct(
            "position" / c.Int32ul,  # 4
            "flags" / StdEnum[_NoteFlags](c.Int16ul),  # 6
            "rack_channel" / c.Int16ul,  # 8
            "length" / c.Int32ul,  # 12
            "key" / c.Int16ul,  # 14
            "group" / c.Int16ul,  # 16
            "fine_pitch" / c.Int8ul,  # 17
            "_u1" / c.Byte,  # 18
            "release" / c.Int8ul,  # 19
            "midi_channel" / c.Int8ul,  # 20
            "pan" / c.Int8ul,  # 21
            "velocity" / c.Int8ul,  # 22
            "mod_x" / c.Int8ul,  # 23
            "mod_y" / c.Int8ul,  # 24
        )
    )


[docs]class PatternsID(EventEnum): PlayTruncatedNotes = (30, BoolEvent) CurrentlySelected = (WORD + 3, U16Event)
# ChannelIID, _161, _162, Looped, Length occur when pattern is looped. # ChannelIID and _161 occur for every channel in order.
[docs]class PatternID(EventEnum): Looped = (26, BoolEvent) New = (WORD + 1, U16Event) # Marks the beginning of a new pattern, twice. Color = (DWORD + 22, ColorEvent) Name = TEXT + 1 # _157 = DWORD + 29 #: 12.5+ # _158 = DWORD + 30 # default: -1 ChannelIID = (DWORD + 32, U32Event) # TODO (FL v20.1b1+) _161 = (DWORD + 33, I32Event) # TODO -3 if channel is looped else 0 (FL v20.1b1+) _162 = (DWORD + 34, U32Event) # TODO Appears when pattern is looped, default: 2 Length = (DWORD + 36, U32Event) Controllers = (DATA + 15, ControllerEvent) Notes = (DATA + 16, NotesEvent)
[docs]class Note(ItemModel[NotesEvent]): _NOTE_NAMES = ("C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B") def __repr__(self) -> str: return "Note(key={}, position={}, length={}, channel={})".format( self.key, self.position, self.length, self.rack_channel ) def __str__(self) -> str: return f"{self.key} note @ {self.position} of {self.length}" fine_pitch = StructProp[int]() """Linear. | Type | Value | Representation | |---------|-------|----------------| | Min | 0 | -1200 cents | | Max | 240 | +1200 cents | | Default | 120 | No fine tuning | *New in FL Studio v3.3.0*. """ group = StructProp[int]() """A number shared by notes in the same group or ``0`` if ungrouped. ![](https://bit.ly/3TgjFva) """ @property def key(self) -> str: """Note name with octave, for e.g. 'C5' or 'A#3' ranging from C0 to B10. Only sharp key names (C#, D#, etc.) are used, flats aren't. Raises: ValueError: A value not in between 0-131 is tried to be set. ValueError: Invalid note name (not in the format {note-name}{octave}). """ return self._NOTE_NAMES[self["key"] % 12] + str(self["key"] // 12) # pyright: ignore @key.setter def key(self, value: int | str) -> None: if isinstance(value, int): if value not in range(132): raise ValueError("Expected a value between 0-131.") self["key"] = value else: for i, name in enumerate(self._NOTE_NAMES): if value.startswith(name): octave = int(value.replace(name, "", 1)) self["key"] = octave * 12 + i raise ValueError(f"Invalid key name: {value}") length = StructProp[int]() """Returns 0 for notes punched in through step sequencer.""" midi_channel = StructProp[int]() """Used for a variety of purposes. For note colors, min: 0, max: 15. +128 for MIDI dragged into the piano roll. *Changed in FL Studio v6.0.1*: Used for both, MIDI channels and colors. """ mod_x = StructProp[int]() """Plugin configurable parameter. | Min | Max | Default | | --- | --- | ------- | | 0 | 255 | 128 | """ mod_y = StructProp[int]() """Plugin configurable parameter. | Min | Max | Default | | --- | --- | ------- | | 0 | 255 | 128 | """ pan = StructProp[int]() """ | Type | Value | Representation | |---------|-------|----------------| | Min | 0 | 100% left | | Max | 128 | 100% right | | Default | 64 | Centered | """ position = StructProp[int]() rack_channel = StructProp[int]() """Containing channel's :attr:`Channel.IID`.""" release = StructProp[int]() """ | Min | Max | Default | | --- | --- | ------- | | 0 | 128 | 64 | """ slide = FlagProp(_NoteFlags.Slide) """Whether note is a sliding note.""" velocity = StructProp[int]() """ | Min | Max | Default | | --- | --- | ------- | | 0 | 128 | 100 | """
[docs]class Controller(ItemModel[ControllerEvent], ModelReprMixin): def __str__(self) -> str: return f"Controller @ {self.position} of channel #{self.channel}" channel = StructProp[int]() """Corresponds to the containing channel's :attr:`Channel.iid`.""" position = StructProp[int]() value = StructProp[float]()
[docs]class Pattern(EventModel): """Represents a pattern which can contain notes, controllers and time markers.""" def __repr__(self) -> str: try: num_notes = len(self.events.first(PatternID.Notes)) # type: ignore except KeyError: num_notes = 0 try: num_ctrls = len(self.events.first(PatternID.Controllers)) # type: ignore except KeyError: num_ctrls = 0 return ( f"Pattern(iid={self.iid}, name={self.name!r}," f"{num_notes} notes, {num_ctrls} controllers)" ) color = EventProp[RGBA](PatternID.Color) """Returns a colour if one is set while saving the project file, else ``None``. ![](https://bit.ly/3eNeSSW) Defaults to #485156 in FL Studio. """ @property def controllers(self) -> Iterator[Controller]: """Parameter automations associated with this pattern (if any).""" if PatternID.Controllers in self.events.ids: event = cast(ControllerEvent, self.events.first(PatternID.Controllers)) yield from (Controller(item, i, event) for i, item in enumerate(event)) @property def iid(self) -> int: """Internal index of the pattern starting from 1. Caution: Changing this will not solve any collisions thay may occur due to 2 patterns that might end up having the same index. """ return self.events.first(PatternID.New).value @iid.setter def iid(self, value: int) -> None: for event in self.events.get(PatternID.New): event.value = value length = EventProp[int](PatternID.Length) """The number of steps multiplied by the :attr:`pyflp.project.Project.ppq`. Returns `None` if pattern is in Auto mode (i.e. :attr:`looped` is `False`). """ looped = EventProp[bool](PatternID.Looped, default=False) """Whether a pattern is in live loop mode. *New in FL Studio v2.5.0*. """ name = EventProp[str](PatternID.Name) """User given name of the pattern; None if not set.""" @property def notes(self) -> Iterator[Note]: """MIDI notes contained inside the pattern. Note: FL Studio uses its own custom format to represent notes internally. However by using the :class:`Note` properties with a MIDI parsing library for example, you can export them to MIDI. """ if PatternID.Notes in self.events.ids: event = cast(NotesEvent, self.events.first(PatternID.Notes)) yield from (Note(item, i, event) for i, item in enumerate(event)) @property def timemarkers(self) -> Iterator[TimeMarker]: """Yields timemarkers inside this pattern.""" yield from (TimeMarker(et) for et in self.events.group(*TimeMarkerID))
[docs]class Patterns(EventModel, ModelCollection[Pattern]): def __str__(self) -> str: iids = [pattern.iid for pattern in self] return f"{len(iids)} Patterns {iids!r}"
[docs] @supports_slice # type: ignore def __getitem__(self, i: int | str | slice) -> Pattern: """Returns the pattern with the specified index or :attr:`Pattern.name`. Args: i: A zero-based index, its name or a slice of indexes. Raises: ModelNotFound: A :class:`Pattern` with the specified name or index isn't found. """ for idx, pattern in enumerate(self): if (isinstance(i, int) and idx == i) or i == pattern.name: return pattern raise ModelNotFound(i)
# Doesn't use EventTree delegates since PatternID.New occurs twice. # Once for note and controller events and again for the rest of them.
[docs] def __iter__(self) -> Iterator[Pattern]: """An iterator over the patterns found in the project.""" cur_pat_id = 0 tmp_dict: DefaultDict[int, list[IndexedEvent]] = defaultdict(list) for ie in self.events.lst: if ie.e.id == PatternID.New: cur_pat_id = ie.e.value if ie.e.id in (*PatternID, *TimeMarkerID): tmp_dict[cur_pat_id].append(ie) for events in tmp_dict.values(): et = EventTree(self.events, events) self.events.children.append(et) yield Pattern(et)
[docs] def __len__(self) -> int: """Returns the number of patterns found in the project. Raises: NoModelsFound: No patterns were found. """ if PatternID.New not in self.events.ids: raise NoModelsFound return len({e.value for e in self.events.get(PatternID.New)})
play_cut_notes = EventProp[bool](PatternsID.PlayTruncatedNotes) """Whether truncated notes of patterns placed in the playlist should be played. Located at :menuselection:`Options -> &Project general settings --> Advanced` under the name :guilabel:`Play truncated notes in clips`. *Changed in FL Studio v12.3 beta 3*: Enabled by default. """ @property def current(self) -> Pattern | None: """Returns the currently selected pattern.""" if PatternsID.CurrentlySelected in self.events.ids: index = self.events.first(PatternsID.CurrentlySelected).value for pattern in self: if pattern.iid == index: return pattern