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,717 @@
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
+ """Asynchronous audio analysis for the GUI."""
22
+
23
+
24
+ __author__ = 'Jessie Blue Cassell'
25
+
26
+
27
+ __all__ = [
28
+ 'AnalyzedAudio',
29
+ ]
30
+
31
+
32
+ import os
33
+
34
+ from PyQt6.QtCore import (
35
+ Qt,
36
+ QDir,
37
+ QObject,
38
+ pyqtSignal,
39
+ QThread,
40
+ QPersistentModelIndex,
41
+ )
42
+ from PyQt6.QtWidgets import (
43
+ QWidget,
44
+ QVBoxLayout,
45
+ QHBoxLayout,
46
+ QToolButton,
47
+ QTableView,
48
+ QCheckBox,
49
+ )
50
+ from PyQt6.QtGui import (
51
+ QIcon,
52
+ QAction,
53
+ QStandardItemModel,
54
+ QStandardItem,
55
+ )
56
+
57
+ import audio_tuner.analysis as anal
58
+ import audio_tuner.error_handling as eh
59
+ import audio_tuner.common as com
60
+
61
+ import audio_tuner_gui.common as gcom
62
+ import audio_tuner_gui.log_viewer as lv
63
+
64
+
65
+ _FFMPEG_ERROR_SS = 'Invalid duration specification for ss'
66
+ _FFMPEG_ERROR_TO = 'Invalid duration specification for to'
67
+ _MPV_ERROR_START = 'Invalid value for mpv option: start'
68
+ _MPV_ERROR_END = 'Invalid value for mpv option: end'
69
+ _START_ERRORS = (_FFMPEG_ERROR_SS, _MPV_ERROR_START)
70
+ _END_ERRORS = (_FFMPEG_ERROR_TO, _MPV_ERROR_END)
71
+
72
+
73
+ class _AudioView(QTableView):
74
+ def __init__(self):
75
+ super().__init__()
76
+
77
+ self.verticalHeader().setSectionsMovable(True)
78
+ rowheight = int(self.fontMetrics().height() * 1.35)
79
+ self.verticalHeader().setDefaultSectionSize(rowheight)
80
+ self.horizontalHeader().setSectionsMovable(True)
81
+ self.setSelectionBehavior(QTableView.SelectionBehavior.SelectRows)
82
+ self.setSelectionMode(QTableView.SelectionMode.SingleSelection)
83
+ self.setTabKeyNavigation(False)
84
+ self.setShowGrid(False)
85
+
86
+
87
+ class _Worker(QObject):
88
+ """A worker class to be run in it's own thread that handles
89
+ analysis. Inherits from QObject.
90
+ """
91
+
92
+ UpdateStatusbar = pyqtSignal(str)
93
+ """Signal emitted to request a string be displayed on the status bar.
94
+
95
+ Parameters
96
+ ----------
97
+ str
98
+ The string.
99
+ """
100
+
101
+ UpdateProgbar = pyqtSignal(float)
102
+ """Signal emitted to request a progress bar update.
103
+
104
+ Parameters
105
+ ----------
106
+ float
107
+ The amount of progress, from 0 to 1.
108
+ """
109
+
110
+ ResultReady = pyqtSignal(anal.Analysis)
111
+ """Signal emitted when analysis is finished and the result is ready.
112
+
113
+ Parameters
114
+ ----------
115
+ audio_tuner.analysis.Analysis
116
+ The analysis object that has finished with it's analysis.
117
+ """
118
+
119
+ ProcessingError = pyqtSignal(anal.Analysis)
120
+ """Signal emitted when the analysis object raises an exception
121
+ during analysis.
122
+
123
+ Parameters
124
+ ----------
125
+ audio_tuner.analysis.Analysis
126
+ The analysis object.
127
+ """
128
+
129
+ AddToLog = pyqtSignal(str, int)
130
+ """Signal emitted to request a message be added to the log.
131
+
132
+ Parameters
133
+ ----------
134
+ str
135
+ The message.
136
+ int
137
+ The severity level as defined in audio_tuner_gui.log_viewer
138
+ (LOG_LEVEL_ERROR, LOG_LEVEL_WARNING or LOG_LEVEL_NORMAL).
139
+ """
140
+
141
+ OptionError = pyqtSignal(str)
142
+ """Signal emitted when an option is set to an invalid value.
143
+
144
+ Parameters
145
+ ----------
146
+ str
147
+ The name of the option, as defined in audio_tuner_gui.common.
148
+ """
149
+
150
+ def __init__(self):
151
+ super().__init__()
152
+
153
+ def _progress_check(self, progress: float) -> bool:
154
+ self.UpdateProgbar.emit(progress)
155
+ return not QThread.currentThread().isInterruptionRequested()
156
+
157
+ def _print_msg(self, msg, level=eh.NORMAL):
158
+ msg = msg.rstrip('\n')
159
+ if level in (eh.DEBUG, eh.NORMAL):
160
+ self.AddToLog.emit(msg, lv.LOG_LEVEL_NORMAL)
161
+ elif level == eh.WARNING:
162
+ self.AddToLog.emit(msg, lv.LOG_LEVEL_WARNING)
163
+ elif level == eh.ERROR:
164
+ self.AddToLog.emit(msg, lv.LOG_LEVEL_ERROR)
165
+ if any(x in msg for x in _START_ERRORS):
166
+ self.OptionError.emit(gcom.OPTION_START)
167
+ if any(x in msg for x in _END_ERRORS):
168
+ self.OptionError.emit(gcom.OPTION_END)
169
+
170
+ def analyze(self,
171
+ analysis: anal.Analysis,
172
+ options: gcom.Options,
173
+ reread_requested: bool) -> None:
174
+ """Do the analysis. This gets passed an instance of
175
+ audio_tuner.analysis.Analysis, and then spits it out again via
176
+ the ResultReady signal when it's done (or via the
177
+ ProcessingError signal if it went wrong). The Analysis instance
178
+ will have `result_rows` and `options` in addition to it's usual
179
+ attributes. `result_rows` is a list of
180
+ audio_tuner_gui.common.RowData objects that can be passed to the
181
+ `update_data` method of audio_tuner_gui.display.Display, and
182
+ `options` is the audio_tuner_gui.common.Options object that was
183
+ passed to the `options` parameter of this method.
184
+
185
+ Parameters
186
+ ----------
187
+ analysis : audio_tuner.analysis.Analysis
188
+ The Analysis instance.
189
+ options : audio_tuner_gui.common.Options
190
+ The analysis options.
191
+ reread_requested : bool
192
+ If True, forces a reread of the audio data with pitch and
193
+ tempo corrections applied.
194
+ """
195
+
196
+ if QThread.currentThread().isInterruptionRequested():
197
+ return
198
+ analysis.try_sequence = options[gcom.OPTION_BACKENDS]
199
+ analysis.print_msg = self._print_msg
200
+ if (reread_requested
201
+ and (analysis.pitch != options[gcom.OPTION_PITCH]
202
+ or analysis.tempo != options[gcom.OPTION_TEMPO])):
203
+ analysis.pitch = options[gcom.OPTION_PITCH]
204
+ analysis.tempo = options[gcom.OPTION_TEMPO]
205
+ redo_level = gcom.REDO_LEVEL_ALL
206
+ else:
207
+ redo_level = options.redo_level(getattr(analysis, 'options', None))
208
+ basename = os.path.basename(analysis.inputfile)
209
+ self.UpdateStatusbar.emit('Analyzing ' + basename + '...')
210
+ if redo_level >= gcom.REDO_LEVEL_ALL:
211
+ try:
212
+ s = '\nLoading "' + basename + '"'
213
+ self.AddToLog.emit(s, lv.LOG_LEVEL_NORMAL)
214
+ size = 2**options[gcom.OPTION_SIZE_EXP]
215
+ if options[gcom.OPTION_PAD]:
216
+ pad_amounts = (size * 2, size * 2)
217
+ else:
218
+ pad_amounts = None
219
+ analysis.load_data(start=options[gcom.OPTION_START],
220
+ end=options[gcom.OPTION_END],
221
+ samplerate=options[gcom.OPTION_SAMPLERATE],
222
+ pad=pad_amounts)
223
+ except eh.LoadError:
224
+ s = 'Error processing ' + basename
225
+ self.UpdateStatusbar.emit(s)
226
+ self.ProcessingError.emit(analysis)
227
+ return
228
+ try:
229
+ self.AddToLog.emit('Analyzing "' + basename + '"',
230
+ lv.LOG_LEVEL_NORMAL)
231
+ analysis.fft(size=size, progress_hook=self._progress_check)
232
+ except eh.ShortError:
233
+ s = 'Error processing ' + basename
234
+ self.UpdateStatusbar.emit(s)
235
+ self.ProcessingError.emit(analysis)
236
+ self.AddToLog.emit(eh.ERRMSG_SHORT,
237
+ lv.LOG_LEVEL_ERROR)
238
+ return
239
+ except eh.Interrupted:
240
+ self.UpdateStatusbar.emit('Processing aborted')
241
+ self.UpdateProgbar.emit(-1)
242
+ self.AddToLog.emit('Processing aborted',
243
+ lv.LOG_LEVEL_WARNING)
244
+ return
245
+ if redo_level >= gcom.REDO_LEVEL_FIND_PEAKS:
246
+ analysis.find_peaks(
247
+ low_cut=options[gcom.OPTION_LOW_CUT] * analysis.pitch,
248
+ high_cut=options[gcom.OPTION_HIGH_CUT] * analysis.pitch,
249
+ max_peaks=options[gcom.OPTION_MAX_PEAKS],
250
+ dB_range=options[gcom.OPTION_DB_RANGE])
251
+ if redo_level >= gcom.REDO_LEVEL_TUNING_SYSTEM:
252
+ peaks = analysis.peaks
253
+ tuning_system = options.tuning_system
254
+ result_rows = []
255
+ pitch_offset = options[gcom.OPTION_PITCH] / analysis.pitch
256
+ for note in tuning_system(
257
+ options[gcom.OPTION_LOW_CUT] * options[gcom.OPTION_PITCH],
258
+ options[gcom.OPTION_HIGH_CUT] * options[gcom.OPTION_PITCH]):
259
+ note_freq, note_name, band_bottom, band_top = note
260
+ for peak in [p[0] * pitch_offset for p in peaks]:
261
+ if peak > band_bottom and peak <= band_top:
262
+ result_row: gcom.RowData = {
263
+ 'note': note_name,
264
+ 'standard': note_freq,
265
+ 'measured': peak
266
+ }
267
+ freq_ratio = note_freq/peak
268
+ result_row['cents'] = -com.ratio_to_cents(freq_ratio)
269
+ result_row['correction'] = freq_ratio * pitch_offset
270
+ result_rows.append(result_row)
271
+ analysis.result_rows = result_rows
272
+
273
+ analysis.options = options
274
+
275
+ self.ResultReady.emit(analysis)
276
+ self.UpdateProgbar.emit(-1)
277
+ self.AddToLog.emit('Finished analyzing "' + basename + '"',
278
+ lv.LOG_LEVEL_NORMAL)
279
+
280
+
281
+ class AnalyzedAudio(QWidget):
282
+ """A widget that analyzes audio and stores the results. Inherits
283
+ from QWidget.
284
+
285
+ .. versionadded:: 0.8.1 current_listed_title
286
+
287
+ Attributes
288
+ ----------
289
+ current_title : str
290
+ The title of the currently selected audio, or None if none is
291
+ selected. This will be the filename if the file has no title
292
+ tag.
293
+ current_listed_title : str
294
+ The title as listed in the widget, or None if it's blank. The
295
+ only difference between this and `current_title` is that this
296
+ will not fall back to the filename if the file has no title tag.
297
+ current_selection : str
298
+ The canonical file path of the currently selected audio, or None
299
+ if none is selected.
300
+ current_options : audio_tuner_gui.common.Options
301
+ The settings used in the analysis of the currently selected
302
+ audio, or None if none is selected.
303
+ current_duration : float
304
+ The duration in seconds of the currently selected audio, or None
305
+ if none is selected.
306
+ current_metadata : dict
307
+ The tags of the currently selected audio, or None if none is
308
+ selected.
309
+ """
310
+
311
+ UpdateStatusbar = pyqtSignal(str)
312
+ """Signal emitted to request a string be displayed on the status bar.
313
+
314
+ Parameters
315
+ ----------
316
+ str
317
+ The string.
318
+ """
319
+
320
+ UpdateProgbar = pyqtSignal(float)
321
+ """Signal emitted to request a progress bar update.
322
+
323
+ Parameters
324
+ ----------
325
+ float
326
+ The amount of progress, from 0 to 1.
327
+ """
328
+
329
+ AnalyzeAudio = pyqtSignal(anal.Analysis, gcom.Options, bool)
330
+ """Signal emitted to tell the worker thread to run an analysis.
331
+
332
+ Parameters
333
+ ----------
334
+ audio_tuner.analysis.Analysis
335
+ The analysis object to use.
336
+ audio_tuner_gui.common.Options
337
+ The options.
338
+ bool
339
+ Whether to force a reread of the file with pitch and tempo
340
+ corrections.
341
+ """
342
+
343
+ DisplayResult = pyqtSignal(str, list)
344
+ """Signal emitted to request a display update.
345
+
346
+ Parameters
347
+ ----------
348
+ str
349
+ The name of the song.
350
+ list[audio_tuner_gui.common.RowData]
351
+ The row data for each row.
352
+ """
353
+
354
+ PushOptions = pyqtSignal(gcom.Options)
355
+ """Signal emitted when analyzed audio is selected so that the
356
+ options panel can be updated.
357
+
358
+ Parameters
359
+ ----------
360
+ audio_tuner_gui.common.Options
361
+ The options of the newly selected analysed audio.
362
+ """
363
+
364
+ Starting = pyqtSignal()
365
+ """Signal emitted when analysis is starting."""
366
+
367
+ Finished = pyqtSignal()
368
+ """Signal emitted when analysis is finished."""
369
+
370
+ AddToLog = pyqtSignal(str, int)
371
+ """Signal emitted to request a message be added to the log.
372
+
373
+ Parameters
374
+ ----------
375
+ str
376
+ The message.
377
+ int
378
+ The severity level as defined in audio_tuner_gui.log_viewer
379
+ (LOG_LEVEL_ERROR, LOG_LEVEL_WARNING or LOG_LEVEL_NORMAL).
380
+ """
381
+
382
+ OptionError = pyqtSignal(str)
383
+ """Signal emitted when an option is set to an invalid value.
384
+
385
+ Parameters
386
+ ----------
387
+ str
388
+ The name of the option, as defined in audio_tuner_gui.common.
389
+ """
390
+
391
+ SomethingSelected = pyqtSignal()
392
+ """Signal emitted when analyzed audio is selected."""
393
+
394
+ NothingSelected = pyqtSignal()
395
+ """Signal emitted when there's no longer anything selected."""
396
+
397
+
398
+ def __init__(self):
399
+ super().__init__()
400
+
401
+ self.current_title = None
402
+ self.current_listed_title = None
403
+ self.current_selection = None
404
+ self.current_options = None
405
+ self.current_duration = None
406
+ self.current_metadata = None
407
+
408
+ self._analysis = {}
409
+ self._in_progress = {}
410
+ self._cancelled = False
411
+
412
+ vbox = QVBoxLayout(self)
413
+ vbox.setSpacing(5)
414
+ vbox.setContentsMargins(3, 3, 3, 3)
415
+
416
+ self._init_audio_view()
417
+ self._init_control_panel()
418
+
419
+ vbox.addWidget(self._view)
420
+ vbox.addWidget(self._panel)
421
+
422
+ self._start_worker_thread()
423
+
424
+ def _start_worker_thread(self):
425
+ worker_thread = QThread(self)
426
+ self._worker_thread = worker_thread
427
+ self._worker = _Worker()
428
+ self._worker.moveToThread(worker_thread)
429
+ worker_thread.finished.connect(self._worker.deleteLater)
430
+ self.AnalyzeAudio.connect(self._worker.analyze,
431
+ Qt.ConnectionType.QueuedConnection)
432
+ self._worker.ResultReady.connect(self._handle_result,
433
+ Qt.ConnectionType.QueuedConnection)
434
+ self._worker.UpdateStatusbar.connect(self._update_statusbar,
435
+ Qt.ConnectionType.QueuedConnection)
436
+ self._worker.UpdateProgbar.connect(self._update_progbar,
437
+ Qt.ConnectionType.QueuedConnection)
438
+ self._worker.ProcessingError.connect(self._handle_error,
439
+ Qt.ConnectionType.QueuedConnection)
440
+ self._worker.AddToLog.connect(self._handle_log_message,
441
+ Qt.ConnectionType.QueuedConnection)
442
+ self._worker.OptionError.connect(self._handle_option_error,
443
+ Qt.ConnectionType.QueuedConnection)
444
+ worker_thread.start()
445
+
446
+ def _update_statusbar(self, string):
447
+ self.UpdateStatusbar.emit(string)
448
+
449
+ def _update_progbar(self, n):
450
+ self.UpdateProgbar.emit(n)
451
+
452
+ def _init_audio_view(self):
453
+ model = QStandardItemModel(0, 3, self)
454
+ headers = ('Trk', 'Title', 'Filename')
455
+ model.setHorizontalHeaderLabels(headers)
456
+
457
+ view = _AudioView()
458
+ view.setModel(model)
459
+
460
+ view.setColumnWidth(0, 20)
461
+
462
+ view.selectionModel().currentRowChanged.connect(self._audio_selected)
463
+ model.itemChanged.connect(self._item_changed)
464
+
465
+ self._model = model
466
+ self._view = view
467
+
468
+ def _init_control_panel(self):
469
+ panel = QWidget()
470
+ hbox = QHBoxLayout(panel)
471
+ hbox.setContentsMargins(0, 0, 0, 0)
472
+
473
+ remove_act = QAction(self)
474
+ remove_act.setIcon(QIcon.fromTheme(gcom.ICON_REMOVE))
475
+ remove_act.setStatusTip('Remove from list (Backspace)')
476
+ remove_act.setShortcut('Backspace')
477
+ remove_act.triggered.connect(self.remove_audio)
478
+
479
+ remove_button = QToolButton()
480
+ remove_button.setDefaultAction(remove_act)
481
+ hbox.addWidget(remove_button)
482
+
483
+ hbox.addStretch()
484
+
485
+ set_remove_enabled_switch = QCheckBox('Enable &removal', self)
486
+ set_remove_enabled_switch.setStatusTip(
487
+ 'Enable removing items from list')
488
+ set_remove_enabled_switch.setCheckState(Qt.CheckState.Unchecked)
489
+ set_remove_enabled_switch.stateChanged.connect(
490
+ self._set_removal_enabled)
491
+ hbox.addWidget(set_remove_enabled_switch)
492
+
493
+ self._panel = panel
494
+ self._remove_act = remove_act
495
+ self._remove_button = remove_button
496
+ self._set_removal_enabled(False)
497
+
498
+ def _audio_selected(self, new, prev):
499
+ title = None
500
+ item1 = self._model.item(new.row(), 1)
501
+ item2 = self._model.item(new.row(), 2)
502
+ if item1:
503
+ title = item1.text()
504
+ listed_title = title
505
+ if not title or title == ' ':
506
+ listed_title = None
507
+ if item2:
508
+ title = item2.text()
509
+ if title:
510
+ self.current_title = title
511
+ self.current_listed_title = listed_title
512
+ self.current_selection = item2.data()
513
+ self.current_options = self._analysis[item2.data()].options
514
+ self.current_duration = self._analysis[item2.data()].file_duration
515
+ self.current_metadata = self._analysis[item2.data()].file_metadata
516
+ self.DisplayResult.emit(title,
517
+ self._analysis[item2.data()].result_rows)
518
+ self.PushOptions.emit(self.current_options)
519
+ self.SomethingSelected.emit()
520
+
521
+ def _item_changed(self, item):
522
+ data = item.data()
523
+ if data is not None:
524
+ if item.text() == '':
525
+ self._model.itemChanged.disconnect(self._item_changed)
526
+ file_title = self._analysis[data].file_title
527
+ item.setText(file_title if file_title else ' ')
528
+ self._model.itemChanged.connect(self._item_changed)
529
+ if data == self.current_selection:
530
+ item1 = item
531
+ item2 = self._model.item(item.row(), 2)
532
+ if item1:
533
+ title = item1.text()
534
+ listed_title = title
535
+ if not title or title == ' ':
536
+ listed_title = None
537
+ if item2:
538
+ title = item2.text()
539
+ self.current_listed_title = listed_title
540
+ if title:
541
+ self.current_title = title
542
+ self.DisplayResult.emit(title,
543
+ self._analysis[item2.data()].result_rows)
544
+
545
+ def add_audio(self, path, options):
546
+ """Analyze an audio file asynchronously and add it to the list
547
+ of analyzed audio.
548
+
549
+ Parameters
550
+ ----------
551
+ path : str
552
+ The path of the audio file to analyze.
553
+ options : audio_tuner_gui.common.Options
554
+ Analysis options.
555
+ """
556
+
557
+ if QDir(path).exists():
558
+ return
559
+
560
+ canonical_path = os.path.realpath(path)
561
+ if canonical_path in self._in_progress:
562
+ return
563
+ if canonical_path in self._analysis:
564
+ index = self._analysis[canonical_path].index
565
+ self._view.selectRow(index.row())
566
+ return
567
+
568
+ if self._cancelled:
569
+ self._worker_thread.quit()
570
+ self._start_worker_thread()
571
+ self._cancelled = False
572
+
573
+ if len(self._in_progress) == 0:
574
+ self.Starting.emit()
575
+
576
+ self._in_progress[canonical_path] = True
577
+ analysis = anal.Analysis(canonical_path)
578
+ self.AnalyzeAudio.emit(analysis, options, False)
579
+
580
+ def change_options(self, new_options, reread_requested):
581
+ """Change the options of the currently selected analyzed audio.
582
+
583
+ Parameters
584
+ ----------
585
+ new_options : audio_tuner_gui.common.Options
586
+ The new options.
587
+ reread_requested : bool
588
+ If True, forces a reread of the audio data with pitch and
589
+ tempo corrections applied.
590
+ """
591
+
592
+ canonical_path = self.current_selection
593
+ if canonical_path is None or canonical_path in self._in_progress:
594
+ return
595
+
596
+ if self._cancelled:
597
+ self._worker_thread.quit()
598
+ self._start_worker_thread()
599
+ self._cancelled = False
600
+
601
+ if len(self._in_progress) == 0:
602
+ self.Starting.emit()
603
+
604
+ self._in_progress[canonical_path] = True
605
+ analysis = self._analysis[canonical_path]
606
+ self.AnalyzeAudio.emit(analysis, new_options, reread_requested)
607
+ self.current_options = new_options
608
+
609
+ def _handle_result(self, analysis):
610
+ canonical_path = analysis.inputfile
611
+ file_title = analysis.file_title
612
+ if canonical_path in self._analysis:
613
+ if canonical_path == self.current_selection:
614
+ self.DisplayResult.emit(self.current_title,
615
+ self._analysis[canonical_path].result_rows)
616
+ else:
617
+ file_track = analysis.file_track
618
+
619
+ track_num = QStandardItem(file_track.partition('/')[0]
620
+ if file_track
621
+ else ' ')
622
+ track_num.setData(None)
623
+ title = QStandardItem(file_title if file_title else ' ')
624
+ title.setData(canonical_path)
625
+ filename = QStandardItem(os.path.basename(canonical_path))
626
+ filename.setEditable(False)
627
+ filename.setData(canonical_path)
628
+
629
+ self._model.appendRow((track_num, title, filename))
630
+
631
+ index = QPersistentModelIndex(self._model.indexFromItem(filename))
632
+ self._model.setVerticalHeaderItem(index.row(), QStandardItem(' '))
633
+ analysis.index = index
634
+ self._analysis[canonical_path] = analysis
635
+
636
+ del self._in_progress[canonical_path]
637
+ if len(self._in_progress) == 0:
638
+ self.Finished.emit()
639
+ self.UpdateStatusbar.emit('Done')
640
+
641
+ self.show_plot('whatever', update=True)
642
+
643
+ def show_plot(self, plot_type, update=False):
644
+ """Open a new window with a plot of the spectrum. If the window
645
+ is already open, update the plot.
646
+
647
+ Parameters
648
+ ----------
649
+ plot_type : str
650
+ The type of plot. Valid types are 'log' and 'linear'.
651
+ update : bool, optional
652
+ If True, plots that are already being shown will be updated,
653
+ but no new plots will be shown. Default False.
654
+ """
655
+
656
+ canonical_path = self.current_selection
657
+ if canonical_path is not None:
658
+ pitch = self._analysis[canonical_path].options[gcom.OPTION_PITCH]
659
+ try:
660
+ self._analysis[canonical_path].show_plot(plot_type=plot_type,
661
+ asynchronous=True,
662
+ title=self.current_title,
663
+ pitch=pitch,
664
+ update=update)
665
+ except ModuleNotFoundError:
666
+ s = 'matplotlib not found.'
667
+ self.AddToLog.emit(s, lv.LOG_LEVEL_ERROR)
668
+
669
+ def _handle_log_message(self, message, level):
670
+ self.AddToLog.emit(message, level)
671
+
672
+ def _handle_error(self, analysis):
673
+ del self._in_progress[analysis.inputfile]
674
+ if len(self._in_progress) == 0:
675
+ self.Finished.emit()
676
+
677
+ def _handle_option_error(self, option):
678
+ self.OptionError.emit(option)
679
+
680
+ def _set_removal_enabled(self, enabled):
681
+ self._remove_act.setEnabled(enabled)
682
+ if enabled:
683
+ self._remove_button.setAutoRaise(False)
684
+ else:
685
+ self._remove_button.setAutoRaise(True)
686
+
687
+ def remove_audio(self):
688
+ """Remove the currently selected analyzed audio from the list."""
689
+
690
+ try:
691
+ row = self._view.selectedIndexes()[0].row()
692
+ except IndexError:
693
+ return
694
+ path = self._model.item(row, 2).data()
695
+ del self._analysis[path]
696
+ self._model.removeRows(row, 1)
697
+ if len(self._analysis) == 0:
698
+ self.DisplayResult.emit(' ', [])
699
+ self.NothingSelected.emit()
700
+ self.current_title = None
701
+ self.current_listed_title = None
702
+ self.current_selection = None
703
+ self.current_options = None
704
+ self.current_duration = None
705
+ self.current_metadata = None
706
+
707
+ def cancel(self):
708
+ """Cancel analysis."""
709
+
710
+ self._worker_thread.requestInterruption()
711
+ self._cancelled = True
712
+ self._in_progress = {}
713
+ self.Finished.emit()
714
+
715
+ def closeEvent(self, event):
716
+ self._worker_thread.quit()
717
+ super().closeEvent(event)