audio-tuner-gui 0.9.1__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,683 @@
1
+ #!/usr/bin/env python3
2
+ #
3
+ # This file is part of Audio Tuner.
4
+ #
5
+ # Copyright 2025, 2026 Jessie Blue Cassell <bluesloth600@gmail.com>
6
+ #
7
+ # This program is free software: you can redistribute it and/or modify
8
+ # it under the terms of the GNU General Public License as published by
9
+ # the Free Software Foundation, either version 3 of the License, or
10
+ # (at your option) any later version.
11
+ #
12
+ # This program is distributed in the hope that it will be useful,
13
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
14
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15
+ # GNU General Public License for more details.
16
+ #
17
+ # You should have received a copy of the GNU General Public License
18
+ # along with this program. If not, see <https://www.gnu.org/licenses/>.
19
+
20
+
21
+ """Option panel for the GUI."""
22
+
23
+
24
+ __author__ = 'Jessie Blue Cassell'
25
+
26
+
27
+ __all__ = [
28
+ 'OptionPanel',
29
+ ]
30
+
31
+
32
+ from PyQt6.QtCore import (
33
+ pyqtSignal,
34
+ )
35
+ from PyQt6.QtWidgets import (
36
+ QWidget,
37
+ QPushButton,
38
+ QVBoxLayout,
39
+ QHBoxLayout,
40
+ QLineEdit,
41
+ QSizePolicy,
42
+ QCheckBox,
43
+ QScrollArea,
44
+ QLabel,
45
+ QGridLayout,
46
+ QComboBox,
47
+ QSpinBox,
48
+ QDoubleSpinBox,
49
+ QFrame,
50
+ QToolButton,
51
+ )
52
+ from PyQt6.QtGui import (
53
+ QColor,
54
+ QPalette,
55
+ )
56
+
57
+ import audio_tuner.tuning_systems as tuning_systems
58
+ import audio_tuner.common as com
59
+ import audio_tuner_gui.common as gcom
60
+
61
+
62
+ class _OptionLineEdit(QWidget):
63
+ def __init__(self, label, unit=None, none_sentinel=None):
64
+ super().__init__()
65
+
66
+ self.none_sentinel = none_sentinel
67
+
68
+ self.error_condition = False
69
+ self.label = QLabel(label)
70
+ self.label.setSizePolicy(QSizePolicy())
71
+ self.default = None
72
+
73
+ if unit is None:
74
+ self.bottom = self.init_main_widget()
75
+ self.bottom.setSizePolicy(QSizePolicy())
76
+ self.widget = self.bottom
77
+ else:
78
+ self.bottom = QWidget()
79
+ self.hbox = QHBoxLayout(self.bottom)
80
+ self.widget = self.init_main_widget()
81
+ self.widget.setSizePolicy(QSizePolicy())
82
+
83
+ self.init_unit(unit)
84
+
85
+ self.hbox.addWidget(self.widget)
86
+ self.hbox.addWidget(self.unit)
87
+ self.hbox.addStretch()
88
+ self.hbox.setContentsMargins(0, 0, 0, 0)
89
+
90
+ self.vbox = QVBoxLayout(self)
91
+ self.vbox.addStretch()
92
+ self.vbox.addWidget(self.label)
93
+ self.vbox.addWidget(self.bottom)
94
+ self.vbox.addStretch()
95
+
96
+ self.orig_palette = self.palette()
97
+
98
+ def init_main_widget(self):
99
+ line_edit = QLineEdit()
100
+ line_edit.editingFinished.connect(self._edit_finished)
101
+ return line_edit
102
+
103
+ def init_unit(self, unit):
104
+ self.unit = QLabel(unit)
105
+ self.unit.setSizePolicy(QSizePolicy())
106
+
107
+ def _edit_finished(self):
108
+ selection = self.widget.text()
109
+ if self.default and (selection is None or selection == ''):
110
+ self.set(self.default)
111
+
112
+ def set(self, selection):
113
+ if self.none_sentinel is not None and selection is None:
114
+ selection = self.none_sentinel
115
+ self.widget.setText(selection)
116
+ self.unset_error()
117
+
118
+ def get(self):
119
+ self.unset_error()
120
+ ret = self.widget.text().strip()
121
+ if self.none_sentinel is not None and ret == self.none_sentinel:
122
+ ret = None
123
+ return ret
124
+
125
+ def set_error(self):
126
+ self.error_condition = True
127
+ palette = self.palette()
128
+ palette.setColor(QPalette.ColorRole.Base, QColor(255, 0, 0))
129
+ self.setPalette(palette)
130
+ self.widget.setFocus()
131
+
132
+ def unset_error(self):
133
+ if self.error_condition:
134
+ self.error_condition = False
135
+ self.setPalette(self.orig_palette)
136
+
137
+
138
+ class _OptionStartEnd(_OptionLineEdit):
139
+ button_clicked = pyqtSignal()
140
+
141
+ def init_unit(self, unit):
142
+ self.unit = QToolButton()
143
+ self.unit.setText('Set to now')
144
+ self.unit.setStatusTip('Set to current position')
145
+ self.unit.setEnabled(False)
146
+ self.unit.clicked.connect(self.button_clicked.emit)
147
+
148
+ def _edit_finished(self):
149
+ selection = self.widget.text()
150
+ if self.none_sentinel and (selection is None or selection == ''):
151
+ self.set(self.none_sentinel)
152
+
153
+ def get(self) -> str:
154
+ self.unset_error()
155
+ ret = self.widget.text().replace('#', '').strip()
156
+ if self.none_sentinel is not None and ret == self.none_sentinel:
157
+ ret = None
158
+ return ret
159
+
160
+
161
+ class _OptionCheckBox(QWidget):
162
+ def __init__(self, label):
163
+ super().__init__()
164
+
165
+ self.vbox = QVBoxLayout(self)
166
+ self.widget = QCheckBox(label)
167
+ self.vbox.addWidget(self.widget)
168
+ self.vbox.setContentsMargins(15, 5, 5, 5)
169
+
170
+ def set(self, selection):
171
+ self.widget.setChecked(selection)
172
+
173
+ def get(self):
174
+ return self.widget.isChecked()
175
+
176
+
177
+ class _OptionFloatLineEdit(_OptionLineEdit):
178
+ def set(self, selection):
179
+ super().set(str(selection))
180
+
181
+ def get(self):
182
+ try:
183
+ ret = float(super().get())
184
+ if ret <= 0:
185
+ raise ValueError
186
+ except ValueError:
187
+ self.set_error()
188
+ return gcom.ERROR_SENTINEL
189
+ return ret
190
+
191
+
192
+ class _OptionIntSpinBox(_OptionLineEdit):
193
+ def init_main_widget(self):
194
+ widget = QSpinBox()
195
+ widget.setMinimum(1)
196
+
197
+ self._revert_to_default = False
198
+ widget.lineEdit().textEdited.connect(self._text_edited)
199
+ widget.lineEdit().editingFinished.connect(self._edit_finished)
200
+
201
+ return widget
202
+
203
+ def _edit_finished(self):
204
+ if self.default and self._revert_to_default:
205
+ self.set(self.default)
206
+ self._revert_to_default = False
207
+
208
+ def _text_edited(self, text):
209
+ if text == '':
210
+ self._revert_to_default = True
211
+ else:
212
+ self._revert_to_default = False
213
+
214
+ def set(self, selection):
215
+ self.widget.setValue(selection)
216
+ self.unset_error()
217
+
218
+ def get(self):
219
+ self.unset_error()
220
+ try:
221
+ ret = self.widget.value()
222
+ if ret <= 0:
223
+ raise ValueError
224
+ except ValueError:
225
+ self.set_error()
226
+ return gcom.ERROR_SENTINEL
227
+ return ret
228
+
229
+
230
+ class _OptionFloatSpinBox(_OptionIntSpinBox):
231
+ def init_main_widget(self):
232
+ widget = QDoubleSpinBox()
233
+ widget.setDecimals(3)
234
+ widget.setMinimum(0.25)
235
+ widget.setMaximum(4.0)
236
+ widget.setSingleStep(.001)
237
+
238
+ self._revert_to_default = False
239
+ widget.lineEdit().textEdited.connect(self._text_edited)
240
+ widget.lineEdit().editingFinished.connect(self._edit_finished)
241
+
242
+ return widget
243
+
244
+ def set(self, selection):
245
+ super().set(selection)
246
+ self._precise_value = selection
247
+
248
+ def get_precise(self):
249
+ imprecise = super().get()
250
+ if (imprecise != gcom.ERROR_SENTINEL
251
+ and abs(imprecise - self._precise_value) < .0006):
252
+ return self._precise_value
253
+ else:
254
+ return imprecise
255
+
256
+
257
+ def _prettify(text):
258
+ text = text.replace('b', tuning_systems.flat_symbol)
259
+ text = text.replace('#', tuning_systems.sharp_symbol)
260
+ return text
261
+
262
+
263
+ class _OptionRefnoteLineEdit(_OptionLineEdit):
264
+ def get(self):
265
+ text = self.widget.text().strip()
266
+ text = text.capitalize()
267
+ text = _prettify(text)
268
+ self.set(text)
269
+ return text
270
+
271
+ def set(self, selection):
272
+ super().set(_prettify(selection))
273
+
274
+
275
+ class _OptionComboBox(_OptionLineEdit):
276
+ def __init__(self, label, items):
277
+ self.items = items
278
+ super().__init__(label)
279
+
280
+ def init_main_widget(self):
281
+ widget = QComboBox()
282
+ widget.addItems(self.items)
283
+ return widget
284
+
285
+ def set(self, selection):
286
+ self.widget.setCurrentText(selection)
287
+
288
+ def get(self):
289
+ return self.widget.currentText()
290
+
291
+
292
+ class OptionPanel(QWidget):
293
+ """Panel full of option widgets. Inherits from QWidget. Includes a
294
+ `Hold` checkbox to hold options at their current values, a `Revert
295
+ to defaults` button and an `Apply to selected` button. The user can
296
+ request that an individual widget return to it's default value by
297
+ setting it's line edit box to a blank value (this only works for
298
+ widgets that actually have a line edit box, and only if the widget's
299
+ `default` attribute has been set, which can be done using the
300
+ `is_defaults` parameter of the `set_options` method (The
301
+ `set_default_options` convenience method, which is what the `Revert
302
+ to defaults` button is connected to, does this automatically)).
303
+
304
+ Parameters
305
+ ----------
306
+ args : argparse.Namespace
307
+ Stores the default options which the `set_default_options`
308
+ method uses. Note that the defaults aren't actually set until
309
+ `set_default_options` is called.
310
+
311
+ Attributes
312
+ ----------
313
+ widgets : dict
314
+ The widgets. The keys are the option names defined in
315
+ audio_tuner_gui.common.
316
+ """
317
+
318
+ PushOptions = pyqtSignal(gcom.Options, bool)
319
+ """Signal emitted when the user pushes a button to request that
320
+ options be applied to the selected analyzed audio.
321
+
322
+ Parameters
323
+ ----------
324
+ audio_tuner_gui.common.Options
325
+ The options to apply.
326
+ """
327
+
328
+ PitchChange = pyqtSignal(float)
329
+ """Signal emitted when the value in the pitch correction widget
330
+ changes.
331
+
332
+ Parameters
333
+ ----------
334
+ float
335
+ The new pitch correction value.
336
+ """
337
+
338
+ PayAttentionToMe = pyqtSignal()
339
+ """Signal emitted to request that the option panel be made visible
340
+ if it isn't already.
341
+ """
342
+
343
+
344
+ def __init__(self, args):
345
+ super().__init__()
346
+
347
+ self._args = args
348
+ self._link_ok = True
349
+
350
+ self._init_option_area()
351
+ self._init_buttons()
352
+
353
+ vbox = QVBoxLayout(self)
354
+ vbox.addWidget(self._scroll_area)
355
+ vbox.addWidget(self._button_panel)
356
+ vbox.setSpacing(5)
357
+ vbox.setContentsMargins(3, 3, 3, 3)
358
+
359
+ self._default_options = gcom.Options(args)
360
+
361
+ def _init_option_area(self):
362
+ self.widgets = {}
363
+
364
+ self._scroll_area = QScrollArea()
365
+ self._panel = QWidget()
366
+ self._grid = QGridLayout(self._panel)
367
+
368
+ box_widget = QWidget()
369
+ hbox = QHBoxLayout(box_widget)
370
+
371
+ # pitch correction
372
+ title = gcom.OPTION_PITCH
373
+ widget = _OptionFloatSpinBox(title)
374
+ hbox.addWidget(widget)
375
+ self.widgets[title] = widget
376
+
377
+ # Link button
378
+ widget = QToolButton()
379
+ widget.setText('&Link')
380
+ widget.setCheckable(True)
381
+ hbox.addWidget(widget)
382
+ self._link = widget
383
+
384
+ # tempo correction
385
+ title = gcom.OPTION_TEMPO
386
+ widget = _OptionFloatSpinBox(title)
387
+ hbox.addWidget(widget)
388
+ self.widgets[title] = widget
389
+
390
+ self._grid.addWidget(box_widget, 0, 0, 1, 2)
391
+
392
+ keybox = QWidget()
393
+ khbox = QHBoxLayout(keybox)
394
+ khbox.setContentsMargins(0, 0, 0, 0)
395
+
396
+ # down one
397
+ widget = QToolButton()
398
+ widget.setText('-100c')
399
+ widget.setShortcut('<')
400
+ widget.clicked.connect(self._pitch_down)
401
+ khbox.addWidget(widget)
402
+
403
+ # up one
404
+ widget = QToolButton()
405
+ widget.setText('+100c')
406
+ widget.setShortcut('>')
407
+ widget.clicked.connect(self._pitch_up)
408
+ khbox.addWidget(widget)
409
+
410
+ self._grid.addWidget(keybox, 1, 0)
411
+
412
+ # Reread button
413
+ self._reread_button = QPushButton()
414
+ self._reread_button.setText('&Reread file with correction')
415
+ self._reread_button.clicked.connect(self._reread)
416
+ self._grid.addWidget(self._reread_button, 1, 1)
417
+
418
+ # Separator line
419
+ widget = QFrame()
420
+ widget.setFrameShape(QFrame.Shape.HLine)
421
+ widget.setFrameShadow(QFrame.Shadow.Sunken)
422
+ self._grid.addWidget(widget, 2, 0, 1, 2)
423
+
424
+ # Analysis option widgets
425
+
426
+ # Tuning System
427
+ title = gcom.OPTION_TUNING_SYSTEM
428
+ widget = _OptionComboBox(title,
429
+ ('Equal Temperament',
430
+ 'Pythagorean'))
431
+ self._grid.addWidget(widget, 3, 0)
432
+ self.widgets[title] = widget
433
+
434
+ # Reference Frequency
435
+ title = gcom.OPTION_REF_FREQ
436
+ widget = _OptionFloatLineEdit(title, unit='Hz')
437
+ widget.setStatusTip('Frequency of the reference note')
438
+ self._grid.addWidget(widget, 4, 0)
439
+ self.widgets[title] = widget
440
+
441
+ # Reference Note
442
+ title = gcom.OPTION_REF_NOTE
443
+ widget = _OptionRefnoteLineEdit(title)
444
+ self._grid.addWidget(widget, 5, 0)
445
+ self.widgets[title] = widget
446
+
447
+ # Start Time
448
+ title = gcom.OPTION_START
449
+ widget = _OptionStartEnd(title, unit=True, none_sentinel='Beginning')
450
+ widget.setStatusTip('Ignore audio before this time')
451
+ self._grid.addWidget(widget, 3, 1)
452
+ self.widgets[title] = widget
453
+
454
+ # End Time
455
+ title = gcom.OPTION_END
456
+ widget = _OptionStartEnd(title, unit=True, none_sentinel='End')
457
+ widget.setStatusTip('Ignore the audio after this time')
458
+ self._grid.addWidget(widget, 4, 1)
459
+ self.widgets[title] = widget
460
+
461
+ # Low Cut
462
+ title = gcom.OPTION_LOW_CUT
463
+ widget = _OptionFloatLineEdit(title, unit='Hz')
464
+ widget.setStatusTip('Ignore frequencies below this')
465
+ self._grid.addWidget(widget, 6, 0)
466
+ self.widgets[title] = widget
467
+
468
+ # High Cut
469
+ title = gcom.OPTION_HIGH_CUT
470
+ widget = _OptionFloatLineEdit(title, unit='Hz')
471
+ widget.setStatusTip('Ignore frequencies above this')
472
+ self._grid.addWidget(widget, 7, 0)
473
+ self.widgets[title] = widget
474
+
475
+ # dB Range
476
+ title = gcom.OPTION_DB_RANGE
477
+ widget = _OptionFloatLineEdit(title, unit='dB')
478
+ widget.setStatusTip('Ignore frequencies this much fainter'
479
+ ' than the highest peak')
480
+ self._grid.addWidget(widget, 5, 1)
481
+ self.widgets[title] = widget
482
+
483
+ # Max Peaks
484
+ title = gcom.OPTION_MAX_PEAKS
485
+ widget = _OptionIntSpinBox(title)
486
+ widget.setStatusTip('Maximum number of frequencies to show')
487
+ self._grid.addWidget(widget, 6, 1)
488
+ self.widgets[title] = widget
489
+
490
+ # Pad input
491
+ title = gcom.OPTION_PAD
492
+ widget = _OptionCheckBox(title)
493
+ widget.setStatusTip("Pad the audio with zeros to ensure the FFT"
494
+ " window doesn't miss the very beginning and end")
495
+ self._grid.addWidget(widget, 7, 1)
496
+ self.widgets[title] = widget
497
+
498
+
499
+ self.widgets[gcom.OPTION_PITCH].widget.valueChanged.connect(
500
+ self._pitch_changed)
501
+ self.widgets[gcom.OPTION_TEMPO].widget.valueChanged.connect(
502
+ self._tempo_changed)
503
+ self._link.toggled.connect(self._link_toggled)
504
+
505
+
506
+ self._scroll_area.setWidget(self._panel)
507
+
508
+ def _init_buttons(self):
509
+ self._button_panel = QWidget(self)
510
+ hbox = QHBoxLayout(self._button_panel)
511
+
512
+ self._hold = QCheckBox('Hold')
513
+ self._hold.setStatusTip("Don't update settings to reflect selection")
514
+ hbox.addWidget(self._hold)
515
+ self._to_defaults = QPushButton('Revert to &defaults')
516
+ self._to_defaults.clicked.connect(self.set_default_options)
517
+ hbox.addWidget(self._to_defaults)
518
+ self._to_selected = QPushButton('&Apply to selected')
519
+ self._to_selected.clicked.connect(self._push_options)
520
+ hbox.addWidget(self._to_selected)
521
+ hbox.setContentsMargins(0, 0, 0, 0)
522
+
523
+ def _link_toggled(self, event):
524
+ if event:
525
+ self._link_ok = False
526
+ pitch = self.widgets[gcom.OPTION_PITCH].get()
527
+ self.widgets[gcom.OPTION_TEMPO].set(pitch)
528
+ self._link_ok = True
529
+
530
+ def _pitch_changed(self, event):
531
+ self.PitchChange.emit(event)
532
+ if self._link_ok and self._link.isChecked():
533
+ self._link_ok = False
534
+ self.widgets[gcom.OPTION_TEMPO].set(event)
535
+ self._link_ok = True
536
+
537
+ def _tempo_changed(self, event):
538
+ if self._link_ok and self._link.isChecked():
539
+ self._link_ok = False
540
+ self.widgets[gcom.OPTION_PITCH].set(event)
541
+ self._link_ok = True
542
+
543
+ def _pitch_up(self):
544
+ p = self.widgets[gcom.OPTION_PITCH].get_precise()
545
+ p *= com.cents_to_ratio(100)
546
+ self.widgets[gcom.OPTION_PITCH].set(p)
547
+
548
+ def _pitch_down(self):
549
+ p = self.widgets[gcom.OPTION_PITCH].get_precise()
550
+ p /= com.cents_to_ratio(100)
551
+ self.widgets[gcom.OPTION_PITCH].set(p)
552
+
553
+ def set_start(self, start):
554
+ """Set the value of the start time option.
555
+
556
+ Parameters
557
+ ----------
558
+ start : str
559
+ The start time.
560
+ """
561
+
562
+ if start is not None:
563
+ self.widgets[gcom.OPTION_START].set(f'{start:.3f}')
564
+
565
+ def set_end(self, end):
566
+ """Set the value of the end time option.
567
+
568
+ Parameters
569
+ ----------
570
+ end : str
571
+ The end time.
572
+ """
573
+
574
+ if end is not None:
575
+ self.widgets[gcom.OPTION_END].set(f'{end:.3f}')
576
+
577
+ def start_end_enable(self, enable):
578
+ """Enable or disable the buttons that set the start and end
579
+ values to the current player position.
580
+
581
+ Parameters
582
+ ----------
583
+ enable : bool
584
+ Enable if True, disable if False.
585
+ """
586
+
587
+ self.widgets[gcom.OPTION_START].unit.setEnabled(enable)
588
+ self.widgets[gcom.OPTION_END].unit.setEnabled(enable)
589
+
590
+ def ensure_visible(self):
591
+ """Trigger emission of the PayAttentionToMe signal."""
592
+
593
+ self.PayAttentionToMe.emit()
594
+
595
+ def set_options(self, options, force=False, is_defaults=False):
596
+ """Set all the widgets in the panel to the specified values,
597
+ unless the `Hold` checkbox is checked, in which case nothing
598
+ changes (however, a `PitchChange` signal is still emitted in
599
+ that case, to make sure anything that uses that signal updates
600
+ properly even when `Hold` is checked).
601
+
602
+ Parameters
603
+ ----------
604
+ options : audio_tuner_gui.common.Options
605
+ An Options object containing the values to set.
606
+ force : bool, optional
607
+ If True, ignore the `Hold` checkbox and set the options even
608
+ if it's checked. Default False.
609
+ is_defaults : bool, optional
610
+ If the options being set are the defaults, set this to True.
611
+ This causes the `default` attribute of the widgets to be set
612
+ in addition to the value, so that the widgets know how to
613
+ return themselves to the default setting if the user
614
+ requests it.
615
+ """
616
+
617
+ if force or not self._hold.isChecked():
618
+ for opt in options:
619
+ try:
620
+ self.widgets[opt].set(options[opt])
621
+ if is_defaults:
622
+ self.widgets[opt].default = options[opt]
623
+ except KeyError:
624
+ pass
625
+ else:
626
+ factor = self.widgets[gcom.OPTION_PITCH].get()
627
+ self.PitchChange.emit(factor)
628
+
629
+ def get_options(self) -> gcom.Options:
630
+ """Get the values currently set in the option widgets.
631
+
632
+ Returns
633
+ -------
634
+ audio_tuner_gui.common.Options
635
+ The values.
636
+ """
637
+
638
+ options = gcom.Options(self._args)
639
+ for title in self.widgets:
640
+ options[title] = self.widgets[title].get()
641
+ try:
642
+ self.widgets[gcom.OPTION_REF_NOTE].unset_error()
643
+ options.init_tuning_system()
644
+ except ValueError as err:
645
+ if err.args[0] == 'Invalid reference note':
646
+ self.widgets[gcom.OPTION_REF_NOTE].set_error()
647
+ options[gcom.OPTION_REF_NOTE] = gcom.ERROR_SENTINEL
648
+ for title in self.widgets:
649
+ if options[title] == gcom.ERROR_SENTINEL:
650
+ return None
651
+ return options
652
+
653
+ def _push_options(self):
654
+ options = self.get_options()
655
+ if options is not None:
656
+ self.PushOptions.emit(options, False)
657
+
658
+ def set_apply_enabled(self, enable):
659
+ """Enable or disable the `Apply to selected` button.
660
+
661
+ Parameters
662
+ ----------
663
+ enable : bool
664
+ Enable if True, disable if False.
665
+ """
666
+
667
+ self._to_selected.setEnabled(enable)
668
+ self._reread_button.setEnabled(enable)
669
+
670
+ def _reread(self):
671
+ options = self.get_options()
672
+ if options is not None:
673
+ options.reread_requested = True
674
+ self.PushOptions.emit(options, True)
675
+
676
+ def set_default_options(self):
677
+ """Set the widgets to the values passed in the constructor's
678
+ `args` parameter. This calls `set_options` with force=True and
679
+ is_defaults=True. It also sets the link button to the linked state.
680
+ """
681
+
682
+ self.set_options(self._default_options, force=True, is_defaults=True)
683
+ self._link.setChecked(True)