kitbash 1.0.1__py2.py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1263 @@
1
+ # kitbash/gui/main_window.py
2
+ #
3
+ # Copyright 2025 liyang <liyang@veronica>
4
+ #
5
+ """
6
+ Provides MainWindow class and
7
+ """
8
+ import os, logging, json
9
+ from os.path import dirname, basename, realpath, exists, join, splitext
10
+ from collections import deque
11
+ from functools import partial
12
+ from signal import signal, SIGINT, SIGTERM
13
+
14
+ from PyQt5 import uic
15
+ from PyQt5.QtCore import Qt, QObject, pyqtSignal, pyqtSlot, QSize, QTimer, \
16
+ QThreadPool, QRunnable, QPoint, QCoreApplication
17
+ from PyQt5.QtWidgets import QApplication, QVBoxLayout, QHBoxLayout, \
18
+ QLabel, QFrame, QSizePolicy, QPushButton, QCheckBox, \
19
+ QMainWindow, QMessageBox, QFileDialog, \
20
+ QAction, QActionGroup, QMenu, \
21
+ QGroupBox, QRadioButton
22
+ from PyQt5.QtGui import QIcon
23
+
24
+ from qt_extras import ShutUpQT, SigBlock, DevilBox
25
+ from qt_extras.list_layout import VListLayout
26
+ from recent_items_list import RecentItemsList
27
+ from midi_notes import MIDI_DRUM_PITCHES
28
+ from liquiphy import LiquidSFZ
29
+ from conn_jack import JackConnectionManager
30
+ from jack_midi_split import MidiSplitter
31
+ from sfzen.drumkits import Drumkit, PercussionInstrument
32
+ from sfzen import SAMPLES_ABSPATH, SAMPLES_RESOLVE, SAMPLES_COPY, \
33
+ SAMPLES_SYMLINK, SAMPLES_HARDLINK
34
+
35
+ from kitbash.gui import group_expanded_icon, group_hidden_icon, remove_icon, \
36
+ GeometrySaver
37
+ from kitbash import styles, set_application_style, settings, \
38
+ APPLICATION_NAME, PACKAGE_DIR, \
39
+ KEY_STYLE, KEY_SAMPLES_MODE, \
40
+ KEY_RECENT_DRUMKIT_FOLDER, KEY_RECENT_DRUMKITS, \
41
+ KEY_RECENT_PROJECT_FOLDER, KEY_RECENT_PROJECTS
42
+
43
+
44
+ class DrumkitWidget(QFrame):
45
+ """
46
+ Graphical representation of a drumkit.
47
+ """
48
+
49
+ sig_inst_toggle = pyqtSignal(QObject, str, bool, bool)
50
+ sig_remove_drumkit = pyqtSignal(QObject)
51
+
52
+ def __init__(self, filename, parent):
53
+ super().__init__(parent)
54
+ self.sfz_filename = filename
55
+ self.moniker = basename(self.sfz_filename)
56
+ self.drumkit = None
57
+ self.synth = None
58
+ self.port_number = None
59
+ self.initial_height = None
60
+ self.velocity = None
61
+
62
+ self.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.Preferred)
63
+
64
+ main_layout = QVBoxLayout()
65
+ main_layout.setContentsMargins(1,1,1,1)
66
+ main_layout.setSpacing(0)
67
+
68
+ frm_top = QFrame()
69
+ frm_top.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred)
70
+ frm_top.setObjectName('frm_top')
71
+
72
+ lo_top = QHBoxLayout()
73
+ lo_top.setContentsMargins(2,2,2,2)
74
+ lo_top.setSpacing(0)
75
+
76
+ self.hide_button = QPushButton(self)
77
+ self.hide_button.setIcon(group_expanded_icon())
78
+ self.hide_button.setIconSize(QSize(16,16))
79
+ self.hide_button.setCheckable(True)
80
+ self.hide_button.toggled.connect(self.slot_hide)
81
+ lo_top.addWidget(self.hide_button)
82
+
83
+ label = QLabel(self)
84
+ label.setText(self.sfz_filename)
85
+ lo_top.addWidget(label)
86
+
87
+ self.lbl_use_count = QLabel(self)
88
+ self.lbl_use_count.setText('(0)')
89
+ lo_top.addWidget(self.lbl_use_count)
90
+
91
+ lo_top.addStretch(20)
92
+
93
+ remove_button = QPushButton(self)
94
+ remove_button.setIcon(remove_icon())
95
+ remove_button.setIconSize(QSize(16,16))
96
+ remove_button.clicked.connect(self.slot_remove_clicked)
97
+ lo_top.addWidget(remove_button)
98
+
99
+ frm_top.setLayout(lo_top)
100
+
101
+ main_layout.addWidget(frm_top)
102
+
103
+ self.frm_groups = QFrame(self)
104
+ self.frm_groups.setObjectName('frm_groups')
105
+ self.groups = QHBoxLayout()
106
+ self.groups.setContentsMargins(1,1,1,1)
107
+ self.groups.setSpacing(0)
108
+
109
+ self.frm_groups.setLayout(self.groups)
110
+ main_layout.addWidget(self.frm_groups)
111
+ self.setLayout(main_layout)
112
+
113
+ def ready(self):
114
+ """
115
+ Returns "True" if this DrumkitWidget has a synth assigned and a bashed kit.
116
+ """
117
+ return not self.synth is None and not self.drumkit is None
118
+
119
+ @pyqtSlot(Drumkit)
120
+ def slot_drumkit_loaded(self, drumkit):
121
+ """
122
+ Called when KitLoader is finshed loading and interpreted SFZ.
123
+ Fills the groups and instruments of this the DrumkitWidget.
124
+ """
125
+ self.drumkit = drumkit
126
+ for group in self.drumkit.groups.values():
127
+ if group.empty():
128
+ continue
129
+ group_frame = GroupFrame(group, self)
130
+ group_frame.group_button.clicked.connect(partial(self.slot_group_clicked, group_frame))
131
+ for inst in group.instruments.values():
132
+ inst_button = InstrumentButton(inst, group_frame)
133
+ inst_button.toggled.connect(partial(self.slot_instrument_toggled, inst_button))
134
+ inst_button.sig_mouse_press.connect(self.slot_instrument_pressed)
135
+ inst_button.sig_mouse_release.connect(self.slot_instrument_released)
136
+ group_frame.group_layout.addWidget(inst_button)
137
+ group_frame.group_layout.addStretch()
138
+ self.groups.addWidget(group_frame)
139
+ self.groups.addStretch()
140
+
141
+ @pyqtSlot(QFrame)
142
+ def slot_group_clicked(self, group_frame):
143
+ """
144
+ Triggered by a GroupButton click event.
145
+ "group_frame" is the QFrame which contains the clicked GroupButton and various
146
+ InstrumentButton instances.
147
+ InstrumentButton signals are not suppressed, and trigger "sig_inst_toggle".
148
+ """
149
+ group_button = group_frame.findChild(GroupButton)
150
+ for inst_button in group_frame.findChildren(InstrumentButton):
151
+ inst_button.setChecked(group_button.isChecked())
152
+
153
+ @pyqtSlot(int)
154
+ def slot_velocity_change(self, velocity):
155
+ self.velocity = velocity
156
+
157
+ @pyqtSlot(PercussionInstrument)
158
+ def slot_instrument_pressed(self, inst):
159
+ """
160
+ Triggered by InstrumentButton mouse press.
161
+ Sends a "noteon" to this widget's synth.
162
+ """
163
+ self.synth.noteon(0, inst.pitch, self.velocity)
164
+
165
+ @pyqtSlot(PercussionInstrument)
166
+ def slot_instrument_released(self, inst):
167
+ """
168
+ Triggered by InstrumentButton mouse relase.
169
+ Sends a "v" to this widget's synth.
170
+ """
171
+ self.synth.noteoff(0, inst.pitch)
172
+
173
+ @pyqtSlot(QPushButton)
174
+ def slot_instrument_toggled(self, button):
175
+ """
176
+ Triggered by an InstrumentButton toggle event.
177
+ "inst_id" is a string key, enumerated in the DrumkitClass.
178
+ "button" is the InstrumentButton which was toggled.
179
+ """
180
+ self.sig_inst_toggle.emit(self, button.inst.inst_id,
181
+ button.isChecked(), self.ctrl_pressed())
182
+ self.update_count()
183
+
184
+ @pyqtSlot()
185
+ def slot_remove_clicked(self):
186
+ """
187
+ Triggered by the "remove" button click event.
188
+ """
189
+ self.sig_remove_drumkit.emit(self)
190
+
191
+ @pyqtSlot(bool)
192
+ def slot_hide(self, state):
193
+ """
194
+ "Roll up" this DrumkitWidget.
195
+ """
196
+ if state:
197
+ self.initial_height = self.height()
198
+ self.frm_groups.hide()
199
+ self.hide_button.setIcon(group_hidden_icon())
200
+ else:
201
+ self.frm_groups.show()
202
+ self.hide_button.setIcon(group_expanded_icon())
203
+
204
+ def update_count(self):
205
+ """
206
+ Updates the "use count" label with the number of selected instruments.
207
+ Sets the audio indicator pixmap based on if playing or not.
208
+ """
209
+ use_count = len([ b for b in self.frm_groups.findChildren(InstrumentButton) if b.isChecked() ])
210
+ self.lbl_use_count.setText('(%d)' % use_count)
211
+ font = self.lbl_use_count.font()
212
+ font.setBold(bool(use_count))
213
+ self.lbl_use_count.setFont(font)
214
+
215
+ def ctrl_pressed(self):
216
+ """
217
+ Returns (bool) True if the CTRL key is being pressed. Useful for making
218
+ multiple selections.
219
+ """
220
+ return QApplication.keyboardModifiers() == Qt.ControlModifier
221
+
222
+ def inst_button(self, inst_id):
223
+ """
224
+ Returns the instrument button identified by the given inst_id.
225
+ """
226
+ return self.findChild(InstrumentButton, inst_id)
227
+
228
+ def deselect_parent_group(self, inst_id):
229
+ """
230
+ Called whenever an InstrumentButton is deselected.
231
+ The parent group button is deselected.
232
+ """
233
+ inst_button = self.inst_button(inst_id)
234
+ inst_button.parentWidget().findChild(GroupButton).setChecked(False)
235
+
236
+ def reselect_parent_group(self, inst_id):
237
+ """
238
+ Called whenever an InstrumentButton is selected.
239
+ The parent group button is selected if all of its' InstrumentButtons are
240
+ selected.
241
+ """
242
+ group = self.inst_button(inst_id).parentWidget()
243
+ if all(inst_button.isChecked() for inst_button in group.findChildren(InstrumentButton)):
244
+ group.findChild(GroupButton).setChecked(True)
245
+
246
+ def deselect_instrument(self, inst_id):
247
+ """
248
+ Called from MainWindow when a button with the same inst_id is selected
249
+ exclusively (not CTRL key pressed).
250
+ """
251
+ button = self.inst_button(inst_id)
252
+ if button: # May not exist, as not all Drumkits use the same instruments
253
+ button.setChecked(False)
254
+ self.update_count()
255
+
256
+ def selected_instrument_ids(self):
257
+ """
258
+ Returns a list of instrument ids from selected instrument buttons.
259
+ """
260
+ return [ button.inst.inst_id \
261
+ for button in self.findChildren(InstrumentButton) \
262
+ if button.isChecked() ]
263
+
264
+ @pyqtSlot()
265
+ def slot_select_all(self):
266
+ """
267
+ Select all instruments.
268
+ Triggered by kits_area context menu.
269
+ Called when this is the first DrumkitWidget added to a project.
270
+ """
271
+ for type_ in [GroupButton, InstrumentButton]:
272
+ for button in self.findChildren(type_):
273
+ button.setChecked(True)
274
+ self.update_count()
275
+
276
+ def saved_selections(self):
277
+ """
278
+ Returns dictionary of button states for saving with project.
279
+ """
280
+ return {
281
+ group_frame.group_id : {
282
+ 'group' : group_frame.findChild(GroupButton).isChecked(),
283
+ 'instruments' : {
284
+ inst_button.inst.inst_id : inst_button.isChecked() \
285
+ for inst_button in group_frame.findChildren(InstrumentButton)
286
+ }
287
+ }
288
+ for group_frame in self.findChildren(GroupFrame)
289
+ }
290
+
291
+ def apply_selections(self, selections):
292
+ """
293
+ Restores button states from dictionary when loading project.
294
+ """
295
+ for group_frame in self.findChildren(GroupFrame):
296
+ if group_frame.group_id in selections:
297
+ sel = selections[group_frame.group_id]
298
+ group_button = group_frame.findChild(GroupButton)
299
+ with SigBlock(group_button):
300
+ group_button.setChecked(sel['group'])
301
+ for inst_button in group_frame.findChildren(InstrumentButton):
302
+ if inst_button.inst.inst_id in sel['instruments']:
303
+ inst_button.setChecked(sel['instruments'][inst_button.inst.inst_id])
304
+ else:
305
+ logging.warning('Button "%s" not found in project def', inst_button.inst.inst_id)
306
+ else:
307
+ logging.warning('Group "%s" not found in project def', group_frame.group_id)
308
+
309
+ def __str__(self):
310
+ return f"<DrumkitWidget {self.moniker}>"
311
+
312
+
313
+ class MainWindow(QMainWindow, GeometrySaver):
314
+
315
+ instance = None
316
+ options = None
317
+ sig_ports_complete = pyqtSignal()
318
+
319
+ def __new__(cls, options):
320
+ if cls.instance is None:
321
+ cls.instance = super().__new__(cls)
322
+ return cls.instance
323
+
324
+ def __init__(self, options):
325
+ if self.options:
326
+ return
327
+ super().__init__()
328
+ self.options = options
329
+ set_application_style()
330
+ with ShutUpQT():
331
+ uic.loadUi(join(PACKAGE_DIR, 'gui', 'main_window.ui'), self)
332
+ self.setWindowIcon(QIcon(join(PACKAGE_DIR, 'res', 'kitbash-icon.png')))
333
+ self.restore_geometry()
334
+ self.recent_projects = RecentItemsList(settings().value(KEY_RECENT_PROJECTS, []))
335
+ self.recent_drumkits = RecentItemsList(settings().value(KEY_RECENT_DRUMKITS, []))
336
+ self.dirty = False
337
+ self.project_filename = None
338
+ self.project_definition = None
339
+ self.project_loading = False
340
+ self.bashed_sfz_filename = None
341
+ self.bashed_sfz_samples_mode = None
342
+ self.drumkit_port_ranges = set( port_number for port_number in range(16) )
343
+ self.synth_creation_queue = deque()
344
+ self.new_synth = None
345
+ self.current_midi_source = None
346
+ self.current_audio_sink = None
347
+ self.audio_sink_ports = []
348
+ self.base_xruns = self.current_xruns = 0
349
+ self.b_xruns.setText('0')
350
+ self.fill_style_menu()
351
+ self.setup_window_elements()
352
+ self.connect_actions()
353
+ self.clear()
354
+ # Setup background threadpool for KitLoader and KitBasher workers
355
+ self.background_threadpool = QThreadPool()
356
+ # Setup connection manager and synth creation pool
357
+ self.conn_man = JackConnectionManager()
358
+ self.conn_man.on_error(self.jack_error)
359
+ self.conn_man.on_xrun(self.jack_xrun)
360
+ self.conn_man.on_shutdown(self.jack_shutdown)
361
+ self.conn_man.on_client_registration(self.jack_client_registration)
362
+ self.conn_man.on_port_registration(self.jack_port_registration)
363
+ # Fill sink/source menus:
364
+ self.fill_cmb_sources()
365
+ self.fill_cmb_sinks()
366
+ # Setup signals
367
+ self.sig_ports_complete.connect(self.slot_ports_complete)
368
+ self.cmb_midi_srcs.currentTextChanged.connect(self.slot_midi_src_changed)
369
+ self.cmb_audio_sinks.currentTextChanged.connect(self.slot_audio_sink_changed)
370
+ # Setup MidiSplitter
371
+ self.midi_splitter = MidiSplitter(APPLICATION_NAME)
372
+ self.splitter_assignments = [ None for i in range(16) ]
373
+ # Setup signals
374
+ signal(SIGINT, self.system_signal)
375
+ signal(SIGTERM, self.system_signal)
376
+ if self.options.Filename:
377
+ QTimer.singleShot(10, partial(self.load_project, self.options.Filename))
378
+
379
+ def setup_window_elements(self):
380
+ self.drumkit_widgets = VListLayout(end_space = 10)
381
+ self.drumkit_widgets.setContentsMargins(0,0,0,0)
382
+ self.drumkit_widgets.setSpacing(2)
383
+ self.kits_area.setLayout(self.drumkit_widgets)
384
+
385
+ def connect_actions(self):
386
+ self.action_collapse_kits.triggered.connect(self.slot_collapse_kits)
387
+ self.action_new_project.triggered.connect(self.slot_new_project)
388
+ self.action_open_project.triggered.connect(self.slot_open_project)
389
+ self.action_save_project.triggered.connect(self.slot_save_project)
390
+ self.action_save_project_as.triggered.connect(self.slot_save_project_as)
391
+ self.action_save_bashed_kit.triggered.connect(self.slot_save_kit)
392
+ self.action_save_kit_as.triggered.connect(self.slot_save_kit_as)
393
+ self.action_add_drumkit.triggered.connect(self.slot_add_drumkit)
394
+ self.action_remove_all_kits.triggered.connect(self.slot_remove_all_kits)
395
+ self.action_reload_style.triggered.connect(self.slot_reload_style)
396
+ self.menu_recent_project.aboutToShow.connect(self.slot_show_recent_projects)
397
+ self.menu_recent_drumkits.aboutToShow.connect(self.slot_show_recent_drumkits)
398
+ self.kits_area.setContextMenuPolicy(Qt.CustomContextMenu)
399
+ self.kits_area.customContextMenuRequested.connect(self.slot_kits_context_menu)
400
+ self.b_copy_path.clicked.connect(self.slot_copy_kit_path)
401
+ self.b_xruns.clicked.connect(self.slot_xruns_clicked)
402
+
403
+ def update_ui(self):
404
+ title = APPLICATION_NAME \
405
+ if self.project_filename is None \
406
+ else f"{self.project_filename} [{APPLICATION_NAME}]"
407
+ self.setWindowTitle("* " + title if self.dirty else title)
408
+ has_kits = bool(len(self.drumkit_widgets))
409
+ self.action_collapse_kits.setEnabled(has_kits)
410
+ self.action_collapse_kits.setChecked(has_kits)
411
+ self.action_remove_all_kits.setEnabled(has_kits)
412
+ self.action_new_project.setEnabled(has_kits)
413
+ self.action_save_project.setEnabled(has_kits and self.dirty)
414
+ self.action_save_bashed_kit.setEnabled(has_kits)
415
+ self.b_save_kit.setEnabled(has_kits)
416
+ self.b_copy_path.setVisible(bool(self.bashed_sfz_filename))
417
+ self.lbl_bashed_sfz_filename.setText(self.bashed_sfz_filename \
418
+ if self.bashed_sfz_filename else '')
419
+
420
+ # -----------------------------------------------------------------
421
+ # Style functions:
422
+
423
+ def fill_style_menu(self):
424
+ """
425
+ Fill the style menu with the list of discovered styles.
426
+ """
427
+ current_style = settings().value(KEY_STYLE)
428
+ actions = QActionGroup(self)
429
+ actions.setExclusive(True)
430
+ for style_name in styles():
431
+ action = QAction(style_name, self)
432
+ action.triggered.connect(partial(self.select_style, style_name))
433
+ action.setCheckable(True)
434
+ action.setChecked(style_name == current_style)
435
+ actions.addAction(action)
436
+ self.menu_style.addAction(action)
437
+
438
+ def select_style(self, style):
439
+ settings().setValue(KEY_STYLE, style)
440
+ set_application_style()
441
+
442
+ @pyqtSlot()
443
+ def slot_reload_style(self):
444
+ set_application_style()
445
+
446
+ # -----------------------------------------------------------------
447
+ # Project loading / saving:
448
+
449
+ def set_dirty(self, state = True):
450
+ if not self.project_loading:
451
+ self.dirty = state
452
+ self.update_ui()
453
+
454
+ def compile_project_def(self):
455
+ return {
456
+ 'bashed_sfz_filename' : self.bashed_sfz_filename,
457
+ 'bashed_sfz_samples_mode' : self.bashed_sfz_samples_mode,
458
+ 'drumkits' : {
459
+ widget.sfz_filename : widget.saved_selections() \
460
+ for widget in self.drumkit_widgets
461
+ }
462
+ }
463
+
464
+ def load_recent_project(self, filename):
465
+ if self.okay_to_clear():
466
+ self.load_project(filename)
467
+
468
+ def load_project(self, filename):
469
+ """
470
+ Called internally - NOT FROM GUI SIGNALS.
471
+ Starts project load; saves recent file name.
472
+ Permission to clear must already have been given.
473
+ """
474
+ if exists(filename):
475
+ try:
476
+ with open(filename, 'r', encoding = 'utf-8') as fh:
477
+ self.project_definition = json.load(fh)
478
+ except json.JSONDecodeError as e:
479
+ DevilBox('There was a problem decoding:\n' +
480
+ f'"{filename}"\n' + \
481
+ f'"{e}"\n' + \
482
+ 'Are you sure it is a kitbash project?')
483
+ else:
484
+ if len(self.drumkit_widgets):
485
+ self.clear()
486
+ self.project_filename = realpath(filename)
487
+ self.register_recent_project()
488
+ self.project_loading = True
489
+ self.bashed_sfz_filename = self.project_definition['bashed_sfz_filename']
490
+ self.bashed_sfz_samples_mode = self.project_definition['bashed_sfz_samples_mode']
491
+ for sfzfile in self.project_definition['drumkits'].keys():
492
+ self.load_drumkit(sfzfile)
493
+ else:
494
+ self.recent_projects.remove(filename)
495
+ settings().setValue(KEY_RECENT_PROJECTS, self.recent_projects.items)
496
+ DevilBox(f"Project not found: {filename}")
497
+
498
+ def save_project(self):
499
+ with open(self.project_filename, 'w', encoding = 'utf-8') as fh:
500
+ json.dump(self.compile_project_def(), fh, indent="\t")
501
+ self.register_recent_project()
502
+ self.set_dirty(False)
503
+
504
+ def save_kit(self):
505
+ worker = KitBasher(self.drumkit_widgets)
506
+ worker.signals.sig_bashed.connect(self.slot_drumkit_bashed)
507
+ self.background_threadpool.start(worker)
508
+
509
+ def register_recent_project(self):
510
+ self.recent_projects.bump(self.project_filename)
511
+ settings().setValue(KEY_RECENT_PROJECT_FOLDER, dirname(self.project_filename))
512
+ settings().setValue(KEY_RECENT_PROJECTS, self.recent_projects.items)
513
+
514
+ def okay_to_clear(self):
515
+ if not self.dirty:
516
+ return True
517
+ dlg = QMessageBox(
518
+ QMessageBox.Warning,
519
+ "Save changes?",
520
+ "There are changes to the current project.\nDo you want to save changes?",
521
+ QMessageBox.Save | QMessageBox.Discard | QMessageBox.Cancel,
522
+ self
523
+ )
524
+ ret = dlg.exec()
525
+ if ret == QMessageBox.Cancel:
526
+ return False
527
+ if ret == QMessageBox.Save:
528
+ self.slot_save_project()
529
+ return True
530
+
531
+ def clear(self):
532
+ self.project_filename = None
533
+ self.project_definition = None
534
+ self.project_loading = False
535
+ self.bashed_kit = None
536
+ self.bashed_sfz_filename = None
537
+ self.bashed_sfz_samples_mode = None
538
+ self.new_synth = None
539
+ self.dirty = False
540
+ for widget in reversed(self.drumkit_widgets):
541
+ self.slot_remove_drumkit(widget)
542
+ self.set_dirty(False)
543
+
544
+ # -----------------------------------------------------------------
545
+ # Quit / close / signals
546
+
547
+ def closeEvent(self, event):
548
+ """
549
+ PyQt closeEvent overload.
550
+ """
551
+ if self.okay_to_clear():
552
+ for drumkit_widget in self.drumkit_widgets:
553
+ drumkit_widget.synth.quit()
554
+ self.save_geometry()
555
+ logging.debug('Total %d xruns', self.current_xruns)
556
+ event.accept()
557
+ else:
558
+ event.ignore()
559
+
560
+ def system_signal(self, *_):
561
+ """
562
+ Catch system signals SIGINT and SIGTERM
563
+ """
564
+ logging.debug('Caught signal - shutting down')
565
+ self.close()
566
+
567
+ # -----------------------------------------------------------------
568
+ # Source / sink combo boxes
569
+
570
+ def fill_cmb_sources(self):
571
+ with SigBlock(self.cmb_midi_srcs):
572
+ self.cmb_midi_srcs.clear()
573
+ self.cmb_midi_srcs.addItem('')
574
+ for port in self.conn_man.output_ports():
575
+ if port.is_midi and APPLICATION_NAME not in port.name:
576
+ self.cmb_midi_srcs.addItem(port.name)
577
+ if self.current_midi_source:
578
+ self.cmb_midi_srcs.setCurrentText(self.current_midi_source)
579
+
580
+ def fill_cmb_sinks(self):
581
+ with SigBlock(self.cmb_audio_sinks):
582
+ self.cmb_audio_sinks.clear()
583
+ items = ['']
584
+ items.extend(self.conn_man.physical_playback_clients())
585
+ self.cmb_audio_sinks.addItems(items)
586
+ if self.current_audio_sink:
587
+ self.cmb_audio_sinks.setCurrentText(self.current_audio_sink)
588
+
589
+ @pyqtSlot(str)
590
+ def slot_midi_src_changed(self, value):
591
+ if self.current_midi_source:
592
+ self.conn_man.disconnect_by_name(self.current_midi_source, self.midi_splitter.input_port.name)
593
+ self.current_midi_source = value
594
+ if self.current_midi_source:
595
+ self.conn_man.connect_by_name(self.current_midi_source, self.midi_splitter.input_port.name)
596
+
597
+ @pyqtSlot(str)
598
+ def slot_audio_sink_changed(self, value):
599
+ if self.current_audio_sink:
600
+ liquid_client_names = [
601
+ drumkit_widget.synth.client_name \
602
+ for drumkit_widget in self.drumkit_widgets \
603
+ if drumkit_widget.synth and drumkit_widget.synth.client_name
604
+ ]
605
+ for audio_sink_port in self.audio_sink_ports:
606
+ for src_port in self.conn_man.get_port_connections(audio_sink_port):
607
+ if src_port.client_name in liquid_client_names:
608
+ self.conn_man.disconnect(src_port, audio_sink_port)
609
+ self.current_audio_sink = value
610
+ if self.current_audio_sink:
611
+ self.audio_sink_ports = [ port for port \
612
+ in self.conn_man.physical_input_ports() \
613
+ if port.client_name == self.current_audio_sink ]
614
+ for drumkit_widget in self.drumkit_widgets:
615
+ self.connect_audio_sink(drumkit_widget.synth)
616
+ else:
617
+ self.audio_sink_ports = []
618
+
619
+ def connect_midi_source(self, synth):
620
+ if self.current_midi_source:
621
+ self.conn_man.connect_by_name(self.current_midi_source, synth.input_port.name)
622
+
623
+ def connect_audio_sink(self, synth):
624
+ for src,tgt in zip(synth.output_ports, self.audio_sink_ports):
625
+ self.conn_man.connect(src, tgt)
626
+
627
+ # -----------------------------------------------------------------
628
+ # Synth / port management
629
+
630
+ def instantiate_synth(self, associated_object):
631
+ self.synth_creation_queue.append(associated_object)
632
+ if self.new_synth is None:
633
+ self.start_new_synth()
634
+
635
+ def start_new_synth(self):
636
+ self.new_synth = JackLiquidSFZ(self.synth_creation_queue[0].sfz_filename)
637
+ self.new_synth.start()
638
+
639
+ def jack_error(self, error_message):
640
+ logging.error('JACK ERROR: %s', error_message)
641
+
642
+ def jack_xrun(self, xruns):
643
+ self.b_xruns.setText(str(xruns - self.base_xruns))
644
+ self.current_xruns = xruns
645
+
646
+ def jack_shutdown(self):
647
+ logging.error('JACK is shutting down')
648
+ self.close()
649
+
650
+ def jack_client_registration(self, client_name, action):
651
+ if action:
652
+ if self.new_synth and 'liquidsfz' in client_name:
653
+ self.new_synth.client_name = client_name
654
+ else:
655
+ if self.cmb_audio_sinks.findText(client_name, Qt.MatchStartsWith) > -1:
656
+ self.fill_cmb_sinks()
657
+ elif self.cmb_midi_srcs.findText(client_name, Qt.MatchStartsWith) > -1:
658
+ self.fill_cmb_sources()
659
+
660
+ def jack_port_registration(self, port, action):
661
+ if action and \
662
+ self.new_synth and \
663
+ self.new_synth.client_name and \
664
+ self.new_synth.client_name in port.name:
665
+ if port.is_input and port.is_midi:
666
+ self.new_synth.input_port = port
667
+ elif port.is_output and port.is_audio:
668
+ self.new_synth.output_ports.append(port)
669
+ else:
670
+ logging.error('Incorrect port type: %s', port)
671
+ if self.new_synth.input_port and len(self.new_synth.output_ports) == 2:
672
+ self.sig_ports_complete.emit()
673
+ elif APPLICATION_NAME not in port.name:
674
+ if port.is_output and port.is_midi:
675
+ self.fill_cmb_sources()
676
+ elif port.is_input and port.is_audio:
677
+ self.fill_cmb_sinks()
678
+
679
+ @pyqtSlot()
680
+ def slot_ports_complete(self):
681
+ associated_object = self.synth_creation_queue.popleft()
682
+ associated_object.synth = self.new_synth
683
+ if len(self.synth_creation_queue):
684
+ self.start_new_synth()
685
+ else:
686
+ self.new_synth = None
687
+ self.connect_audio_sink(associated_object.synth)
688
+ if isinstance(associated_object, DrumkitWidget):
689
+ logging.debug('%s ports complete', associated_object)
690
+ src = self.midi_splitter.output_ports[associated_object.port_number].name
691
+ tgt = associated_object.synth.input_port.name
692
+ self.conn_man.connect_by_name(src, tgt)
693
+ self.check_drumkit_ready(associated_object)
694
+ else:
695
+ self.connect_midi_source(associated_object.synth)
696
+
697
+ # -----------------------------------------------------------------
698
+ # Drumkit load / delete / instrument selection
699
+
700
+ def load_drumkit(self, filename):
701
+ """
702
+ Adds a drumkit.
703
+ 1. called at project load
704
+ 2. triggered by "Edit -> Load Drumkit" menu
705
+ 3. triggered by kits_area custom context menu
706
+ """
707
+ if exists(filename):
708
+ drumkit_widget = DrumkitWidget(filename, self)
709
+ available_ports = self.available_port_numbers()
710
+ if available_ports:
711
+ drumkit_widget.port_number = available_ports[0]
712
+ else:
713
+ DevilBox('Not enough ports (Maximum 16)')
714
+ self.drumkit_widgets.append(drumkit_widget)
715
+ QApplication.instance().processEvents()
716
+ self.instantiate_synth(drumkit_widget)
717
+ worker = KitLoader(drumkit_widget)
718
+ worker.signals.sig_loaded.connect(drumkit_widget.slot_drumkit_loaded)
719
+ worker.signals.sig_widget_loaded.connect(self.slot_drumkit_widget_loaded)
720
+ self.background_threadpool.start(worker)
721
+ if not self.project_loading:
722
+ self.recent_drumkits.bump(filename)
723
+ settings().setValue(KEY_RECENT_DRUMKIT_FOLDER, dirname(filename))
724
+ else:
725
+ self.recent_drumkits.remove(filename)
726
+ DevilBox(f"File not found: {filename}")
727
+ if not self.project_loading:
728
+ settings().setValue(KEY_RECENT_DRUMKITS, self.recent_drumkits.items)
729
+
730
+ @pyqtSlot(DrumkitWidget)
731
+ def slot_drumkit_widget_loaded(self, drumkit_widget):
732
+ """
733
+ Called when KitLoader is finshed loading and interpreted SFZ.
734
+ """
735
+ self.update_ui()
736
+ self.check_drumkit_ready(drumkit_widget)
737
+
738
+ def check_drumkit_ready(self, drumkit_widget):
739
+ """
740
+ Check if any/all drumkit widget has a synth assigned and a bashed kit.
741
+ 1. called after KitLoader is finished,
742
+ 2. called after synth is assigned (ports ready)
743
+ If ready when project_loading, applies saved selections.
744
+ """
745
+ if drumkit_widget.ready():
746
+ drumkit_widget.sig_inst_toggle.connect(self.slot_inst_toggle)
747
+ drumkit_widget.sig_remove_drumkit.connect(self.slot_remove_drumkit)
748
+ drumkit_widget.velocity = self.spn_velocity.value()
749
+ self.spn_velocity.valueChanged.connect(drumkit_widget.slot_velocity_change)
750
+ if self.project_loading:
751
+ drumkit_widget.apply_selections(
752
+ self.project_definition['drumkits'][drumkit_widget.sfz_filename])
753
+ if all(drumkit_widget.ready() for drumkit_widget in self.drumkit_widgets):
754
+ self.project_loading = False
755
+ self.update_ui()
756
+ else:
757
+ if len(self.drumkit_widgets) == 1:
758
+ drumkit_widget.slot_select_all()
759
+ self.set_dirty()
760
+
761
+ @pyqtSlot(QObject)
762
+ def slot_remove_drumkit(self, drumkit_widget):
763
+ """
764
+ Directly triggered by kits_area custom context menu;
765
+ called in any place where a drumkit_widget needs to be removed,
766
+ including clear(), slot_remove_all_kits().
767
+ """
768
+ self.midi_splitter.clear_port_assignments(drumkit_widget.port_number)
769
+ drumkit_widget.synth.quit()
770
+ self.drumkit_widgets.remove(drumkit_widget)
771
+ drumkit_widget.deleteLater()
772
+ self.set_dirty()
773
+
774
+ @pyqtSlot(QObject, str, bool, bool)
775
+ def slot_inst_toggle(self, source_widget, inst_id, state, ctrl_state):
776
+ """
777
+ Triggered by DrumkitWidget InstrumentButton toggle event.
778
+ Parameters are:
779
+ "source_widget": DrumkitWidget containing the button clicked
780
+ "inst_id": (str) Identifies the button clicked
781
+ "state": (bool) True if "checked"
782
+ "ctrl_state": (bool) True if CTRL key pressed when clicking
783
+ """
784
+ # Deselect all other InstrumentButton if not CTRL key pressed:
785
+ if state:
786
+ if not ctrl_state:
787
+ for drumkit_widget in self.drumkit_widgets:
788
+ if not drumkit_widget is source_widget:
789
+ drumkit_widget.deselect_instrument(inst_id)
790
+ source_widget.reselect_parent_group(inst_id)
791
+ # Deselect the GroupButton if instrument deselected:
792
+ else:
793
+ source_widget.deselect_parent_group(inst_id)
794
+ # Enable/disable routing midi events to the source_widget's synth:
795
+ if state:
796
+ self.midi_splitter.assign_note(
797
+ MIDI_DRUM_PITCHES[inst_id],
798
+ source_widget.port_number)
799
+ else:
800
+ self.midi_splitter.clear_note_assignment(
801
+ MIDI_DRUM_PITCHES[inst_id],
802
+ source_widget.port_number)
803
+ self.set_dirty()
804
+
805
+ @pyqtSlot(Drumkit)
806
+ def slot_drumkit_bashed(self, bashed_kit):
807
+ """
808
+ Triggered from KitBasher signal when bashing is finished.
809
+ """
810
+ try:
811
+ bashed_kit.save_as(self.bashed_sfz_filename, self.bashed_sfz_samples_mode)
812
+ self.lbl_bashed_sfz_filename.setText(self.bashed_sfz_filename)
813
+ logging.debug('Saved bashed .sfz at %s', self.bashed_sfz_filename)
814
+ except OSError as e:
815
+ DevilBox('Hardlinks between devices are not allowed.\n' +\
816
+ 'Choose a different path or sample mode.' if e.errno == 18 \
817
+ else str(e))
818
+
819
+ def used_port_numbers(self):
820
+ """
821
+ Returns a set of MidiSplitter port numbers assigned to drumkit widget's synth
822
+ """
823
+ return set(drumkit_widget.port_number \
824
+ for drumkit_widget in self.drumkit_widgets)
825
+
826
+ def available_port_numbers(self):
827
+ """
828
+ Returns a list of MidiSplitter port numbers not yet assigned to drumkit widget's synth
829
+ """
830
+ return list(self.drumkit_port_ranges ^ self.used_port_numbers())
831
+
832
+ # -----------------------------------------------------------------
833
+ # UI handling slots:
834
+
835
+ @pyqtSlot()
836
+ def slot_xruns_clicked(self):
837
+ """
838
+ Triggered by b_xruns.click()
839
+ """
840
+ self.base_xruns = self.current_xruns
841
+ self.b_xruns.setText('0')
842
+
843
+ @pyqtSlot(QPoint)
844
+ def slot_kits_context_menu(self, position):
845
+ """
846
+ Triggered by kits_area.customContextMenuRequested
847
+ """
848
+ menu = QMenu()
849
+ clicked_drumkit_widget = self.kits_area.childAt(position)
850
+ if clicked_drumkit_widget is not None:
851
+ while not isinstance(clicked_drumkit_widget, DrumkitWidget) and \
852
+ clicked_drumkit_widget.parent() is not None:
853
+ clicked_drumkit_widget = clicked_drumkit_widget.parent()
854
+ if isinstance(clicked_drumkit_widget, DrumkitWidget):
855
+ action = QAction('Select all', self)
856
+ action.triggered.connect(clicked_drumkit_widget.slot_select_all)
857
+ menu.addAction(action)
858
+ action = QAction(f'Remove "{clicked_drumkit_widget.moniker}"', self)
859
+ action.triggered.connect(partial(self.slot_remove_drumkit, clicked_drumkit_widget))
860
+ menu.addAction(action)
861
+ menu.addAction(self.action_add_drumkit)
862
+ menu.addAction(self.action_remove_all_kits)
863
+ menu.addAction(self.action_collapse_kits)
864
+ menu.exec(self.kits_area.mapToGlobal(position))
865
+
866
+ @pyqtSlot()
867
+ def slot_remove_all_kits(self):
868
+ """
869
+ Triggered by 'Edit -> Remove All Drumkits" menu and kits_area context menu.
870
+ """
871
+ for drumkit_widget in reversed(self.drumkit_widgets):
872
+ self.slot_remove_drumkit(drumkit_widget)
873
+
874
+ @pyqtSlot()
875
+ def slot_collapse_kits(self):
876
+ """
877
+ Triggered by "View -> Collapse Kits"
878
+ """
879
+ for widget in self.drumkit_widgets:
880
+ widget.hide_button.setChecked(True)
881
+
882
+ @pyqtSlot()
883
+ def slot_show_recent_drumkits(self):
884
+ """
885
+ Fills "recent_drumkits" menu before expanding
886
+ """
887
+ self.menu_recent_drumkits.clear()
888
+ actions = []
889
+ for filename in self.recent_drumkits:
890
+ action = QAction(filename, self)
891
+ action.triggered.connect(partial(self.load_drumkit, filename))
892
+ actions.append(action)
893
+ self.menu_recent_drumkits.addActions(actions)
894
+
895
+ @pyqtSlot()
896
+ def slot_show_recent_projects(self):
897
+ """
898
+ Fills "recent_projects" menu before expanding
899
+ """
900
+ self.menu_recent_project.clear()
901
+ actions = []
902
+ for filename in self.recent_projects:
903
+ action = QAction(filename, self)
904
+ action.triggered.connect(partial(self.load_recent_project, filename))
905
+ actions.append(action)
906
+ self.menu_recent_project.addActions(actions)
907
+
908
+ @pyqtSlot()
909
+ def slot_new_project(self):
910
+ """
911
+ Triggered by "File -> New"
912
+ """
913
+ if self.okay_to_clear():
914
+ self.clear()
915
+
916
+ @pyqtSlot()
917
+ def slot_open_project(self):
918
+ """
919
+ Triggered by "File -> Open Project"
920
+ """
921
+ if self.okay_to_clear():
922
+ QCoreApplication.setAttribute(Qt.AA_DontUseNativeDialogs, False)
923
+ filename = QFileDialog.getOpenFileName(self,
924
+ "Open saved project",
925
+ settings().value(KEY_RECENT_PROJECT_FOLDER, ""),
926
+ "Kitbash project (*.json)"
927
+ )[0]
928
+ if filename != '':
929
+ self.load_project(filename)
930
+
931
+ @pyqtSlot()
932
+ def slot_save_project(self):
933
+ """
934
+ Triggered by "File -> Save Project"
935
+ Opens the file save dialog if project_filename is None; calls "save_project".
936
+ """
937
+ if self.project_filename is None:
938
+ self.slot_save_project_as()
939
+ else:
940
+ self.save_project()
941
+
942
+ @pyqtSlot()
943
+ def slot_save_project_as(self):
944
+ """
945
+ Triggered by "File -> Save Project As"
946
+ Opens the file save dialog, sets project_filename, calls "save_project".
947
+ """
948
+ QCoreApplication.setAttribute(Qt.AA_DontUseNativeDialogs, False)
949
+ filename, _ = QFileDialog.getSaveFileName(
950
+ self,
951
+ "Save Kitbash project ...",
952
+ settings().value(KEY_RECENT_PROJECT_FOLDER, os.getcwd() \
953
+ if self.project_filename is None \
954
+ else dirname(self.project_filename)),
955
+ "Kitbash project (*.json)"
956
+ )
957
+ if filename :
958
+ self.project_filename = realpath(
959
+ filename \
960
+ if splitext(filename)[-1].lower() == '.json' \
961
+ else filename + '.json')
962
+ self.save_project()
963
+
964
+ @pyqtSlot()
965
+ def slot_save_kit(self):
966
+ if self.bashed_sfz_filename is None:
967
+ self.slot_save_kit_as()
968
+ else:
969
+ self.save_kit()
970
+
971
+ @pyqtSlot()
972
+ def slot_save_kit_as(self):
973
+ """
974
+ Triggered by "File -> Save bashed kit" menu
975
+ See also: slot_drumkit_bashed
976
+ """
977
+ dlg = KitSaveDialog(self,
978
+ int(settings().value(KEY_SAMPLES_MODE, SAMPLES_ABSPATH)) \
979
+ if self.bashed_sfz_samples_mode is None \
980
+ else self.bashed_sfz_samples_mode)
981
+ if dlg.exec_() and dlg.selected_file:
982
+ self.bashed_sfz_filename = dlg.selected_file
983
+ self.bashed_sfz_samples_mode = dlg.samples_mode
984
+ self.set_dirty()
985
+ self.save_kit()
986
+
987
+ @pyqtSlot()
988
+ def slot_add_drumkit(self):
989
+ """
990
+ Triggered by "Edit -> Add Drumkit" menu, and kits_area custom context menu..
991
+ """
992
+ QCoreApplication.setAttribute(Qt.AA_DontUseNativeDialogs, False)
993
+ filename = QFileDialog.getOpenFileName(self,
994
+ "Load Drumkit",
995
+ settings().value(KEY_RECENT_DRUMKIT_FOLDER, ''),
996
+ "SFZ file (*.sfz)"
997
+ )[0]
998
+ if filename != '':
999
+ self.load_drumkit(filename)
1000
+
1001
+ @pyqtSlot()
1002
+ def slot_copy_kit_path(self):
1003
+ QApplication.instance().clipboard().setText(self.lbl_bashed_sfz_filename.text())
1004
+
1005
+
1006
+ class KitWorkerSignals(QObject):
1007
+ """
1008
+ Signals common to KitLoader and KitBasher.
1009
+ (PyQt QRunnable does not support its own signals)
1010
+ """
1011
+ sig_loaded = pyqtSignal(Drumkit)
1012
+ sig_widget_loaded = pyqtSignal(DrumkitWidget, Drumkit)
1013
+ sig_bashed = pyqtSignal(Drumkit)
1014
+
1015
+
1016
+ class KitLoader(QRunnable):
1017
+ """
1018
+ Loads a drumkit in a background thread and emits "sig_loaded" when done.
1019
+ """
1020
+
1021
+ def __init__(self, drumkit_widget):
1022
+ super().__init__()
1023
+ self.drumkit_widget = drumkit_widget
1024
+ self.signals = KitWorkerSignals()
1025
+
1026
+ @pyqtSlot()
1027
+ def run(self):
1028
+ drumkit = Drumkit(self.drumkit_widget.sfz_filename)
1029
+ self.signals.sig_loaded.emit(drumkit)
1030
+ self.signals.sig_widget_loaded.emit(self.drumkit_widget, drumkit)
1031
+
1032
+
1033
+ class KitBasher(QRunnable):
1034
+ """
1035
+ Compiles a bashed kit and signals that its ready to be saved.
1036
+ """
1037
+
1038
+ def __init__(self, drumkit_widgets):
1039
+ super().__init__()
1040
+ self.drumkit_widgets = drumkit_widgets
1041
+ self.signals = KitWorkerSignals()
1042
+
1043
+ @pyqtSlot()
1044
+ def run(self):
1045
+ bashed_kit = Drumkit()
1046
+ for drumkit_widget in self.drumkit_widgets:
1047
+ for inst_id in drumkit_widget.selected_instrument_ids():
1048
+ bashed_kit.import_instrument(inst_id, drumkit_widget.drumkit)
1049
+ self.signals.sig_bashed.emit(bashed_kit)
1050
+
1051
+
1052
+ class JackLiquidSFZ(LiquidSFZ):
1053
+ """
1054
+ Wraps a LiquidSFZ instance in order to hold references to jacklib ports created
1055
+ by JackConnectionManager.
1056
+ """
1057
+
1058
+ def __init__(self, filename):
1059
+ self.client_name = None
1060
+ self.input_port = None
1061
+ self.output_ports = []
1062
+ super().__init__(filename, defer_start = True)
1063
+
1064
+
1065
+ class KitSaveDialog(QFileDialog, GeometrySaver):
1066
+ """
1067
+ Custom file dialog with added option for choosing samples_mode.
1068
+ """
1069
+
1070
+ def __init__(self, parent, samples_mode):
1071
+ QCoreApplication.setAttribute(Qt.AA_DontUseNativeDialogs)
1072
+ super().__init__(parent)
1073
+ self.samples_mode = samples_mode
1074
+ self.restore_geometry()
1075
+ self.setWindowTitle("Save bashed kit as .sfz")
1076
+ self.setFileMode(QFileDialog.AnyFile)
1077
+ self.setViewMode(QFileDialog.List)
1078
+ lbl = QLabel()
1079
+ self.layout().addWidget(lbl)
1080
+ gb = QGroupBox('Sample location')
1081
+ self.r_abspath = QRadioButton('Point to the original samples - absolute path')
1082
+ self.r_resolve = QRadioButton('Point to the original samples - relative path')
1083
+ self.r_copy = QRadioButton('Copy samples to the "./samples" folder')
1084
+ self.r_symlink = QRadioButton('Create symlinks in the "./samples" folder')
1085
+ self.r_hardlink = QRadioButton('Hardlink the originals in the "./samples" folder')
1086
+ self.r_abspath.clicked.connect(partial(self.slot_set_mode, SAMPLES_ABSPATH))
1087
+ self.r_resolve.clicked.connect(partial(self.slot_set_mode, SAMPLES_RESOLVE))
1088
+ self.r_copy.clicked.connect(partial(self.slot_set_mode, SAMPLES_COPY))
1089
+ self.r_symlink.clicked.connect(partial(self.slot_set_mode, SAMPLES_SYMLINK))
1090
+ self.r_hardlink.clicked.connect(partial(self.slot_set_mode, SAMPLES_HARDLINK))
1091
+ lo = QVBoxLayout()
1092
+ lo.setContentsMargins(2,2,2,2)
1093
+ lo.setSpacing(2)
1094
+ lo.addWidget(self.r_abspath)
1095
+ lo.addWidget(self.r_resolve)
1096
+ lo.addWidget(self.r_copy)
1097
+ lo.addWidget(self.r_symlink)
1098
+ lo.addWidget(self.r_hardlink)
1099
+ gb.setLayout(lo)
1100
+ if self.samples_mode == SAMPLES_ABSPATH:
1101
+ self.r_abspath.setChecked(True)
1102
+ elif self.samples_mode == SAMPLES_RESOLVE:
1103
+ self.r_resolve.setChecked(True)
1104
+ elif self.samples_mode == SAMPLES_COPY:
1105
+ self.r_copy.setChecked(True)
1106
+ elif self.samples_mode == SAMPLES_SYMLINK:
1107
+ self.r_symlink.setChecked(True)
1108
+ else:
1109
+ self.r_hardlink.setChecked(True)
1110
+ self.layout().addWidget(gb)
1111
+ self.selected_file = None
1112
+
1113
+ @pyqtSlot(int, bool)
1114
+ def slot_set_mode(self, mode, _):
1115
+ """
1116
+ Tiggered by any sample mode selection radio button.
1117
+ """
1118
+ self.samples_mode = mode
1119
+
1120
+ @pyqtSlot()
1121
+ def accept(self):
1122
+ """
1123
+ Overloaded function saves preferred mode, sets "selected_file".
1124
+ """
1125
+ settings().setValue(KEY_SAMPLES_MODE, self.samples_mode)
1126
+ selected_files = self.selectedFiles()
1127
+ if selected_files:
1128
+ self.selected_file = realpath(
1129
+ selected_files[0] \
1130
+ if splitext(selected_files[0])[-1].lower() == '.sfz' \
1131
+ else selected_files[0] + '.sfz')
1132
+ else:
1133
+ self.selected_file = None
1134
+ super().accept()
1135
+
1136
+ def done(self, result):
1137
+ """
1138
+ Overloaded function saves geometry.
1139
+ """
1140
+ self.save_geometry()
1141
+ super().done(result)
1142
+
1143
+
1144
+ class GroupFrame(QFrame):
1145
+ """
1146
+ QFrame which contains one GroupButton and one or more InstrumentButton
1147
+ """
1148
+
1149
+ def __init__(self, group, parent):
1150
+ super().__init__(parent)
1151
+ self.setFrameShape(QFrame.NoFrame)
1152
+ self.setObjectName(group.group_id) # GroupFrame identified by group_id
1153
+ self.group_id = group.group_id
1154
+ self.group_layout = QVBoxLayout()
1155
+ self.group_layout.setSpacing(0)
1156
+ self.group_layout.setContentsMargins(0,0,0,0)
1157
+ self.setLayout(self.group_layout)
1158
+ self.group_button = GroupButton(self) # GroupButton has no unique object name
1159
+ self.group_button.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Preferred)
1160
+ self.group_button.setText(group.name)
1161
+ self.group_button.setCheckable(True)
1162
+ self.group_layout.addWidget(self.group_button)
1163
+
1164
+
1165
+ class GroupButton(QPushButton):
1166
+ """
1167
+ Defined here to provide a distinct .css class name.
1168
+ """
1169
+
1170
+
1171
+ class InstrumentButton(QPushButton):
1172
+ """
1173
+ Custom button with a contained InstrumentLabel and QCheckBox.
1174
+ The InstrumentLabel traps mouse press events, while the QCheckBox mirrors the
1175
+ "checked" state of this QPushButton.
1176
+ """
1177
+
1178
+ sig_mouse_press = pyqtSignal(PercussionInstrument)
1179
+ sig_mouse_release = pyqtSignal(PercussionInstrument)
1180
+
1181
+ def __init__(self, inst, parent):
1182
+ super().__init__(parent)
1183
+ self.inst = inst
1184
+ self.setObjectName(inst.inst_id)
1185
+ self.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Preferred)
1186
+ lo = QHBoxLayout()
1187
+ lo.setContentsMargins(0,0,0,0)
1188
+ lo.setSpacing(0)
1189
+ self.setLayout(lo)
1190
+ self.setCheckable(True)
1191
+ lo.addWidget(InstrumentLabel(inst, self))
1192
+ lo.addStretch()
1193
+ self.checkbox = QCheckBox(self)
1194
+ self.checkbox.stateChanged.connect(self.slot_checkbox_state_change)
1195
+ lo.addWidget(self.checkbox)
1196
+
1197
+ @pyqtSlot(int)
1198
+ def slot_checkbox_state_change(self, state):
1199
+ """
1200
+ Triggered when contained checkbox is clicked.
1201
+ """
1202
+ self.setChecked(state == Qt.Checked)
1203
+
1204
+ def checkStateSet(self):
1205
+ """
1206
+ Extends QAbstractButton.checkStateSet.
1207
+ This is called in response to the gui setting the "checked" property of this QPushButton.
1208
+ """
1209
+ with SigBlock(self.checkbox):
1210
+ self.checkbox.setChecked(self.isChecked())
1211
+
1212
+ def mousePressEvent(self, event):
1213
+ """
1214
+ Overrides mouse so that only the contained checkbox will toggle this widget's state.
1215
+ """
1216
+ event.accept()
1217
+ self.mouse_press()
1218
+
1219
+ def mouseReleaseEvent(self, event):
1220
+ """
1221
+ Overrides mouse so that only the contained checkbox will toggle this widget's state.
1222
+ """
1223
+ event.accept()
1224
+ self.mouse_release()
1225
+
1226
+ def mouse_press(self):
1227
+ """
1228
+ Called from contained label. Sets the "down" state of this widget,
1229
+ (which is identified in the CSS as the ":pressed" pseudo-selector).
1230
+ """
1231
+ self.setDown(True)
1232
+ self.sig_mouse_press.emit(self.inst)
1233
+
1234
+ def mouse_release(self):
1235
+ """
1236
+ Called from contained label. Unsets the "down" state of this widget,
1237
+ (which is identified in the CSS as the ":pressed" pseudo-selector).
1238
+ """
1239
+ self.setDown(False)
1240
+ self.sig_mouse_release.emit(self.inst)
1241
+
1242
+
1243
+ class InstrumentLabel(QLabel):
1244
+ """
1245
+ Label contained inside an InstrumentButton, delegating its' mouse press /
1246
+ release events to its' parent.
1247
+ """
1248
+
1249
+ def __init__(self, inst, parent):
1250
+ super().__init__(parent)
1251
+ self.setText(inst.name)
1252
+ self.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.Preferred)
1253
+
1254
+ def mousePressEvent(self, event):
1255
+ self.parent().mouse_press()
1256
+ event.accept()
1257
+
1258
+ def mouseReleaseEvent(self, event):
1259
+ self.parent().mouse_release()
1260
+ event.accept()
1261
+
1262
+
1263
+ # end kitbash/gui/main_window.py