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.
- audio_tuner_gui/__init__.py +39 -0
- audio_tuner_gui/analysis.py +717 -0
- audio_tuner_gui/common.py +350 -0
- audio_tuner_gui/display.py +605 -0
- audio_tuner_gui/export.py +563 -0
- audio_tuner_gui/file_selector.py +435 -0
- audio_tuner_gui/icons/audio_tuner_icon.ico +0 -0
- audio_tuner_gui/icons/audio_tuner_icon.svg +215 -0
- audio_tuner_gui/icons/audio_tuner_icon_hires.svg +215 -0
- audio_tuner_gui/icons/audio_tuner_logo_dark.svg +563 -0
- audio_tuner_gui/icons/audio_tuner_logo_light.svg +563 -0
- audio_tuner_gui/icons/black-logo.svg +24 -0
- audio_tuner_gui/icons/folder.png +0 -0
- audio_tuner_gui/icons/gpl-v3-logo_red.svg +223 -0
- audio_tuner_gui/icons/gpl-v3-logo_white.svg +224 -0
- audio_tuner_gui/icons/preferences-other.png +0 -0
- audio_tuner_gui/icons/process-stop.png +0 -0
- audio_tuner_gui/icons/white-logo.svg +24 -0
- audio_tuner_gui/log_viewer.py +154 -0
- audio_tuner_gui/option_panel.py +683 -0
- audio_tuner_gui/player.py +757 -0
- audio_tuner_gui/scripts/__init__.py +0 -0
- audio_tuner_gui/scripts/tuner_gui.py +863 -0
- audio_tuner_gui-0.9.1.dist-info/METADATA +93 -0
- audio_tuner_gui-0.9.1.dist-info/RECORD +28 -0
- audio_tuner_gui-0.9.1.dist-info/WHEEL +4 -0
- audio_tuner_gui-0.9.1.dist-info/entry_points.txt +2 -0
- audio_tuner_gui-0.9.1.dist-info/licenses/COPYING +674 -0
|
@@ -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)
|