├── .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 | 
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 | 
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 | 
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 | """"""
287 |
288 | grouped = StructProp[bool](TrackID.Data)
289 | """Whether grouped with the track above (index - 1) or not.
290 |
291 | 
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 | 
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 | 
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 | 
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 | """"""
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 | """
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 | """
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 | 
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 | 
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 | """"""
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 | 
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 | 
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 | 
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 | 
461 | """
462 |
463 | @property
464 | def eq(self) -> InsertEQ:
465 | """3-band post EQ.
466 |
467 | 
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 | 
475 | """
476 |
477 | input = EventProp[int](InsertID.Input)
478 | """"""
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 | 
487 | """
488 |
489 | name = EventProp[str](InsertID.Name)
490 | """*New in FL Studio v3.5.4*."""
491 |
492 | output = EventProp[int](InsertID.Output)
493 | """"""
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 | 
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 | 
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 | 
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 | 
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 | 
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 | 
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 | 
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 | 
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 | 
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 | """"""
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 | """"""
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 | """"""
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 | """"""
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 | """"""
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 | """"""
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 | """"""
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 | """"""
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 | """"""
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 | """"""
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 | """"""
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 | """"""
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 | 
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 | 
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 | 
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 | 
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 | 
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 | []()
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 |
--------------------------------------------------------------------------------