├── .DS_Store ├── .gitignore ├── LICENSE ├── README.md ├── __main__.py ├── filetree.py ├── flpobject.py ├── icon.ico └── pyflp ├── __init__.py ├── __pycache__ ├── __init__.cpython-310.pyc ├── _adapters.cpython-310.pyc ├── _descriptors.cpython-310.pyc ├── _events.cpython-310.pyc ├── _models.cpython-310.pyc ├── arrangement.cpython-310.pyc ├── channel.cpython-310.pyc ├── exceptions.cpython-310.pyc ├── mixer.cpython-310.pyc ├── pattern.cpython-310.pyc ├── plugin.cpython-310.pyc ├── project.cpython-310.pyc ├── timemarker.cpython-310.pyc └── types.cpython-310.pyc ├── _adapters.py ├── _descriptors.py ├── _events.py ├── _models.py ├── arrangement.py ├── channel.py ├── controller.py ├── exceptions.py ├── mixer.py ├── pattern.py ├── plugin.py ├── project.py ├── py.typed ├── timemarker.py └── types.py /.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ZappyMan/FL-Studio-Time-Calculator/ed1ffe7757eb7e063b9a6dc77489ff6a7e28b006/.DS_Store -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | *.spec 3 | *.html 4 | *.txt 5 | *.toc 6 | *.zip 7 | *.pkg 8 | __pycache__/project_counter_GUI.cpython-37.pyc 9 | build/project_counter_GUI/project_counter_GUI.exe.manifest 10 | build/project_counter_GUI/PYZ-00.pyz 11 | dist/FL Hour Calculator.exe 12 | dist/project_counter_GUI.exe 13 | v4 FL/NewStuff.flp 14 | v10 FL/bwum.flp 15 | v12 FL/NewStuff (1).flp 16 | v8 FL/Zircon - Just Hold On.flp 17 | dist/FLcounter.exe 18 | v12 FL/NewStuff (2).flp 19 | tests/untitled.flp 20 | pyflp/__pycache__/__init__.cpython-310.pyc 21 | *.pyc 22 | *.pyc 23 | *.pyc 24 | *.pyz 25 | *.exe 26 | *.pyc 27 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 ZappyMan 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # FL-Studio-Time-Calculator 2 | FL Studio keeps track of how many active hours you spend on each project, but not your total time in the program. With this application, you can finally see your total hours without having to open up all your files and adding everything together! Idle time is still not accounted for, so the time this program displays to you only shows your active working hours. You may also select dates for the program to search between, say, if you wanted to see how many hours you spent on FL in a given month (or whatever time frame you like)! 3 | 4 | ![alt text](https://i.imgur.com/OZkqrj4.png) 5 | 6 | This program was created out of the primal, human urge to keep track of and gawk at the amount of time we all spend on FL Studio. For better or for worse, we will never stop producing! 7 | 8 | This program does not modify your save files in any way, its only allowed to read file contents. 9 | 10 | Support us at https://ko-fi.com/flhourcounterguys 11 | -------------------------------------------------------------------------------- /__main__.py: -------------------------------------------------------------------------------- 1 | # --------------------------------------------------- 2 | # Version: 2.0.0 3 | # Creators: Elliott Chimienti, Zane Little 4 | # Support us!: https://ko-fi.com/flhourcounterguys 5 | # --------------------------------------------------- 6 | # Python 3.10 7 | # PyFLP 2.2.1 8 | # PySide6 9 | 10 | import sys, os, datetime 11 | from PySide6.QtWidgets import QAbstractItemView, QTabWidget, QLabel, QSplitter ,QApplication, QMainWindow, QToolButton, QDateEdit, QVBoxLayout, QHBoxLayout, QWidget, QFileDialog, QTreeWidget, QTreeWidgetItem, QGroupBox 12 | from PySide6.QtCore import QDateTime, Qt 13 | import pyqtgraph as pg 14 | from filetree import CustomTree 15 | from flpobject import FLP_Object 16 | 17 | # Local clone of pyflp library used 18 | import pyflp 19 | # CHANGES MADE TO LIBRARY 20 | # __init__.py 21 | # - Line 131 divided file_size by 2 22 | # channel.py 23 | # - Line 279 Commented out 24 | 25 | # -------------- 26 | # Main Window 27 | # -------------- 28 | class Window(QMainWindow): 29 | def __init__(self): 30 | super(Window, self).__init__() 31 | self.setWindowTitle("FL Studio Time Calculator") 32 | window_size = QApplication.primaryScreen().availableSize() 33 | self.resize(window_size) # Set app to size of screen 34 | self.resize(window_size.width()*0.85,window_size.height()*0.85) # Set app to size of screen 35 | 36 | self.flp_objects = [] # Empty list to fold FLP_Object()'s 37 | self.load_state = False 38 | 39 | # ----- Define Buttons and widgets in order of appearance ------ 40 | 41 | self.tabs = QTabWidget() 42 | 43 | self.filetree = CustomTree(True) 44 | self.filelist = CustomTree(False) 45 | 46 | self.filetree.tree.itemSelectionChanged.connect(self.file_selection_signal) 47 | self.filelist.tree.itemSelectionChanged.connect(self.file_selection_signal) 48 | self.filetree.tree.model().dataChanged.connect(self.file_view_signal) 49 | self.filelist.tree.model().dataChanged.connect(self.file_view_signal) 50 | 51 | self.tabs.addTab(self.filelist.tree, "List") 52 | self.tabs.addTab(self.filetree.tree, "Tree") 53 | 54 | self.tabs.setTabEnabled(1, True) 55 | self.tabs.setTabEnabled(2, True) 56 | self.tabs.currentChanged.connect(self.update_file_selection_tab) 57 | 58 | self.end = QDateEdit(calendarPopup=True) 59 | self.end.setDateTime(QDateTime.currentDateTime()) 60 | 61 | self.start = QDateEdit(calendarPopup=True) 62 | self.start.setDateTime(QDateTime(1997, 12, 18, 1, 0, 0)) # December, 18 1997; 1:00 am 63 | 64 | self.browse = QToolButton(self) 65 | self.browse.setText('Import') 66 | self.browse.clicked.connect(self.load_folders) 67 | 68 | self.MasterLayout = QHBoxLayout() # Verticle Box layout 69 | self.SectionDivider = QSplitter(Qt.Orientation.Horizontal) 70 | self.SectionDivider.setStyleSheet("QSplitter::handle{background: gray;}") 71 | self.Lwidget = QWidget() 72 | self.Rwidget = QWidget() 73 | 74 | # Left side --------- 75 | self.Sublayout1 = QVBoxLayout() 76 | self.buttonLayout = QHBoxLayout() 77 | self.Filetreelayout = QHBoxLayout() 78 | 79 | self.buttonLayout.addWidget(self.browse) 80 | self.Filetreelayout.addWidget(self.tabs) 81 | 82 | self.Sublayout1.addLayout(self.buttonLayout) 83 | self.Sublayout1.addLayout(self.Filetreelayout) 84 | self.Lwidget.setLayout(self.Sublayout1) 85 | 86 | # Right side --------- 87 | self.Sublayout2 = QVBoxLayout() 88 | self.InfoLayout = QHBoxLayout() 89 | 90 | # QLabels to hold total project data 91 | self.total_num_files = QLabel("--") 92 | self.total_time_days = QLabel("--") 93 | self.total_time_hours = QLabel("--") 94 | self.average_time = QLabel("--") 95 | self.average_break_time = QLabel("--") 96 | 97 | pixel_size = int(window_size.height()/70) 98 | 99 | self.GB1 = QGroupBox("Total Files",) 100 | self.GB1.setStyleSheet("font: bold {:d}px".format(pixel_size)) 101 | temp_layout1 = QVBoxLayout() 102 | temp_layout1.addWidget(self.total_num_files) 103 | self.GB1.setLayout(temp_layout1) 104 | 105 | self.GB2 = QGroupBox("Total Time in Days") 106 | self.GB2.setStyleSheet("font: bold {:d}px".format(pixel_size)) 107 | temp_layout2 = QVBoxLayout() 108 | temp_layout2.addWidget(self.total_time_days) 109 | self.GB2.setLayout(temp_layout2) 110 | 111 | self.GB3 = QGroupBox("Total Time in Hours") 112 | self.GB3.setStyleSheet("font: bold {:d}px".format(pixel_size)) 113 | temp_layout3 = QVBoxLayout() 114 | temp_layout3.addWidget(self.total_time_hours) 115 | self.GB3.setLayout(temp_layout3) 116 | 117 | self.GB4 = QGroupBox("Avg. Project Time") 118 | self.GB4.setStyleSheet("font: bold {:d}px".format(pixel_size)) 119 | temp_layout4 = QVBoxLayout() 120 | temp_layout4.addWidget(self.average_time) 121 | self.GB4.setLayout(temp_layout4) 122 | 123 | self.GB5 = QGroupBox("Avg. Time Between Projects") 124 | self.GB5.setStyleSheet("font: bold {:d}px".format(pixel_size)) 125 | temp_layout5 = QVBoxLayout() 126 | temp_layout5.addWidget(self.average_break_time) 127 | self.GB5.setLayout(temp_layout5) 128 | 129 | # Top Level Project Info 130 | self.InfoLayout.addWidget(self.GB1) 131 | self.InfoLayout.addWidget(self.GB2) 132 | self.InfoLayout.addWidget(self.GB3) 133 | self.InfoLayout.addWidget(self.GB4) 134 | self.InfoLayout.addWidget(self.GB5) 135 | 136 | # Scatter Plot 137 | axisX = pg.DateAxisItem(orientation='bottom') 138 | axisY = pg.AxisItem(orientation='left',text="Project Hours") 139 | self.plotItem = pg.PlotItem(title="Project Hours vs. Creation Date",axisItems={'bottom': axisX, 'left':axisY}) 140 | self.plotItem.getAxis('bottom').setLabel("Creation Dates") 141 | self.plotItem.getAxis('left').setLabel("Time Spent (Hours)") 142 | self.scatter = pg.PlotWidget(plotItem=self.plotItem) 143 | 144 | self.Sublayout2.addLayout(self.InfoLayout) 145 | self.Sublayout2.addWidget(self.scatter) 146 | self.Rwidget.setLayout(self.Sublayout2) 147 | 148 | # Set Main Layout 149 | self.SectionDivider.addWidget(self.Lwidget) 150 | self.SectionDivider.addWidget(self.Rwidget) 151 | self.SectionDivider.setSizes([self.width()*.30,self.width()*.70]) # Set sections to 25% and 75% screen width 152 | self.filetree.tree.header().resizeSection(0,int(self.width()*.12)) 153 | self.filelist.tree.header().resizeSection(0,int(self.width()*.12)) 154 | self.filetree.tree.header().setMinimumSectionSize(int(self.width()*.05)) 155 | self.filelist.tree.header().setMinimumSectionSize(int(self.width()*.05)) 156 | 157 | widget = QWidget() 158 | self.MasterLayout.addWidget(self.SectionDivider) 159 | widget.setLayout(self.MasterLayout) 160 | self.setCentralWidget(widget) 161 | 162 | # Activated when user changes tabs 163 | # Index of current tab passed when signaled 164 | def update_file_selection_tab(self): 165 | self.filetree.tree.clear() 166 | self.filelist.tree.clear() 167 | if self.tabs.currentIndex() == 0: 168 | for _object in self.flp_objects: 169 | _object.create_standard_item() 170 | self.filelist.add_item(_object) 171 | else: 172 | for _object in self.flp_objects: 173 | _object.create_standard_item() 174 | self.filetree.add_item(_object) 175 | self.filetree.compress_filepaths() 176 | 177 | # Function activated by selection changed singal in either list or tree 178 | def file_selection_signal(self): 179 | self.update_visuals() 180 | 181 | # Function activated by data changed singal in either list or tree 182 | def file_view_signal(self, signal): 183 | if not self.load_state: 184 | if self.tabs.currentIndex() == 0: # List 185 | selected_items = self.filelist.tree.selectedItems() # Get list of user selected items 186 | triggered_item = self.filelist.tree.itemFromIndex(signal) # Get tree widget item 187 | file_system = self.filelist.tree 188 | else: # Tree 189 | selected_items = self.filetree.tree.selectedItems() 190 | triggered_item = self.filetree.tree.itemFromIndex(signal) # Get tree widget item 191 | file_system = self.filetree.tree 192 | 193 | triggered_state = triggered_item.checkState(0) 194 | file_system.model().blockSignals(True) 195 | if triggered_item not in selected_items: 196 | selected_items.append(triggered_item) 197 | for item in selected_items: # Update all selected items 198 | if Qt.ItemFlag.ItemIsUserCheckable in item.flags(): # Check if item has children 199 | item.setCheckState(0,triggered_state) 200 | self.change_checkstate_of_children(item, triggered_state) 201 | file_system.model().blockSignals(False) 202 | file_system.viewport().update() 203 | self.update_object_states() 204 | self.update_visuals() 205 | 206 | # Recursivly change checkstate of all children from starting item 207 | def change_checkstate_of_children(self, item: QTreeWidgetItem, state: Qt.CheckState): 208 | for child_index in range(item.childCount()): 209 | if Qt.ItemFlag.ItemIsUserCheckable in item.flags(): 210 | self.change_checkstate_of_children(item.child(child_index), state) 211 | item.child(child_index).setCheckState(0,state) 212 | 213 | # After file view has data changed, update personal class states 214 | def update_object_states(self): 215 | for item in self.flp_objects: 216 | item.update_state() 217 | 218 | # Parses FL Projects, loads into file list/tree, and plots data 219 | def load_folders(self): 220 | path = QFileDialog().getExistingDirectory(self, 'Select a directory') 221 | if(path): 222 | filepaths_full, filepaths_relative_dir = self.walk(path) # Returns FLPFile struct Object 223 | if len(filepaths_full) > 0: # If project(s) found 224 | self.load_state = True 225 | for full_path, relative_path in zip(filepaths_full, filepaths_relative_dir): 226 | project = FLP_Object(full_path, relative_path) 227 | project.parse() # parse project 228 | self.flp_objects.append(project) 229 | self.update_file_selection_tab() 230 | self.update_visuals() 231 | QApplication.processEvents() 232 | # self.filetree.compress_filepaths() 233 | self.filetree.tree.setSortingEnabled(True) 234 | self.filelist.tree.setSortingEnabled(True) 235 | self.load_state = False 236 | 237 | # Update graph and header information with FLP data 238 | def update_visuals(self): 239 | # -- Collect data from project states 240 | x_data = [] 241 | y_data = [] 242 | x_selected = [] 243 | y_selected = [] 244 | x_nselected = [] 245 | y_nselected = [] 246 | for project in self.flp_objects: 247 | if project.check_state == Qt.CheckState.Checked: 248 | if project.tree_item.isSelected(): 249 | x_selected.append(project.creation_date) 250 | y_selected.append(project.project_hours) 251 | else: 252 | x_nselected.append(project.creation_date) 253 | y_nselected.append(project.project_hours) 254 | x_data.append(project.creation_date) 255 | y_data.append(project.project_hours) 256 | # -- Update Information Header 257 | y_length = len(y_data) 258 | if y_length > 0: 259 | total_hours = sum(y_data) 260 | self.total_num_files.setText(str(y_length)) 261 | self.total_time_hours.setText(str("{:.2f}".format(total_hours))) 262 | self.total_time_days.setText("{:.2f}".format(float(self.total_time_hours.text())/24)) 263 | self.average_time.setText(str("{:.2f}".format(total_hours/y_length))) 264 | if y_length > 1: 265 | timedetla = ((max(x_data)-min(x_data)).total_seconds()/86400)/(y_length-1) 266 | else: 267 | timedetla = 0 268 | self.average_break_time.setText("{:.2f} Days".format(timedetla)) 269 | else: 270 | self.total_num_files.setText("--") 271 | self.total_time_hours.setText("--") 272 | self.total_time_days.setText("--") 273 | self.average_time.setText("--") 274 | self.average_break_time.setText("--") 275 | 276 | # -- Update Visual plot 277 | self.plotItem.clear() 278 | viewBox = self.plotItem.getViewBox() 279 | viewBox.setLimits(xMin=-62135596800.0, xMax=253370764800.0,yMin=-1e+307,yMax=1e+307) # Library defaults 280 | self.plotItem.plot(x=[x.timestamp() for x in x_nselected],y=y_nselected,pen=None,symbol='o') 281 | self.plotItem.plot(x=[x.timestamp() for x in x_selected],y=y_selected,pen=None,symbolBrush=pg.mkColor('r'),symbol='o') # Draw selected second for overlay effect 282 | viewBox.updateViewRange() 283 | _range = viewBox.viewRange() 284 | viewBox.setLimits(xMin=_range[0][0], xMax=_range[0][1],yMin=_range[1][0],yMax=_range[1][1]) 285 | 286 | # Fast search of selected root directory and sub-directories 287 | # Output nexted array in compress filepath form 288 | def walk(self, path: str): 289 | filepath_full = [] # FULL path directory for importing 290 | filepath_relative_dir = [] # Relative directory for file tree 291 | root_folder_name = path.split(os.sep)[-1] 292 | update_process_counter = 0 293 | for p, _, f in os.walk(path): 294 | for file in f: 295 | if file.endswith('.flp') and "autosave" not in file and "overwritten" not in file: 296 | full_path = os.path.join(p,file) 297 | flp_path_string = os.path.relpath(full_path,path) 298 | flp_path_string = os.path.join(root_folder_name,flp_path_string) 299 | filepath_relative_dir.append(flp_path_string) 300 | filepath_full.append(full_path) 301 | update_process_counter += 1 302 | if update_process_counter >= 1000: # Must update process when searching through deep heiarchy 303 | QApplication.processEvents() # Update in future to custom loading screen 304 | update_process_counter = 0 305 | 306 | return filepath_full, filepath_relative_dir # Return list 307 | 308 | 309 | if __name__ == '__main__': 310 | app = QApplication(sys.argv) 311 | window = Window() # Main Window 312 | window.show() 313 | sys.exit(app.exec()) -------------------------------------------------------------------------------- /filetree.py: -------------------------------------------------------------------------------- 1 | # --------------------------------------------------- 2 | # Version: 2.0.0 3 | # Creators: Elliott Chimienti, Zane Little 4 | # Support us!: https://ko-fi.com/flhourcounterguys 5 | # --------------------------------------------------- 6 | # Python 3.10 7 | # PyFLP 2.2.1 8 | # PySide6 9 | 10 | from typing import Union 11 | from PySide6.QtWidgets import QGraphicsTextItem, QTreeView, QAbstractItemView, QHeaderView, QSplitter ,QApplication, QMainWindow, QToolButton, QDateEdit, QVBoxLayout, QHBoxLayout, QWidget, QFileDialog, QTreeWidget, QTreeWidgetItem, QGroupBox 12 | from PySide6.QtCore import Qt, QAbstractItemModel 13 | from PySide6.QtGui import QStandardItemModel, QStandardItem 14 | from flpobject import FLP_Object 15 | import os 16 | 17 | # Holds QTreeWidget file tree and appropriate customization functions 18 | class CustomTree(): 19 | def __init__(self, tree_bool): 20 | self.tree = QTreeWidget() 21 | self.tree.setColumnCount(3) 22 | self.tree.setHeaderLabels(["Files","Hours","Creation Date"]) 23 | # self.tree.header().setSizeAdjustPolicy(QAbstractItemView.SizeAdjustPolicy.AdjustIgnored) 24 | self.tree.header().setSectionResizeMode(QHeaderView.ResizeMode.Fixed) 25 | self.tree.header().setSectionResizeMode(0,QHeaderView.ResizeMode.Stretch) 26 | self.tree.header().setStretchLastSection(False) 27 | self.tree.header().resizeSection(1,15) 28 | self.tree.header().resizeSection(2,90) 29 | self.tree.setSortingEnabled(False) # By default, disable when bulk updating 30 | self.tree.setAutoScroll(False) 31 | # configure sorting characteristics 32 | self.tree.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection) # Extended Selection 33 | self.tree_bool = tree_bool # True == Tree, False == List 34 | 35 | # Add item to widget 36 | def add_item(self, flp_object: FLP_Object): 37 | if self.tree_bool: # If this object is a tree 38 | self.insert_into_tree(flp_object) 39 | else: 40 | self.tree.insertTopLevelItem(0,flp_object.tree_item) 41 | 42 | # Inserts object into file tree 43 | def insert_into_tree(self, flp_object: FLP_Object): 44 | tree_pointer = None 45 | for directory in flp_object.path_to_array(flp_object.relative_path): 46 | # check for root directory 47 | if self.tree.topLevelItemCount() == 0: 48 | root = QTreeWidgetItem([directory,"",""]) 49 | root.setCheckState(0,Qt.CheckState.Checked) 50 | self.tree.insertTopLevelItem(0,root) 51 | tree_pointer = root 52 | else: 53 | matches = self.tree.findItems(directory, Qt.MatchFlag.MatchExactly|Qt.MatchFlag.MatchRecursive, column=0) 54 | if len(matches) > 0: 55 | tree_pointer = matches[0] 56 | else: 57 | if directory.endswith(".flp"): # Add file 58 | tree_pointer.addChild(flp_object.tree_item) 59 | tree_pointer.setExpanded(True) 60 | else: 61 | dir = QTreeWidgetItem([directory,"",""]) 62 | dir.setCheckState(0,Qt.CheckState.Checked) 63 | tree_pointer.addChild(dir) 64 | tree_pointer.setExpanded(True) 65 | tree_pointer = dir 66 | 67 | # Compress filespaths for long paths with single children 68 | def compress_filepaths(self): 69 | # Check through toplevel items and their children 70 | for index in range(self.tree.topLevelItemCount()): 71 | top_level_item = self.tree.topLevelItem(index) 72 | for child_index in range(top_level_item.childCount()): 73 | self.recursive_depth_search(top_level_item.child(child_index)) 74 | 75 | # .takechildren() 76 | 77 | # Recursivly search through children and find instances of paths that need to be compressed 78 | def recursive_depth_search(self, item: QTreeWidgetItem): 79 | count = item.childCount() 80 | if count == 0: # Contains no children 81 | return 82 | elif count == 1: 83 | # if item has children, take them and merge strings 84 | if item.child(0).text(0).endswith(".flp"): 85 | return 86 | else: 87 | new_children = item.child(0).takeChildren() # get childs child 88 | item.setText(0,os.path.join(item.text(0),item.child(0).text(0))) # Update current items text 89 | item.removeChild(item.child(0)) 90 | item.addChildren(new_children) 91 | self.recursive_depth_search(item) # continue recursion 92 | else: # Contains more than one child 93 | for child_index in range(item.childCount()): 94 | self.recursive_depth_search(item.child(child_index)) 95 | -------------------------------------------------------------------------------- /flpobject.py: -------------------------------------------------------------------------------- 1 | # --------------------------------------------------- 2 | # Version: 2.0.0 3 | # Creators: Elliott Chimienti, Zane Little 4 | # Support us!: https://ko-fi.com/flhourcounterguys 5 | # --------------------------------------------------- 6 | # Python 3.10 7 | # PyFLP 2.2.1 8 | # PySide6 9 | 10 | import datetime, os 11 | from PySide6.QtWidgets import QTreeWidgetItem 12 | from PySide6.QtCore import Qt 13 | from PySide6.QtGui import QStandardItem 14 | 15 | # Local clone of pyflp library used 16 | import pyflp 17 | 18 | 19 | # Custom object to hold information about parsed song 20 | class FLP_Object(): 21 | def __init__(self, file_path = None, relative_file_path = None): 22 | self.file_path = file_path 23 | self.relative_path = relative_file_path 24 | self.file_name = self.path_to_array(relative_file_path)[-1] 25 | self.creation_date = None # datetime 26 | self.project_hours = None # float 27 | # self.total_num_notes # int 28 | self.tree_item = None 29 | self.check_state = Qt.CheckState.Checked 30 | 31 | # Convert filepath from string to file array 32 | # Ex: "folder1/folder2/file1" == ["folder1","folder2","file1"] 33 | def path_to_array(self, path) -> list: 34 | # path_array = os.path.normpath(path) # Normalize path 35 | return path.split(os.sep) 36 | 37 | # Parse song 38 | def parse(self): 39 | if self.file_path: # If file path exists 40 | try: # attempt to parse file 41 | temp = pyflp.parse(self.file_path) 42 | self.project_hours = temp.time_spent/datetime.timedelta(hours=1) # Float 43 | self.creation_date = temp.created_on 44 | # total notes 45 | # other metrics 46 | except: 47 | print("Error: Could not parse file ", self.file_name) 48 | self.project_hours = 0 49 | 50 | # Create standard item for different trees 51 | def create_standard_item(self): 52 | if self.creation_date: # If file could be parsed 53 | self.tree_item = QTreeWidgetItem([self.file_name,str("{:.2f}".format(self.project_hours)),str(self.creation_date.date())]) 54 | self.tree_item.setCheckState(0,self.check_state) 55 | else: 56 | self.tree_item = QTreeWidgetItem([self.file_name,"",""]) 57 | self.tree_item.setBackground(0,Qt.GlobalColor.red) 58 | 59 | # Update personal class state based on tree_items checkstate 60 | def update_state(self): 61 | if Qt.ItemFlag.ItemIsUserCheckable in self.tree_item.flags(): 62 | self.check_state = self.tree_item.checkState(0) 63 | 64 | 65 | -------------------------------------------------------------------------------- /icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ZappyMan/FL-Studio-Time-Calculator/ed1ffe7757eb7e063b9a6dc77489ff6a7e28b006/icon.ico -------------------------------------------------------------------------------- /pyflp/__init__.py: -------------------------------------------------------------------------------- 1 | # PyFLP - An FL Studio project file (.flp) parser 2 | # Copyright (C) 2022 demberto 3 | # 4 | # This program is free software: you can redistribute it and/or modify it 5 | # under the terms of the GNU General Public License as published by the Free 6 | # Software Foundation, either version 3 of the License, or (at your option) 7 | # any later version. This program is distributed in the hope that it will be 8 | # useful, but WITHOUT ANY WARRANTY; without even the implied warranty of 9 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General 10 | # Public License for more details. You should have received a copy of the 11 | # GNU General Public License along with this program. If not, see 12 | # . 13 | 14 | """ 15 | PyFLP - FL Studio project file parser 16 | ===================================== 17 | 18 | Load a project file: 19 | 20 | >>> import pyflp 21 | >>> project = pyflp.parse("/path/to/parse.flp") 22 | 23 | Save the project: 24 | 25 | >>> pyflp.save(project, "/path/to/save.flp") 26 | 27 | Full docs are available at https://pyflp.rtfd.io. 28 | """ # noqa 29 | 30 | from __future__ import annotations 31 | 32 | import io 33 | import os 34 | import pathlib 35 | import struct 36 | import sys 37 | import time 38 | 39 | import construct as c 40 | 41 | from pyflp._events import ( 42 | DATA, 43 | DWORD, 44 | NEW_TEXT_IDS, 45 | TEXT, 46 | WORD, 47 | AnyEvent, 48 | AsciiEvent, 49 | EventEnum, 50 | EventTree, 51 | IndexedEvent, 52 | U8Event, 53 | U16Event, 54 | U32Event, 55 | UnicodeEvent, 56 | UnknownDataEvent, 57 | ) 58 | from pyflp.exceptions import HeaderCorrupted, VersionNotDetected 59 | from pyflp.plugin import PluginID, get_event_by_internal_name 60 | from pyflp.project import VALID_PPQS, FileFormat, Project, ProjectID 61 | 62 | __all__ = ["parse", "save"] 63 | 64 | FLP_HEADER = struct.Struct("4sIh2H") 65 | 66 | if sys.version_info < (3, 11): # https://github.com/Bobronium/fastenum/issues/2 67 | import fastenum 68 | 69 | fastenum.enable() # 33% faster parse() 70 | 71 | 72 | def parse(file: pathlib.Path | str) -> Project: 73 | """Parses an FL Studio project file and returns a parsed :class:`Project`. 74 | 75 | Args: 76 | file: Path to the FLP. 77 | 78 | Raises: 79 | HeaderCorrupted: When an invalid value is found in the file header. 80 | VersionNotDetected: A correct string type couldn't be determined. 81 | """ 82 | cur_time = time.time() 83 | with open(file, "rb") as flp: 84 | stream = io.BytesIO(flp.read()) 85 | 86 | events: list[AnyEvent] = [] 87 | header = stream.read(FLP_HEADER.size) 88 | 89 | try: 90 | hdr_magic, hdr_size, fmt, channel_count, ppq = FLP_HEADER.unpack(header) 91 | except struct.error as exc: 92 | raise HeaderCorrupted("Couldn't read the header entirely") from exc 93 | 94 | if hdr_magic != b"FLhd": 95 | raise HeaderCorrupted("Unexpected header chunk magic; expected 'FLhd'") 96 | 97 | if hdr_size != 6: 98 | raise HeaderCorrupted("Unexpected header chunk size; expected 6") 99 | 100 | try: 101 | file_format = FileFormat(fmt) 102 | except ValueError as exc: 103 | raise HeaderCorrupted("Unsupported project file format") from exc 104 | 105 | if ppq not in VALID_PPQS: 106 | raise HeaderCorrupted("Invalid PPQ") 107 | 108 | if stream.read(4) != b"FLdt": 109 | raise HeaderCorrupted("Unexpected data chunk magic; expected 'FLdt'") 110 | 111 | events_size = int.from_bytes(stream.read(4), "little") 112 | if not events_size: # pragma: no cover 113 | raise HeaderCorrupted("Data chunk size couldn't be read") 114 | 115 | stream.seek(0, os.SEEK_END) 116 | file_size = stream.tell() 117 | if file_size != events_size + 22: 118 | raise HeaderCorrupted("Data chunk size corrupted") 119 | 120 | plug_name = None 121 | str_type: type[AsciiEvent] | type[UnicodeEvent] | None = None 122 | stream.seek(22) # Back to start of events 123 | while stream.tell() < 1000: 124 | event_type: type[AnyEvent] | None = None 125 | id = EventEnum(int.from_bytes(stream.read(1), "little")) 126 | 127 | if id < WORD: 128 | value = stream.read(1) 129 | elif id < DWORD: 130 | value = stream.read(2) 131 | elif id < TEXT: 132 | value = stream.read(4) 133 | else: 134 | size = c.VarInt.parse_stream(stream) 135 | value = stream.read(size) 136 | 137 | if id == ProjectID.FLVersion: 138 | parts = value.decode("ascii").rstrip("\0").split(".") 139 | if [int(part) for part in parts][0:2] >= [11, 5]: 140 | str_type = UnicodeEvent 141 | else: 142 | str_type = AsciiEvent 143 | 144 | for enum_ in EventEnum.__subclasses__(): 145 | if id in enum_: 146 | event_type = getattr(enum_(id), "type") 147 | break 148 | 149 | if event_type is None: 150 | if id < WORD: 151 | event_type = U8Event 152 | elif id < DWORD: 153 | event_type = U16Event 154 | elif id < TEXT: 155 | event_type = U32Event 156 | elif id < DATA or id.value in NEW_TEXT_IDS: 157 | if str_type is None: # pragma: no cover 158 | raise VersionNotDetected # ! This should never happen 159 | event_type = str_type 160 | 161 | if id == PluginID.InternalName: 162 | plug_name = event_type(id, value).value 163 | elif id == PluginID.Data and plug_name is not None: 164 | event_type = get_event_by_internal_name(plug_name) 165 | else: 166 | event_type = UnknownDataEvent 167 | 168 | events.append(event_type(id, value)) 169 | 170 | 171 | return Project( 172 | EventTree(init=(IndexedEvent(r, e) for r, e in enumerate(events))), 173 | channel_count=channel_count, 174 | format=file_format, 175 | ppq=ppq, 176 | ) 177 | 178 | 179 | def save(project: Project, file: pathlib.Path | str) -> None: 180 | """Save a parsed project back into a file. 181 | 182 | Caution: 183 | Always have a backup ready, just in case 😉 184 | 185 | Args: 186 | project: The object returned by :meth:`parse`. 187 | file: The file in which the contents of :attr:`project` are serialised back. 188 | """ 189 | buf = bytearray() 190 | num_channels = len(project.channels) 191 | header = FLP_HEADER.pack(b"FLhd", 6, project.format, num_channels, project.ppq) 192 | buf.extend(header) 193 | buf.extend(b"FLdt" + (b"\0" * 4)) 194 | total_size = 0 195 | for event in project.events: 196 | raw = bytes(event) 197 | total_size += len(raw) 198 | buf.extend(raw) 199 | buf[18:22] = total_size.to_bytes(4, "little") 200 | 201 | with open(file, "wb") as fp: 202 | fp.write(buf) 203 | -------------------------------------------------------------------------------- /pyflp/__pycache__/__init__.cpython-310.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ZappyMan/FL-Studio-Time-Calculator/ed1ffe7757eb7e063b9a6dc77489ff6a7e28b006/pyflp/__pycache__/__init__.cpython-310.pyc -------------------------------------------------------------------------------- /pyflp/__pycache__/_adapters.cpython-310.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ZappyMan/FL-Studio-Time-Calculator/ed1ffe7757eb7e063b9a6dc77489ff6a7e28b006/pyflp/__pycache__/_adapters.cpython-310.pyc -------------------------------------------------------------------------------- /pyflp/__pycache__/_descriptors.cpython-310.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ZappyMan/FL-Studio-Time-Calculator/ed1ffe7757eb7e063b9a6dc77489ff6a7e28b006/pyflp/__pycache__/_descriptors.cpython-310.pyc -------------------------------------------------------------------------------- /pyflp/__pycache__/_events.cpython-310.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ZappyMan/FL-Studio-Time-Calculator/ed1ffe7757eb7e063b9a6dc77489ff6a7e28b006/pyflp/__pycache__/_events.cpython-310.pyc -------------------------------------------------------------------------------- /pyflp/__pycache__/_models.cpython-310.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ZappyMan/FL-Studio-Time-Calculator/ed1ffe7757eb7e063b9a6dc77489ff6a7e28b006/pyflp/__pycache__/_models.cpython-310.pyc -------------------------------------------------------------------------------- /pyflp/__pycache__/arrangement.cpython-310.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ZappyMan/FL-Studio-Time-Calculator/ed1ffe7757eb7e063b9a6dc77489ff6a7e28b006/pyflp/__pycache__/arrangement.cpython-310.pyc -------------------------------------------------------------------------------- /pyflp/__pycache__/channel.cpython-310.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ZappyMan/FL-Studio-Time-Calculator/ed1ffe7757eb7e063b9a6dc77489ff6a7e28b006/pyflp/__pycache__/channel.cpython-310.pyc -------------------------------------------------------------------------------- /pyflp/__pycache__/exceptions.cpython-310.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ZappyMan/FL-Studio-Time-Calculator/ed1ffe7757eb7e063b9a6dc77489ff6a7e28b006/pyflp/__pycache__/exceptions.cpython-310.pyc -------------------------------------------------------------------------------- /pyflp/__pycache__/mixer.cpython-310.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ZappyMan/FL-Studio-Time-Calculator/ed1ffe7757eb7e063b9a6dc77489ff6a7e28b006/pyflp/__pycache__/mixer.cpython-310.pyc -------------------------------------------------------------------------------- /pyflp/__pycache__/pattern.cpython-310.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ZappyMan/FL-Studio-Time-Calculator/ed1ffe7757eb7e063b9a6dc77489ff6a7e28b006/pyflp/__pycache__/pattern.cpython-310.pyc -------------------------------------------------------------------------------- /pyflp/__pycache__/plugin.cpython-310.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ZappyMan/FL-Studio-Time-Calculator/ed1ffe7757eb7e063b9a6dc77489ff6a7e28b006/pyflp/__pycache__/plugin.cpython-310.pyc -------------------------------------------------------------------------------- /pyflp/__pycache__/project.cpython-310.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ZappyMan/FL-Studio-Time-Calculator/ed1ffe7757eb7e063b9a6dc77489ff6a7e28b006/pyflp/__pycache__/project.cpython-310.pyc -------------------------------------------------------------------------------- /pyflp/__pycache__/timemarker.cpython-310.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ZappyMan/FL-Studio-Time-Calculator/ed1ffe7757eb7e063b9a6dc77489ff6a7e28b006/pyflp/__pycache__/timemarker.cpython-310.pyc -------------------------------------------------------------------------------- /pyflp/__pycache__/types.cpython-310.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ZappyMan/FL-Studio-Time-Calculator/ed1ffe7757eb7e063b9a6dc77489ff6a7e28b006/pyflp/__pycache__/types.cpython-310.pyc -------------------------------------------------------------------------------- /pyflp/_adapters.py: -------------------------------------------------------------------------------- 1 | # PyFLP - An FL Studio project file (.flp) parser 2 | # Copyright (C) 2022 demberto 3 | # 4 | # This program is free software: you can redistribute it and/or modify it 5 | # under the terms of the GNU General Public License as published by the Free 6 | # Software Foundation, either version 3 of the License, or (at your option) 7 | # any later version. This program is distributed in the hope that it will be 8 | # useful, but WITHOUT ANY WARRANTY; without even the implied warranty of 9 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General 10 | # Public License for more details. You should have received a copy of the 11 | # GNU General Public License along with this program. If not, see 12 | # . 13 | 14 | from __future__ import annotations 15 | 16 | import math 17 | import warnings 18 | from typing import Any, List, Tuple 19 | 20 | import construct as c 21 | import construct_typed as ct 22 | from typing_extensions import TypeAlias 23 | 24 | from pyflp.types import ET, MusicalTime, T, U 25 | 26 | SimpleAdapter: TypeAlias = ct.Adapter[T, T, U, U] 27 | """Duplicates type parameters for `construct.Adapter`.""" 28 | 29 | FourByteBool: c.ExprAdapter[int, int, bool, int] = c.ExprAdapter( 30 | c.Int32ul, lambda obj_, *_: bool(obj_), lambda obj_, *_: int(obj_) # type: ignore 31 | ) 32 | 33 | 34 | class List2Tuple(SimpleAdapter[Any, Tuple[int, int]]): 35 | def _decode(self, obj: c.ListContainer[int], *_: Any) -> tuple[int, int]: 36 | _1, _2 = tuple(obj) 37 | return _1, _2 38 | 39 | def _encode(self, obj: tuple[int, int], *_: Any) -> c.ListContainer[int]: 40 | return c.ListContainer([*obj]) 41 | 42 | 43 | class LinearMusical(SimpleAdapter[int, MusicalTime]): 44 | def _encode(self, obj: MusicalTime, *_: Any) -> int: 45 | if obj.ticks % 5: 46 | warnings.warn("Ticks must be a multiple of 5", UserWarning) 47 | 48 | return (obj.bars * 768) + (obj.beats * 48) + int(obj.ticks * 0.2) 49 | 50 | def _decode(self, obj: int, *_: Any) -> MusicalTime: 51 | bars, remainder = divmod(obj, 768) 52 | beats, remainder = divmod(remainder, 48) 53 | return MusicalTime(bars, beats, ticks=remainder * 5) 54 | 55 | 56 | class Log2(SimpleAdapter[int, float]): 57 | def __init__(self, subcon: Any, factor: int) -> None: 58 | super().__init__(subcon) # type: ignore[call-arg] 59 | self.factor = factor 60 | 61 | def _encode(self, obj: float, *_: Any) -> int: 62 | return int(self.factor * math.log2(obj)) 63 | 64 | def _decode(self, obj: int, *_: Any) -> float: 65 | return 2 ** (obj / self.factor) 66 | 67 | 68 | # Thanks to @algmyr from Python Discord server for finding out the formulae used 69 | # ! See https://github.com/construct/construct/issues/999 70 | class LogNormal(SimpleAdapter[List[int], float]): 71 | def __init__(self, subcon: Any, bound: tuple[int, int]) -> None: 72 | super().__init__(subcon) # type: ignore[call-arg] 73 | self.lo, self.hi = bound 74 | 75 | def _encode(self, obj: float, *_: Any) -> list[int]: 76 | """Clamps the integer representation of ``obj`` and returns it.""" 77 | if not 0.0 <= obj <= 1.0: 78 | raise ValueError(f"Expected a value between 0.0 to 1.0; got {obj}") 79 | 80 | if not obj: # log2(0.0) --> -inf ==> 0 81 | return [0, 0] 82 | 83 | return [min(max(self.lo, int(2**12 * (math.log2(obj) + 15))), self.hi), 63] 84 | 85 | def _decode(self, obj: list[int], *_: Any) -> float: 86 | """Returns a float representation of ``obj[0]`` between 0.0 to 1.0.""" 87 | if not obj[0]: 88 | return 0.0 89 | 90 | if obj[1] != 63: 91 | raise ValueError(f"Not a LogNormal, 2nd int must be 63; not {obj[1]}") 92 | 93 | return max(min(1.0, 2 ** (obj[0] / 2**12) / 2**15), 0.0) 94 | 95 | 96 | class StdEnum(SimpleAdapter[int, ET]): 97 | def _encode(self, obj: ET, *_: Any) -> int: 98 | return obj.value 99 | 100 | def _decode(self, obj: int, *_: Any) -> ET: 101 | return self.__orig_class__.__args__[0](obj) # type: ignore 102 | -------------------------------------------------------------------------------- /pyflp/_descriptors.py: -------------------------------------------------------------------------------- 1 | # PyFLP - An FL Studio project file (.flp) parser 2 | # Copyright (C) 2022 demberto 3 | # 4 | # This program is free software: you can redistribute it and/or modify it 5 | # under the terms of the GNU General Public License as published by the Free 6 | # Software Foundation, either version 3 of the License, or (at your option) 7 | # any later version. This program is distributed in the hope that it will be 8 | # useful, but WITHOUT ANY WARRANTY; without even the implied warranty of 9 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General 10 | # Public License for more details. You should have received a copy of the 11 | # GNU General Public License along with this program. If not, see 12 | # . 13 | 14 | """Contains the descriptor and adaptor classes used by models and events.""" 15 | 16 | from __future__ import annotations 17 | 18 | import abc 19 | import enum 20 | from typing import Any, Protocol, overload, runtime_checkable 21 | 22 | from typing_extensions import Self, final 23 | 24 | from pyflp._events import AnyEvent, EventEnum, StructEventBase 25 | from pyflp._models import VE, EMT_co, EventModel, ItemModel, ModelBase 26 | from pyflp.exceptions import PropertyCannotBeSet 27 | from pyflp.types import T, T_co 28 | 29 | 30 | @runtime_checkable 31 | class ROProperty(Protocol[T_co]): 32 | """Protocol for a read-only descriptor.""" 33 | 34 | def __get__(self, ins: Any, owner: Any = None) -> T_co | Self | None: 35 | ... 36 | 37 | 38 | @runtime_checkable 39 | class RWProperty(ROProperty[T], Protocol): 40 | """Protocol for a read-write descriptor.""" 41 | 42 | def __set__(self, ins: Any, value: T) -> None: 43 | ... 44 | 45 | 46 | class NamedPropMixin: 47 | def __init__(self, prop: str | None = None) -> None: 48 | self._prop = prop or "" 49 | 50 | def __set_name__(self, _: Any, name: str) -> None: 51 | if not self._prop: 52 | self._prop = name 53 | 54 | 55 | class PropBase(abc.ABC, RWProperty[T]): 56 | def __init__(self, *ids: EventEnum, default: T | None = None, readonly: bool = False): 57 | self._ids = ids 58 | self._default = default 59 | self._readonly = readonly 60 | 61 | @overload 62 | def _get_event(self, ins: ItemModel[VE]) -> ItemModel[VE]: 63 | ... 64 | 65 | @overload 66 | def _get_event(self, ins: EventModel) -> AnyEvent | None: 67 | ... 68 | 69 | def _get_event(self, ins: ItemModel[VE] | EventModel): 70 | if isinstance(ins, ItemModel): 71 | return ins 72 | 73 | if not self._ids: 74 | if len(ins.events) > 1: # Prevent ambiguous situations 75 | raise LookupError("Event ID not specified") 76 | 77 | return tuple(ins.events)[0] 78 | 79 | for id in self._ids: 80 | if id in ins.events: 81 | return ins.events.first(id) 82 | 83 | @property 84 | def default(self) -> T | None: # Configure version based defaults here 85 | return self._default 86 | 87 | @abc.abstractmethod 88 | def _get(self, ev_or_ins: Any) -> T | None: 89 | ... 90 | 91 | @abc.abstractmethod 92 | def _set(self, ev_or_ins: Any, value: T) -> None: 93 | ... 94 | 95 | @final 96 | def __get__(self, ins: Any, owner: Any = None) -> T | Self | None: 97 | if ins is None: 98 | return self 99 | 100 | if owner is None: 101 | return NotImplemented 102 | 103 | event: Any = self._get_event(ins) 104 | if event is not None: 105 | return self._get(event) 106 | 107 | return self.default 108 | 109 | @final 110 | def __set__(self, ins: Any, value: T) -> None: 111 | if self._readonly: 112 | raise PropertyCannotBeSet(*self._ids) 113 | 114 | event: Any = self._get_event(ins) 115 | if event is not None: 116 | self._set(event, value) 117 | else: 118 | raise PropertyCannotBeSet(*self._ids) 119 | 120 | 121 | class FlagProp(PropBase[bool]): 122 | """Properties derived from enum flags.""" 123 | 124 | def __init__( 125 | self, 126 | flag: enum.IntFlag, 127 | *ids: EventEnum, 128 | prop: str = "flags", 129 | inverted: bool = False, 130 | default: bool | None = None, 131 | ) -> None: 132 | """ 133 | Args: 134 | flag: The flag which is to be checked for. 135 | id: Event ID (required for MultiEventModel). 136 | prop: The dict key which contains the flags in a `Struct`. 137 | inverted: If this is true, property getter and setters 138 | invert the value to be set / returned. 139 | """ 140 | self._flag = flag 141 | self._flag_type = type(flag) 142 | self._prop = prop 143 | self._inverted = inverted 144 | super().__init__(*ids, default=default) 145 | 146 | def _get(self, ev_or_ins: Any) -> bool | None: 147 | if isinstance(ev_or_ins, (ItemModel, StructEventBase)): 148 | flags = ev_or_ins[self._prop] 149 | else: 150 | flags = ev_or_ins.value # type: ignore 151 | 152 | if flags is not None: 153 | retbool = self._flag in self._flag_type(flags) 154 | return not retbool if self._inverted else retbool 155 | 156 | def _set(self, ev_or_ins: Any, value: bool) -> None: 157 | if self._inverted: 158 | value = not value 159 | 160 | if isinstance(ev_or_ins, (ItemModel, StructEventBase)): 161 | if value: 162 | ev_or_ins[self._prop] |= self._flag 163 | else: 164 | ev_or_ins[self._prop] &= ~self._flag 165 | else: 166 | if value: 167 | ev_or_ins.value |= self._flag # type: ignore 168 | else: 169 | ev_or_ins.value &= ~self._flag # type: ignore 170 | 171 | 172 | class KWProp(NamedPropMixin, RWProperty[T]): 173 | """Properties derived from non-local event values. 174 | 175 | These values are passed to the class constructor as keyword arguments. 176 | """ 177 | 178 | def __get__(self, ins: ModelBase | None, owner: Any = None) -> T | Self: 179 | if ins is None: 180 | return self 181 | 182 | if owner is None: 183 | return NotImplemented 184 | return ins._kw[self._prop] 185 | 186 | def __set__(self, ins: ModelBase, value: T) -> None: 187 | if self._prop not in ins._kw: 188 | raise KeyError(self._prop) 189 | ins._kw[self._prop] = value 190 | 191 | 192 | class EventProp(PropBase[T]): 193 | """Properties bound directly to one of fixed size or string events.""" 194 | 195 | def _get(self, ev_or_ins: AnyEvent) -> T | None: 196 | return ev_or_ins.value 197 | 198 | def _set(self, ev_or_ins: AnyEvent, value: T) -> None: 199 | ev_or_ins.value = value 200 | 201 | 202 | class NestedProp(ROProperty[EMT_co]): 203 | def __init__(self, type: type[EMT_co], *ids: EventEnum) -> None: 204 | self._ids = ids 205 | self._type = type 206 | 207 | def __get__(self, ins: EventModel, owner: Any = None) -> EMT_co: 208 | if owner is None: 209 | return NotImplemented 210 | 211 | return self._type(ins.events.subtree(lambda e: e.id in self._ids)) 212 | 213 | 214 | class StructProp(PropBase[T], NamedPropMixin): 215 | """Properties obtained from a :class:`construct.Struct`.""" 216 | 217 | def __init__(self, *ids: EventEnum, prop: str | None = None, **kwds: Any) -> None: 218 | super().__init__(*ids, **kwds) 219 | NamedPropMixin.__init__(self, prop) 220 | 221 | def _get(self, ev_or_ins: ItemModel[Any]) -> T | None: 222 | return ev_or_ins[self._prop] 223 | 224 | def _set(self, ev_or_ins: ItemModel[Any], value: T) -> None: 225 | ev_or_ins[self._prop] = value 226 | -------------------------------------------------------------------------------- /pyflp/_events.py: -------------------------------------------------------------------------------- 1 | # PyFLP - An FL Studio project file (.flp) parser 2 | # Copyright (C) 2022 demberto 3 | # 4 | # This program is free software/or modify it 5 | # under the terms of the GNU General Public License as published by the Free 6 | # Software Foundation, either version 3 of the License, or (at your option) 7 | # any later version. This program is distributed in the hope that it will be 8 | # useful, but WITHOUT ANY WARRANTY; without even the implied warranty of 9 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General 10 | # Public License for more details. You should have received a copy of the 11 | # GNU General Public License along with this program. If not, see 12 | # . 13 | 14 | """Contains implementations for various types of event data and its container. 15 | 16 | These types serve as the backbone for model creation and simplify marshalling 17 | and unmarshalling. 18 | """ 19 | 20 | from __future__ import annotations 21 | 22 | import abc 23 | import enum 24 | import warnings 25 | from collections.abc import Callable, Iterable, Iterator, Sequence 26 | from dataclasses import dataclass, field 27 | from itertools import zip_longest 28 | from typing import TYPE_CHECKING, Any, ClassVar, Final, Generic, Tuple, cast 29 | 30 | import construct as c 31 | from sortedcontainers import SortedList 32 | from typing_extensions import Concatenate, TypeAlias 33 | 34 | from pyflp.exceptions import ( 35 | EventIDOutOfRange, 36 | InvalidEventChunkSize, 37 | PropertyCannotBeSet, 38 | ) 39 | from pyflp.types import RGBA, P, T, AnyContainer, AnyListContainer, AnyList, AnyDict 40 | 41 | BYTE: Final = 0 42 | WORD: Final = 64 43 | DWORD: Final = 128 44 | TEXT: Final = 192 45 | DATA: Final = 208 46 | NEW_TEXT_IDS: Final = ( 47 | TEXT + 49, # ArrangementID.Name 48 | TEXT + 39, # DisplayGroupID.Name 49 | TEXT + 47, # TrackID.Name 50 | ) 51 | 52 | 53 | class _EventEnumMeta(enum.EnumMeta): 54 | def __contains__(self, obj: object) -> bool: 55 | """Whether ``obj`` is one of the integer values of enum members. 56 | 57 | Args: 58 | obj: Can be an ``int`` or an ``EventEnum``. 59 | """ 60 | return obj in tuple(self) 61 | 62 | 63 | class EventEnum(int, enum.Enum, metaclass=_EventEnumMeta): 64 | """IDs used by events. 65 | 66 | Event values are stored as a tuple of event ID and its designated type. 67 | The types are used to serialise/deserialise events by the parser. 68 | 69 | All event names prefixed with an underscore (_) are deprecated w.r.t to 70 | the latest version of FL Studio, *to the best of my knowledge*. 71 | """ 72 | 73 | def __new__(cls, id: int, type: type[AnyEvent] | None = None): 74 | obj = int.__new__(cls, id) 75 | obj._value_ = id 76 | setattr(obj, "type", type) 77 | return obj 78 | 79 | # This allows EventBase.id to actually use EventEnum for representation and 80 | # not just equality checks. It will be much simpler to debug problematic 81 | # events, if the name of the ID is directly visible. 82 | @classmethod 83 | def _missing_(cls, value: object) -> EventEnum | None: 84 | """Allows unknown IDs in the range of 0-255.""" 85 | if isinstance(value, int) and 0 <= value <= 255: 86 | # First check in existing subclasses 87 | for sc in cls.__subclasses__(): 88 | if value in sc: 89 | return sc(value) 90 | 91 | # Else create a new pseudo member 92 | pseudo_member = cls._value2member_map_.get(value, None) 93 | if pseudo_member is None: 94 | new_member = int.__new__(cls, value) 95 | new_member._name_ = str(value) 96 | new_member._value_ = value 97 | pseudo_member = cls._value2member_map_.setdefault(value, new_member) 98 | return cast(EventEnum, pseudo_member) 99 | # Raises ValueError in Enum.__new__ 100 | 101 | 102 | class EventBase(Generic[T]): 103 | """Generic ABC representing an event.""" 104 | 105 | STRUCT: c.Construct[T, T] 106 | ALLOWED_IDS: ClassVar[Sequence[int]] = [] 107 | 108 | def __init__(self, id: EventEnum, data: bytes, **kwds: Any) -> None: 109 | if self.ALLOWED_IDS and id not in self.ALLOWED_IDS: 110 | raise EventIDOutOfRange(id, *self.ALLOWED_IDS) 111 | 112 | if id < TEXT: 113 | if id < WORD: 114 | expected_size = 1 115 | elif id < DWORD: 116 | expected_size = 2 117 | else: 118 | expected_size = 4 119 | 120 | if len(data) != expected_size: 121 | raise InvalidEventChunkSize(expected_size, len(data)) 122 | 123 | self.id = EventEnum(id) 124 | self._kwds = kwds 125 | self.value = self.STRUCT.parse(data, **self._kwds) 126 | 127 | def __eq__(self, o: object) -> bool: 128 | if not isinstance(o, EventBase): 129 | raise TypeError(f"Cannot find equality of an {type(o)} and {type(self)!r}") 130 | return self.id == o.id and self.value == cast(EventBase[T], o).value 131 | 132 | def __ne__(self, o: object) -> bool: 133 | if not isinstance(o, EventBase): 134 | raise TypeError(f"Cannot find inequality of a {type(o)} and {type(self)!r}") 135 | return self.id != o.id or self.value != cast(EventBase[T], o).value 136 | 137 | def __bytes__(self) -> bytes: 138 | id = c.Byte.build(self.id) 139 | data = self.STRUCT.build(self.value, **self._kwds) 140 | 141 | if self.id < TEXT: 142 | return id + data 143 | 144 | length = c.VarInt.build(len(data)) 145 | return id + length + data 146 | 147 | def __repr__(self) -> str: 148 | return f"<{type(self)!r}(id={self.id!r}, value={self.value!r})>" 149 | 150 | @property 151 | def size(self) -> int: 152 | """Serialised event size (in bytes).""" 153 | 154 | if self.id >= TEXT: 155 | return len(bytes(self)) 156 | elif self.id >= DWORD: 157 | return 5 158 | elif self.id >= WORD: 159 | return 3 160 | else: 161 | return 2 162 | 163 | 164 | AnyEvent: TypeAlias = EventBase[Any] 165 | 166 | 167 | class ByteEventBase(EventBase[T]): 168 | """Base class of events used for storing 1 byte data.""" 169 | 170 | ALLOWED_IDS = range(BYTE, WORD) 171 | 172 | def __init__(self, id: EventEnum, data: bytes) -> None: 173 | """ 174 | Args: 175 | id: **0** to **63**. 176 | data: Event data of size 1. 177 | 178 | Raises: 179 | EventIDOutOfRangeError: When ``id`` is not in range of 0-63. 180 | InvalidEventChunkSizeError: When size of `data` is not 1. 181 | """ 182 | super().__init__(id, data) 183 | 184 | 185 | class BoolEvent(ByteEventBase[bool]): 186 | """An event used for storing a boolean.""" 187 | 188 | STRUCT = c.Flag 189 | 190 | 191 | class I8Event(ByteEventBase[int]): 192 | """An event used for storing a 1 byte signed integer.""" 193 | 194 | STRUCT = c.Int8sl 195 | 196 | 197 | class U8Event(ByteEventBase[int]): 198 | """An event used for storing a 1 byte unsigned integer.""" 199 | 200 | STRUCT = c.Int8ul 201 | 202 | 203 | class WordEventBase(EventBase[int], abc.ABC): 204 | """Base class of events used for storing 2 byte data.""" 205 | 206 | ALLOWED_IDS = range(WORD, DWORD) 207 | 208 | def __init__(self, id: EventEnum, data: bytes) -> None: 209 | """ 210 | Args: 211 | id: **64** to **127**. 212 | data: Event data of size 2. 213 | 214 | Raises: 215 | EventIDOutOfRangeError: When ``id`` is not in range of 64-127. 216 | InvalidEventChunkSizeError: When size of `data` is not 2. 217 | """ 218 | super().__init__(id, data) 219 | 220 | 221 | class I16Event(WordEventBase): 222 | """An event used for storing a 2 byte signed integer.""" 223 | 224 | STRUCT = c.Int16sl 225 | 226 | 227 | class U16Event(WordEventBase): 228 | """An event used for storing a 2 byte unsigned integer.""" 229 | 230 | STRUCT = c.Int16ul 231 | 232 | 233 | class DWordEventBase(EventBase[T], abc.ABC): 234 | """Base class of events used for storing 4 byte data.""" 235 | 236 | ALLOWED_IDS = range(DWORD, TEXT) 237 | 238 | def __init__(self, id: EventEnum, data: bytes) -> None: 239 | """ 240 | Args: 241 | id: **128** to **191**. 242 | data: Event data of size 4. 243 | 244 | Raises: 245 | EventIDOutOfRangeError: When ``id`` is not in range of 128-191. 246 | InvalidEventChunkSizeError: When size of `data` is not 4. 247 | """ 248 | super().__init__(id, data) 249 | 250 | 251 | class F32Event(DWordEventBase[float]): 252 | """An event used for storing 4 byte floats.""" 253 | 254 | STRUCT = c.Float32l 255 | 256 | 257 | class I32Event(DWordEventBase[int]): 258 | """An event used for storing a 4 byte signed integer.""" 259 | 260 | STRUCT = c.Int32sl 261 | 262 | 263 | class U32Event(DWordEventBase[int]): 264 | """An event used for storing a 4 byte unsigned integer.""" 265 | 266 | STRUCT = c.Int32ul 267 | 268 | 269 | class U16TupleEvent(DWordEventBase[Tuple[int, int]]): 270 | """An event used for storing a two-tuple of 2 byte unsigned integers.""" 271 | 272 | STRUCT = c.ExprAdapter( 273 | c.Int16ul[2], 274 | lambda obj_, *_: tuple(obj_), # type: ignore 275 | lambda obj_, *_: list(obj_), # type: ignore 276 | ) 277 | 278 | 279 | class ColorEvent(DWordEventBase[RGBA]): 280 | """A 4 byte event which stores a color.""" 281 | 282 | STRUCT = c.ExprAdapter( 283 | c.Bytes(4), 284 | lambda obj, *_: RGBA.from_bytes(obj), # type: ignore 285 | lambda obj, *_: bytes(obj), # type: ignore 286 | ) 287 | 288 | 289 | class StrEventBase(EventBase[str]): 290 | """Base class of events used for storing strings.""" 291 | 292 | ALLOWED_IDS = (*range(TEXT, DATA), *NEW_TEXT_IDS) 293 | 294 | def __init__(self, id: EventEnum, data: bytes) -> None: 295 | """ 296 | Args: 297 | id: **192** to **207** or in :attr:`NEW_TEXT_IDS`. 298 | data: ASCII or UTF16 encoded string data. 299 | 300 | Raises: 301 | ValueError: When ``id`` is not in 192-207 or in :attr:`NEW_TEXT_IDS`. 302 | """ 303 | super().__init__(id, data) 304 | 305 | 306 | class AsciiEvent(StrEventBase): 307 | if TYPE_CHECKING: 308 | STRUCT: c.ExprAdapter[str, str, str, str] 309 | else: 310 | STRUCT = c.ExprAdapter( 311 | c.GreedyString("ascii"), 312 | lambda obj, *_: obj.rstrip("\0"), 313 | lambda obj, *_: obj + "\0", 314 | ) 315 | 316 | 317 | class UnicodeEvent(StrEventBase): 318 | if TYPE_CHECKING: 319 | STRUCT: c.ExprAdapter[str, str, str, str] 320 | else: 321 | STRUCT = c.ExprAdapter( 322 | c.GreedyString("utf-16-le"), 323 | lambda obj, *_: obj.rstrip("\0"), 324 | lambda obj, *_: obj + "\0", 325 | ) 326 | 327 | 328 | class StructEventBase(EventBase[AnyContainer], AnyDict): 329 | """Base class for events used for storing fixed size structured data. 330 | 331 | Consists of a collection of POD types like int, bool, float, but not strings. 332 | Its size is determined by the event as well as FL version. 333 | """ 334 | 335 | def __init__(self, id: EventEnum, data: bytes) -> None: 336 | super().__init__(id, data, len=len(data)) 337 | self.data = self.value # Akin to UserDict.__init__ 338 | 339 | def __setitem__(self, key: str, value: Any) -> None: 340 | if key not in self: 341 | raise KeyError 342 | 343 | if self[key] is None: 344 | raise PropertyCannotBeSet 345 | 346 | self.data[key] = value 347 | 348 | 349 | class ListEventBase(EventBase[AnyListContainer], AnyList): 350 | """Base class for events storing an array of structured data. 351 | 352 | Attributes: 353 | kwds: Keyword args passed to :meth:`STRUCT.parse` & :meth:`STRUCT.build`. 354 | """ 355 | 356 | STRUCT: c.Subconstruct[Any, Any, Any, Any] 357 | SIZES: ClassVar[list[int]] = [] 358 | """Manual :meth:`STRUCT.sizeof` override(s).""" 359 | 360 | def __init__(self, id: EventEnum, data: bytes, **kwds: Any) -> None: 361 | super().__init__(id, data, **kwds) 362 | self._struct_size: int | None = None 363 | 364 | if not self.SIZES: 365 | self._struct_size = self.STRUCT.subcon.sizeof() 366 | 367 | for size in self.SIZES: 368 | if not len(data) % size: 369 | self._struct_size = size 370 | break 371 | 372 | if self._struct_size is None: # pragma: no cover 373 | warnings.warn( 374 | f"Cannot parse event {id} as event size {len(data)} " 375 | f"is not a multiple of struct size(s) {self.SIZES}" 376 | ) 377 | else: 378 | self.data = self.value # Akin to UserList.__init__ 379 | 380 | 381 | class UnknownDataEvent(EventBase[bytes]): 382 | """Used for events whose structure is unknown as of yet.""" 383 | 384 | STRUCT = c.GreedyBytes 385 | 386 | 387 | @dataclass(order=True) 388 | class IndexedEvent: 389 | r: int 390 | """Root index of occurence of :attr:`e`.""" 391 | 392 | e: AnyEvent = field(compare=False) 393 | """The indexed event.""" 394 | 395 | 396 | def yields_child(func: Callable[Concatenate[EventTree, P], Iterator[EventTree]]): 397 | """Adds an :class:`EventTree` to its parent's list of children and yields it.""" 398 | 399 | def wrapper(self: EventTree, *args: P.args, **kwds: P.kwargs): 400 | for child in func(self, *args, **kwds): 401 | self.children.append(child) 402 | yield child 403 | 404 | return wrapper 405 | 406 | 407 | class EventTree: 408 | """Provides mutable "views" which propagate changes back to parents. 409 | 410 | This tree is analogous to the hierarchy used by models. 411 | 412 | Attributes: 413 | parent: Immediate ancestor / parent. Defaults to self. 414 | root: Parent of all parent trees. 415 | children: List of children. 416 | """ 417 | 418 | def __init__( 419 | self, 420 | parent: EventTree | None = None, 421 | init: Iterable[IndexedEvent] | None = None, 422 | ) -> None: 423 | """Create a new dictionary with an optional :attr:`parent`.""" 424 | self.children: list[EventTree] = [] 425 | self.lst: list[IndexedEvent] = SortedList(init or []) # type: ignore 426 | 427 | self.parent = parent 428 | if parent is not None: 429 | parent.children.append(self) 430 | 431 | while parent is not None and parent.parent is not None: 432 | parent = parent.parent 433 | self.root = parent or self 434 | 435 | def __contains__(self, id: EventEnum) -> bool: 436 | """Whether the key :attr:`id` exists in the list.""" 437 | return any(ie.e.id == id for ie in self.lst) 438 | 439 | def __eq__(self, o: object) -> bool: 440 | """Compares equality of internal lists.""" 441 | if not isinstance(o, EventTree): 442 | return NotImplemented 443 | 444 | return self.lst == o.lst 445 | 446 | def __iadd__(self, *events: AnyEvent) -> None: 447 | """Analogous to :meth:`list.extend`.""" 448 | for event in events: 449 | self.append(event) 450 | 451 | def __iter__(self) -> Iterator[AnyEvent]: 452 | return (ie.e for ie in self.lst) 453 | 454 | def __len__(self) -> int: 455 | return len(self.lst) 456 | 457 | def __repr__(self) -> str: 458 | return f"EventTree({len(self.ids)} IDs, {len(self)} events)" 459 | 460 | def _get_ie(self, *ids: EventEnum) -> Iterator[IndexedEvent]: 461 | return (ie for ie in self.lst if ie.e.id in ids) 462 | 463 | def _recursive(self, action: Callable[[EventTree], None]) -> None: 464 | """Recursively performs :attr:`action` on self and all parents.""" 465 | action(self) 466 | ancestor = self.parent 467 | while ancestor is not None: 468 | action(ancestor) 469 | ancestor = ancestor.parent 470 | 471 | def append(self, event: AnyEvent) -> None: 472 | """Appends an event at its corresponding key's list's end.""" 473 | self.insert(len(self), event) 474 | 475 | def count(self, id: EventEnum) -> int: 476 | """Returns the count of the events with :attr:`id`.""" 477 | return len(list(self._get_ie(id))) 478 | 479 | @yields_child 480 | def divide(self, separator: EventEnum, *ids: EventEnum) -> Iterator[EventTree]: 481 | """Yields subtrees containing events separated by ``separator`` infinitely.""" 482 | el: list[IndexedEvent] = [] 483 | first = True 484 | for ie in self.lst: 485 | if ie.e.id == separator: 486 | if not first: 487 | yield EventTree(self, el) 488 | el = [] 489 | else: 490 | first = False 491 | 492 | if ie.e.id in ids: 493 | el.append(ie) 494 | yield EventTree(self, el) # Yield the last one 495 | 496 | def first(self, id: EventEnum) -> AnyEvent: 497 | """Returns the first event with :attr:`id`. 498 | 499 | Raises: 500 | KeyError: An event with :attr:`id` isn't found. 501 | """ 502 | try: 503 | return next(self.get(id)) 504 | except StopIteration as exc: 505 | raise KeyError(id) from exc 506 | 507 | def get(self, *ids: EventEnum) -> Iterator[AnyEvent]: 508 | """Yields events whose ID is one of :attr:`ids`.""" 509 | return (e for e in self if e.id in ids) 510 | 511 | @yields_child 512 | def group(self, *ids: EventEnum) -> Iterator[EventTree]: 513 | """Yields EventTrees of zip objects of events with matching :attr:`ids`.""" 514 | for iet in zip_longest(*(self._get_ie(id) for id in ids)): # unpack magic 515 | yield EventTree(self, [ie for ie in iet if ie]) # filter out None values 516 | 517 | def insert(self, pos: int, e: AnyEvent) -> None: 518 | """Inserts :attr:`ev` at :attr:`pos` in this and all parent trees.""" 519 | rootidx = sorted(self.indexes)[pos] if len(self) else 0 520 | 521 | # Shift all root indexes after rootidx by +1 to prevent collisions 522 | # while sorting the entire list by root indexes before serialising. 523 | for ie in self.root.lst: 524 | if ie.r >= rootidx: 525 | ie.r += 1 526 | 527 | self._recursive(lambda et: et.lst.add(IndexedEvent(rootidx, e))) # type: ignore 528 | 529 | def pop(self, id: EventEnum, pos: int = 0) -> AnyEvent: 530 | """Pops the event with ``id`` at ``pos`` in ``self`` and all parents.""" 531 | if id not in self.ids: 532 | raise KeyError(id) 533 | 534 | ie = [ie for ie in self.lst if ie.e.id == id][pos] 535 | self._recursive(lambda et: et.lst.remove(ie)) 536 | 537 | # Shift all root indexes of events after rootidx by -1. 538 | for root_ie in self.root.lst: 539 | if root_ie.r >= ie.r: 540 | root_ie.r -= 1 541 | 542 | return ie.e 543 | 544 | def remove(self, id: EventEnum, pos: int = 0) -> None: 545 | """Removes the event with ``id`` at ``pos`` in ``self`` and all parents.""" 546 | self.pop(id, pos) 547 | 548 | @yields_child 549 | def separate(self, id: EventEnum) -> Iterator[EventTree]: 550 | """Yields a separate ``EventTree`` for every event with matching ``id``.""" 551 | yield from (EventTree(self, [ie]) for ie in self._get_ie(id)) 552 | 553 | def subtree(self, select: Callable[[AnyEvent], bool | None]) -> EventTree: 554 | """Returns a mutable view containing events for which ``select`` was True. 555 | 556 | Caution: 557 | Always use this function to create a mutable view. Maintaining 558 | chilren and passing parent to a child are best done here. 559 | """ 560 | el: list[IndexedEvent] = [] 561 | for ie in self.lst: 562 | if select(ie.e): 563 | el.append(ie) 564 | obj = EventTree(self, el) 565 | self.children.append(obj) 566 | return obj 567 | 568 | @yields_child 569 | def subtrees( 570 | self, select: Callable[[AnyEvent], bool | None], repeat: int 571 | ) -> Iterator[EventTree]: 572 | """Yields mutable views till ``select`` and ``repeat`` are satisfied. 573 | 574 | Args: 575 | select: Called for every event in this dictionary by iterating over 576 | a chained, sorted list. Returns True if event must be included. 577 | Once it returns False, rest of them are ignored and resulting 578 | EventTree is returned. Return None to skip an event. 579 | repeat: Use -1 for infinite iterations. 580 | """ 581 | el: list[IndexedEvent] = [] 582 | for ie in self.lst: 583 | if not repeat: 584 | return 585 | 586 | result = select(ie.e) 587 | if result is False: 588 | yield EventTree(self, el) 589 | el = [ie] # Don't skip current event 590 | repeat -= 1 591 | elif result is not None: 592 | el.append(ie) 593 | 594 | @property 595 | def ids(self) -> frozenset[EventEnum]: 596 | return frozenset(ie.e.id for ie in self.lst) 597 | 598 | @property 599 | def indexes(self) -> frozenset[int]: 600 | """Returns root indexes for all events in ``self``.""" 601 | return frozenset(ie.r for ie in self.lst) 602 | -------------------------------------------------------------------------------- /pyflp/_models.py: -------------------------------------------------------------------------------- 1 | # PyFLP - An FL Studio project file (.flp) parser 2 | # Copyright (C) 2022 demberto 3 | # 4 | # This program is free software: you can redistribute it and/or modify it 5 | # under the terms of the GNU General Public License as published by the Free 6 | # Software Foundation, either version 3 of the License, or (at your option) 7 | # any later version. This program is distributed in the hope that it will be 8 | # useful, but WITHOUT ANY WARRANTY; without even the implied warranty of 9 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General 10 | # Public License for more details. You should have received a copy of the 11 | # GNU General Public License along with this program. If not, see 12 | # . 13 | 14 | """Contains the ABCs used by model classes and some shared classes.""" 15 | 16 | from __future__ import annotations 17 | 18 | import abc 19 | import functools 20 | from typing import ( 21 | Any, 22 | Callable, 23 | Generic, 24 | Iterable, 25 | Protocol, 26 | Sequence, 27 | TypeVar, 28 | Union, 29 | overload, 30 | runtime_checkable, 31 | ) 32 | 33 | import construct as c 34 | 35 | from pyflp._events import EventTree, ListEventBase, StructEventBase 36 | 37 | VE = TypeVar("VE", bound=Union[StructEventBase, ListEventBase]) 38 | 39 | 40 | class ModelBase(abc.ABC): 41 | def __init__(self, *args: Any, **kw: Any) -> None: 42 | self._kw = kw 43 | 44 | 45 | class ItemModel(ModelBase, Generic[VE]): 46 | """Base class for event-less models.""" 47 | 48 | def __init__(self, item: c.Container[Any], index: int, parent: VE, **kw: Any) -> None: 49 | """Create a new item model. 50 | 51 | Args: 52 | item: Parsed :class:`construct.Struct` instance from :attr:`parent`. 53 | index: 0-based index used to propagate changes back to :attr:`parent`. 54 | parent: A :class:`StructEventBase` or :class:`ListEventBase` instance. 55 | """ 56 | self._item = item 57 | self._index = index 58 | self._parent = parent 59 | super().__init__(**kw) 60 | 61 | def __getitem__(self, prop: str): 62 | return self._item[prop] 63 | 64 | def __setitem__(self, prop: str, value: Any) -> None: 65 | self._item[prop] = value 66 | 67 | if not isinstance(self._parent, ListEventBase): 68 | raise NotImplementedError 69 | 70 | self._parent[self._index] = self._item 71 | 72 | 73 | class EventModel(ModelBase): 74 | def __init__(self, events: EventTree, **kw: Any) -> None: 75 | super().__init__(**kw) 76 | self.events = events 77 | 78 | def __eq__(self, o: object) -> bool: 79 | if not isinstance(o, type(self)): 80 | raise TypeError(f"Cannot compare {type(o)!r} with {type(self)!r}") 81 | 82 | return o.events == self.events 83 | 84 | 85 | MT_co = TypeVar("MT_co", bound=ModelBase, covariant=True) 86 | EMT_co = TypeVar("EMT_co", bound=EventModel, covariant=True) 87 | 88 | 89 | @runtime_checkable 90 | class ModelCollection(Iterable[MT_co], Protocol[MT_co]): 91 | @overload 92 | def __getitem__(self, i: int | str) -> MT_co: 93 | ... 94 | 95 | @overload 96 | def __getitem__(self, i: slice) -> Sequence[MT_co]: 97 | ... 98 | 99 | 100 | def supports_slice(func: Callable[[ModelCollection[MT_co], str | int | slice], MT_co]): 101 | """Wraps a :meth:`ModelCollection.__getitem__` to return a sequence if required.""" 102 | 103 | @overload 104 | def wrapper(self: ModelCollection[MT_co], i: int | str) -> MT_co: 105 | ... 106 | 107 | @overload 108 | def wrapper(self: ModelCollection[MT_co], i: slice) -> Sequence[MT_co]: 109 | ... 110 | 111 | @functools.wraps(func) 112 | def wrapper(self: Any, i: Any) -> MT_co | Sequence[MT_co]: 113 | if isinstance(i, slice): 114 | return [model for idx, model in enumerate(self) if idx in range(i.start, i.stop)] 115 | return func(self, i) 116 | 117 | return wrapper 118 | 119 | 120 | class ModelReprMixin: 121 | """I am too lazy to make one `__repr__()` for every model.""" 122 | 123 | def __repr__(self) -> str: 124 | mapping: dict[str, Any] = {} 125 | for var in [var for var in vars(type(self)) if not var.startswith("_")]: 126 | mapping[var] = getattr(self, var, None) 127 | 128 | params = ", ".join([f"{k}={v!r}" for k, v in mapping.items()]) 129 | return f"{type(self).__name__}({params})" 130 | -------------------------------------------------------------------------------- /pyflp/arrangement.py: -------------------------------------------------------------------------------- 1 | # PyFLP - An FL Studio project file (.flp) parser 2 | # Copyright (C) 2022 demberto 3 | # 4 | # This program is free software: you can redistribute it and/or modify it 5 | # under the terms of the GNU General Public License as published by the Free 6 | # Software Foundation, either version 3 of the License, or (at your option) 7 | # any later version. This program is distributed in the hope that it will be 8 | # useful, but WITHOUT ANY WARRANTY; without even the implied warranty of 9 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General 10 | # Public License for more details. You should have received a copy of the 11 | # GNU General Public License along with this program. If not, see 12 | # . 13 | 14 | """Contains the types used by tracks and arrangements.""" 15 | 16 | from __future__ import annotations 17 | 18 | import enum 19 | from typing import Any, Iterator, Literal, Optional, cast 20 | 21 | import construct as c 22 | import construct_typed as ct 23 | from typing_extensions import TypedDict, Unpack 24 | 25 | from pyflp._adapters import FourByteBool, StdEnum 26 | from pyflp._descriptors import EventProp, NestedProp, StructProp 27 | from pyflp._events import ( 28 | DATA, 29 | DWORD, 30 | TEXT, 31 | WORD, 32 | AnyEvent, 33 | EventEnum, 34 | EventTree, 35 | ListEventBase, 36 | StructEventBase, 37 | U8Event, 38 | U16Event, 39 | U16TupleEvent, 40 | ) 41 | from pyflp._models import ( 42 | EventModel, 43 | ItemModel, 44 | ModelCollection, 45 | ModelReprMixin, 46 | supports_slice, 47 | ) 48 | from pyflp.channel import Channel, ChannelRack 49 | from pyflp.exceptions import ModelNotFound, NoModelsFound, PropertyCannotBeSet 50 | from pyflp.pattern import Pattern, Patterns 51 | from pyflp.timemarker import TimeMarker, TimeMarkerID 52 | from pyflp.types import RGBA, FLVersion 53 | 54 | __all__ = [ 55 | "Arrangements", 56 | "Arrangement", 57 | "Track", 58 | "TrackMotion", 59 | "TrackPress", 60 | "TrackSync", 61 | "ChannelPLItem", 62 | "PatternPLItem", 63 | ] 64 | 65 | 66 | class PLSelectionEvent(StructEventBase): 67 | STRUCT = c.Struct("start" / c.Optional(c.Int32ul), "end" / c.Optional(c.Int32ul)).compile() 68 | 69 | 70 | class PlaylistEvent(ListEventBase): 71 | STRUCT = c.GreedyRange( 72 | c.Struct( 73 | "position" / c.Int32ul, # 4 74 | "pattern_base" / c.Int16ul * "Always 20480", # 6 75 | "item_index" / c.Int16ul, # 8 76 | "length" / c.Int32ul, # 12 77 | "track_rvidx" / c.Int16ul * "Stored reversed i.e. Track 1 would be 499", # 14 78 | "group" / c.Int16ul, # 16 79 | "_u1" / c.Bytes(2) * "Always (120, 0)", # 18 80 | "item_flags" / c.Int16ul * "Always (64, 0)", # 20 81 | "_u2" / c.Bytes(4) * "Always (64, 100, 128, 128)", # 24 82 | "start_offset" / c.Float32l, # 28 83 | "end_offset" / c.Float32l, # 32 84 | "_u3" / c.If(c.this._params["new"], c.Bytes(28)) * "New in FL 21", # 60 85 | ) 86 | ) 87 | SIZES = [32, 60] 88 | 89 | def __init__(self, id: EventEnum, data: bytes) -> None: 90 | super().__init__(id, data, new=not len(data) % 60) 91 | 92 | 93 | @enum.unique 94 | class TrackMotion(ct.EnumBase): 95 | Stay = 0 96 | OneShot = 1 97 | MarchWrap = 2 98 | MarchStay = 3 99 | MarchStop = 4 100 | Random = 5 101 | ExclusiveRandom = 6 102 | 103 | 104 | @enum.unique 105 | class TrackPress(ct.EnumBase): 106 | Retrigger = 0 107 | HoldStop = 1 108 | HoldMotion = 2 109 | Latch = 3 110 | 111 | 112 | @enum.unique 113 | class TrackSync(ct.EnumBase): 114 | Off = 0 115 | QuarterBeat = 1 116 | HalfBeat = 2 117 | Beat = 3 118 | TwoBeats = 4 119 | FourBeats = 5 120 | Auto = 6 121 | 122 | 123 | class HeightAdapter(ct.Adapter[float, float, str, str]): 124 | def _decode(self, obj: float, *_: Any) -> str: 125 | return str(int(obj * 100)) + "%" 126 | 127 | def _encode(self, obj: str, *_: Any) -> float: 128 | return int(obj[:-1]) / 100 129 | 130 | 131 | class TrackEvent(StructEventBase): 132 | STRUCT = c.Struct( 133 | "iid" / c.Optional(c.Int32ul), # 4 134 | "color" / c.Optional(c.Int32ul), # 8 135 | "icon" / c.Optional(c.Int32ul), # 12 136 | "enabled" / c.Optional(c.Flag), # 13 137 | "height" / c.Optional(HeightAdapter(c.Float32l)), # 17 138 | "locked_height" / c.Optional(c.Int32sl), # 21 139 | "content_locked" / c.Optional(c.Flag), # 22 140 | "motion" / c.Optional(StdEnum[TrackMotion](c.Int32ul)), # 26 141 | "press" / c.Optional(StdEnum[TrackPress](c.Int32ul)), # 30 142 | "trigger_sync" / c.Optional(StdEnum[TrackSync](c.Int32ul)), # 34 143 | "queued" / c.Optional(FourByteBool), # 38 144 | "tolerant" / c.Optional(FourByteBool), # 42 145 | "position_sync" / c.Optional(StdEnum[TrackSync](c.Int32ul)), # 46 146 | "grouped" / c.Optional(c.Flag), # 47 147 | "locked" / c.Optional(c.Flag), # 48 148 | "_u1" / c.Optional(c.GreedyBytes), # * 66 as of 20.9.1 149 | ).compile() 150 | 151 | 152 | @enum.unique 153 | class ArrangementsID(EventEnum): 154 | TimeSigNum = (17, U8Event) 155 | TimeSigBeat = (18, U8Event) 156 | Current = (WORD + 36, U16Event) 157 | _LoopPos = (DWORD + 24, U16TupleEvent) #: 1.3.8+ 158 | PLSelection = (DATA + 9, PLSelectionEvent) 159 | """.. versionadded:: v2.1.0""" 160 | 161 | 162 | @enum.unique 163 | class ArrangementID(EventEnum): 164 | New = (WORD + 35, U16Event) 165 | # _PlaylistItem = DWORD + 1 166 | Name = TEXT + 49 167 | Playlist = (DATA + 25, PlaylistEvent) 168 | 169 | 170 | @enum.unique 171 | class TrackID(EventEnum): 172 | Name = TEXT + 47 173 | Data = (DATA + 30, TrackEvent) 174 | 175 | 176 | class PLItemBase(ItemModel[PlaylistEvent], ModelReprMixin): 177 | group = StructProp[int]() 178 | """Returns 0 for no group, else a group number for clips in the same group.""" 179 | 180 | length = StructProp[int]() 181 | """PPQ-dependant quantity.""" 182 | 183 | muted = StructProp[bool]() 184 | """Whether muted / disabled in the playlist. *New in FL Studio v9.0.0*.""" 185 | 186 | @property 187 | def offsets(self) -> tuple[float, float]: 188 | """Returns a ``(start, end)`` offset tuple. 189 | 190 | An offset is the distance from the item's actual start or end. 191 | """ 192 | return (self["start_offset"], self["end_offset"]) 193 | 194 | @offsets.setter 195 | def offsets(self, value: tuple[float, float]) -> None: 196 | self["start_offset"], self["end_offset"] = value 197 | 198 | position = StructProp[int]() 199 | """PPQ-dependant quantity.""" 200 | 201 | 202 | class ChannelPLItem(PLItemBase, ModelReprMixin): 203 | """An audio clip or automation on the playlist of an arrangement. 204 | 205 | *New in FL Studio v2.0.1*. 206 | """ 207 | 208 | @property 209 | def channel(self) -> Channel: 210 | return self._kw["channel"] 211 | 212 | @channel.setter 213 | def channel(self, channel: Channel) -> None: 214 | self._kw["channel"] = channel 215 | self["item_index"] = channel.iid 216 | 217 | 218 | class PatternPLItem(PLItemBase, ModelReprMixin): 219 | """A pattern block or clip on the playlist of an arrangement. 220 | 221 | *New in FL Studio v7.0.0*. 222 | """ 223 | 224 | @property 225 | def pattern(self) -> Pattern: 226 | return self._kw["pattern"] 227 | 228 | @pattern.setter 229 | def pattern(self, pattern: Pattern) -> None: 230 | self._kw["pattern"] = pattern 231 | self["item_index"] = pattern.iid + self["pattern_base"] 232 | 233 | 234 | class _TrackColorProp(StructProp[RGBA]): 235 | def _get(self, ev_or_ins: Any) -> RGBA | None: 236 | value = cast(Optional[int], super()._get(ev_or_ins)) 237 | if value is not None: 238 | return RGBA.from_bytes(value.to_bytes(4, "little")) 239 | 240 | def _set(self, ev_or_ins: Any, value: RGBA) -> None: 241 | super()._set(ev_or_ins, int.from_bytes(bytes(value), "little")) # type: ignore 242 | 243 | 244 | class _TrackKW(TypedDict): 245 | items: list[PLItemBase] 246 | 247 | 248 | class Track(EventModel, ModelCollection[PLItemBase]): 249 | """Represents a track in an arrangement on which playlist items are arranged. 250 | 251 | ![](https://bit.ly/3de6R8y) 252 | """ 253 | 254 | def __init__(self, events: EventTree, **kw: Unpack[_TrackKW]) -> None: 255 | super().__init__(events, **kw) 256 | 257 | def __getitem__(self, index: int | slice | str): 258 | if isinstance(index, str): 259 | return NotImplemented 260 | return self._kw["items"][index] 261 | 262 | def __iter__(self) -> Iterator[PLItemBase]: 263 | """An iterator over :attr:`items`.""" 264 | yield from self._kw["items"] 265 | 266 | def __len__(self) -> int: 267 | return len(self._kw["items"]) 268 | 269 | def __repr__(self) -> str: 270 | return f"Track(name={self.name}, iid={self.iid}, {len(self)} items)" 271 | 272 | color = _TrackColorProp(TrackID.Data) 273 | """Defaults to #485156 (dark slate gray). 274 | 275 | ![](https://bit.ly/3yVGGuW) 276 | 277 | Note: 278 | Unlike :attr:`Channel.color` and :attr:`Insert.color`, values below ``20`` for 279 | any color component (i.e red, green or blue) are NOT ignored by FL Studio. 280 | """ 281 | 282 | content_locked = StructProp[bool](TrackID.Data) 283 | """:guilabel:`Lock to content`, defaults to ``False``.""" 284 | 285 | enabled = StructProp[bool](TrackID.Data) 286 | """![](https://bit.ly/3eGd91O)""" 287 | 288 | grouped = StructProp[bool](TrackID.Data) 289 | """Whether grouped with the track above (index - 1) or not. 290 | 291 | ![](https://bit.ly/3yXO5tM) 292 | 293 | :guilabel:`&Group with above track` 294 | """ 295 | 296 | height = StructProp[str](TrackID.Data) 297 | """Track height in FL's interface. Linear. :guilabel:`&Size`.""" 298 | 299 | icon = StructProp[int](TrackID.Data) 300 | """Returns ``0`` if not set, else an internal icon ID. 301 | 302 | ![](https://bit.ly/3gln8Kc) 303 | 304 | :guilabel:`Change icon` 305 | """ 306 | 307 | iid = StructProp[int](TrackID.Data) 308 | """An integer in the range of 1 to :attr:`Arrangements.max_tracks`.""" 309 | 310 | locked = StructProp[bool](TrackID.Data) 311 | """Whether the tracked is in a locked state. 312 | 313 | ![](https://bit.ly/3VFG6eP) 314 | """ 315 | 316 | motion = StructProp[TrackMotion](TrackID.Data) 317 | """:guilabel:`&Performance settings`, defaults to :attr:`TrackMotion.Stay`.""" 318 | 319 | name = EventProp[str](TrackID.Name) 320 | """Returns a string or ``None`` if not set.""" 321 | 322 | position_sync = StructProp[TrackSync](TrackID.Data) 323 | """:guilabel:`&Performance settings`, defaults to :attr:`TrackSync.Off`.""" 324 | 325 | press = StructProp[TrackPress](TrackID.Data) 326 | """:guilabel:`&Performance settings`, defaults to :attr:`TrackPress.Retrigger`.""" 327 | 328 | tolerant = StructProp[bool](TrackID.Data) 329 | """:guilabel:`&Performance settings`, defaults to ``True``.""" 330 | 331 | trigger_sync = StructProp[TrackSync](TrackID.Data) 332 | """:guilabel:`&Performance settings`, defaults to :attr:`TrackSync.FourBeats`.""" 333 | 334 | queued = StructProp[bool](TrackID.Data) 335 | """:guilabel:`&Performance settings`, defaults to ``False``.""" 336 | 337 | 338 | class _ArrangementKW(TypedDict): 339 | channels: ChannelRack 340 | patterns: Patterns 341 | version: FLVersion 342 | 343 | 344 | class Arrangement(EventModel): 345 | """Contains the timemarkers and tracks in an arrangement. 346 | 347 | ![](https://bit.ly/3B6is1z) 348 | 349 | *New in FL Studio v12.9.1*: Support for multiple arrangements. 350 | """ 351 | 352 | def __init__(self, events: EventTree, **kw: Unpack[_ArrangementKW]) -> None: 353 | super().__init__(events, **kw) 354 | 355 | def __repr__(self) -> str: 356 | return "Arrangement(iid={}, name={}, {} timemarkers, {} tracks)".format( 357 | self.iid, 358 | repr(self.name), 359 | len(tuple(self.timemarkers)), 360 | len(tuple(self.tracks)), 361 | ) 362 | 363 | iid = EventProp[int](ArrangementID.New) 364 | """A 1-based internal index.""" 365 | 366 | name = EventProp[str](ArrangementID.Name) 367 | """Name of the arrangement; defaults to **Arrangement**.""" 368 | 369 | @property 370 | def timemarkers(self) -> Iterator[TimeMarker]: 371 | yield from (TimeMarker(ed) for ed in self.events.group(*TimeMarkerID)) 372 | 373 | @property 374 | def tracks(self) -> Iterator[Track]: 375 | pl_evt = None 376 | max_idx = 499 if self._kw["version"] >= FLVersion(12, 9, 1) else 198 377 | channels = {channel.iid: channel for channel in self._kw["channels"]} 378 | patterns = {pattern.iid: pattern for pattern in self._kw["patterns"]} 379 | 380 | if ArrangementID.Playlist in self.events.ids: 381 | pl_evt = cast(PlaylistEvent, self.events.first(ArrangementID.Playlist)) 382 | 383 | for track_idx, ed in enumerate(self.events.divide(TrackID.Data, *TrackID)): 384 | if pl_evt is None: 385 | yield Track(ed, items=[]) 386 | continue 387 | 388 | items: list[PLItemBase] = [] 389 | for i, item in enumerate(pl_evt): 390 | if max_idx - item["track_rvidx"] != track_idx: 391 | continue 392 | 393 | if item["item_index"] <= item["pattern_base"]: 394 | iid = item["item_index"] 395 | items.append(ChannelPLItem(item, i, pl_evt, channel=channels[iid])) 396 | else: 397 | num = item["item_index"] - item["pattern_base"] 398 | items.append(PatternPLItem(item, i, pl_evt, pattern=patterns[num])) 399 | yield Track(ed, items=items) 400 | 401 | 402 | # TODO Find whether time is set to signature or division mode. 403 | class TimeSignature(EventModel, ModelReprMixin): 404 | """![](https://bit.ly/3EYiMmy)""" 405 | 406 | def __str__(self) -> str: 407 | return f"Global time signature: {self.num}/{self.beat}" 408 | 409 | num = EventProp[int](ArrangementsID.TimeSigNum) 410 | """Beats per bar in time division & numerator in time signature mode. 411 | 412 | | Min | Max | Default | 413 | |-----|-----|---------| 414 | | 1 | 16 | 4 | 415 | """ 416 | 417 | beat = EventProp[int](ArrangementsID.TimeSigBeat) 418 | """Steps per beat in time division & denominator in time signature mode. 419 | 420 | In time signature mode it can be 2, 4, 8 or 16 but in time division mode: 421 | 422 | | Min | Max | Default | 423 | |-----|-----|---------| 424 | | 1 | 16 | 4 | 425 | """ 426 | 427 | 428 | class Arrangements(EventModel, ModelCollection[Arrangement]): 429 | """Iterator over arrangements in the project and some related properties.""" 430 | 431 | def __init__(self, events: EventTree, **kw: Unpack[_ArrangementKW]) -> None: 432 | super().__init__(events, **kw) 433 | 434 | @supports_slice # type: ignore 435 | def __getitem__(self, i: int | str | slice) -> Arrangement: 436 | """Returns an arrangement based either on its index or name. 437 | 438 | Args: 439 | i: The index of the arrangement in which they occur or 440 | :attr:`Arrangement.name` of the arrangement to lookup for or a 441 | slice of indexes. 442 | 443 | Raises: 444 | ModelNotFound: An :class:`Arrangement` with the specifed name or 445 | index isn't found. 446 | """ 447 | for idx, arr in enumerate(self): 448 | if (isinstance(i, str) and i == arr.name) or idx == i: 449 | return arr 450 | raise ModelNotFound(i) 451 | 452 | # TODO Verify ArrangementsID.Current is the end 453 | # FL changed event ordering a lot, the latest being the most easiest to 454 | # parse; it contains ArrangementID.New event followed by TimeMarker events 455 | # followed by 500 TrackID events. TimeMarkers occured before new arrangement 456 | # event in initial versions of FL20, making them harder to group. 457 | # TODO This logic might not work on older versions of FL. 458 | def __iter__(self) -> Iterator[Arrangement]: 459 | """Yields :class:`Arrangement` found in the project. 460 | 461 | Raises: 462 | NoModelsFound: When no arrangements are found. 463 | """ 464 | arrnew_occured = False 465 | 466 | def select(e: AnyEvent) -> bool | None: 467 | nonlocal arrnew_occured 468 | if e.id == ArrangementID.New: 469 | if arrnew_occured: 470 | return False 471 | arrnew_occured = True 472 | 473 | if e.id in (*ArrangementID, *TimeMarkerID, *TrackID): 474 | return True 475 | 476 | if e.id == ArrangementsID.Current: 477 | return False # Yield out last arrangement 478 | 479 | yield from (Arrangement(ed, **self._kw) for ed in self.events.subtrees(select, len(self))) 480 | 481 | def __len__(self) -> int: 482 | """The number of arrangements present in the project. 483 | 484 | Raises: 485 | NoModelsFound: When no arrangements are found. 486 | """ 487 | if ArrangementID.New not in self.events.ids: 488 | raise NoModelsFound 489 | return self.events.count(ArrangementID.New) 490 | 491 | def __repr__(self) -> str: 492 | return f"{len(self)} arrangements" 493 | 494 | @property 495 | def current(self) -> Arrangement | None: 496 | """Currently selected arrangement (via FL's interface). 497 | 498 | Raises: 499 | ModelNotFound: When the underlying event value points to an 500 | invalid arrangement index. 501 | """ 502 | if ArrangementsID.Current in self.events.ids: 503 | event = self.events.first(ArrangementsID.Current) 504 | index: int = event.value 505 | try: 506 | return list(self)[index] 507 | except IndexError as exc: 508 | raise ModelNotFound(index) from exc 509 | 510 | @property 511 | def loop_pos(self) -> tuple[int, int] | None: 512 | """Playlist loop start and end points. PPQ dependant. 513 | 514 | .. versionchanged:: v2.1.0 515 | 516 | :attr:`ArrangementsID.PLSelection` is used by default 517 | while :attr:`ArrangementsID._LoopPos` is a fallback. 518 | 519 | *New in FL Studio v1.3.8*. 520 | """ 521 | if ArrangementsID.PLSelection in self.events: 522 | event = cast(PLSelectionEvent, self.events.first(ArrangementsID.PLSelection)) 523 | return event["start"], event["end"] 524 | 525 | if ArrangementsID._LoopPos in self.events: 526 | return self.events.first(ArrangementsID._LoopPos).value 527 | 528 | @loop_pos.setter 529 | def loop_pos(self, value: tuple[int, int]) -> None: 530 | if ArrangementsID.PLSelection in self.events: 531 | event = cast(PLSelectionEvent, self.events.first(ArrangementsID.PLSelection)) 532 | event["start"], event["end"] = value 533 | elif ArrangementsID._LoopPos in self.events: 534 | self.events.first(ArrangementsID._LoopPos).value = value 535 | else: 536 | raise PropertyCannotBeSet(ArrangementsID.PLSelection, ArrangementsID._LoopPos) 537 | 538 | @property 539 | def max_tracks(self) -> Literal[500, 199]: 540 | return 500 if self._kw["version"] >= FLVersion(12, 9, 1) else 199 541 | 542 | time_signature = NestedProp( 543 | TimeSignature, ArrangementsID.TimeSigNum, ArrangementsID.TimeSigBeat 544 | ) 545 | """Project time signature (also used by playlist). 546 | 547 | :menuselection:`Options --> &Project general settings --> Time settings` 548 | """ 549 | -------------------------------------------------------------------------------- /pyflp/controller.py: -------------------------------------------------------------------------------- 1 | # PyFLP - An FL Studio project file (.flp) parser 2 | # Copyright (C) 2022 demberto 3 | # 4 | # This program is free software: you can redistribute it and/or modify it 5 | # under the terms of the GNU General Public License as published by the Free 6 | # Software Foundation, either version 3 of the License, or (at your option) 7 | # any later version. This program is distributed in the hope that it will be 8 | # useful, but WITHOUT ANY WARRANTY; without even the implied warranty of 9 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General 10 | # Public License for more details. You should have received a copy of the 11 | # GNU General Public License along with this program. If not, see 12 | # . 13 | 14 | """Contains the types used by MIDI and remote ("internal") controllers.""" 15 | 16 | from __future__ import annotations 17 | 18 | import enum 19 | from typing import cast 20 | 21 | import construct as c 22 | 23 | from pyflp._events import DATA, EventEnum, StructEventBase 24 | from pyflp._models import EventModel, ModelReprMixin 25 | 26 | __all__ = ["RemoteController"] 27 | 28 | 29 | class MIDIControllerEvent(StructEventBase): 30 | STRUCT = c.Struct("_u1" / c.GreedyBytes) 31 | 32 | 33 | class RemoteControllerEvent(StructEventBase): 34 | STRUCT = c.Struct( 35 | "_u1" / c.Optional(c.Bytes(2)), # 2 36 | "_u2" / c.Optional(c.Byte), # 3 37 | "_u3" / c.Optional(c.Byte), # 4 38 | "parameter_data" / c.Optional(c.Int16ul), # 6 39 | "destination_data" / c.Optional(c.Int16sl), # 8 40 | "_u4" / c.Optional(c.Bytes(8)), # 16 41 | "_u5" / c.Optional(c.Bytes(4)), # 20 42 | ).compile() 43 | 44 | 45 | @enum.unique 46 | class ControllerID(EventEnum): 47 | MIDI = (DATA + 18, MIDIControllerEvent) 48 | Remote = (DATA + 19, RemoteControllerEvent) 49 | 50 | 51 | class RemoteController(EventModel, ModelReprMixin): 52 | """![](https://bit.ly/3S0i4Zf) 53 | 54 | *New in FL Studio v3.3.0*. 55 | """ 56 | 57 | @property 58 | def parameter(self) -> int | None: 59 | """The ID of the plugin parameter to which controller is linked to.""" 60 | if ( 61 | value := cast(StructEventBase, self.events.first(ControllerID.Remote))["parameter_data"] 62 | is not None 63 | ): 64 | return value & 0x7FFF 65 | 66 | @property 67 | def controls_vst(self) -> bool | None: 68 | """Whether `parameter` is linked to a VST plugin. 69 | 70 | None when linked to a plugin parameter on an insert slot. 71 | """ 72 | if ( 73 | value := cast(StructEventBase, self.events.first(ControllerID.Remote))["parameter_data"] 74 | is not None 75 | ): 76 | return (value & 0x8000) > 0 77 | -------------------------------------------------------------------------------- /pyflp/exceptions.py: -------------------------------------------------------------------------------- 1 | # PyFLP - An FL Studio project file (.flp) parser 2 | # Copyright (C) 2022 demberto 3 | # 4 | # This program is free software/or modify it 5 | # under the terms of the GNU General Public License as published by the Free 6 | # Software Foundation, either version 3 of the License, or (at your option) 7 | # any later version. This program is distributed in the hope that it will be 8 | # useful, but WITHOUT ANY WARRANTY; without even the implied warranty of 9 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General 10 | # Public License for more details. You should have received a copy of the 11 | # GNU General Public License along with this program. If not, see 12 | # . 13 | 14 | """Contains the exceptions used by and shared across PyFLP.""" 15 | 16 | from __future__ import annotations 17 | 18 | import enum 19 | 20 | __all__ = [ 21 | "Error", 22 | "NoModelsFound", 23 | "EventIDOutOfRange", 24 | "InvalidEventChunkSize", 25 | "PropertyCannotBeSet", 26 | "HeaderCorrupted", 27 | "VersionNotDetected", 28 | "ModelNotFound", 29 | "DataCorrupted", 30 | ] 31 | 32 | 33 | class Error(Exception): 34 | """Base class for PyFLP exceptions. 35 | 36 | It is not guaranteed that exceptions raised from PyFLP always subclass Error. 37 | This is done to prevent duplication of exceptions. All exceptions raised by 38 | a function (in its body) explicitly are documented. 39 | 40 | Some exceptions derive from standard Python exceptions to ease handling. 41 | """ 42 | 43 | 44 | class EventIDOutOfRange(Error, ValueError): 45 | """An event is created with an ID out of its allowed range.""" 46 | 47 | def __init__(self, id: int, *expected: int) -> None: 48 | super().__init__(f"Expected ID in {expected!r}; got {id!r} instead") 49 | 50 | 51 | class InvalidEventChunkSize(Error, BufferError): 52 | """A fixed size event is created with a wrong amount of bytes.""" 53 | 54 | def __init__(self, expected: int, got: int) -> None: 55 | super().__init__(f"Expected a bytes object of length {expected}; got {got}") 56 | 57 | 58 | class PropertyCannotBeSet(Error, AttributeError): 59 | def __init__(self, *ids: enum.Enum | int) -> None: 60 | super().__init__(f"Event(s) {ids!r} was / were not found") 61 | 62 | 63 | class DataCorrupted(Error): 64 | """Base class for parsing exceptions.""" 65 | 66 | 67 | class HeaderCorrupted(DataCorrupted, ValueError): 68 | """Header chunk contains an unexpected / invalid value. 69 | 70 | Args: 71 | desc: A string containing details about what is corrupted. 72 | """ 73 | 74 | def __init__(self, desc: str) -> None: 75 | super().__init__(f"Error parsing header: {desc}") 76 | 77 | 78 | class NoModelsFound(DataCorrupted, LookupError): 79 | """Model's `__iter__` method fails to generate any model.""" 80 | 81 | 82 | class ModelNotFound(DataCorrupted, IndexError): 83 | """An invalid index is passed to model's `__getitem__` method.""" 84 | 85 | 86 | class VersionNotDetected(DataCorrupted): 87 | """String decoder couldn't be decided due to absence of project version.""" 88 | -------------------------------------------------------------------------------- /pyflp/mixer.py: -------------------------------------------------------------------------------- 1 | # PyFLP - An FL Studio project file (.flp) parser 2 | # Copyright (C) 2022 demberto 3 | # 4 | # This program is free software: you can redistribute it and/or modify it 5 | # under the terms of the GNU General Public License as published by the Free 6 | # Software Foundation, either version 3 of the License, or (at your option) 7 | # any later version. This program is distributed in the hope that it will be 8 | # useful, but WITHOUT ANY WARRANTY; without even the implied warranty of 9 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General 10 | # Public License for more details. You should have received a copy of the 11 | # GNU General Public License along with this program. If not, see 12 | # . 13 | 14 | """Contains the types used by the mixer, inserts and effect slots.""" 15 | 16 | from __future__ import annotations 17 | 18 | import dataclasses 19 | import enum 20 | from collections import defaultdict 21 | from typing import Any, DefaultDict, Iterator, NamedTuple, cast 22 | 23 | import construct as c 24 | import construct_typed as ct 25 | from typing_extensions import NotRequired, TypedDict, Unpack 26 | 27 | from pyflp._adapters import StdEnum 28 | from pyflp._descriptors import EventProp, FlagProp, NamedPropMixin, ROProperty, RWProperty 29 | from pyflp._events import ( 30 | DATA, 31 | DWORD, 32 | TEXT, 33 | WORD, 34 | AnyEvent, 35 | ColorEvent, 36 | EventEnum, 37 | EventTree, 38 | I16Event, 39 | I32Event, 40 | ListEventBase, 41 | StructEventBase, 42 | U16Event, 43 | ) 44 | from pyflp._models import EventModel, ModelBase, ModelCollection, ModelReprMixin, supports_slice 45 | from pyflp.exceptions import ModelNotFound, NoModelsFound, PropertyCannotBeSet 46 | from pyflp.plugin import ( 47 | FruityBalance, 48 | FruityBloodOverdrive, 49 | FruityCenter, 50 | FruityFastDist, 51 | FruityNotebook2, 52 | FruitySend, 53 | FruitySoftClipper, 54 | FruityStereoEnhancer, 55 | PluginID, 56 | PluginProp, 57 | Soundgoodizer, 58 | VSTPlugin, 59 | ) 60 | from pyflp.types import RGBA, FLVersion, T 61 | 62 | __all__ = ["Insert", "InsertDock", "InsertEQ", "InsertEQBand", "Mixer", "Slot"] 63 | 64 | 65 | @enum.unique 66 | class _InsertFlags(enum.IntFlag): 67 | None_ = 0 68 | PolarityReversed = 1 << 0 69 | SwapLeftRight = 1 << 1 70 | EnableEffects = 1 << 2 71 | Enabled = 1 << 3 72 | DisableThreadedProcessing = 1 << 4 73 | U5 = 1 << 5 74 | DockMiddle = 1 << 6 75 | DockRight = 1 << 7 76 | U8 = 1 << 8 77 | U9 = 1 << 9 78 | SeparatorShown = 1 << 10 79 | Locked = 1 << 11 80 | Solo = 1 << 12 81 | U13 = 1 << 13 82 | U14 = 1 << 14 83 | AudioTrack = 1 << 15 # Whether insert is linked to an audio track 84 | 85 | 86 | @enum.unique 87 | class _MixerParamsID(ct.EnumBase): 88 | SlotEnabled = 0 89 | SlotMix = 1 90 | RouteVolStart = 64 # 64 - 191 are send level events 91 | Volume = 192 92 | Pan = 193 93 | StereoSeparation = 194 94 | LowGain = 208 95 | MidGain = 209 96 | HighGain = 210 97 | LowFreq = 216 98 | MidFreq = 217 99 | HighFreq = 218 100 | LowQ = 224 101 | MidQ = 225 102 | HighQ = 226 103 | 104 | 105 | class InsertFlagsEvent(StructEventBase): 106 | STRUCT = c.Struct( 107 | "_u1" / c.Optional(c.Bytes(4)), # 4 108 | "flags" / c.Optional(StdEnum[_InsertFlags](c.Int32ul)), # 8 109 | "_u2" / c.Optional(c.Bytes(4)), # 12 110 | ).compile() 111 | 112 | 113 | class InsertRoutingEvent(ListEventBase): 114 | STRUCT = c.GreedyRange(c.Flag) 115 | 116 | 117 | @dataclasses.dataclass 118 | class _InsertItems: 119 | slots: DefaultDict[int, dict[int, dict[str, Any]]] = dataclasses.field( 120 | default_factory=lambda: defaultdict(dict) 121 | ) 122 | own: dict[int, dict[str, Any]] = dataclasses.field(default_factory=dict) 123 | 124 | 125 | class MixerParamsEvent(ListEventBase): 126 | STRUCT = c.GreedyRange( 127 | c.Struct( 128 | "_u4" / c.Bytes(4), # 4 129 | "id" / StdEnum[_MixerParamsID](c.Byte), # 5 130 | "_u1" / c.Byte, # 6 131 | "channel_data" / c.Int16ul, # 8 132 | "msg" / c.Int32sl, # 12 133 | ) 134 | ) 135 | 136 | def __init__(self, id: Any, data: bytearray) -> None: 137 | super().__init__(id, data) 138 | self.items_: DefaultDict[int, _InsertItems] = defaultdict(_InsertItems) 139 | 140 | for item in self.data: 141 | insert_idx = (item["channel_data"] >> 6) & 0x7F 142 | slot_idx = item["channel_data"] & 0x3F 143 | insert = self.items_[insert_idx] 144 | id = item["id"] 145 | 146 | if id in (_MixerParamsID.SlotEnabled, _MixerParamsID.SlotMix): 147 | insert.slots[slot_idx][id] = item 148 | else: 149 | insert.own[id] = item 150 | 151 | 152 | @enum.unique 153 | class InsertID(EventEnum): 154 | Icon = (WORD + 31, I16Event) 155 | Output = (DWORD + 19, I32Event) 156 | Color = (DWORD + 21, ColorEvent) #: 4.0+ 157 | Input = (DWORD + 26, I32Event) 158 | Name = TEXT + 12 #: 3.5.4+ 159 | Routing = (DATA + 27, InsertRoutingEvent) 160 | Flags = (DATA + 28, InsertFlagsEvent) 161 | 162 | 163 | @enum.unique 164 | class MixerID(EventEnum): 165 | APDC = 29 166 | Params = (DATA + 17, MixerParamsEvent) 167 | 168 | 169 | @enum.unique 170 | class SlotID(EventEnum): 171 | Index = (WORD + 34, U16Event) 172 | 173 | 174 | # ? Maybe added in FL Studio v6.0.1 175 | class InsertDock(enum.Enum): 176 | """![](https://bit.ly/3eLum9D) 177 | 178 | See Also: 179 | :attr:`Insert.dock` 180 | """ # noqa 181 | 182 | Left = enum.auto() 183 | Middle = enum.auto() 184 | Right = enum.auto() 185 | 186 | 187 | class _InsertEQBandKW(TypedDict, total=False): 188 | gain: dict[str, Any] 189 | freq: dict[str, Any] 190 | reso: dict[str, Any] 191 | 192 | 193 | class _InsertEQBandProp(NamedPropMixin, RWProperty[int]): 194 | def __get__(self, ins: InsertEQBand, owner: Any = None) -> int | None: 195 | if owner is None: 196 | return NotImplemented 197 | return ins._kw[self._prop]["msg"] 198 | 199 | def __set__(self, ins: InsertEQBand, value: int) -> None: 200 | ins._kw[self._prop]["msg"] = value 201 | 202 | 203 | class InsertEQBand(ModelBase, ModelReprMixin): 204 | def __init__(self, **kw: Unpack[_InsertEQBandKW]) -> None: 205 | super().__init__(**kw) 206 | 207 | @property 208 | def size(self) -> int: 209 | return 12 * len(self._kw) # ! TODO 210 | 211 | gain = _InsertEQBandProp() 212 | """ 213 | | Min | Max | Default | 214 | |-------|------|---------| 215 | | -1800 | 1800 | 0 | 216 | """ 217 | 218 | freq = _InsertEQBandProp() 219 | """Nonlinear. Default depends on band e.g. ``InsertEQ.low``. 220 | 221 | | Type | Value | Representation | 222 | |------|-------|----------------| 223 | | Min | 0 | 10 Hz | 224 | | Max | 65536 | 16 kHz | 225 | """ 226 | 227 | reso = _InsertEQBandProp() 228 | """ 229 | | Min | Max | Default | 230 | |-----|-------|---------| 231 | | 0 | 65536 | 17500 | 232 | """ 233 | 234 | 235 | class _InsertEQPropArgs(NamedTuple): 236 | freq: int 237 | gain: int 238 | reso: int 239 | 240 | 241 | class _InsertEQProp(NamedPropMixin, ROProperty[InsertEQBand]): 242 | def __init__(self, ids: _InsertEQPropArgs) -> None: 243 | super().__init__() 244 | self._ids = ids 245 | 246 | def __get__(self, ins: InsertEQ, owner: Any = None) -> InsertEQBand: 247 | if owner is None: 248 | return NotImplemented 249 | 250 | items: _InsertEQBandKW = {} 251 | for id, param in cast(_InsertItems, ins._kw["params"]).own.items(): 252 | if id == self._ids.freq: 253 | items["freq"] = param 254 | elif id == self._ids.gain: 255 | items["gain"] = param 256 | elif id == self._ids.reso: 257 | items["reso"] = param 258 | return InsertEQBand(**items) 259 | 260 | 261 | # Stored in MixerID.Params event. 262 | class InsertEQ(ModelBase, ModelReprMixin): 263 | """Post-effect :class:`Insert` EQ with 3 adjustable bands. 264 | 265 | ![](https://bit.ly/3RUCQt6) 266 | 267 | See Also: 268 | :attr:`Insert.eq` 269 | """ 270 | 271 | def __init__(self, params: _InsertItems) -> None: 272 | super().__init__(params=params) 273 | 274 | @property 275 | def size(self) -> int: 276 | return 12 * self._kw["param"] # ! TODO 277 | 278 | low = _InsertEQProp( 279 | _InsertEQPropArgs(_MixerParamsID.LowFreq, _MixerParamsID.LowGain, _MixerParamsID.LowQ) 280 | ) 281 | """Low shelf band. Default frequency - 5777 (90 Hz).""" 282 | 283 | mid = _InsertEQProp( 284 | _InsertEQPropArgs(_MixerParamsID.MidFreq, _MixerParamsID.MidGain, _MixerParamsID.MidQ) 285 | ) 286 | """Middle band. Default frequency - 33145 (1500 Hz).""" 287 | 288 | high = _InsertEQProp( 289 | _InsertEQPropArgs(_MixerParamsID.HighFreq, _MixerParamsID.HighGain, _MixerParamsID.HighQ) 290 | ) 291 | """High shelf band. Default frequency - 55825 (8000 Hz).""" 292 | 293 | 294 | class _MixerParamProp(RWProperty[T]): 295 | def __init__(self, id: int) -> None: 296 | self._id = id 297 | 298 | def __get__(self, ins: Insert, owner: object = None) -> T | None: 299 | if owner is None: 300 | return NotImplemented 301 | 302 | for id, item in cast(_InsertItems, ins._kw["params"]).own.items(): 303 | if id == self._id: 304 | return item["msg"] 305 | 306 | def __set__(self, ins: Insert, value: T) -> None: 307 | for id, item in cast(_InsertItems, ins._kw["params"]).own.items(): 308 | if id == self._id: 309 | item["msg"] = value 310 | return 311 | raise PropertyCannotBeSet(self._id) 312 | 313 | 314 | class Slot(EventModel): 315 | """Represents an effect slot in an `Insert` / mixer channel. 316 | 317 | ![](https://bit.ly/3RUDtTu) 318 | """ 319 | 320 | def __init__(self, events: EventTree, params: list[dict[str, Any]] | None = None) -> None: 321 | super().__init__(events, params=params or []) 322 | 323 | def __repr__(self) -> str: 324 | return f"Slot (name={self.name}, iid={self.index}, plugin={self.plugin!r})" 325 | 326 | color = EventProp[RGBA](PluginID.Color) 327 | # TODO controllers = KWProp[List[RemoteController]]() 328 | iid = EventProp[int](SlotID.Index) 329 | """A 0-based internal index.""" 330 | 331 | internal_name = EventProp[str](PluginID.InternalName) 332 | """'Fruity Wrapper' for VST/AU plugins or factory name for native plugins.""" 333 | 334 | enabled = _MixerParamProp[bool](_MixerParamsID.SlotEnabled) 335 | """![](https://bit.ly/3eN4Ile)""" 336 | 337 | icon = EventProp[int](PluginID.Icon) 338 | index = EventProp[int](SlotID.Index) 339 | mix = _MixerParamProp[int](_MixerParamsID.SlotMix) 340 | """Dry/Wet mix. Defaults to maximum value. 341 | 342 | | Type | Value | Representation | 343 | |---------|-------|----------------| 344 | | Min | -6400 | 100% left | 345 | | Max | 6400 | 100% right | 346 | | Default | 0 | Centred | 347 | """ 348 | 349 | name = EventProp[str](PluginID.Name) 350 | plugin = PluginProp( 351 | VSTPlugin, 352 | FruityBalance, 353 | FruityBloodOverdrive, 354 | FruityCenter, 355 | FruityFastDist, 356 | FruityNotebook2, 357 | FruitySend, 358 | FruitySoftClipper, 359 | FruityStereoEnhancer, 360 | Soundgoodizer, 361 | ) 362 | """The effect loaded into the slot.""" 363 | 364 | 365 | class _InsertKW(TypedDict): 366 | iid: int 367 | max_slots: int 368 | params: NotRequired[_InsertItems] 369 | 370 | 371 | # TODO Need to make a `load()` method which will be able to parse preset files 372 | # (by looking at Project.format) and use `MixerParameterEvent.items` to get 373 | # remaining data. Normally, the `Mixer` passes this information to the Inserts 374 | # (and Inserts to the `Slot`s directly). 375 | class Insert(EventModel, ModelCollection[Slot]): 376 | """Represents a mixer track to which channel from the rack are routed to. 377 | 378 | ![](https://bit.ly/3LeGKuN) 379 | """ 380 | 381 | def __init__(self, events: EventTree, **kw: Unpack[_InsertKW]) -> None: 382 | super().__init__(events, **kw) 383 | 384 | # TODO Add number of used slots 385 | def __repr__(self) -> str: 386 | return f"Insert(name={self.name!r}, iid={self.iid})" 387 | 388 | @supports_slice # type: ignore 389 | def __getitem__(self, i: int | str) -> Slot: 390 | """Returns an effect slot of the specified index or name. 391 | 392 | Args: 393 | i: An index in the range of 0 to :attr:`Mixer.max_slots` 394 | or the name of the :class:`Slot`. 395 | 396 | Raises: 397 | ModelNotFound: An effect :class:`Slot` with the specified index 398 | or name isn't found. 399 | """ 400 | for idx, slot in enumerate(self): 401 | if (isinstance(i, int) and idx == i) or i == slot.name: 402 | return slot 403 | raise ModelNotFound(i) 404 | 405 | @property 406 | def iid(self) -> int: 407 | """-1 for "current" insert, 0 for master and upto :attr:`Mixer.max_inserts`.""" 408 | return self._kw["iid"] 409 | 410 | def __iter__(self) -> Iterator[Slot]: 411 | """Iterator over the effect empty and used slots.""" 412 | for idx, ed in enumerate(self.events.divide(SlotID.Index, *SlotID, *PluginID)): 413 | yield Slot(ed, params=self._kw["params"].slots[idx]) 414 | 415 | def __len__(self) -> int: 416 | try: 417 | return self.events.count(SlotID.Index) 418 | except KeyError: 419 | return len(list(self)) 420 | 421 | bypassed = FlagProp(_InsertFlags.EnableEffects, InsertID.Flags, inverted=True) 422 | """Whether all slots are bypassed.""" 423 | 424 | channels_swapped = FlagProp(_InsertFlags.SwapLeftRight, InsertID.Flags) 425 | """Whether the left and right channels are swapped.""" 426 | 427 | color = EventProp[RGBA](InsertID.Color) 428 | """Defaults to #636C71 (granite gray) in FL Studio. 429 | 430 | ![](https://bit.ly/3yVKXPc) 431 | 432 | Values below 20 for any color component (R, G, B) are ignored by FL. 433 | 434 | *New in FL Studio v4.0*. 435 | """ 436 | 437 | @property 438 | def dock(self) -> InsertDock | None: 439 | """The position (left, middle or right) where insert is docked in mixer. 440 | 441 | :menuselection:`Insert --> Layout --> Dock to` 442 | 443 | ![](https://bit.ly/3eLum9D) 444 | """ 445 | try: 446 | event = cast(InsertFlagsEvent, self.events.first(InsertID.Flags)) 447 | except KeyError: 448 | return None 449 | 450 | flags = _InsertFlags(event["flags"]) 451 | if _InsertFlags.DockMiddle in flags: 452 | return InsertDock.Middle 453 | if _InsertFlags.DockRight in flags: 454 | return InsertDock.Right 455 | return InsertDock.Left 456 | 457 | enabled = FlagProp(_InsertFlags.Enabled, InsertID.Flags) 458 | """Whether an insert in the mixer is enabled or disabled. 459 | 460 | ![](https://bit.ly/3BoRBOj) 461 | """ 462 | 463 | @property 464 | def eq(self) -> InsertEQ: 465 | """3-band post EQ. 466 | 467 | ![](https://bit.ly/3RUCQt6) 468 | """ 469 | return InsertEQ(self._kw["params"]) 470 | 471 | icon = EventProp[int](InsertID.Icon) 472 | """Internal ID of the icon shown beside ``name``. 473 | 474 | ![](https://bit.ly/3Slr6jc) 475 | """ 476 | 477 | input = EventProp[int](InsertID.Input) 478 | """![](https://bit.ly/3RO0ckC)""" 479 | 480 | is_solo = FlagProp(_InsertFlags.Solo, InsertID.Flags) 481 | """Whether the insert is solo'd.""" 482 | 483 | locked = FlagProp(_InsertFlags.Locked, InsertID.Flags) 484 | """Whether an insert in the mixer is in locked state. 485 | 486 | ![](https://bit.ly/3SdPbc2) 487 | """ 488 | 489 | name = EventProp[str](InsertID.Name) 490 | """*New in FL Studio v3.5.4*.""" 491 | 492 | output = EventProp[int](InsertID.Output) 493 | """![](https://bit.ly/3LjWjBD)""" 494 | 495 | pan = _MixerParamProp[int](_MixerParamsID.Pan) 496 | """Linear. 497 | 498 | | Type | Value | Representation | 499 | |---------|-------|----------------| 500 | | Min | -6400 | 100% left | 501 | | Max | 6400 | 100% right | 502 | | Default | 0 | Centred | 503 | 504 | ![](https://bit.ly/3DsZRj4) 505 | """ 506 | 507 | polarity_reversed = FlagProp(_InsertFlags.PolarityReversed, InsertID.Flags) 508 | """Whether phase / polarity is reversed / inverted.""" 509 | 510 | @property 511 | def routes(self) -> Iterator[int]: 512 | """Send volumes to routed inserts. 513 | 514 | *New in FL Studio v4.0*. 515 | """ 516 | items = iter(cast(InsertRoutingEvent, self.events.first(InsertID.Routing))) 517 | for id, item in cast(_InsertItems, self._kw["params"]).own.items(): 518 | if id >= _MixerParamsID.RouteVolStart: 519 | try: 520 | cond = next(items) 521 | except StopIteration: 522 | continue 523 | else: 524 | if cond: 525 | yield item["msg"] 526 | 527 | separator_shown = FlagProp(_InsertFlags.SeparatorShown, InsertID.Flags) 528 | """Whether separator is shown before the insert. 529 | 530 | :menuselection:`Insert --> Group --> Separator` 531 | """ 532 | 533 | stereo_separation = _MixerParamProp[int](_MixerParamsID.StereoSeparation) 534 | """Linear. 535 | 536 | | Type | Value | Representation | 537 | |---------|-------|----------------| 538 | | Min | 64 | 100% merged | 539 | | Max | -64 | 100% separated | 540 | | Default | 0 | No effect | 541 | """ 542 | 543 | volume = _MixerParamProp[int](_MixerParamsID.Volume) 544 | """Post volume fader. Logarithmic. 545 | 546 | | Type | Value | Representation | 547 | |---------|-------|---------------------| 548 | | Min | 0 | 0% / -INFdB / 0.00 | 549 | | Max | 16000 | 125% / 5.6dB / 1.90 | 550 | | Default | 12800 | 100% / 0.0dB / 1.00 | 551 | """ 552 | 553 | 554 | class _MixerKW(TypedDict): 555 | version: FLVersion 556 | 557 | 558 | # TODO FL Studio version in which slots were increased to 10 559 | # TODO A move() method to change the placement of Inserts; it's difficult! 560 | class Mixer(EventModel, ModelCollection[Insert]): 561 | """Represents the mixer which contains :class:`Insert` instances. 562 | 563 | ![](https://bit.ly/3eOsblF) 564 | """ 565 | 566 | _MAX_INSERTS = { 567 | (1, 6, 5): 5, 568 | (2, 0, 1): 8, 569 | (3, 0, 0): 18, 570 | (3, 3, 0): 20, 571 | (4, 0, 0): 64, 572 | (9, 0, 0): 105, 573 | (12, 9, 0): 127, 574 | } 575 | 576 | _MAX_SLOTS = {(1, 6, 5): 4, (3, 0, 0): 8} 577 | 578 | def __init__(self, events: EventTree, **kw: Unpack[_MixerKW]) -> None: 579 | super().__init__(events, **kw) 580 | 581 | # Inserts don't store their index internally. 582 | @supports_slice # type: ignore 583 | def __getitem__(self, i: int | str | slice) -> Insert: 584 | """Returns an insert with the specified index or name. 585 | 586 | Args: 587 | i: An index between 0 to :attr:`Mixer.max_inserts` resembling the 588 | one shown by FL Studio or the name of the insert. Use 0 for 589 | master and -1 for "current" insert. 590 | 591 | Raises: 592 | ModelNotFound: An :class:`Insert` with the specifcied name or index 593 | isn't found. 594 | """ 595 | for idx, insert in enumerate(self): 596 | if (isinstance(i, int) and idx == i + 1) or i == insert.name: 597 | return insert 598 | raise ModelNotFound(i) 599 | 600 | def __iter__(self) -> Iterator[Insert]: 601 | def select(e: AnyEvent) -> bool | None: 602 | if e.id == InsertID.Output: 603 | return False 604 | 605 | if e.id in (*InsertID, *PluginID, *SlotID): 606 | return True 607 | 608 | params: dict[int, _InsertItems] = {} 609 | if MixerID.Params in self.events.ids: 610 | params = cast(MixerParamsEvent, self.events.first(MixerID.Params)).items_ 611 | 612 | for i, ed in enumerate(self.events.subtrees(select, self.max_inserts)): 613 | if i in params: 614 | yield Insert(ed, iid=i - 1, max_slots=self.max_slots, params=params[i]) 615 | else: 616 | yield Insert(ed, iid=i - 1, max_slots=self.max_slots) 617 | 618 | def __len__(self) -> int: 619 | """Returns the number of inserts present in the project. 620 | 621 | Raises: 622 | NoModelsFound: No inserts could be found. 623 | """ 624 | if InsertID.Flags not in self.events.ids: 625 | raise NoModelsFound 626 | return self.events.count(InsertID.Flags) 627 | 628 | def __str__(self) -> str: 629 | return f"Mixer: {len(self)} inserts" 630 | 631 | apdc = EventProp[bool](MixerID.APDC) 632 | """Whether automatic plugin delay compensation is enabled for the inserts.""" 633 | 634 | @property 635 | def max_inserts(self) -> int: 636 | """Estimated max number of inserts including sends, master and current. 637 | 638 | Maximum number of slots w.r.t. FL Studio: 639 | 640 | * 1.6.5: 4 inserts + master, 5 in total 641 | * 2.0.1: 8 642 | * 3.0.0: 16 inserts, 2 sends. 643 | * 3.3.0: +2 sends. 644 | * 4.0.0: 64 645 | * 9.0.0: 99 inserts, 105 in total. 646 | * 12.9.0: 125 + master + current. 647 | """ 648 | version = dataclasses.astuple(self._kw["version"]) 649 | for k, v in self._MAX_INSERTS.items(): 650 | if version <= k: 651 | return v 652 | return 127 653 | 654 | @property 655 | def max_slots(self) -> int: 656 | """Estimated max number of effect slots per insert. 657 | 658 | Maximum number of slots w.r.t. FL Studio: 659 | 660 | * 1.6.5: 4 661 | * 3.3.0: 8 662 | """ 663 | version = dataclasses.astuple(self._kw["version"]) 664 | for k, v in self._MAX_SLOTS.items(): 665 | if version <= k: 666 | return v 667 | return 10 668 | -------------------------------------------------------------------------------- /pyflp/pattern.py: -------------------------------------------------------------------------------- 1 | # PyFLP - An FL Studio project file (.flp) parser 2 | # Copyright (C) 2022 demberto 3 | # 4 | # This program is free software: you can redistribute it and/or modify it 5 | # under the terms of the GNU General Public License as published by the Free 6 | # Software Foundation, either version 3 of the License, or (at your option) 7 | # any later version. This program is distributed in the hope that it will be 8 | # useful, but WITHOUT ANY WARRANTY; without even the implied warranty of 9 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General 10 | # Public License for more details. You should have received a copy of the 11 | # GNU General Public License along with this program. If not, see 12 | # . 13 | 14 | """Contains the types used by patterns, MIDI notes and their automation data.""" 15 | 16 | from __future__ import annotations 17 | 18 | import enum 19 | from collections import defaultdict 20 | from typing import DefaultDict, Iterator, cast 21 | 22 | import construct as c 23 | 24 | from pyflp._adapters import StdEnum 25 | from pyflp._descriptors import EventProp, FlagProp, StructProp 26 | from pyflp._events import ( 27 | DATA, 28 | DWORD, 29 | TEXT, 30 | WORD, 31 | BoolEvent, 32 | ColorEvent, 33 | EventEnum, 34 | EventTree, 35 | I32Event, 36 | IndexedEvent, 37 | ListEventBase, 38 | U16Event, 39 | U32Event, 40 | ) 41 | from pyflp._models import EventModel, ItemModel, ModelCollection, ModelReprMixin, supports_slice 42 | from pyflp.exceptions import ModelNotFound, NoModelsFound 43 | from pyflp.timemarker import TimeMarker, TimeMarkerID 44 | from pyflp.types import RGBA 45 | 46 | __all__ = ["Note", "Controller", "Pattern", "Patterns"] 47 | 48 | 49 | class ControllerEvent(ListEventBase): 50 | STRUCT = c.GreedyRange( 51 | c.Struct( 52 | "position" / c.Int32ul, # 4, can be delta as well! 53 | "_u1" / c.Byte, # 5 54 | "_u2" / c.Byte, # 6 55 | "channel" / c.Int8ul, # 7 56 | "_flags" / c.Int8ul, # 8 57 | "value" / c.Float32l, # 12 58 | ) 59 | ) 60 | 61 | 62 | @enum.unique 63 | class _NoteFlags(enum.IntFlag): 64 | Slide = 1 << 3 65 | 66 | 67 | class NotesEvent(ListEventBase): 68 | STRUCT = c.GreedyRange( 69 | c.Struct( 70 | "position" / c.Int32ul, # 4 71 | "flags" / StdEnum[_NoteFlags](c.Int16ul), # 6 72 | "rack_channel" / c.Int16ul, # 8 73 | "length" / c.Int32ul, # 12 74 | "key" / c.Int16ul, # 14 75 | "group" / c.Int16ul, # 16 76 | "fine_pitch" / c.Int8ul, # 17 77 | "_u1" / c.Byte, # 18 78 | "release" / c.Int8ul, # 19 79 | "midi_channel" / c.Int8ul, # 20 80 | "pan" / c.Int8ul, # 21 81 | "velocity" / c.Int8ul, # 22 82 | "mod_x" / c.Int8ul, # 23 83 | "mod_y" / c.Int8ul, # 24 84 | ) 85 | ) 86 | 87 | 88 | class PatternsID(EventEnum): 89 | PlayTruncatedNotes = (30, BoolEvent) 90 | CurrentlySelected = (WORD + 3, U16Event) 91 | 92 | 93 | # ChannelIID, _161, _162, Looped, Length occur when pattern is looped. 94 | # ChannelIID and _161 occur for every channel in order. 95 | class PatternID(EventEnum): 96 | Looped = (26, BoolEvent) 97 | New = (WORD + 1, U16Event) # Marks the beginning of a new pattern, twice. 98 | Color = (DWORD + 22, ColorEvent) 99 | Name = TEXT + 1 100 | # _157 = DWORD + 29 #: 12.5+ 101 | # _158 = DWORD + 30 # default: -1 102 | ChannelIID = (DWORD + 32, U32Event) # TODO (FL v20.1b1+) 103 | _161 = (DWORD + 33, I32Event) # TODO -3 if channel is looped else 0 (FL v20.1b1+) 104 | _162 = (DWORD + 34, U32Event) # TODO Appears when pattern is looped, default: 2 105 | Length = (DWORD + 36, U32Event) 106 | Controllers = (DATA + 15, ControllerEvent) 107 | Notes = (DATA + 16, NotesEvent) 108 | 109 | 110 | class Note(ItemModel[NotesEvent]): 111 | _NOTE_NAMES = ("C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B") 112 | 113 | def __repr__(self) -> str: 114 | return "Note(key={}, position={}, length={}, channel={})".format( 115 | self.key, self.position, self.length, self.rack_channel 116 | ) 117 | 118 | def __str__(self) -> str: 119 | return f"{self.key} note @ {self.position} of {self.length}" 120 | 121 | fine_pitch = StructProp[int]() 122 | """Linear. 123 | 124 | | Type | Value | Representation | 125 | |---------|-------|----------------| 126 | | Min | 0 | -1200 cents | 127 | | Max | 240 | +1200 cents | 128 | | Default | 120 | No fine tuning | 129 | 130 | *New in FL Studio v3.3.0*. 131 | """ 132 | 133 | group = StructProp[int]() 134 | """A number shared by notes in the same group or ``0`` if ungrouped. 135 | 136 | ![](https://bit.ly/3TgjFva) 137 | """ 138 | 139 | @property 140 | def key(self) -> str: 141 | """Note name with octave, for e.g. 'C5' or 'A#3' ranging from C0 to B10. 142 | 143 | Only sharp key names (C#, D#, etc.) are used, flats aren't. 144 | 145 | Raises: 146 | ValueError: A value not in between 0-131 is tried to be set. 147 | ValueError: Invalid note name (not in the format {note-name}{octave}). 148 | """ 149 | return self._NOTE_NAMES[self["key"] % 12] + str(self["key"] // 12) # pyright: ignore 150 | 151 | @key.setter 152 | def key(self, value: int | str) -> None: 153 | if isinstance(value, int): 154 | if value not in range(132): 155 | raise ValueError("Expected a value between 0-131.") 156 | self["key"] = value 157 | else: 158 | for i, name in enumerate(self._NOTE_NAMES): 159 | if value.startswith(name): 160 | octave = int(value.replace(name, "", 1)) 161 | self["key"] = octave * 12 + i 162 | raise ValueError(f"Invalid key name: {value}") 163 | 164 | length = StructProp[int]() 165 | """Returns 0 for notes punched in through step sequencer.""" 166 | 167 | midi_channel = StructProp[int]() 168 | """Used for a variety of purposes. 169 | 170 | For note colors, min: 0, max: 15. 171 | +128 for MIDI dragged into the piano roll. 172 | 173 | *Changed in FL Studio v6.0.1*: Used for both, MIDI channels and colors. 174 | """ 175 | 176 | mod_x = StructProp[int]() 177 | """Plugin configurable parameter. 178 | 179 | | Min | Max | Default | 180 | | --- | --- | ------- | 181 | | 0 | 255 | 128 | 182 | """ 183 | 184 | mod_y = StructProp[int]() 185 | """Plugin configurable parameter. 186 | 187 | | Min | Max | Default | 188 | | --- | --- | ------- | 189 | | 0 | 255 | 128 | 190 | """ 191 | 192 | pan = StructProp[int]() 193 | """ 194 | | Type | Value | Representation | 195 | |---------|-------|----------------| 196 | | Min | 0 | 100% left | 197 | | Max | 128 | 100% right | 198 | | Default | 64 | Centered | 199 | """ 200 | 201 | position = StructProp[int]() 202 | rack_channel = StructProp[int]() 203 | """Containing channel's :attr:`Channel.IID`.""" 204 | 205 | release = StructProp[int]() 206 | """ 207 | | Min | Max | Default | 208 | | --- | --- | ------- | 209 | | 0 | 128 | 64 | 210 | """ 211 | 212 | slide = FlagProp(_NoteFlags.Slide) 213 | """Whether note is a sliding note.""" 214 | 215 | velocity = StructProp[int]() 216 | """ 217 | | Min | Max | Default | 218 | | --- | --- | ------- | 219 | | 0 | 128 | 100 | 220 | """ 221 | 222 | 223 | class Controller(ItemModel[ControllerEvent], ModelReprMixin): 224 | def __str__(self) -> str: 225 | return f"Controller @ {self.position} of channel #{self.channel}" 226 | 227 | channel = StructProp[int]() 228 | """Corresponds to the containing channel's :attr:`Channel.iid`.""" 229 | 230 | position = StructProp[int]() 231 | value = StructProp[float]() 232 | 233 | 234 | class Pattern(EventModel): 235 | """Represents a pattern which can contain notes, controllers and time markers.""" 236 | 237 | def __repr__(self) -> str: 238 | try: 239 | num_notes = len(self.events.first(PatternID.Notes)) # type: ignore 240 | except KeyError: 241 | num_notes = 0 242 | 243 | try: 244 | num_ctrls = len(self.events.first(PatternID.Controllers)) # type: ignore 245 | except KeyError: 246 | num_ctrls = 0 247 | 248 | return ( 249 | f"Pattern(iid={self.iid}, name={self.name!r}," 250 | f"{num_notes} notes, {num_ctrls} controllers)" 251 | ) 252 | 253 | color = EventProp[RGBA](PatternID.Color) 254 | """Returns a colour if one is set while saving the project file, else ``None``. 255 | 256 | ![](https://bit.ly/3eNeSSW) 257 | 258 | Defaults to #485156 in FL Studio. 259 | """ 260 | 261 | @property 262 | def controllers(self) -> Iterator[Controller]: 263 | """Parameter automations associated with this pattern (if any).""" 264 | if PatternID.Controllers in self.events.ids: 265 | event = cast(ControllerEvent, self.events.first(PatternID.Controllers)) 266 | yield from (Controller(item, i, event) for i, item in enumerate(event)) 267 | 268 | @property 269 | def iid(self) -> int: 270 | """Internal index of the pattern starting from 1. 271 | 272 | Caution: 273 | Changing this will not solve any collisions thay may occur due to 274 | 2 patterns that might end up having the same index. 275 | """ 276 | return self.events.first(PatternID.New).value 277 | 278 | @iid.setter 279 | def iid(self, value: int) -> None: 280 | for event in self.events.get(PatternID.New): 281 | event.value = value 282 | 283 | length = EventProp[int](PatternID.Length) 284 | """The number of steps multiplied by the :attr:`pyflp.project.Project.ppq`. 285 | 286 | Returns `None` if pattern is in Auto mode (i.e. :attr:`looped` is `False`). 287 | """ 288 | 289 | looped = EventProp[bool](PatternID.Looped, default=False) 290 | """Whether a pattern is in live loop mode. 291 | 292 | *New in FL Studio v2.5.0*. 293 | """ 294 | 295 | name = EventProp[str](PatternID.Name) 296 | """User given name of the pattern; None if not set.""" 297 | 298 | @property 299 | def notes(self) -> Iterator[Note]: 300 | """MIDI notes contained inside the pattern. 301 | 302 | Note: 303 | FL Studio uses its own custom format to represent notes internally. 304 | However by using the :class:`Note` properties with a MIDI parsing 305 | library for example, you can export them to MIDI. 306 | """ 307 | if PatternID.Notes in self.events.ids: 308 | event = cast(NotesEvent, self.events.first(PatternID.Notes)) 309 | yield from (Note(item, i, event) for i, item in enumerate(event)) 310 | 311 | @property 312 | def timemarkers(self) -> Iterator[TimeMarker]: 313 | """Yields timemarkers inside this pattern.""" 314 | yield from (TimeMarker(et) for et in self.events.group(*TimeMarkerID)) 315 | 316 | 317 | class Patterns(EventModel, ModelCollection[Pattern]): 318 | def __str__(self) -> str: 319 | iids = [pattern.iid for pattern in self] 320 | return f"{len(iids)} Patterns {iids!r}" 321 | 322 | @supports_slice # type: ignore 323 | def __getitem__(self, i: int | str | slice) -> Pattern: 324 | """Returns the pattern with the specified index or :attr:`Pattern.name`. 325 | 326 | Args: 327 | i: A zero-based index, its name or a slice of indexes. 328 | 329 | Raises: 330 | ModelNotFound: A :class:`Pattern` with the specified name or index 331 | isn't found. 332 | """ 333 | for idx, pattern in enumerate(self): 334 | if (isinstance(i, int) and idx == i) or i == pattern.name: 335 | return pattern 336 | raise ModelNotFound(i) 337 | 338 | # Doesn't use EventTree delegates since PatternID.New occurs twice. 339 | # Once for note and controller events and again for the rest of them. 340 | def __iter__(self) -> Iterator[Pattern]: 341 | """An iterator over the patterns found in the project.""" 342 | cur_pat_id = 0 343 | tmp_dict: DefaultDict[int, list[IndexedEvent]] = defaultdict(list) 344 | 345 | for ie in self.events.lst: 346 | if ie.e.id == PatternID.New: 347 | cur_pat_id = ie.e.value 348 | 349 | if ie.e.id in (*PatternID, *TimeMarkerID): 350 | tmp_dict[cur_pat_id].append(ie) 351 | 352 | for events in tmp_dict.values(): 353 | et = EventTree(self.events, events) 354 | self.events.children.append(et) 355 | yield Pattern(et) 356 | 357 | def __len__(self) -> int: 358 | """Returns the number of patterns found in the project. 359 | 360 | Raises: 361 | NoModelsFound: No patterns were found. 362 | """ 363 | if PatternID.New not in self.events.ids: 364 | raise NoModelsFound 365 | return len({e.value for e in self.events.get(PatternID.New)}) 366 | 367 | play_cut_notes = EventProp[bool](PatternsID.PlayTruncatedNotes) 368 | """Whether truncated notes of patterns placed in the playlist should be played. 369 | 370 | Located at :menuselection:`Options -> &Project general settings --> Advanced` 371 | under the name :guilabel:`Play truncated notes in clips`. 372 | 373 | *Changed in FL Studio v12.3 beta 3*: Enabled by default. 374 | """ 375 | 376 | @property 377 | def current(self) -> Pattern | None: 378 | """Returns the currently selected pattern.""" 379 | if PatternsID.CurrentlySelected in self.events.ids: 380 | index = self.events.first(PatternsID.CurrentlySelected).value 381 | for pattern in self: 382 | if pattern.iid == index: 383 | return pattern 384 | -------------------------------------------------------------------------------- /pyflp/plugin.py: -------------------------------------------------------------------------------- 1 | # PyFLP - An FL Studio project file (.flp) parser 2 | # Copyright (C) 2022 demberto 3 | # 4 | # This program is free software: you can redistribute it and/or modify it 5 | # under the terms of the GNU General Public License as published by the Free 6 | # Software Foundation, either version 3 of the License, or (at your option) 7 | # any later version. This program is distributed in the hope that it will be 8 | # useful, but WITHOUT ANY WARRANTY; without even the implied warranty of 9 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General 10 | # Public License for more details. You should have received a copy of the 11 | # GNU General Public License along with this program. If not, see 12 | # . 13 | 14 | """Contains the types used by native and VST plugins to store their preset data.""" 15 | 16 | from __future__ import annotations 17 | 18 | import enum 19 | import warnings 20 | from typing import Any, ClassVar, Dict, Generic, Literal, Protocol, TypeVar, cast, runtime_checkable 21 | 22 | import construct as c 23 | import construct_typed as ct 24 | 25 | from pyflp._adapters import FourByteBool, StdEnum 26 | from pyflp._descriptors import FlagProp, NamedPropMixin, RWProperty, StructProp 27 | from pyflp._events import ( 28 | DATA, 29 | DWORD, 30 | TEXT, 31 | AnyEvent, 32 | ColorEvent, 33 | EventEnum, 34 | EventTree, 35 | StructEventBase, 36 | U32Event, 37 | UnknownDataEvent, 38 | ) 39 | from pyflp._models import EventModel, ModelReprMixin 40 | from pyflp.types import T 41 | 42 | __all__ = [ 43 | "BooBass", 44 | "FruitKick", 45 | "FruityBalance", 46 | "FruityBloodOverdrive", 47 | "FruityFastDist", 48 | "FruityNotebook2", 49 | "FruitySend", 50 | "FruitySoftClipper", 51 | "FruityStereoEnhancer", 52 | "Plucked", 53 | "PluginID", 54 | "PluginIOInfo", 55 | "Soundgoodizer", 56 | "VSTPlugin", 57 | ] 58 | 59 | 60 | @enum.unique 61 | class _WrapperFlags(enum.IntFlag): 62 | Visible = 1 << 0 63 | _Disabled = 1 << 1 64 | Detached = 1 << 2 65 | # _U3 = 1 << 3 66 | Generator = 1 << 4 67 | SmartDisable = 1 << 5 68 | ThreadedProcessing = 1 << 6 69 | DemoMode = 1 << 7 # saved with a demo version 70 | HideSettings = 1 << 8 71 | Minimized = 1 << 9 72 | _DirectX = 1 << 16 # indicates the plugin is a DirectX plugin 73 | _EditorSize = 2 << 16 74 | 75 | 76 | class BooBassEvent(StructEventBase): 77 | STRUCT = c.Struct( 78 | "_u1" / c.If(c.this._.len == 16, c.Bytes(4)), 79 | "bass" / c.Int32ul, 80 | "mid" / c.Int32ul, 81 | "high" / c.Int32ul, 82 | ).compile() 83 | 84 | 85 | class FruitKickEvent(StructEventBase): 86 | STRUCT = c.Struct( 87 | "_u1" / c.Bytes(4), 88 | "max_freq" / c.Int32sl, 89 | "min_freq" / c.Int32sl, 90 | "freq_decay" / c.Int32ul, 91 | "amp_decay" / c.Int32ul, 92 | "click" / c.Int32ul, 93 | "distortion" / c.Int32ul, 94 | "_u2" / c.Bytes(4), 95 | ).compile() 96 | 97 | 98 | class FruityBalanceEvent(StructEventBase): 99 | STRUCT = c.Struct("pan" / c.Int32ul, "volume" / c.Int32ul).compile() 100 | 101 | 102 | class FruityBloodOverdriveEvent(StructEventBase): 103 | STRUCT = c.Struct( 104 | "plugin_marker" / c.If(c.this._.len == 36, c.Bytes(4)), # redesigned native plugin marker 105 | "pre_band" / c.Int32ul, 106 | "color" / c.Int32ul, 107 | "pre_amp" / c.Int32ul, 108 | "x100" / FourByteBool, 109 | "post_filter" / c.Int32ul, 110 | "post_gain" / c.Int32ul, 111 | "_u1" / c.Bytes(4), 112 | "_u2" / c.Bytes(4), 113 | ).compile() 114 | 115 | 116 | class FruityCenterEvent(StructEventBase): 117 | STRUCT = c.Struct( 118 | "_u1" / c.If(c.this._.len == 8, c.Bytes(4)), "enabled" / FourByteBool 119 | ).compile() 120 | 121 | 122 | class FruityFastDistEvent(StructEventBase): 123 | STRUCT = c.Struct( 124 | "pre" / c.Int32ul, 125 | "threshold" / c.Int32ul, 126 | "kind" / c.Enum(c.Int32ul, A=0, B=1), 127 | "mix" / c.Int32ul, 128 | "post" / c.Int32ul, 129 | ).compile() 130 | 131 | 132 | class FruityNotebook2Event(StructEventBase): 133 | STRUCT = c.Struct( 134 | "_u1" / c.Bytes(4), 135 | "active_page" / c.Int32ul, 136 | "pages" 137 | / c.GreedyRange( 138 | c.Struct( 139 | "index" / c.Int32sl, 140 | c.StopIf(lambda ctx: ctx["index"] == -1), 141 | "length" / c.VarInt, 142 | "value" / c.PaddedString(lambda ctx: ctx["length"] * 2, "utf-16-le"), 143 | ), 144 | ), 145 | "editable" / c.Flag, 146 | ) 147 | 148 | 149 | class FruitySendEvent(StructEventBase): 150 | STRUCT = c.Struct( 151 | "pan" / c.Int32sl, 152 | "dry" / c.Int32ul, 153 | "volume" / c.Int32ul, 154 | "send_to" / c.Int32sl, 155 | ).compile() 156 | 157 | 158 | class FruitySoftClipperEvent(StructEventBase): 159 | STRUCT = c.Struct("threshold" / c.Int32ul, "post" / c.Int32ul).compile() 160 | 161 | 162 | class FruityStereoEnhancerEvent(StructEventBase): 163 | STRUCT = c.Struct( 164 | "pan" / c.Int32sl, 165 | "volume" / c.Int32ul, 166 | "stereo_separation" / c.Int32ul, 167 | "phase_offset" / c.Int32ul, 168 | "effect_position" / c.Enum(c.Int32ul, pre=0, post=1), 169 | "phase_inversion" / c.Enum(c.Int32ul, none=0, left=1, right=2), 170 | ).compile() 171 | 172 | 173 | class PluckedEvent(StructEventBase): 174 | STRUCT = c.Struct( 175 | "decay" / c.Int32ul, 176 | "color" / c.Int32ul, 177 | "normalize" / FourByteBool, 178 | "gate" / FourByteBool, 179 | "widen" / FourByteBool, 180 | ).compile() 181 | 182 | 183 | class SoundgoodizerEvent(StructEventBase): 184 | STRUCT = c.Struct( 185 | "_u1" / c.If(c.this._.len == 12, c.Bytes(4)), 186 | "mode" / c.Enum(c.Int32ul, A=0, B=1, C=2, D=3), 187 | "amount" / c.Int32ul, 188 | ).compile() 189 | 190 | 191 | NativePluginEvent = UnknownDataEvent 192 | """Placeholder event type for unimplemented native :attr:`PluginID.Data` events.""" 193 | 194 | 195 | class WrapperPage(ct.EnumBase): 196 | Editor = 0 197 | """:guilabel:`Plugin editor`.""" 198 | 199 | Settings = 1 200 | """:guilabel:`VST wrapper settings`.""" 201 | 202 | Sample = 3 203 | """:guilabel:`Sample settings`.""" 204 | 205 | Envelope = 4 206 | """:guilabel:`Envelope / instrument settings`.""" 207 | 208 | Miscellaneous = 5 209 | """:guilabel:`Miscallenous functions`.""" 210 | 211 | 212 | class WrapperEvent(StructEventBase): 213 | STRUCT = c.Struct( 214 | "_u1" / c.Optional(c.Bytes(16)), # 16 215 | "flags" / c.Optional(StdEnum[_WrapperFlags](c.Int16ul)), # 18 216 | "_u2" / c.Optional(c.Bytes(2)), # 20 217 | "page" / c.Optional(StdEnum[WrapperPage](c.Int8ul)), # 21 218 | "_u3" / c.Optional(c.Bytes(23)), # 44 219 | "width" / c.Optional(c.Int32ul), # 48 220 | "height" / c.Optional(c.Int32ul), # 52 221 | "_extra" / c.GreedyBytes, # None as of 20.9.2 222 | ).compile() 223 | 224 | 225 | @enum.unique 226 | class _VSTPluginEventID(ct.EnumBase): 227 | MIDI = 1 228 | Flags = 2 229 | IO = 30 230 | Inputs = 31 231 | Outputs = 32 232 | PluginInfo = 50 233 | FourCC = 51 # Not present for Waveshells & VST3 234 | GUID = 52 235 | State = 53 236 | Name = 54 237 | PluginPath = 55 238 | Vendor = 56 239 | _57 = 57 # TODO, not present for Waveshells 240 | 241 | 242 | class _VSTFlags(enum.IntFlag): 243 | SendPBRange = 1 << 0 244 | FixedSizeBuffers = 1 << 1 245 | NotifyRender = 1 << 2 246 | ProcessInactive = 1 << 3 247 | DontSendRelVelo = 1 << 5 248 | DontNotifyChanges = 1 << 6 249 | SendLoopPos = 1 << 11 250 | AllowThreaded = 1 << 12 251 | KeepFocus = 1 << 15 252 | DontKeepCPUState = 1 << 16 253 | SendModX = 1 << 17 254 | LoadBridged = 1 << 18 255 | ExternalWindow = 1 << 21 256 | UpdateWhenHidden = 1 << 23 257 | DontResetOnTransport = 1 << 25 258 | DPIAwareBridged = 1 << 26 259 | AcceptFileDrop = 1 << 28 260 | AllowSmartDisable = 1 << 29 261 | ScaleEditor = 1 << 30 262 | DontUseTimeOffset = 1 << 31 263 | 264 | 265 | class _VSTFlags2(enum.IntFlag): 266 | ProcessMaxSize = 1 << 0 267 | UseMaxFromHost = 1 << 1 268 | 269 | 270 | class VSTPluginEvent(StructEventBase): 271 | _MIDIStruct = c.Struct( 272 | "input" / c.Optional(c.Int32sl), # 4 273 | "output" / c.Optional(c.Int32sl), # 8 274 | "pb_range" / c.Optional(c.Int32ul), # 12 275 | "_extra" / c.GreedyBytes, # upto 20 276 | ).compile() 277 | 278 | _FlagsStruct = c.Struct( 279 | "_u1" / c.Optional(c.Bytes(9)), # 9 280 | "flags" / c.Optional(StdEnum[_VSTFlags](c.Int32ul)), # 13 281 | "flags2" / c.Optional(StdEnum[_VSTFlags2](c.Int32ul)), # 17 282 | "_u2" / c.Optional(c.Bytes(5)), # 22 283 | "fast_idle" / c.Optional(c.Flag), # 23 284 | "_extra" / c.GreedyBytes, 285 | ).compile() 286 | 287 | STRUCT = c.Struct( 288 | "type" / c.Int32ul, # * 8 or 10 for VSTs, but I am not forcing it 289 | "events" 290 | / c.GreedyRange( 291 | c.Struct( 292 | "id" / StdEnum[_VSTPluginEventID](c.Int32ul), 293 | # ! Using a c.Select or c.IfThenElse doesn't work here 294 | # Check https://github.com/construct/construct/issues/993 295 | "data" # pyright: ignore 296 | / c.Prefixed( 297 | c.Int64ul, 298 | c.Switch( 299 | c.this["id"], 300 | { 301 | _VSTPluginEventID.MIDI: _MIDIStruct, 302 | _VSTPluginEventID.Flags: _FlagsStruct, 303 | _VSTPluginEventID.FourCC: c.GreedyString("utf8"), 304 | _VSTPluginEventID.Name: c.GreedyString("utf8"), # See #150 305 | _VSTPluginEventID.Vendor: c.GreedyString("utf8"), 306 | _VSTPluginEventID.PluginPath: c.GreedyString("utf8"), 307 | }, 308 | default=c.GreedyBytes, 309 | ), 310 | ), 311 | ), 312 | ), 313 | ).compile() 314 | 315 | def __init__(self, id: Any, data: bytearray) -> None: 316 | if data[0] not in (8, 10): 317 | warnings.warn( 318 | f"VSTPluginEvent: Unknown marker {data[0]} detected. " 319 | "Open an issue at https://github.com/demberto/PyFLP/issues " 320 | "if you are seeing this!", 321 | RuntimeWarning, 322 | stacklevel=3, 323 | ) 324 | super().__init__(id, data) 325 | 326 | 327 | @enum.unique 328 | class PluginID(EventEnum): 329 | """IDs shared by :class:`pyflp.channel.Channel` and :class:`pyflp.mixer.Slot`.""" 330 | 331 | Color = (DWORD, ColorEvent) 332 | Icon = (DWORD + 27, U32Event) 333 | InternalName = TEXT + 9 334 | Name = TEXT + 11 #: 3.3.0+ for :class:`pyflp.mixer.Slot`. 335 | # TODO Additional possible fields: Plugin wrapper data, window 336 | # positions of plugin, currently selected plugin wrapper page, etc. 337 | Wrapper = (DATA + 4, WrapperEvent) 338 | # * The type of this event is decided during event collection 339 | Data = DATA + 5 #: 1.6.5+ 340 | 341 | 342 | @runtime_checkable 343 | class _IPlugin(Protocol): 344 | INTERNAL_NAME: ClassVar[str] 345 | """The name used internally by FL to decide the type of plugin data.""" 346 | 347 | 348 | _PE_co = TypeVar("_PE_co", bound=AnyEvent, covariant=True) 349 | 350 | 351 | class _WrapperProp(FlagProp): 352 | def __init__(self, flag: _WrapperFlags, **kw: Any) -> None: 353 | super().__init__(flag, PluginID.Wrapper, **kw) 354 | 355 | 356 | class _PluginBase(EventModel, Generic[_PE_co]): 357 | def __init__(self, events: EventTree, **kw: Any) -> None: 358 | super().__init__(events, **kw) 359 | 360 | compact = _WrapperProp(_WrapperFlags.HideSettings) 361 | """Whether plugin page toolbar (:guilabel:`Detailed settings`) is hidden. 362 | 363 | ![](https://bit.ly/3qzOMoO) 364 | """ 365 | 366 | demo_mode = _WrapperProp(_WrapperFlags.DemoMode) # TODO Verify if this works 367 | """Whether the plugin state was saved in a demo / trial version.""" 368 | 369 | detached = _WrapperProp(_WrapperFlags.Detached) 370 | """Plugin editor can be moved between different monitors when detached.""" 371 | 372 | disabled = _WrapperProp(_WrapperFlags._Disabled) 373 | """This is a legacy property; DON'T use it. 374 | 375 | Check :attr:`Channel.enabled` or :attr:`Slot.enabled` instead. 376 | """ 377 | 378 | directx = _WrapperProp(_WrapperFlags._DirectX) 379 | """Whether the plugin is a DirectX plugin or not.""" 380 | 381 | generator = _WrapperProp(_WrapperFlags.Generator) 382 | """Whether the plugin is a generator or an effect.""" 383 | 384 | height = StructProp[int](PluginID.Wrapper) 385 | """Height of the plugin editor (in pixels).""" 386 | 387 | minimized = _WrapperProp(_WrapperFlags.Minimized) 388 | """Whether the plugin editor is maximized or minimized. 389 | 390 | ![](https://bit.ly/3QDMWO3) 391 | """ 392 | 393 | multithreaded = _WrapperProp(_WrapperFlags.ThreadedProcessing) 394 | """Whether threaded processing is enabled or not.""" 395 | 396 | page = StructProp[WrapperPage](PluginID.Wrapper) 397 | """Active / selected / current page. 398 | 399 | ![](https://bit.ly/3ffJKM3) 400 | """ 401 | 402 | smart_disable = _WrapperProp(_WrapperFlags.SmartDisable) 403 | """Whether smart disable is enabled or not.""" 404 | 405 | visible = _WrapperProp(_WrapperFlags.Visible) 406 | """Whether the editor of the plugin is visible or closed.""" 407 | 408 | width = StructProp[int](PluginID.Wrapper) 409 | """Width of the plugin editor (in pixels).""" 410 | 411 | 412 | AnyPlugin = _PluginBase[AnyEvent] # TODO alias to _IPlugin + _PluginBase (both) 413 | 414 | 415 | class PluginProp(RWProperty[AnyPlugin]): 416 | def __init__(self, *types: type[AnyPlugin]) -> None: 417 | self._types = types 418 | 419 | @staticmethod 420 | def _get_plugin_events(ins: EventModel) -> EventTree: 421 | return ins.events.subtree(lambda e: e.id in (PluginID.Wrapper, PluginID.Data)) 422 | 423 | def __get__(self, ins: EventModel, owner: Any = None) -> AnyPlugin | None: 424 | if owner is None: 425 | return NotImplemented 426 | 427 | try: 428 | data_event = ins.events.first(PluginID.Data) 429 | except KeyError: 430 | return None 431 | 432 | if isinstance(data_event, UnknownDataEvent): 433 | return _PluginBase(self._get_plugin_events(ins)) 434 | 435 | for ptype in self._types: 436 | event_type = ptype.__orig_bases__[0].__args__[0] # type: ignore 437 | if isinstance(data_event, event_type): 438 | return ptype(self._get_plugin_events(ins)) 439 | 440 | def __set__(self, ins: EventModel, value: AnyPlugin) -> None: 441 | if isinstance(value, _IPlugin): 442 | setattr(ins, "internal_name", value.INTERNAL_NAME) 443 | 444 | for id in (PluginID.Data, PluginID.Wrapper): 445 | for ie in ins.events.lst: 446 | if ie.e.id == id: 447 | ie.e = value.events.first(id) 448 | 449 | 450 | class _NativePluginProp(StructProp[T]): 451 | def __init__(self, prop: str | None = None, **kwds: Any) -> None: 452 | super().__init__(PluginID.Data, prop=prop, **kwds) 453 | 454 | 455 | class _VSTPluginProp(RWProperty[T], NamedPropMixin): 456 | def __init__(self, id: _VSTPluginEventID, prop: str | None = None) -> None: 457 | self._id = id 458 | NamedPropMixin.__init__(self, prop) 459 | 460 | def __get__(self, ins: EventModel, _=None) -> T: 461 | event = cast(VSTPluginEvent, ins.events.first(PluginID.Data)) 462 | for e in event["events"]: 463 | if e["id"] == self._id: 464 | return self._get(e["data"]) 465 | raise AttributeError(self._id) 466 | 467 | def _get(self, value: Any) -> T: 468 | return cast(T, value if isinstance(value, (str, bytes)) else value[self._prop]) 469 | 470 | def __set__(self, ins: EventModel, value: T) -> None: 471 | self._set(cast(VSTPluginEvent, ins.events.first(PluginID.Data)), value) 472 | 473 | def _set(self, event: VSTPluginEvent, value: T) -> None: 474 | for e in event["events"]: 475 | if e["id"] == self._id: 476 | e["data"] = value 477 | break 478 | 479 | 480 | class _VSTFlagProp(_VSTPluginProp[bool]): 481 | def __init__( 482 | self, flag: _VSTFlags | _VSTFlags2, prop: str = "flags", inverted: bool = False 483 | ) -> None: 484 | super().__init__(_VSTPluginEventID.Flags, prop) 485 | self._flag = flag 486 | self._inverted = inverted 487 | 488 | def _get(self, value: Any) -> bool: 489 | retbool = self._flag in value[self._prop] 490 | return retbool if not self._inverted else not retbool 491 | 492 | def _set(self, event: VSTPluginEvent, value: bool) -> None: 493 | if self._inverted: 494 | value = not value 495 | 496 | for e in event["events"]: 497 | if e["id"] == self._id: 498 | if value: 499 | e["data"][self._prop] |= value 500 | else: 501 | e["data"][self._prop] &= ~value 502 | break 503 | 504 | 505 | class PluginIOInfo(EventModel): 506 | mixer_offset = StructProp[int]() 507 | flags = StructProp[int]() 508 | 509 | 510 | class VSTPlugin(_PluginBase[VSTPluginEvent], _IPlugin): 511 | """Represents a VST2 or a VST3 generator or effect. 512 | 513 | *New in FL Studio v1.5.23*: VST2 support (beta). 514 | *New in FL Studio v9.0.3*: VST3 support. 515 | """ 516 | 517 | INTERNAL_NAME = "Fruity Wrapper" 518 | 519 | def __repr__(self) -> str: 520 | return f"VSTPlugin(name={self.name!r}, vendor={self.vendor!r})" 521 | 522 | class _AutomationOptions(EventModel): 523 | """See :attr:`VSTPlugin.automation`.""" 524 | 525 | notify_changes = _VSTFlagProp(_VSTFlags.DontNotifyChanges, inverted=True) 526 | """Record parameter changes as automation. 527 | 528 | :guilabel:`Notify about parameter changes`. Defaults to ``True``. 529 | """ 530 | 531 | class _CompatibilityOptions(EventModel): 532 | """See :attr:`VSTPlugin.compatibility`.""" 533 | 534 | buffers_maxsize = _VSTFlagProp(_VSTFlags2.UseMaxFromHost, prop="flags2") 535 | """:guilabel:`Use maximum buffer size from host`. Defaults to ``False``.""" 536 | 537 | fast_idle = _VSTPluginProp[bool](_VSTPluginEventID.Flags) 538 | """Increases idle rate - can make plugin GUI feel more responsive if its slow. 539 | 540 | May increase CPU usage. Defaults to ``False``. 541 | """ 542 | 543 | fixed_buffers = _VSTFlagProp(_VSTFlags.FixedSizeBuffers) 544 | """:guilabel:`Use fixed size buffers`. Defaults to ``False``. 545 | 546 | Makes FL Studio send fixed size buffers instead of variable ones when ``True``. 547 | Can fix rendering errors caused by plugins. Increases latency by 2ms. 548 | """ 549 | 550 | process_maximum = _VSTFlagProp(_VSTFlags2.ProcessMaxSize, prop="flags2") 551 | """:guilabel:`Process maximum size buffers`. Defaults to ``False``.""" 552 | 553 | reset_on_transport = _VSTFlagProp(_VSTFlags.DontResetOnTransport, inverted=True) 554 | """:guilabel:`Reset plugin when FL Studio resets`. Defaults to ``True``.""" 555 | 556 | send_loop = _VSTFlagProp(_VSTFlags.SendLoopPos) 557 | """Lets the plugin know about :attr:`Arrangemnt.loop_pos`. 558 | 559 | :guilabel:`Send loop position`. Defaults to ``True``. 560 | """ 561 | 562 | use_time_offset = _VSTFlagProp(_VSTFlags.DontUseTimeOffset, inverted=True) 563 | """Adjust time information reported by plugin. 564 | 565 | Can fix timing issues caused by plugins in FL Studio <20.7 project. 566 | :guilabel:`Use time offset`. Defaults to ``False``. 567 | """ 568 | 569 | class _MIDIOptions(EventModel): 570 | """See :attr:`VSTPlugin.midi`. 571 | 572 | ![](https://bit.ly/3NbGr4U) 573 | """ 574 | 575 | input = _VSTPluginProp[int](_VSTPluginEventID.MIDI) 576 | """MIDI Input Port. Min = 0, Max = 255. Not selected = -1 (default).""" 577 | 578 | output = _VSTPluginProp[int](_VSTPluginEventID.MIDI) 579 | """MIDI Output Port. Min = 0, Max = 255. Not selected = -1 (default).""" 580 | 581 | pb_range = _VSTPluginProp[int](_VSTPluginEventID.MIDI) 582 | """Pitch bend range MIDI RPN sent to the plugin (in semitones). 583 | 584 | Min = 1. Max = 48. Defaults to 12. 585 | """ 586 | 587 | send_modx = _VSTFlagProp(_VSTFlags.SendModX) 588 | """:guilabel:`Send MOD X as polyphonic aftertouch`. Defaults to ``False``.""" 589 | 590 | send_pb = _VSTFlagProp(_VSTFlags.SendPBRange) 591 | """:guilabel:`Send pitch bend range (semitones)`. Defaults to ``False``. 592 | 593 | See also: 594 | :attr:`pb_range` - Sent to plugin as a MIDI RPN if this is ``True``. 595 | """ 596 | 597 | send_release = _VSTFlagProp(_VSTFlags.DontSendRelVelo, inverted=True) 598 | """Whether release velocity should be sent in note off messages. 599 | 600 | :guilabel:`Send note release velocity`. Defaults to ``True``. 601 | """ 602 | 603 | class _ProcessingOptions(EventModel): 604 | """See :attr:`VSTPlugin.processing`.""" 605 | 606 | allow_sd = _VSTFlagProp(_VSTFlags.AllowSmartDisable) 607 | """:guilabel:`Allow smart disable`. Defaults to ``True``. 608 | 609 | Disables the :attr:`VSTPlugin.smart_disable` feature if ``False``. 610 | """ 611 | 612 | bridged = _VSTFlagProp(_VSTFlags.LoadBridged) 613 | """Load a plugin in separate process. 614 | 615 | :guilabel:`Make bridged`. Defaults to ``False``. 616 | """ 617 | 618 | external = _VSTFlagProp(_VSTFlags.ExternalWindow) 619 | """Keep plugin editor in bridge process. 620 | 621 | :guilabel:`External window`. Defaults to ``False``. 622 | """ 623 | 624 | keep_state = _VSTFlagProp(_VSTFlags.DontKeepCPUState, inverted=True) 625 | """Don't touch unless you have issues like DC offsets, spikes and crashes. 626 | 627 | :guilabel:`Ensure processor state in callbacks`. Defaults to ``True``. 628 | """ 629 | 630 | multithreaded = _VSTFlagProp(_VSTFlags.AllowThreaded) 631 | """Allow plugin to be multi-threaded by FL Studio. 632 | 633 | Disables the :attr:`VSTPlugin.multithreaded` feature if ``False``. 634 | 635 | :guilabel:`Allow threaded processing`. Defaults to ``True``. 636 | """ 637 | 638 | notify_render = _VSTFlagProp(_VSTFlags.NotifyRender) 639 | """Lets the plugin know when rendering to audio file. 640 | 641 | This can be used by the plugin to switch to HQ processing or disable 642 | output entirely if it is in demo mode (depends on the plugin logic). 643 | 644 | :guilabel:`Notify about rendering mode`. Defaults to ``True``. 645 | """ 646 | 647 | process_inactive = _VSTFlagProp(_VSTFlags.ProcessInactive) 648 | """Make FL Studio also process inputs / outputs marked as inactive by plugin. 649 | 650 | :guilabel:`Process inactive inputs and outputs`. Defaults to ``True``. 651 | """ 652 | 653 | class _UIOptions(EventModel): 654 | """See :attr:`VSTPlugin.ui`. 655 | 656 | ![](https://bit.ly/3Nb3dtP) 657 | """ 658 | 659 | accept_drop = _VSTFlagProp(_VSTFlags.AcceptFileDrop) 660 | """Host is bypassed when a file is dropped on the plugin editor. 661 | 662 | :guilabel:`Accept dropped files`. Defaults to ``False``. 663 | """ 664 | 665 | always_update = _VSTFlagProp(_VSTFlags.UpdateWhenHidden) 666 | """Whether plugin UI should be updated when hidden; default to ``False``.""" 667 | 668 | dpi_aware = _VSTFlagProp(_VSTFlags.DPIAwareBridged) 669 | """Enable if plugin editors look too big or small. 670 | 671 | :guilabel:`DPI aware when bridged`. Defaults to ``True``. 672 | """ 673 | 674 | scale_editor = _VSTFlagProp(_VSTFlags.ScaleEditor) 675 | """Scale dimensions of editor that appear cut-off on high-res screens. 676 | 677 | :guilabel:`Scale editor dimensions`. Defaults to ``False``. 678 | """ 679 | 680 | def __init__(self, events: EventTree, **kw: Any) -> None: 681 | super().__init__(events, **kw) 682 | 683 | # This doesn't break lazy evaluation in any way 684 | self.automation = self._AutomationOptions(events) 685 | self.compatibility = self._CompatibilityOptions(events) 686 | self.midi = self._MIDIOptions(events) 687 | self.processing = self._ProcessingOptions(events) 688 | self.ui = self._UIOptions(events) 689 | 690 | fourcc = _VSTPluginProp[str](_VSTPluginEventID.FourCC) 691 | """A unique four character code identifying the plugin. 692 | 693 | A database can be found on Steinberg's developer portal. 694 | """ 695 | 696 | guid = _VSTPluginProp[bytes](_VSTPluginEventID.GUID) # See issue #8 697 | name = _VSTPluginProp[str](_VSTPluginEventID.Name) 698 | """Factory name of the plugin.""" 699 | 700 | # num_inputs = _VSTPluginProp[int]() 701 | # """Number of inputs the plugin supports.""" 702 | 703 | # num_outputs = _VSTPluginProp[int]() 704 | # """Number of outputs the plugin supports.""" 705 | 706 | plugin_path = _VSTPluginProp[str](_VSTPluginEventID.PluginPath) 707 | """The absolute path to the plugin binary.""" 708 | 709 | state = _VSTPluginProp[bytes](_VSTPluginEventID.State) 710 | """Plugin specific preset data blob.""" 711 | 712 | vendor = _VSTPluginProp[str](_VSTPluginEventID.Vendor) 713 | """Plugin developer (vendor) name.""" 714 | 715 | # vst_number = _VSTPluginProp[int]() # TODO 716 | 717 | 718 | class BooBass(_PluginBase[BooBassEvent], _IPlugin, ModelReprMixin): 719 | """![](https://bit.ly/3Bk3aGK)""" 720 | 721 | INTERNAL_NAME = "BooBass" 722 | bass = _NativePluginProp[int]() 723 | """Volume of the bass region. 724 | 725 | | Min | Max | Default | 726 | |-----|-------|---------| 727 | | 0 | 65535 | 32767 | 728 | """ 729 | 730 | high = _NativePluginProp[int]() 731 | """Volume of the high region. 732 | 733 | | Min | Max | Default | 734 | |-----|-------|---------| 735 | | 0 | 65535 | 32767 | 736 | """ 737 | 738 | mid = _NativePluginProp[int]() 739 | """Volume of the mid region. 740 | 741 | | Min | Max | Default | 742 | |-----|-------|---------| 743 | | 0 | 65535 | 32767 | 744 | """ 745 | 746 | 747 | class FruitKick(_PluginBase[FruitKickEvent], _IPlugin, ModelReprMixin): 748 | """![](https://bit.ly/41fIPxE)""" 749 | 750 | INTERNAL_NAME = "Fruit Kick" 751 | amp_decay = _NativePluginProp[int]() 752 | """Amplitude (volume) decay length. Linear. 753 | 754 | | Type | Value | Representation | 755 | |---------|-------|----------------| 756 | | Min | 0 | 0% | 757 | | Max | 256 | 100% | 758 | | Default | 128 | 50% | 759 | """ 760 | 761 | click = _NativePluginProp[int]() 762 | """Amount of phase offset added to produce a click. Linear. 763 | 764 | | Type | Value | Representation | 765 | |---------|-------|----------------| 766 | | Min | 0 | 0% | 767 | | Max | 64 | 100% | 768 | | Default | 32 | 50% | 769 | """ 770 | 771 | distortion = _NativePluginProp[int]() 772 | """Linear. Defaults to minimum. 773 | 774 | | Type | Value | Representation | 775 | |---------|-------|----------------| 776 | | Min | 0 | 0% | 777 | | Max | 128 | 100% | 778 | """ 779 | 780 | freq_decay = _NativePluginProp[int]() 781 | """Pitch sweep time / pitch decay. Linear. 782 | 783 | | Type | Value | Representation | 784 | |---------|-------|----------------| 785 | | Min | 0 | 0% | 786 | | Max | 256 | 100% | 787 | | Default | 64 | 25% | 788 | """ 789 | 790 | max_freq = _NativePluginProp[int]() 791 | """Start frequency. Linear. 792 | 793 | | Type | Value | Representation | 794 | |---------|-------|----------------| 795 | | Min | -900 | -67% | 796 | | Max | 3600 | 100% | 797 | | Default | 0 | 0% | 798 | """ 799 | 800 | min_freq = _NativePluginProp[int]() 801 | """Sweep to / end frequency. Linear. 802 | 803 | | Type | Value | Representation | 804 | |---------|-------|----------------| 805 | | Min | -1200 | -100% | 806 | | Max | 1200 | 100% | 807 | | Default | -600 | -50% | 808 | """ 809 | 810 | 811 | class FruityBalance(_PluginBase[FruityBalanceEvent], _IPlugin, ModelReprMixin): 812 | """![](https://bit.ly/3RWItqU)""" 813 | 814 | INTERNAL_NAME = "Fruity Balance" 815 | pan = _NativePluginProp[int]() 816 | """Linear. 817 | 818 | | Type | Value | Representation | 819 | |---------|-------|----------------| 820 | | Min | -128 | 100% left | 821 | | Max | 127 | 100% right | 822 | | Default | 0 | Centred | 823 | """ 824 | 825 | volume = _NativePluginProp[int]() 826 | """Logarithmic. 827 | 828 | | Type | Value | Representation | 829 | |---------|-------|----------------| 830 | | Min | 0 | -INFdB / 0.00 | 831 | | Max | 320 | 5.6dB / 1.90 | 832 | | Default | 256 | 0.0dB / 1.00 | 833 | """ 834 | 835 | 836 | class FruityBloodOverdrive(_PluginBase[FruityBloodOverdriveEvent], _IPlugin, ModelReprMixin): 837 | """![](https://bit.ly/3LnS1LE)""" 838 | 839 | INTERNAL_NAME = "Fruity Blood Overdrive" 840 | 841 | pre_band = _NativePluginProp[int]() 842 | """Linear. 843 | 844 | | Type | Value | Representation | 845 | |---------|-------|----------------| 846 | | Min | 0 | 0.0000 | 847 | | Max | 10000 | 1.0000 | 848 | | Default | 0 | 0.0000 | 849 | """ 850 | 851 | color = _NativePluginProp[int]() 852 | """Linear. 853 | 854 | | Type | Value | Representation | 855 | |---------|-------|----------------| 856 | | Min | 0 | 0.0000 | 857 | | Max | 10000 | 1.0000 | 858 | | Default | 5000 | 0.5000 | 859 | """ 860 | 861 | pre_amp = _NativePluginProp[int]() 862 | """Linear. 863 | 864 | | Type | Value | Representation | 865 | |---------|-------|----------------| 866 | | Min | 0 | 0.0000 | 867 | | Max | 10000 | 1.0000 | 868 | | Default | 0 | 0.0000 | 869 | """ 870 | 871 | x100 = _NativePluginProp[bool]() 872 | """Boolean. 873 | 874 | | Type | Value | Representation | 875 | |---------|-------|----------------| 876 | | Off | 0 | Off | 877 | | On | 1 | On | 878 | | Default | 0 | Off | 879 | """ 880 | 881 | post_filter = _NativePluginProp[int]() 882 | """Linear. 883 | 884 | | Type | Value | Representation | 885 | |---------|-------|----------------| 886 | | Min | 0 | 0.0000 | 887 | | Max | 10000 | 1.0000 | 888 | | Default | 0 | 0.0000 | 889 | """ 890 | 891 | post_gain = _NativePluginProp[int]() 892 | """Linear. 893 | 894 | | Type | Value | Representation | 895 | |---------|-------|----------------| 896 | | Min | 0 | -1.0000 | 897 | | Max | 10000 | 0.0000 | 898 | | Default | 10000 | 0.0000 | 899 | """ 900 | 901 | 902 | class FruityCenter(_PluginBase[FruityCenterEvent], _IPlugin, ModelReprMixin): 903 | """![](https://bit.ly/3TA9IIv)""" 904 | 905 | INTERNAL_NAME = "Fruity Center" 906 | enabled = _NativePluginProp[bool]() 907 | """Removes DC offset if True; effectively behaving like a bypass button. 908 | 909 | Labelled as **Status** for some reason in the UI. 910 | """ 911 | 912 | 913 | class FruityFastDist(_PluginBase[FruityFastDistEvent], _IPlugin, ModelReprMixin): 914 | """![](https://bit.ly/3qT6Jil)""" 915 | 916 | INTERNAL_NAME = "Fruity Fast Dist" 917 | kind = _NativePluginProp[Literal["A", "B"]]() 918 | mix = _NativePluginProp[int]() 919 | """Linear. Defaults to maximum value. 920 | 921 | | Type | Value | Mix (wet) | 922 | |------|-------|-----------| 923 | | Min | 0 | 0% | 924 | | Max | 128 | 100% | 925 | """ 926 | 927 | post = _NativePluginProp[int]() 928 | """Linear. Defaults to maximum value. 929 | 930 | | Type | Value | Mix (wet) | 931 | |------|-------|-----------| 932 | | Min | 0 | 0% | 933 | | Max | 128 | 100% | 934 | """ 935 | 936 | pre = _NativePluginProp[int]() 937 | """Linear. 938 | 939 | | Type | Value | Percentage | 940 | |---------|-------|------------| 941 | | Min | 64 | 33% | 942 | | Max | 192 | 100% | 943 | | Default | 128 | 67% | 944 | """ 945 | 946 | threshold = _NativePluginProp[int]() 947 | """Linear, Stepped. Defaults to maximum value. 948 | 949 | | Type | Value | Percentage | 950 | |------|-------|------------| 951 | | Min | 1 | 10% | 952 | | Max | 10 | 100% | 953 | """ 954 | 955 | 956 | class FruityNotebook2(_PluginBase[FruityNotebook2Event], _IPlugin, ModelReprMixin): 957 | """![](https://bit.ly/3RHa4g5)""" 958 | 959 | INTERNAL_NAME = "Fruity NoteBook 2" 960 | active_page = _NativePluginProp[int]() 961 | """Active page number of the notebook. Min: 0, Max: 100.""" 962 | 963 | editable = _NativePluginProp[bool]() 964 | """Whether the notebook is marked as editable or read-only. 965 | 966 | This attribute is just a visual marker used by FL Studio. 967 | """ 968 | 969 | pages = _NativePluginProp[Dict[int, str]]() 970 | """A dict of page numbers to their contents.""" 971 | 972 | 973 | class FruitySend(_PluginBase[FruitySendEvent], _IPlugin, ModelReprMixin): 974 | """![](https://bit.ly/3DqjvMu)""" 975 | 976 | INTERNAL_NAME = "Fruity Send" 977 | dry = _NativePluginProp[int]() 978 | """Linear. Defaults to maximum value. 979 | 980 | | Type | Value | Mix (wet) | 981 | |------|-------|-----------| 982 | | Min | 0 | 0% | 983 | | Max | 256 | 100% | 984 | """ 985 | 986 | pan = _NativePluginProp[int]() 987 | """Linear. 988 | 989 | | Type | Value | Representation | 990 | |---------|-------|----------------| 991 | | Min | -128 | 100% left | 992 | | Max | 127 | 100% right | 993 | | Default | 0 | Centred | 994 | """ 995 | 996 | send_to = _NativePluginProp[int]() 997 | """Target insert index; depends on insert routing. Defaults to -1 (Master).""" 998 | 999 | volume = _NativePluginProp[int]() 1000 | """Logarithmic. 1001 | 1002 | | Type | Value | Representation | 1003 | |---------|-------|----------------| 1004 | | Min | 0 | -INFdB / 0.00 | 1005 | | Max | 320 | 5.6dB / 1.90 | 1006 | | Default | 256 | 0.0dB / 1.00 | 1007 | """ 1008 | 1009 | 1010 | class FruitySoftClipper(_PluginBase[FruitySoftClipperEvent], _IPlugin, ModelReprMixin): 1011 | """![](https://bit.ly/3BCWfJX)""" 1012 | 1013 | INTERNAL_NAME = "Fruity Soft Clipper" 1014 | post = _NativePluginProp[int]() 1015 | """Linear. 1016 | 1017 | | Type | Value | Mix (wet) | 1018 | |---------|-------|-----------| 1019 | | Min | 0 | 0% | 1020 | | Max | 160 | 100% | 1021 | | Default | 128 | 80% | 1022 | """ 1023 | 1024 | threshold = _NativePluginProp[int]() 1025 | """Logarithmic. 1026 | 1027 | | Type | Value | Representation | 1028 | |---------|-------|----------------| 1029 | | Min | 1 | -INFdB / 0.00 | 1030 | | Max | 127 | 0.0dB / 1.00 | 1031 | | Default | 100 | -4.4dB / 0.60 | 1032 | """ 1033 | 1034 | 1035 | class FruityStereoEnhancer(_PluginBase[FruityStereoEnhancerEvent], _IPlugin, ModelReprMixin): 1036 | """![](https://bit.ly/3DoHvji)""" 1037 | 1038 | INTERNAL_NAME = "Fruity Stereo Enhancer" 1039 | effect_position = _NativePluginProp[Literal["pre", "post"]]() 1040 | """Defaults to ``post``.""" 1041 | 1042 | pan = _NativePluginProp[int]() 1043 | """Linear. 1044 | 1045 | | Type | Value | Representation | 1046 | |---------|-------|----------------| 1047 | | Min | -128 | 100% left | 1048 | | Max | 127 | 100% right | 1049 | | Default | 0 | Centred | 1050 | """ 1051 | 1052 | phase_inversion = _NativePluginProp[Literal["none", "left", "right"]]() 1053 | """Default to ``None``.""" 1054 | 1055 | phase_offset = _NativePluginProp[int]() 1056 | """Linear. 1057 | 1058 | | Type | Value | Representation | 1059 | |---------|-------|----------------| 1060 | | Min | -512 | 500ms L | 1061 | | Max | 512 | 500ms R | 1062 | | Default | 0 | No offset | 1063 | """ 1064 | 1065 | stereo_separation = _NativePluginProp[int]() 1066 | """Linear. 1067 | 1068 | | Type | Value | Representation | 1069 | |---------|-------|----------------| 1070 | | Min | -96 | 100% separated | 1071 | | Max | 96 | 100% merged | 1072 | | Default | 0 | No effect | 1073 | """ 1074 | 1075 | volume = _NativePluginProp[int]() 1076 | """Logarithmic. 1077 | 1078 | | Type | Value | Representation | 1079 | |---------|-------|----------------| 1080 | | Min | 0 | -INFdB / 0.00 | 1081 | | Max | 320 | 5.6dB / 1.90 | 1082 | | Default | 256 | 0.0dB / 1.00 | 1083 | """ 1084 | 1085 | 1086 | class Plucked(_PluginBase[PluckedEvent], _IPlugin, ModelReprMixin): 1087 | """![](https://bit.ly/3GuFz9k)""" 1088 | 1089 | INTERNAL_NAME = "Plucked!" 1090 | color = _NativePluginProp[int]() 1091 | """Linear. 1092 | 1093 | | Min | Max | Default | 1094 | |-----|------|---------| 1095 | | 0 | 128 | 64 | 1096 | """ 1097 | 1098 | decay = _NativePluginProp[int]() 1099 | """Linear. 1100 | 1101 | | Min | Max | Default | 1102 | |-----|------|---------| 1103 | | 0 | 256 | 128 | 1104 | """ 1105 | 1106 | gate = _NativePluginProp[bool]() 1107 | """Stops the voices abruptly when released, otherwise the decay keeps going.""" 1108 | 1109 | normalize = _NativePluginProp[bool]() 1110 | """Same :attr:`decay` is tried to be used for all semitones. 1111 | 1112 | If not, higher notes have a shorter decay. 1113 | """ 1114 | 1115 | widen = _NativePluginProp[bool]() 1116 | """Enriches the stereo panorama of the sound.""" 1117 | 1118 | 1119 | class Soundgoodizer(_PluginBase[SoundgoodizerEvent], _IPlugin, ModelReprMixin): 1120 | """![](https://bit.ly/3dip70y)""" 1121 | 1122 | INTERNAL_NAME = "Soundgoodizer" 1123 | amount = _NativePluginProp[int]() 1124 | """Logarithmic. 1125 | 1126 | | Min | Max | Default | 1127 | |-----|------|---------| 1128 | | 0 | 1000 | 600 | 1129 | """ 1130 | 1131 | mode = _NativePluginProp[Literal["A", "B", "C", "D"]]() 1132 | """4 preset modes (A, B, C and D). Defaults to ``A``.""" 1133 | 1134 | 1135 | def get_event_by_internal_name(name: str) -> type[AnyEvent]: 1136 | for cls in _PluginBase.__subclasses__(): 1137 | if getattr(cls, "INTERNAL_NAME", None) == name: 1138 | return cls.__orig_bases__[0].__args__[0] # type: ignore 1139 | return NativePluginEvent 1140 | -------------------------------------------------------------------------------- /pyflp/project.py: -------------------------------------------------------------------------------- 1 | # PyFLP - An FL Studio project file (.flp) parser 2 | # Copyright (C) 2022 demberto 3 | # 4 | # This program is free software: you can redistribute it and/or modify it 5 | # under the terms of the GNU General Public License as published by the Free 6 | # Software Foundation, either version 3 of the License, or (at your option) 7 | # any later version. This program is distributed in the hope that it will be 8 | # useful, but WITHOUT ANY WARRANTY; without even the implied warranty of 9 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General 10 | # Public License for more details. You should have received a copy of the 11 | # GNU General Public License along with this program. If not, see 12 | # . 13 | 14 | """Contains the class (and types it uses) used by the parser and serializer.""" 15 | 16 | from __future__ import annotations 17 | 18 | import datetime 19 | import enum 20 | import math 21 | import pathlib 22 | from typing import Final, Literal, cast 23 | 24 | import construct as c 25 | import construct_typed as ct 26 | from typing_extensions import TypedDict, Unpack 27 | 28 | from pyflp._descriptors import EventProp, KWProp 29 | from pyflp._events import ( 30 | DATA, 31 | DWORD, 32 | TEXT, 33 | WORD, 34 | AnyEvent, 35 | AsciiEvent, 36 | BoolEvent, 37 | EventEnum, 38 | EventTree, 39 | I16Event, 40 | I32Event, 41 | StructEventBase, 42 | U8Event, 43 | U32Event, 44 | ) 45 | from pyflp._models import EventModel 46 | from pyflp.arrangement import ArrangementID, Arrangements, ArrangementsID, TrackID 47 | from pyflp.channel import ChannelID, ChannelRack, DisplayGroupID, RackID 48 | from pyflp.exceptions import PropertyCannotBeSet 49 | from pyflp.mixer import InsertID, Mixer, MixerID, SlotID 50 | from pyflp.pattern import PatternID, Patterns, PatternsID 51 | from pyflp.plugin import PluginID 52 | from pyflp.timemarker import TimeMarkerID 53 | from pyflp.types import FLVersion 54 | 55 | __all__ = ["PanLaw", "Project", "FileFormat", "VALID_PPQS"] 56 | 57 | _DELPHI_EPOCH: Final = datetime.datetime(1899, 12, 30) 58 | MIN_TEMPO: Final = 10.000 59 | """Minimum tempo (in BPM) FL Studio supports.""" 60 | 61 | VALID_PPQS: Final = (24, 48, 72, 96, 120, 144, 168, 192, 384, 768, 960) 62 | """PPQs / timebase supported by FL Studio as of its latest version.""" 63 | 64 | 65 | class TimestampEvent(StructEventBase): 66 | STRUCT = c.Struct("created_on" / c.Float64l, "time_spent" / c.Float64l).compile() 67 | 68 | 69 | @enum.unique 70 | class PanLaw(ct.EnumBase): 71 | """Used by :attr:`Project.pan_law`.""" 72 | 73 | Circular = 0 74 | Triangular = 2 75 | 76 | 77 | @enum.unique 78 | class FileFormat(enum.IntEnum): 79 | """File formats used by FL Studio. 80 | 81 | *New in FL Studio v2.5.0*: FST (FL Studio state) file format. 82 | """ 83 | 84 | None_ = -1 85 | """Temporary file.""" 86 | 87 | Project = 0 88 | """FL Studio project (.flp).""" 89 | 90 | Score = 0x10 91 | """FL Studio score (.fsc). Stores pattern notes and controller events.""" 92 | 93 | Automation = 24 94 | """Stores controller events and automation channels as FST.""" 95 | 96 | ChannelState = 0x20 97 | """Entire channel (including plugin events). Stored as FST.""" 98 | 99 | PluginState = 0x30 100 | """Events of a native plugin on a channel or insert slot. Stored as FST.""" 101 | 102 | GeneratorState = 0x31 103 | """Plugins events of a VST instrument. Stored as FST.""" 104 | 105 | FXState = 0x32 106 | """Plugin events of a VST effect. Stored as FST.""" 107 | 108 | InsertState = 0x40 109 | """Insert and all its slots. Stored as FST.""" 110 | 111 | _ProbablyPatcher = 0x50 # * Patcher presets are stored as `PluginState`. 112 | 113 | 114 | class ProjectID(EventEnum): 115 | LoopActive = (9, BoolEvent) 116 | ShowInfo = (10, BoolEvent) 117 | _Volume = (12, U8Event) 118 | PanLaw = (23, U8Event) 119 | Licensed = (28, BoolEvent) 120 | _TempoCoarse = WORD + 2 121 | Pitch = (WORD + 16, I16Event) 122 | _TempoFine = WORD + 29 #: 3.4.0+ 123 | CurGroupId = (DWORD + 18, I32Event) 124 | Tempo = (DWORD + 28, U32Event) 125 | FLBuild = (DWORD + 31, U32Event) 126 | Title = TEXT + 2 127 | Comments = TEXT + 3 128 | Url = TEXT + 5 129 | _RTFComments = TEXT + 6 #: 1.2.10+ 130 | FLVersion = (TEXT + 7, AsciiEvent) 131 | Licensee = TEXT + 8 #: 1.3.9+ 132 | DataPath = TEXT + 10 #: 9.0+ 133 | Genre = TEXT + 14 #: 5.0+ 134 | Artists = TEXT + 15 #: 5.0+ 135 | Timestamp = (DATA + 29, TimestampEvent) 136 | 137 | 138 | class _ProjectKW(TypedDict): 139 | channel_count: int 140 | ppq: int 141 | format: FileFormat 142 | 143 | 144 | class Project(EventModel): 145 | """Represents an FL Studio project.""" 146 | 147 | def __init__(self, events: EventTree, **kw: Unpack[_ProjectKW]) -> None: 148 | super().__init__(events, **kw) 149 | 150 | def __repr__(self) -> str: 151 | return f"" 152 | 153 | def __str__(self) -> str: 154 | return f"FL Studio v{self.version!s} {self.format.name}" # type: ignore 155 | 156 | @property 157 | def arrangements(self) -> Arrangements: 158 | """Provides an iterator over arrangements and other related properties.""" 159 | arrnew_occured = False 160 | 161 | def select(e: AnyEvent) -> Literal[True] | None: 162 | nonlocal arrnew_occured 163 | 164 | if e.id == ArrangementID.New: 165 | arrnew_occured = True 166 | 167 | # * Prevents accidentally passing on Pattern's timemarkers 168 | # TODO This logic will still be incorrect if arrangement's 169 | # timemarkers occur before ArrangementID.New event. 170 | if e.id in TimeMarkerID and arrnew_occured: 171 | return True 172 | 173 | if e.id in (*ArrangementID, *ArrangementsID, *TrackID): 174 | return True 175 | 176 | return Arrangements( 177 | self.events.subtree(select), 178 | channels=self.channels, 179 | patterns=self.patterns, 180 | version=self.version, 181 | ) 182 | 183 | artists = EventProp[str](ProjectID.Artists) 184 | """Authors / artists info. to be embedded in exported WAV & MP3. 185 | 186 | :menuselection:`Options --> &Project info --> Author` 187 | 188 | *New in FL Studio v5.0.* 189 | """ 190 | 191 | @property 192 | def channel_count(self) -> int: 193 | """Number of channels in the rack. 194 | 195 | For Patcher presets, the total number of plugins used inside it. 196 | 197 | Raises: 198 | ValueError: When a value less than zero is tried to be set. 199 | """ 200 | return self._kw["channel_count"] 201 | 202 | @channel_count.setter 203 | def channel_count(self, value: int) -> None: 204 | if value < 0: 205 | raise ValueError("Channel count cannot be less than zero") 206 | self._kw["channel_count"] = value 207 | 208 | @property 209 | def channels(self) -> ChannelRack: 210 | """Provides an iterator over channels and channel rack properties.""" 211 | 212 | def select(e: AnyEvent) -> bool | None: 213 | if e.id == InsertID.Flags: 214 | return False 215 | 216 | if e.id in (*ChannelID, *DisplayGroupID, *PluginID, *RackID): 217 | return True 218 | 219 | return ChannelRack( 220 | self.events.subtree(select), 221 | channel_count=self.channel_count, 222 | ) 223 | 224 | comments = EventProp[str](ProjectID.Comments, ProjectID._RTFComments) 225 | """Comments / project description / summary. 226 | 227 | :menuselection:`Options --> &Project info --> Comments` 228 | 229 | Caution: 230 | Very old versions of FL used to store comments in RTF (Rich Text Format). 231 | PyFLP makes no efforts to parse that and stores it like a normal string 232 | as it is. It is upto you to extract the text out of it. 233 | """ 234 | 235 | # Stored as a duration in days since the Delphi epoch (30 Dec, 1899). 236 | @property 237 | def created_on(self) -> datetime.datetime | None: 238 | """The local date and time on which this project was created. 239 | 240 | Located at the bottom of :menuselection:`Options --> &Project info` page. 241 | """ 242 | if ProjectID.Timestamp in self.events.ids: 243 | event = cast(TimestampEvent, self.events.first(ProjectID.Timestamp)) 244 | return _DELPHI_EPOCH + datetime.timedelta(days=event["created_on"]) 245 | 246 | format = KWProp[FileFormat]() 247 | """Internal format marker used by FL Studio to distinguish between types.""" 248 | 249 | @property 250 | def data_path(self) -> pathlib.Path | None: 251 | """The absolute path used by FL to store all your renders. 252 | 253 | :menuselection:`Options --> &Project general settings --> Data folder` 254 | 255 | *New in FL Studio v9.0.0.* 256 | """ 257 | if ProjectID.DataPath in self.events.ids: 258 | return pathlib.Path(self.events.first(ProjectID.DataPath).value) 259 | 260 | @data_path.setter 261 | def data_path(self, value: str | pathlib.Path) -> None: 262 | if ProjectID.DataPath not in self.events.ids: 263 | raise PropertyCannotBeSet(ProjectID.DataPath) 264 | 265 | if isinstance(value, pathlib.Path): 266 | value = str(value) 267 | 268 | path = "" if value == "." else value 269 | self.events.first(ProjectID.DataPath).value = path 270 | 271 | genre = EventProp[str](ProjectID.Genre) 272 | """Genre of the song to be embedded in exported WAV & MP3. 273 | 274 | :menuselection:`Options --> &Project info --> Genre` 275 | 276 | *New in FL Studio v5.0*. 277 | """ 278 | 279 | licensed = EventProp[bool](ProjectID.Licensed) 280 | """Whether the project was last saved with a licensed copy of FL Studio. 281 | 282 | Tip: 283 | Setting this to `True` and saving back the FLP will make it load the 284 | next time in a trial version of FL if it wouldn't open before. 285 | """ 286 | 287 | # Internally, this is jumbled up. Thanks to @codecat/libflp for decode algo. 288 | @property 289 | def licensee(self) -> str | None: 290 | """The license holder's username who last saved the project file. 291 | 292 | If saved with a trial version this is empty. 293 | 294 | Tip: 295 | As of the latest version, FL doesn't check for the contents of 296 | this for deciding whether to open or not when in trial version. 297 | 298 | *New in FL Studio v1.3.9*. 299 | """ 300 | if ProjectID.Licensee in self.events.ids: 301 | event = self.events.first(ProjectID.Licensee) 302 | licensee = bytearray() 303 | for idx, char in enumerate(event.value): 304 | c1 = ord(char) - 26 + idx 305 | c2 = ord(char) + 49 + idx 306 | for num in c1, c2: 307 | if chr(num).isalnum(): 308 | licensee.append(num) 309 | break 310 | 311 | return licensee.decode("ascii") 312 | 313 | @licensee.setter 314 | def licensee(self, value: str) -> None: 315 | if self.version < FLVersion(1, 3, 9): 316 | pass 317 | 318 | if ProjectID.Licensee not in self.events.ids: 319 | raise PropertyCannotBeSet(ProjectID.Licensee) 320 | 321 | event = self.events.first(ProjectID.Licensee) 322 | licensee = bytearray() 323 | for idx, char in enumerate(value): 324 | c1 = ord(char) + 26 - idx 325 | c2 = ord(char) - 49 - idx 326 | for cp in c1, c2: 327 | if 0 < cp <= 127: 328 | licensee.append(cp) 329 | break 330 | event.value = licensee.decode("ascii") 331 | 332 | looped = EventProp[bool](ProjectID.LoopActive) 333 | """Whether a portion of the playlist is selected.""" 334 | 335 | main_pitch = EventProp[int](ProjectID.Pitch) 336 | """:guilabel:`Master pitch` (in cents). Min = -1200. Max = +1200. Defaults to 0.""" 337 | 338 | main_volume = EventProp[int](ProjectID._Volume) 339 | """*Changed in FL Studio v1.7.6*: Can be upto 125% (+5.6dB) now.""" 340 | 341 | @property 342 | def mixer(self) -> Mixer: 343 | """Provides an iterator over inserts and other mixer related properties.""" 344 | inserts_began = False 345 | 346 | def select(e: AnyEvent) -> Literal[True] | None: 347 | nonlocal inserts_began 348 | if e.id in (*MixerID, *InsertID, *SlotID): 349 | # TODO Find a more reliable to detect when inserts start. 350 | inserts_began = True 351 | return True 352 | 353 | if inserts_began and e.id in PluginID: 354 | return True 355 | 356 | return Mixer(self.events.subtree(select), version=self.version) 357 | 358 | @property 359 | def patterns(self) -> Patterns: 360 | """Returns a collection of patterns and other related properties.""" 361 | arrnew_occured = False 362 | 363 | def select(e: AnyEvent) -> Literal[True] | None: 364 | nonlocal arrnew_occured 365 | 366 | if e.id == ArrangementID.New: 367 | arrnew_occured = True 368 | 369 | # * Prevents accidentally passing on Arrangement's timemarkers 370 | elif e.id in TimeMarkerID and not arrnew_occured: 371 | return True 372 | 373 | elif e.id in (*PatternID, *PatternsID): 374 | return True 375 | 376 | return Patterns(self.events.subtree(select)) 377 | 378 | pan_law = EventProp[PanLaw](ProjectID.PanLaw) 379 | """Whether a circular or a triangular pan law is used for the project. 380 | 381 | :menuselection:`Options -> &Project general settings -> Advanced -> Panning law` 382 | """ 383 | 384 | @property 385 | def ppq(self) -> int: 386 | """Pulses per quarter. 387 | 388 | ![](https://bit.ly/3F0UrMT) 389 | 390 | :menuselection:`Options --> &Project general settings --> Timebase (PPQ)`. 391 | 392 | Note: 393 | All types of lengths, positions and offsets internally use the PPQ 394 | as a multiplying factor. 395 | 396 | Danger: 397 | Don't try to set this property, it affects all the length, position 398 | and offset calculations used for deciding the placement of playlist, 399 | automations, timemarkers and patterns. 400 | 401 | When you change this in FL, it recalculates all the above. It is 402 | beyond PyFLP's scope to properly recalculate the timings. 403 | 404 | Raises: 405 | ValueError: When a value not in ``VALID_PPQS`` is tried to be set. 406 | 407 | *Changed in FL Studio v2.1.1*: Defaults to ``96``. 408 | """ 409 | return self._kw["ppq"] 410 | 411 | @ppq.setter 412 | def ppq(self, value: int) -> None: 413 | if value not in VALID_PPQS: 414 | raise ValueError(f"Expected one of {VALID_PPQS}; got {value} instead") 415 | self._kw["ppq"] = value 416 | 417 | show_info = EventProp[bool](ProjectID.ShowInfo) 418 | """Whether to show a banner while the project is loading inside FL Studio. 419 | 420 | :menuselection:`Options --> &Project info --> Show info on opening` 421 | 422 | The banner shows the :attr:`title`, :attr:`artists`, :attr:`genre`, 423 | :attr:`comments` and :attr:`url`. 424 | """ 425 | 426 | title = EventProp[str](ProjectID.Title) 427 | """Name of the song / project. 428 | 429 | :menuselection:`Options --> &Project info --> Title` 430 | """ 431 | 432 | # Stored internally as the actual BPM * 1000 as an integer. 433 | @property 434 | def tempo(self) -> int | float | None: 435 | """Tempo at the current position of the playhead (in BPM). 436 | 437 | ![](https://bit.ly/3MKdAEO) 438 | 439 | Raises: 440 | TypeError: When a fine-tuned tempo (``float``) isn't 441 | supported. Use an ``int`` (coarse tempo) value. 442 | PropertyCannotBeSet: If underlying event isn't found. 443 | ValueError: When a tempo outside the allowed range is set. 444 | 445 | * *Changed in FL Studio v1.4.2*: Max tempo increased to ``999`` (int). 446 | * *New in FL Studio v3.4.0*: Fine tuned tempo (a float). 447 | * *Changed in FL Studio v11*: Max tempo limited to ``522.000``. 448 | Probably when tempo automations 449 | """ 450 | if ProjectID.Tempo in self.events.ids: 451 | return self.events.first(ProjectID.Tempo).value / 1000 452 | 453 | tempo = None 454 | if ProjectID._TempoCoarse in self.events.ids: 455 | tempo = self.events.first(ProjectID._TempoCoarse).value 456 | if ProjectID._TempoFine in self.events.ids: 457 | tempo += self.events.first(ProjectID._TempoFine).value / 1000 458 | return tempo 459 | 460 | @tempo.setter 461 | def tempo(self, value: int | float) -> None: 462 | if self.tempo is None: 463 | raise PropertyCannotBeSet(ProjectID.Tempo, ProjectID._TempoCoarse, ProjectID._TempoFine) 464 | 465 | max_tempo = 999.0 if FLVersion(1, 4, 2) <= self.version < FLVersion(11) else 522.0 466 | 467 | if isinstance(value, float) and self.version < FLVersion(3, 4, 0): 468 | raise TypeError("Expected an 'int' object got a 'float' instead") 469 | 470 | if float(value) > max_tempo or float(value) < MIN_TEMPO: 471 | raise ValueError(f"Invalid tempo {value}; expected {MIN_TEMPO}-{max_tempo}") 472 | 473 | if ProjectID.Tempo in self.events.ids: 474 | self.events.first(ProjectID.Tempo).value = int(value * 1000) 475 | 476 | if ProjectID._TempoFine in self.events.ids: 477 | tempo_fine = int((value - math.floor(value)) * 1000) 478 | self.events.first(ProjectID._TempoFine).value = tempo_fine 479 | 480 | if ProjectID._TempoCoarse in self.events.ids: 481 | self.events.first(ProjectID._TempoCoarse).value = math.floor(value) 482 | 483 | @property 484 | def time_spent(self) -> datetime.timedelta | None: 485 | """Time spent on the project since its creation. 486 | 487 | ![](https://bit.ly/3TsBzdM) 488 | 489 | Located at the bottom of :menuselection:`Options --> &Project info` page. 490 | """ 491 | if ProjectID.Timestamp in self.events.ids: 492 | event = cast(TimestampEvent, self.events.first(ProjectID.Timestamp)) 493 | return datetime.timedelta(days=event["time_spent"]) 494 | 495 | url = EventProp[str](ProjectID.Url) 496 | """:menuselection:`Options --> &Project info --> Web link`.""" 497 | 498 | # Internally represented as a string with a format of 499 | # `major.minor.patch.build?` *where `build` is optional, since older 500 | # versions of FL didn't follow the same versioning scheme*. 501 | # 502 | # To maintain backward compatibility with FL Studio prior to v11.5 which 503 | # stored strings in ASCII, this event is always stored with ASCII data, 504 | # even if the rest of the strings use Windows Unicode (UTF16). 505 | @property 506 | def version(self) -> FLVersion: 507 | """The version of FL Studio which was used to save the file. 508 | 509 | ![](https://bit.ly/3TD3BU0) 510 | 511 | Located at the top of :menuselection:`Help --> &About` page. 512 | 513 | Caution: 514 | Changing this to a lower version will not make a file load magically 515 | inside FL Studio, as newer events and/or plugins might have been used. 516 | 517 | Raises: 518 | PropertyCannotBeSet: This error should NEVER occur; if it does, 519 | it indicates possible corruption. 520 | ValueError: When a string with an invalid format is tried to be set. 521 | """ 522 | event = cast(AsciiEvent, self.events.first(ProjectID.FLVersion)) 523 | return FLVersion(*tuple(int(part) for part in event.value.split("."))) 524 | 525 | @version.setter 526 | def version(self, value: FLVersion | str | tuple[int, ...]) -> None: 527 | if ProjectID.FLVersion not in self.events.ids: 528 | raise PropertyCannotBeSet(ProjectID.FLVersion) 529 | 530 | if isinstance(value, FLVersion): 531 | parts = [value.major, value.minor, value.patch] 532 | if value.build is not None: 533 | parts.append(value.build) 534 | elif isinstance(value, str): 535 | parts = [int(part) for part in value.split(".")] 536 | else: 537 | parts = list(value) 538 | 539 | if len(parts) < 3 or len(parts) > 4: 540 | raise ValueError("Expected format: major.minor.build.patch?") 541 | 542 | version = ".".join(str(part) for part in parts) 543 | self.events.first(ProjectID.FLVersion).value = version 544 | if len(parts) == 4 and ProjectID.FLBuild in self.events.ids: 545 | self.events.first(ProjectID.FLBuild).value = parts[3] 546 | -------------------------------------------------------------------------------- /pyflp/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ZappyMan/FL-Studio-Time-Calculator/ed1ffe7757eb7e063b9a6dc77489ff6a7e28b006/pyflp/py.typed -------------------------------------------------------------------------------- /pyflp/timemarker.py: -------------------------------------------------------------------------------- 1 | # PyFLP - An FL Studio project file (.flp) parser 2 | # Copyright (C) 2022 demberto 3 | # 4 | # This program is free software: you can redistribute it and/or modify it 5 | # under the terms of the GNU General Public License as published by the Free 6 | # Software Foundation, either version 3 of the License, or (at your option) 7 | # any later version. This program is distributed in the hope that it will be 8 | # useful, but WITHOUT ANY WARRANTY; without even the implied warranty of 9 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General 10 | # Public License for more details. You should have received a copy of the 11 | # GNU General Public License along with this program. If not, see 12 | # . 13 | 14 | """Contains the types required for pattern and playlist timemarkers.""" 15 | 16 | from __future__ import annotations 17 | 18 | import enum 19 | 20 | from pyflp._descriptors import EventProp 21 | from pyflp._events import DWORD, TEXT, EventEnum, U8Event, U32Event 22 | from pyflp._models import EventModel, ModelReprMixin 23 | 24 | __all__ = ["TimeMarkerID", "TimeMarkerType", "TimeMarker"] 25 | 26 | 27 | @enum.unique 28 | class TimeMarkerID(EventEnum): 29 | Numerator = (33, U8Event) 30 | Denominator = (34, U8Event) 31 | Position = (DWORD + 20, U32Event) 32 | Name = TEXT + 13 33 | 34 | 35 | class TimeMarkerType(enum.IntEnum): 36 | Marker = 0 37 | """Normal text marker.""" 38 | 39 | Signature = 134217728 40 | """Used for time signature markers.""" 41 | 42 | 43 | class TimeMarker(EventModel, ModelReprMixin): 44 | """A marker in the timeline of an :class:`Arrangement`. 45 | 46 | ![](https://bit.ly/3gltKbt) 47 | """ 48 | 49 | def __str__(self) -> str: 50 | if self.type == TimeMarkerType.Marker: 51 | if self.name: 52 | return f"Marker {self.name!r} @ {self.position!r}" 53 | return f"Unnamed marker @ {self.position!r}" 54 | 55 | time_sig = f"{self.numerator}/{self.denominator}" 56 | if self.name: 57 | return f"Signature {self.name!r} ({time_sig}) @ {self.position!r}" 58 | return f"Unnamed {time_sig} signature @ {self.position!r}" 59 | 60 | denominator: EventProp[int] = EventProp[int](TimeMarkerID.Denominator) 61 | name = EventProp[str](TimeMarkerID.Name) 62 | numerator = EventProp[int](TimeMarkerID.Numerator) 63 | 64 | @property 65 | def position(self) -> int | None: 66 | if TimeMarkerID.Position in self.events.ids: 67 | event = self.events.first(TimeMarkerID.Position) 68 | if event.value < TimeMarkerType.Signature: 69 | return event.value 70 | return event.value - TimeMarkerType.Signature 71 | 72 | @property 73 | def type(self) -> TimeMarkerType | None: 74 | """The action with which a time marker is associated. 75 | 76 | [![](https://bit.ly/3RDM1yn)]() 77 | """ 78 | if TimeMarkerID.Position in self.events.ids: 79 | event = self.events.first(TimeMarkerID.Position) 80 | if event.value >= TimeMarkerType.Signature: 81 | return TimeMarkerType.Signature 82 | return TimeMarkerType.Marker 83 | -------------------------------------------------------------------------------- /pyflp/types.py: -------------------------------------------------------------------------------- 1 | # PyFLP - An FL Studio project file (.flp) parser 2 | # Copyright (C) 2023 demberto 3 | # 4 | # This program is free software: you can redistribute it and/or modify it 5 | # under the terms of the GNU General Public License as published by the Free 6 | # Software Foundation, either version 3 of the License, or (at your option) 7 | # any later version. This program is distributed in the hope that it will be 8 | # useful, but WITHOUT ANY WARRANTY; without even the implied warranty of 9 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General 10 | # Public License for more details. You should have received a copy of the 11 | # GNU General Public License along with this program. If not, see 12 | # . 13 | 14 | from __future__ import annotations 15 | 16 | import enum 17 | from collections import UserDict, UserList 18 | from dataclasses import dataclass 19 | from typing import Any, NamedTuple, TypeVar, Union, TYPE_CHECKING 20 | 21 | import construct 22 | import construct_typed as ct 23 | from typing_extensions import ParamSpec, TypeAlias 24 | 25 | P = ParamSpec("P") 26 | T = TypeVar("T") 27 | U = TypeVar("U") 28 | ET = TypeVar("ET", bound=Union[ct.EnumBase, enum.IntFlag]) 29 | T_co = TypeVar("T_co", covariant=True) 30 | 31 | 32 | @dataclass(frozen=True, order=True) 33 | class FLVersion: 34 | major: int 35 | minor: int = 0 36 | patch: int = 0 37 | build: int | None = None 38 | 39 | def __str__(self) -> str: 40 | version = f"{self.major}.{self.minor}.{self.patch}" 41 | if self.build is not None: 42 | return f"{version}.{self.build}" 43 | return version 44 | 45 | 46 | class MusicalTime(NamedTuple): 47 | bars: int 48 | """1 bar == 16 beats == 768 (internal representation).""" 49 | 50 | beats: int 51 | """1 beat == 240 ticks == 48 (internal representation).""" 52 | 53 | ticks: int 54 | """5 ticks == 1 (internal representation).""" 55 | 56 | 57 | class RGBA(NamedTuple): 58 | red: float 59 | green: float 60 | blue: float 61 | alpha: float 62 | 63 | @staticmethod 64 | def from_bytes(buf: bytes) -> RGBA: 65 | return RGBA(*(c / 255 for c in buf)) 66 | 67 | def __bytes__(self) -> bytes: 68 | return bytes(round(c * 255) for c in self) 69 | 70 | 71 | if TYPE_CHECKING: 72 | AnyContainer: TypeAlias = construct.Container[Any] 73 | AnyListContainer: TypeAlias = construct.ListContainer[Any] 74 | AnyDict: TypeAlias = UserDict[str, Any] 75 | AnyList: TypeAlias = UserList[AnyContainer] 76 | else: 77 | AnyContainer = construct.Container 78 | AnyListContainer = construct.ListContainer 79 | AnyDict = UserDict 80 | AnyList = UserList 81 | --------------------------------------------------------------------------------