audio-tuner-gui 0.9.1__tar.gz → 0.11.0__tar.gz

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.
Files changed (28) hide show
  1. {audio_tuner_gui-0.9.1 → audio_tuner_gui-0.11.0}/PKG-INFO +21 -15
  2. {audio_tuner_gui-0.9.1 → audio_tuner_gui-0.11.0}/README.rst +18 -13
  3. {audio_tuner_gui-0.9.1 → audio_tuner_gui-0.11.0}/audio_tuner_gui/__init__.py +1 -1
  4. {audio_tuner_gui-0.9.1 → audio_tuner_gui-0.11.0}/audio_tuner_gui/analysis.py +107 -81
  5. {audio_tuner_gui-0.9.1 → audio_tuner_gui-0.11.0}/audio_tuner_gui/common.py +73 -18
  6. {audio_tuner_gui-0.9.1 → audio_tuner_gui-0.11.0}/audio_tuner_gui/file_selector.py +71 -21
  7. {audio_tuner_gui-0.9.1 → audio_tuner_gui-0.11.0}/audio_tuner_gui/option_panel.py +49 -21
  8. {audio_tuner_gui-0.9.1 → audio_tuner_gui-0.11.0}/audio_tuner_gui/scripts/tuner_gui.py +126 -13
  9. {audio_tuner_gui-0.9.1 → audio_tuner_gui-0.11.0}/pyproject.toml +2 -1
  10. {audio_tuner_gui-0.9.1 → audio_tuner_gui-0.11.0}/.gitignore +0 -0
  11. {audio_tuner_gui-0.9.1 → audio_tuner_gui-0.11.0}/COPYING +0 -0
  12. {audio_tuner_gui-0.9.1 → audio_tuner_gui-0.11.0}/audio_tuner_gui/display.py +0 -0
  13. {audio_tuner_gui-0.9.1 → audio_tuner_gui-0.11.0}/audio_tuner_gui/export.py +0 -0
  14. {audio_tuner_gui-0.9.1 → audio_tuner_gui-0.11.0}/audio_tuner_gui/icons/audio_tuner_icon.ico +0 -0
  15. {audio_tuner_gui-0.9.1 → audio_tuner_gui-0.11.0}/audio_tuner_gui/icons/audio_tuner_icon.svg +0 -0
  16. {audio_tuner_gui-0.9.1 → audio_tuner_gui-0.11.0}/audio_tuner_gui/icons/audio_tuner_icon_hires.svg +0 -0
  17. {audio_tuner_gui-0.9.1 → audio_tuner_gui-0.11.0}/audio_tuner_gui/icons/audio_tuner_logo_dark.svg +0 -0
  18. {audio_tuner_gui-0.9.1 → audio_tuner_gui-0.11.0}/audio_tuner_gui/icons/audio_tuner_logo_light.svg +0 -0
  19. {audio_tuner_gui-0.9.1 → audio_tuner_gui-0.11.0}/audio_tuner_gui/icons/black-logo.svg +0 -0
  20. {audio_tuner_gui-0.9.1 → audio_tuner_gui-0.11.0}/audio_tuner_gui/icons/folder.png +0 -0
  21. {audio_tuner_gui-0.9.1 → audio_tuner_gui-0.11.0}/audio_tuner_gui/icons/gpl-v3-logo_red.svg +0 -0
  22. {audio_tuner_gui-0.9.1 → audio_tuner_gui-0.11.0}/audio_tuner_gui/icons/gpl-v3-logo_white.svg +0 -0
  23. {audio_tuner_gui-0.9.1 → audio_tuner_gui-0.11.0}/audio_tuner_gui/icons/preferences-other.png +0 -0
  24. {audio_tuner_gui-0.9.1 → audio_tuner_gui-0.11.0}/audio_tuner_gui/icons/process-stop.png +0 -0
  25. {audio_tuner_gui-0.9.1 → audio_tuner_gui-0.11.0}/audio_tuner_gui/icons/white-logo.svg +0 -0
  26. {audio_tuner_gui-0.9.1 → audio_tuner_gui-0.11.0}/audio_tuner_gui/log_viewer.py +0 -0
  27. {audio_tuner_gui-0.9.1 → audio_tuner_gui-0.11.0}/audio_tuner_gui/player.py +0 -0
  28. {audio_tuner_gui-0.9.1 → audio_tuner_gui-0.11.0}/audio_tuner_gui/scripts/__init__.py +0 -0
@@ -1,11 +1,12 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: audio-tuner-gui
3
- Version: 0.9.1
3
+ Version: 0.11.0
4
4
  Summary: Graphical interface for Audio Tuner
5
5
  Project-URL: Homepage, https://codeberg.org/bluesloth/audio_tuner
6
6
  Project-URL: Repository, https://codeberg.org/bluesloth/audio_tuner.git
7
7
  Project-URL: Issues, https://codeberg.org/bluesloth/audio_tuner/issues
8
- Project-URL: Documentation, https://audio-tuner.readthedocs.io/en/latest/
8
+ Project-URL: Documentation, https://audio-tuner.readthedocs.io/
9
+ Project-URL: News, https://codeberg.org/bluesloth/audio_tuner/src/branch/master/NEWS
9
10
  Author-email: Jessie Blue Cassell <bluesloth600@gmail.com>
10
11
  License-File: COPYING
11
12
  Keywords: audio,music,pitch shift,player,tempo shift,tuning
@@ -48,6 +49,23 @@ What it's not
48
49
  Audio Tuner is not autotune. It pitch corrects entire songs, not individual
49
50
  notes.
50
51
 
52
+ Dependencies
53
+ ~~~~~~~~~~~~
54
+
55
+ **audio-tuner-gui** requires **Python version 3.11 or higher**, **libmpv2**,
56
+ and the `audio-tuner`_ and `PyQt6`_ python packages.
57
+
58
+ Note that pip is not able to install **libmpv2**. If your operating system
59
+ has a package manager, use that to install it. See the `documentation`_
60
+ for details.
61
+
62
+ Audio Tuner can be configured to use **ffmpeg** and **ffprobe** instead of
63
+ libmpv2, at the cost of reduced functionality in the GUI.
64
+
65
+ .. _documentation: https://audio-tuner.readthedocs.io/
66
+ .. _audio-tuner: https://pypi.org/project/audio-tuner/
67
+ .. _PyQt6: https://www.riverbankcomputing.com/software/pyqt/
68
+
51
69
  Free as in Freedom
52
70
  ~~~~~~~~~~~~~~~~~~
53
71
 
@@ -75,19 +93,7 @@ stable, but of course nothing's guaranteed until it reaches version 1.0.0.
75
93
  .. _Semantic Versioning: https://semver.org
76
94
  .. _PEP 440: https://peps.python.org/pep-0440/
77
95
 
78
- Dependencies
79
- ~~~~~~~~~~~~
80
-
81
- **audio-tuner-gui** requires **Python version 3.11 or higher**, **libmpv2**,
82
- and the **audio-tuner** and **PyQt6** python packages.
83
-
84
- Audio Tuner can be configured to use **ffmpeg** and **ffprobe** instead of
85
- libmpv2, at the cost of reduced functionality in the GUI.
86
-
87
96
  See Also
88
97
  ~~~~~~~~
89
98
 
90
- See the `html documentation`_ for more information and detailed installation
91
- instructions.
92
-
93
- .. _html documentation: https://audio-tuner.readthedocs.io/en/latest/
99
+ audio-tuner: https://pypi.org/project/audio-tuner/
@@ -28,6 +28,23 @@ What it's not
28
28
  Audio Tuner is not autotune. It pitch corrects entire songs, not individual
29
29
  notes.
30
30
 
31
+ Dependencies
32
+ ~~~~~~~~~~~~
33
+
34
+ **audio-tuner-gui** requires **Python version 3.11 or higher**, **libmpv2**,
35
+ and the `audio-tuner`_ and `PyQt6`_ python packages.
36
+
37
+ Note that pip is not able to install **libmpv2**. If your operating system
38
+ has a package manager, use that to install it. See the `documentation`_
39
+ for details.
40
+
41
+ Audio Tuner can be configured to use **ffmpeg** and **ffprobe** instead of
42
+ libmpv2, at the cost of reduced functionality in the GUI.
43
+
44
+ .. _documentation: https://audio-tuner.readthedocs.io/
45
+ .. _audio-tuner: https://pypi.org/project/audio-tuner/
46
+ .. _PyQt6: https://www.riverbankcomputing.com/software/pyqt/
47
+
31
48
  Free as in Freedom
32
49
  ~~~~~~~~~~~~~~~~~~
33
50
 
@@ -55,19 +72,7 @@ stable, but of course nothing's guaranteed until it reaches version 1.0.0.
55
72
  .. _Semantic Versioning: https://semver.org
56
73
  .. _PEP 440: https://peps.python.org/pep-0440/
57
74
 
58
- Dependencies
59
- ~~~~~~~~~~~~
60
-
61
- **audio-tuner-gui** requires **Python version 3.11 or higher**, **libmpv2**,
62
- and the **audio-tuner** and **PyQt6** python packages.
63
-
64
- Audio Tuner can be configured to use **ffmpeg** and **ffprobe** instead of
65
- libmpv2, at the cost of reduced functionality in the GUI.
66
-
67
75
  See Also
68
76
  ~~~~~~~~
69
77
 
70
- See the `html documentation`_ for more information and detailed installation
71
- instructions.
72
-
73
- .. _html documentation: https://audio-tuner.readthedocs.io/en/latest/
78
+ audio-tuner: https://pypi.org/project/audio-tuner/
@@ -32,7 +32,7 @@ __all__ = [
32
32
  # Release candidates: X.Y.ZrcN
33
33
  # Releases: X.Y.Z
34
34
 
35
- VERSION = '0.9.1'
35
+ VERSION = '0.11.0'
36
36
 
37
37
  from os.path import dirname
38
38
 
@@ -38,6 +38,7 @@ from PyQt6.QtCore import (
38
38
  pyqtSignal,
39
39
  QThread,
40
40
  QPersistentModelIndex,
41
+ QModelIndex,
41
42
  )
42
43
  from PyQt6.QtWidgets import (
43
44
  QWidget,
@@ -84,6 +85,32 @@ class _AudioView(QTableView):
84
85
  self.setShowGrid(False)
85
86
 
86
87
 
88
+ class _Result(anal.Analysis):
89
+ """A class that inherits from audio_tuner.analysis.Analysis and
90
+ extends it with a few more attributes necessary for the GUI.
91
+
92
+ Attributes
93
+ ----------
94
+ result_rows : list[audio_tuner_gui.common.RowData] | None
95
+ A list of RowData objects that can be passed to the
96
+ `update_data` method of audio_tuner_gui.display.Display, or None
97
+ if it hasn't been set yet.
98
+ options : audio_tuner_gui.common.Options | None
99
+ The options used to do the analysis, or None if it hasn't been
100
+ set yet.
101
+ index : PyQt6.QtCore.QPersistentModelIndex | None
102
+ The index of the results entry in the analyzed audio widget, or
103
+ None if it hasn't been set yet.
104
+ """
105
+
106
+ def __init__(self, *args, **kwargs):
107
+ super().__init__(*args, **kwargs)
108
+
109
+ self.result_rows = None
110
+ self.options = None
111
+ self.index = None
112
+
113
+
87
114
  class _Worker(QObject):
88
115
  """A worker class to be run in it's own thread that handles
89
116
  analysis. Inherits from QObject.
@@ -107,23 +134,23 @@ class _Worker(QObject):
107
134
  The amount of progress, from 0 to 1.
108
135
  """
109
136
 
110
- ResultReady = pyqtSignal(anal.Analysis)
137
+ ResultReady = pyqtSignal(_Result)
111
138
  """Signal emitted when analysis is finished and the result is ready.
112
139
 
113
140
  Parameters
114
141
  ----------
115
- audio_tuner.analysis.Analysis
116
- The analysis object that has finished with it's analysis.
142
+ _Result
143
+ The result of the analysis.
117
144
  """
118
145
 
119
- ProcessingError = pyqtSignal(anal.Analysis)
120
- """Signal emitted when the analysis object raises an exception
121
- during analysis.
146
+ ProcessingError = pyqtSignal(_Result)
147
+ """Signal emitted when the _Result object raises an exception during
148
+ analysis.
122
149
 
123
150
  Parameters
124
151
  ----------
125
- audio_tuner.analysis.Analysis
126
- The analysis object.
152
+ _Result
153
+ The _Result object.
127
154
  """
128
155
 
129
156
  AddToLog = pyqtSignal(str, int)
@@ -147,9 +174,6 @@ class _Worker(QObject):
147
174
  The name of the option, as defined in audio_tuner_gui.common.
148
175
  """
149
176
 
150
- def __init__(self):
151
- super().__init__()
152
-
153
177
  def _progress_check(self, progress: float) -> bool:
154
178
  self.UpdateProgbar.emit(progress)
155
179
  return not QThread.currentThread().isInterruptionRequested()
@@ -168,24 +192,17 @@ class _Worker(QObject):
168
192
  self.OptionError.emit(gcom.OPTION_END)
169
193
 
170
194
  def analyze(self,
171
- analysis: anal.Analysis,
195
+ result: _Result,
172
196
  options: gcom.Options,
173
197
  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.
198
+ """Do the analysis. This gets passed an instance of _Result,
199
+ and then spits it out again via the ResultReady signal when it's
200
+ done (or via the ProcessingError signal if it went wrong).
184
201
 
185
202
  Parameters
186
203
  ----------
187
- analysis : audio_tuner.analysis.Analysis
188
- The Analysis instance.
204
+ result : _Result
205
+ The _Result instance to put the results in.
189
206
  options : audio_tuner_gui.common.Options
190
207
  The analysis options.
191
208
  reread_requested : bool
@@ -195,44 +212,41 @@ class _Worker(QObject):
195
212
 
196
213
  if QThread.currentThread().isInterruptionRequested():
197
214
  return
198
- analysis.try_sequence = options[gcom.OPTION_BACKENDS]
199
- analysis.print_msg = self._print_msg
215
+ result.try_sequence = options[gcom.OPTION_BACKENDS]
216
+ result.print_msg = self._print_msg
200
217
  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]
218
+ and (result.pitch != options[gcom.OPTION_PITCH]
219
+ or result.tempo != options[gcom.OPTION_TEMPO])):
220
+ result.pitch = options[gcom.OPTION_PITCH]
221
+ result.tempo = options[gcom.OPTION_TEMPO]
205
222
  redo_level = gcom.REDO_LEVEL_ALL
206
223
  else:
207
- redo_level = options.redo_level(getattr(analysis, 'options', None))
208
- basename = os.path.basename(analysis.inputfile)
224
+ redo_level = options.redo_level(result.options)
225
+ basename = os.path.basename(result.inputfile)
209
226
  self.UpdateStatusbar.emit('Analyzing ' + basename + '...')
210
227
  if redo_level >= gcom.REDO_LEVEL_ALL:
211
228
  try:
212
229
  s = '\nLoading "' + basename + '"'
213
230
  self.AddToLog.emit(s, lv.LOG_LEVEL_NORMAL)
214
231
  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)
232
+ pad_amounts = (size * 2, size * 2)
233
+ result.load_data(start=options[gcom.OPTION_START],
234
+ end=options[gcom.OPTION_END],
235
+ samplerate=options[gcom.OPTION_SAMPLERATE],
236
+ pad=pad_amounts)
223
237
  except eh.LoadError:
224
238
  s = 'Error processing ' + basename
225
239
  self.UpdateStatusbar.emit(s)
226
- self.ProcessingError.emit(analysis)
240
+ self.ProcessingError.emit(result)
227
241
  return
228
242
  try:
229
243
  self.AddToLog.emit('Analyzing "' + basename + '"',
230
244
  lv.LOG_LEVEL_NORMAL)
231
- analysis.fft(size=size, progress_hook=self._progress_check)
245
+ result.fft(size=size, progress_hook=self._progress_check)
232
246
  except eh.ShortError:
233
247
  s = 'Error processing ' + basename
234
248
  self.UpdateStatusbar.emit(s)
235
- self.ProcessingError.emit(analysis)
249
+ self.ProcessingError.emit(result)
236
250
  self.AddToLog.emit(eh.ERRMSG_SHORT,
237
251
  lv.LOG_LEVEL_ERROR)
238
252
  return
@@ -243,16 +257,16 @@ class _Worker(QObject):
243
257
  lv.LOG_LEVEL_WARNING)
244
258
  return
245
259
  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,
260
+ result.find_peaks(
261
+ low_cut=options[gcom.OPTION_LOW_CUT] * result.pitch,
262
+ high_cut=options[gcom.OPTION_HIGH_CUT] * result.pitch,
249
263
  max_peaks=options[gcom.OPTION_MAX_PEAKS],
250
264
  dB_range=options[gcom.OPTION_DB_RANGE])
251
265
  if redo_level >= gcom.REDO_LEVEL_TUNING_SYSTEM:
252
- peaks = analysis.peaks
266
+ peaks = result.peaks
253
267
  tuning_system = options.tuning_system
254
268
  result_rows = []
255
- pitch_offset = options[gcom.OPTION_PITCH] / analysis.pitch
269
+ pitch_offset = options[gcom.OPTION_PITCH] / result.pitch
256
270
  for note in tuning_system(
257
271
  options[gcom.OPTION_LOW_CUT] * options[gcom.OPTION_PITCH],
258
272
  options[gcom.OPTION_HIGH_CUT] * options[gcom.OPTION_PITCH]):
@@ -268,11 +282,11 @@ class _Worker(QObject):
268
282
  result_row['cents'] = -com.ratio_to_cents(freq_ratio)
269
283
  result_row['correction'] = freq_ratio * pitch_offset
270
284
  result_rows.append(result_row)
271
- analysis.result_rows = result_rows
285
+ result.result_rows = result_rows
272
286
 
273
- analysis.options = options
287
+ result.options = options
274
288
 
275
- self.ResultReady.emit(analysis)
289
+ self.ResultReady.emit(result)
276
290
  self.UpdateProgbar.emit(-1)
277
291
  self.AddToLog.emit('Finished analyzing "' + basename + '"',
278
292
  lv.LOG_LEVEL_NORMAL)
@@ -326,13 +340,13 @@ class AnalyzedAudio(QWidget):
326
340
  The amount of progress, from 0 to 1.
327
341
  """
328
342
 
329
- AnalyzeAudio = pyqtSignal(anal.Analysis, gcom.Options, bool)
343
+ AnalyzeAudio = pyqtSignal(_Result, gcom.Options, bool)
330
344
  """Signal emitted to tell the worker thread to run an analysis.
331
345
 
332
346
  Parameters
333
347
  ----------
334
- audio_tuner.analysis.Analysis
335
- The analysis object to use.
348
+ _Result
349
+ The _Result object to use.
336
350
  audio_tuner_gui.common.Options
337
351
  The options.
338
352
  bool
@@ -405,7 +419,7 @@ class AnalyzedAudio(QWidget):
405
419
  self.current_duration = None
406
420
  self.current_metadata = None
407
421
 
408
- self._analysis = {}
422
+ self._results = {}
409
423
  self._in_progress = {}
410
424
  self._cancelled = False
411
425
 
@@ -460,6 +474,7 @@ class AnalyzedAudio(QWidget):
460
474
  view.setColumnWidth(0, 20)
461
475
 
462
476
  view.selectionModel().currentRowChanged.connect(self._audio_selected)
477
+ view.clicked.connect(self._clicked)
463
478
  model.itemChanged.connect(self._item_changed)
464
479
 
465
480
  self._model = model
@@ -495,6 +510,14 @@ class AnalyzedAudio(QWidget):
495
510
  self._remove_button = remove_button
496
511
  self._set_removal_enabled(False)
497
512
 
513
+ def _clicked(self, index: QModelIndex):
514
+ item2 = self._model.item(index.row(), 2)
515
+ if item2:
516
+ options = self._results[item2.data()].options
517
+ # FIXME: PushOptions gets emitted twice when the user
518
+ # changes the selection by clicking.
519
+ self.PushOptions.emit(options)
520
+
498
521
  def _audio_selected(self, new, prev):
499
522
  title = None
500
523
  item1 = self._model.item(new.row(), 1)
@@ -510,11 +533,11 @@ class AnalyzedAudio(QWidget):
510
533
  self.current_title = title
511
534
  self.current_listed_title = listed_title
512
535
  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
536
+ self.current_options = self._results[item2.data()].options
537
+ self.current_duration = self._results[item2.data()].file_duration
538
+ self.current_metadata = self._results[item2.data()].file_metadata
516
539
  self.DisplayResult.emit(title,
517
- self._analysis[item2.data()].result_rows)
540
+ self._results[item2.data()].result_rows)
518
541
  self.PushOptions.emit(self.current_options)
519
542
  self.SomethingSelected.emit()
520
543
 
@@ -523,7 +546,7 @@ class AnalyzedAudio(QWidget):
523
546
  if data is not None:
524
547
  if item.text() == '':
525
548
  self._model.itemChanged.disconnect(self._item_changed)
526
- file_title = self._analysis[data].file_title
549
+ file_title = self._results[data].file_title
527
550
  item.setText(file_title if file_title else ' ')
528
551
  self._model.itemChanged.connect(self._item_changed)
529
552
  if data == self.current_selection:
@@ -540,7 +563,7 @@ class AnalyzedAudio(QWidget):
540
563
  if title:
541
564
  self.current_title = title
542
565
  self.DisplayResult.emit(title,
543
- self._analysis[item2.data()].result_rows)
566
+ self._results[item2.data()].result_rows)
544
567
 
545
568
  def add_audio(self, path, options):
546
569
  """Analyze an audio file asynchronously and add it to the list
@@ -560,8 +583,8 @@ class AnalyzedAudio(QWidget):
560
583
  canonical_path = os.path.realpath(path)
561
584
  if canonical_path in self._in_progress:
562
585
  return
563
- if canonical_path in self._analysis:
564
- index = self._analysis[canonical_path].index
586
+ if canonical_path in self._results:
587
+ index = self._results[canonical_path].index
565
588
  self._view.selectRow(index.row())
566
589
  return
567
590
 
@@ -574,8 +597,11 @@ class AnalyzedAudio(QWidget):
574
597
  self.Starting.emit()
575
598
 
576
599
  self._in_progress[canonical_path] = True
577
- analysis = anal.Analysis(canonical_path)
578
- self.AnalyzeAudio.emit(analysis, options, False)
600
+
601
+ # This is a misnomer at this point because there's no results in
602
+ # "result" yet, but that will soon change.
603
+ result = _Result(canonical_path)
604
+ self.AnalyzeAudio.emit(result, options, False)
579
605
 
580
606
  def change_options(self, new_options, reread_requested):
581
607
  """Change the options of the currently selected analyzed audio.
@@ -602,19 +628,19 @@ class AnalyzedAudio(QWidget):
602
628
  self.Starting.emit()
603
629
 
604
630
  self._in_progress[canonical_path] = True
605
- analysis = self._analysis[canonical_path]
606
- self.AnalyzeAudio.emit(analysis, new_options, reread_requested)
631
+ result = self._results[canonical_path]
632
+ self.AnalyzeAudio.emit(result, new_options, reread_requested)
607
633
  self.current_options = new_options
608
634
 
609
- def _handle_result(self, analysis):
610
- canonical_path = analysis.inputfile
611
- file_title = analysis.file_title
612
- if canonical_path in self._analysis:
635
+ def _handle_result(self, result):
636
+ canonical_path = result.inputfile
637
+ file_title = result.file_title
638
+ if canonical_path in self._results:
613
639
  if canonical_path == self.current_selection:
614
640
  self.DisplayResult.emit(self.current_title,
615
- self._analysis[canonical_path].result_rows)
641
+ self._results[canonical_path].result_rows)
616
642
  else:
617
- file_track = analysis.file_track
643
+ file_track = result.file_track
618
644
 
619
645
  track_num = QStandardItem(file_track.partition('/')[0]
620
646
  if file_track
@@ -630,8 +656,8 @@ class AnalyzedAudio(QWidget):
630
656
 
631
657
  index = QPersistentModelIndex(self._model.indexFromItem(filename))
632
658
  self._model.setVerticalHeaderItem(index.row(), QStandardItem(' '))
633
- analysis.index = index
634
- self._analysis[canonical_path] = analysis
659
+ result.index = index
660
+ self._results[canonical_path] = result
635
661
 
636
662
  del self._in_progress[canonical_path]
637
663
  if len(self._in_progress) == 0:
@@ -655,9 +681,9 @@ class AnalyzedAudio(QWidget):
655
681
 
656
682
  canonical_path = self.current_selection
657
683
  if canonical_path is not None:
658
- pitch = self._analysis[canonical_path].options[gcom.OPTION_PITCH]
684
+ pitch = self._results[canonical_path].options[gcom.OPTION_PITCH]
659
685
  try:
660
- self._analysis[canonical_path].show_plot(plot_type=plot_type,
686
+ self._results[canonical_path].show_plot(plot_type=plot_type,
661
687
  asynchronous=True,
662
688
  title=self.current_title,
663
689
  pitch=pitch,
@@ -669,8 +695,8 @@ class AnalyzedAudio(QWidget):
669
695
  def _handle_log_message(self, message, level):
670
696
  self.AddToLog.emit(message, level)
671
697
 
672
- def _handle_error(self, analysis):
673
- del self._in_progress[analysis.inputfile]
698
+ def _handle_error(self, result):
699
+ del self._in_progress[result.inputfile]
674
700
  if len(self._in_progress) == 0:
675
701
  self.Finished.emit()
676
702
 
@@ -692,9 +718,9 @@ class AnalyzedAudio(QWidget):
692
718
  except IndexError:
693
719
  return
694
720
  path = self._model.item(row, 2).data()
695
- del self._analysis[path]
721
+ del self._results[path]
696
722
  self._model.removeRows(row, 1)
697
- if len(self._analysis) == 0:
723
+ if len(self._results) == 0:
698
724
  self.DisplayResult.emit(' ', [])
699
725
  self.NothingSelected.emit()
700
726
  self.current_title = None
@@ -34,7 +34,6 @@ __all__ = [
34
34
  'OPTION_HIGH_CUT',
35
35
  'OPTION_DB_RANGE',
36
36
  'OPTION_MAX_PEAKS',
37
- 'OPTION_PAD',
38
37
  'OPTION_SIZE_EXP',
39
38
  'OPTION_SAMPLERATE',
40
39
  'OPTION_PITCH',
@@ -65,6 +64,8 @@ __all__ = [
65
64
  'ICON_PLAYER_BACK',
66
65
  'ICON_PLAYER_FORWARD',
67
66
  'ICON_AUDIO_DEVICE',
67
+ 'ICON_SAVE',
68
+ 'ICON_DEL',
68
69
  'RowData',
69
70
  'SplitAction',
70
71
  'Options',
@@ -73,6 +74,7 @@ __all__ = [
73
74
 
74
75
  import os
75
76
  from typing import TypedDict
77
+ from argparse import Namespace
76
78
 
77
79
  from PyQt6.QtGui import (
78
80
  QIcon,
@@ -94,7 +96,6 @@ OPTION_LOW_CUT = 'Low cut'
94
96
  OPTION_HIGH_CUT = 'High cut'
95
97
  OPTION_DB_RANGE = 'dB range'
96
98
  OPTION_MAX_PEAKS = 'Max peaks'
97
- OPTION_PAD = 'Pad input'
98
99
  OPTION_SIZE_EXP = 'FFT size exponent'
99
100
  OPTION_SAMPLERATE = 'Sample rate'
100
101
  OPTION_BACKENDS = 'backends'
@@ -145,6 +146,8 @@ try:
145
146
  ICON_PLAYER_BACK = QIcon.ThemeIcon.MediaSeekBackward
146
147
  ICON_PLAYER_FORWARD = QIcon.ThemeIcon.MediaSeekForward
147
148
  ICON_AUDIO_DEVICE = QIcon.ThemeIcon.AudioCard
149
+ ICON_SAVE = QIcon.ThemeIcon.DocumentSave
150
+ ICON_DEL = QIcon.ThemeIcon.EditDelete
148
151
  except AttributeError:
149
152
  ICON_BACK = 'go-previous'
150
153
  ICON_FORWARD = 'go-next'
@@ -166,6 +169,8 @@ except AttributeError:
166
169
  ICON_PLAYER_BACK = 'media-seek-backward'
167
170
  ICON_PLAYER_FORWARD = 'media-seek-forward'
168
171
  ICON_AUDIO_DEVICE = 'audio-card'
172
+ ICON_SAVE = 'document-save'
173
+ ICON_DEL = 'edit-delete'
169
174
 
170
175
 
171
176
  class RowData(TypedDict):
@@ -275,25 +280,76 @@ class Options(dict):
275
280
 
276
281
  def __init__(self, args):
277
282
  super().__init__()
278
- self[OPTION_TUNING_SYSTEM] = self._guify_tuning_system(args.tuning)
279
- self[OPTION_REF_FREQ] = args.ref_freq
280
- self[OPTION_REF_NOTE] = args.ref_note
281
- self[OPTION_START] = args.start
282
- self[OPTION_END] = args.end
283
- self[OPTION_LOW_CUT] = args.low_cut
284
- self[OPTION_HIGH_CUT] = args.high_cut
285
- self[OPTION_DB_RANGE] = args.dB_range
286
- self[OPTION_MAX_PEAKS] = args.max_peaks
287
- self[OPTION_PAD] = not args.nopad
288
- self[OPTION_SIZE_EXP] = args.size_exp
289
- self[OPTION_SAMPLERATE] = args.samplerate
290
- self[OPTION_BACKENDS] = args.backends
291
- self[OPTION_PITCH] = 1.0
292
- self[OPTION_TEMPO] = 1.0
283
+
284
+ self._options_and_args = (
285
+ (OPTION_TUNING_SYSTEM, 'tuning'),
286
+ (OPTION_REF_FREQ, 'ref_freq'),
287
+ (OPTION_REF_NOTE, 'ref_note'),
288
+ (OPTION_START, 'start'),
289
+ (OPTION_END, 'end'),
290
+ (OPTION_LOW_CUT, 'low_cut'),
291
+ (OPTION_HIGH_CUT, 'high_cut'),
292
+ (OPTION_DB_RANGE, 'dB_range'),
293
+ (OPTION_MAX_PEAKS, 'max_peaks'),
294
+ (OPTION_SIZE_EXP, 'size_exp'),
295
+ (OPTION_SAMPLERATE, 'samplerate'),
296
+ (OPTION_BACKENDS, 'backends'),
297
+ (OPTION_PITCH, 'pitch'),
298
+ (OPTION_TEMPO, 'tempo'),
299
+ )
300
+
301
+ for opt, arg in self._options_and_args:
302
+ if arg == 'tuning':
303
+ val = getattr(args, arg)
304
+ self[opt] = self._guify_tuning_system(val)
305
+ else:
306
+ self[opt] = getattr(args, arg)
293
307
 
294
308
  def _guify_tuning_system(self, tuning_system):
295
309
  return tuning_system.replace('_', ' ').title()
296
310
 
311
+ def _unguify_tuning_system(self, tuning_system):
312
+ return tuning_system.replace(' ', '_').lower()
313
+
314
+ def argparse_namespace(self) -> Namespace:
315
+ """Return an argparse.Namespace object containing the options.
316
+
317
+ Returns
318
+ -------
319
+ argparse.Namespace
320
+ The options.
321
+ """
322
+
323
+ args = Namespace()
324
+
325
+ for opt, arg in self._options_and_args:
326
+ if arg == 'tuning':
327
+ val = self[opt]
328
+ setattr(args, arg, self._unguify_tuning_system(val))
329
+ else:
330
+ setattr(args, arg, self[opt])
331
+
332
+ return args
333
+
334
+ def merge_args(self, args: Namespace):
335
+ """Modify the options according to the arguments in an
336
+ argparse.Namespace object.
337
+
338
+ Parameters
339
+ ----------
340
+ args : argparse.Namespace
341
+ The arguments.
342
+ """
343
+
344
+ for opt, arg in self._options_and_args:
345
+ if arg == 'tuning':
346
+ if (val := getattr(args, arg, None)) is not None:
347
+ self[opt] = self._guify_tuning_system(val)
348
+ else:
349
+ if (val := getattr(args, arg, None)) is not None:
350
+ self[opt] = val
351
+
352
+
297
353
  def redo_level(self, old_options):
298
354
  """Compare the options stored in this instance of Options to the
299
355
  options stored in an older one to find out how much analysis
@@ -313,7 +369,6 @@ class Options(dict):
313
369
  if (old_options is None
314
370
  or self[OPTION_START] != old_options[OPTION_START]
315
371
  or self[OPTION_END] != old_options[OPTION_END]
316
- or self[OPTION_PAD] != old_options[OPTION_PAD]
317
372
  or self[OPTION_SIZE_EXP] != old_options[OPTION_SIZE_EXP]
318
373
  or self[OPTION_BACKENDS] != old_options[OPTION_BACKENDS]
319
374
  or self[OPTION_SAMPLERATE] != old_options[OPTION_SAMPLERATE]):
@@ -38,6 +38,8 @@ from PyQt6.QtCore import (
38
38
  QDir,
39
39
  pyqtSignal,
40
40
  QModelIndex,
41
+ QMutex,
42
+ QMutexLocker,
41
43
  )
42
44
  from PyQt6.QtWidgets import (
43
45
  QWidget,
@@ -147,13 +149,13 @@ class FileSelector(QWidget):
147
149
  The option panel widget to get analysis options from.
148
150
  """
149
151
 
150
- SelectForAnalysis = pyqtSignal(str, gcom.Options)
152
+ SelectForAnalysis = pyqtSignal(str)
151
153
  """Signal emitted when a file is selected.
152
154
 
153
155
  Parameters
154
156
  ----------
155
- audio_tuner_gui.common.Options
156
- The options to use when the file is analyzed.
157
+ str
158
+ The path of the audio file.
157
159
  """
158
160
 
159
161
  UpdateStatusbar = pyqtSignal(str)
@@ -169,6 +171,8 @@ class FileSelector(QWidget):
169
171
  def __init__(self, option_panel):
170
172
  super().__init__()
171
173
 
174
+ self._init_complete = False
175
+
172
176
  self.option_panel = option_panel
173
177
 
174
178
  vbox = QVBoxLayout(self)
@@ -184,6 +188,12 @@ class FileSelector(QWidget):
184
188
  vbox.addWidget(self.panel)
185
189
  vbox.addWidget(self.view)
186
190
 
191
+ self._scroll_positions = {}
192
+ self._scroll_mutex = QMutex()
193
+ self._scroll_update_needed = False
194
+
195
+ self._init_complete = True
196
+
187
197
  def _init_file_view(self):
188
198
  model = QFileSystemModel(self)
189
199
 
@@ -203,7 +213,15 @@ class FileSelector(QWidget):
203
213
  self.model = model
204
214
  self.view = view
205
215
 
206
- def _set_show_hidden(self, checked):
216
+ def set_show_hidden(self, checked):
217
+ """Select whether to show hidden files.
218
+
219
+ Parameters
220
+ ----------
221
+ checked : bool
222
+ Whether or not to show hidden files.
223
+ """
224
+
207
225
  if checked:
208
226
  self.model.setFilter(self.showhidden_filter)
209
227
  else:
@@ -274,11 +292,7 @@ class FileSelector(QWidget):
274
292
  self._cd(QDir.fromNativeSeparators(self.pathedit.text()))
275
293
  except NotADirectoryError:
276
294
  path = self.pathedit.text()
277
- options = self.option_panel.get_options()
278
- if options is None:
279
- self.option_panel.ensure_visible()
280
- else:
281
- self.SelectForAnalysis.emit(path, options)
295
+ self.SelectForAnalysis.emit(path)
282
296
  self._select_filename()
283
297
 
284
298
  def handle_command_line_arg(self, arg):
@@ -300,11 +314,7 @@ class FileSelector(QWidget):
300
314
  try:
301
315
  self._cd(QDir.fromNativeSeparators(arg))
302
316
  except NotADirectoryError:
303
- options = self.option_panel.get_options()
304
- if options is None:
305
- self.option_panel.ensure_visible()
306
- else:
307
- self.SelectForAnalysis.emit(arg, options)
317
+ self.SelectForAnalysis.emit(arg)
308
318
 
309
319
  def handle_drop(self, path):
310
320
  """If `path` is a file, act like it's been selected and send it
@@ -346,9 +356,35 @@ class FileSelector(QWidget):
346
356
  dest)
347
357
  path = dest
348
358
  index = self.model.index(dest)
359
+
360
+ if self._init_complete:
361
+ self._save_scroll_position()
362
+ self.model.directoryLoaded.connect(self._load_scroll_position,
363
+ Qt.ConnectionType.SingleShotConnection)
364
+
365
+ self._scroll_update_needed = True
349
366
  self.model.setRootPath(path)
350
367
  self.view.setRootIndex(index)
368
+
369
+ # The directoryLoaded signal is NOT always emitted when the
370
+ # directory is finished loading, so this is necessary to make
371
+ # sure the scroll position is updated anyway.
372
+ testindex = self.model.index(path)
373
+ if testindex.isValid():
374
+ try:
375
+ # testindex being valid means the new directory is
376
+ # already loaded, so we'll go ahead and update the
377
+ # scroll position and not bother to wait for the signal
378
+ self.model.directoryLoaded.disconnect(
379
+ self._load_scroll_position)
380
+ self._load_scroll_position(path)
381
+ except TypeError:
382
+ # The signal's already been emitted, so there's nothing
383
+ # to do
384
+ pass
385
+
351
386
  self.pathedit.setText(QDir(path).absolutePath())
387
+
352
388
  if hist == 'previous':
353
389
  self.path_history.back()
354
390
  elif hist == 'next':
@@ -400,6 +436,25 @@ class FileSelector(QWidget):
400
436
  if self.up_button.underMouse():
401
437
  self.UpdateStatusbar.emit(status)
402
438
 
439
+ def _save_scroll_position(self):
440
+ current_path = QDir(self.model.rootPath()).absolutePath()
441
+ point = self.view.rect().topLeft()
442
+ position = self.view.indexAt(point).data()
443
+ self._scroll_positions[current_path] = position
444
+
445
+ def _load_scroll_position(self, path):
446
+ with QMutexLocker(self._scroll_mutex):
447
+ if not self._scroll_update_needed:
448
+ return
449
+ abs_path = QDir(path).absolutePath()
450
+ position = self._scroll_positions.get(abs_path)
451
+ if position:
452
+ rowindex = self.model.index(os.path.join(path, position))
453
+ if rowindex.isValid():
454
+ self.view.scrollTo(rowindex,
455
+ QAbstractItemView.ScrollHint.PositionAtTop)
456
+ self._scroll_update_needed = False
457
+
403
458
  def _file_selector_doubleclick(self, index):
404
459
  if self.model.isDir(index):
405
460
  self._cd(index)
@@ -411,13 +466,8 @@ class FileSelector(QWidget):
411
466
  if len(selected) == 1 and self.model.isDir(selected[0]):
412
467
  self._cd(selected[0])
413
468
  else:
414
- options = self.option_panel.get_options()
415
- if options is None:
416
- self.option_panel.ensure_visible()
417
- else:
418
- for index in selected:
419
- self.SelectForAnalysis.emit(self.model.filePath(index),
420
- options)
469
+ for index in selected:
470
+ self.SelectForAnalysis.emit(self.model.filePath(index))
421
471
 
422
472
  def _updir(self):
423
473
  directory = self.model.rootDirectory()
@@ -29,8 +29,11 @@ __all__ = [
29
29
  ]
30
30
 
31
31
 
32
+ import copy
33
+
32
34
  from PyQt6.QtCore import (
33
35
  pyqtSignal,
36
+ Qt,
34
37
  )
35
38
  from PyQt6.QtWidgets import (
36
39
  QWidget,
@@ -231,8 +234,8 @@ class _OptionFloatSpinBox(_OptionIntSpinBox):
231
234
  def init_main_widget(self):
232
235
  widget = QDoubleSpinBox()
233
236
  widget.setDecimals(3)
234
- widget.setMinimum(0.25)
235
- widget.setMaximum(4.0)
237
+ widget.setMinimum(com.PITCH_TEMPO_MIN)
238
+ widget.setMaximum(com.PITCH_TEMPO_MAX)
236
239
  widget.setSingleStep(.001)
237
240
 
238
241
  self._revert_to_default = False
@@ -291,15 +294,16 @@ class _OptionComboBox(_OptionLineEdit):
291
294
 
292
295
  class OptionPanel(QWidget):
293
296
  """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)).
297
+ `Hold` checkbox to hold options at their current values, a `Use in
298
+ next analysis` checkbox, a `Revert to defaults` button and an `Apply
299
+ to selected` button. The user can request that an individual widget
300
+ return to it's default value by setting it's line edit box to a
301
+ blank value (this only works for widgets that actually have a line
302
+ edit box, and only if the widget's `default` attribute has been set,
303
+ which can be done using the `is_defaults` parameter of the
304
+ `set_options` method (The `set_default_options` convenience method,
305
+ which is what the `Revert to defaults` button is connected to, does
306
+ this automatically)).
303
307
 
304
308
  Parameters
305
309
  ----------
@@ -376,7 +380,9 @@ class OptionPanel(QWidget):
376
380
 
377
381
  # Link button
378
382
  widget = QToolButton()
379
- widget.setText('&Link')
383
+ widget.setText('Link')
384
+ widget.setShortcut('=')
385
+ widget.setStatusTip('Link pitch and tempo (=)')
380
386
  widget.setCheckable(True)
381
387
  hbox.addWidget(widget)
382
388
  self._link = widget
@@ -487,14 +493,6 @@ class OptionPanel(QWidget):
487
493
  self._grid.addWidget(widget, 6, 1)
488
494
  self.widgets[title] = widget
489
495
 
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
496
 
499
497
  self.widgets[gcom.OPTION_PITCH].widget.valueChanged.connect(
500
498
  self._pitch_changed)
@@ -509,9 +507,14 @@ class OptionPanel(QWidget):
509
507
  self._button_panel = QWidget(self)
510
508
  hbox = QHBoxLayout(self._button_panel)
511
509
 
512
- self._hold = QCheckBox('Hold')
510
+ self._hold = QCheckBox('Ho&ld')
513
511
  self._hold.setStatusTip("Don't update settings to reflect selection")
514
512
  hbox.addWidget(self._hold)
513
+ self._use = QCheckBox('&Use in next analysis')
514
+ self._use.setStatusTip('Use these settings to analyze the next'
515
+ ' file selected for analysis')
516
+ self._use.checkStateChanged.connect(self._use_changed)
517
+ hbox.addWidget(self._use)
515
518
  self._to_defaults = QPushButton('Revert to &defaults')
516
519
  self._to_defaults.clicked.connect(self.set_default_options)
517
520
  hbox.addWidget(self._to_defaults)
@@ -520,6 +523,10 @@ class OptionPanel(QWidget):
520
523
  hbox.addWidget(self._to_selected)
521
524
  hbox.setContentsMargins(0, 0, 0, 0)
522
525
 
526
+ def _use_changed(self, event):
527
+ if event == Qt.CheckState.Checked:
528
+ self._hold.setChecked(True)
529
+
523
530
  def _link_toggled(self, event):
524
531
  if event:
525
532
  self._link_ok = False
@@ -626,6 +633,17 @@ class OptionPanel(QWidget):
626
633
  factor = self.widgets[gcom.OPTION_PITCH].get()
627
634
  self.PitchChange.emit(factor)
628
635
 
636
+ def use_options(self) -> bool:
637
+ """Determine whether the 'Use in next analysis' checkbox is
638
+ checked.
639
+
640
+ Returns
641
+ -------
642
+ bool
643
+ True if checked, False if not.
644
+ """
645
+ return self._use.isChecked()
646
+
629
647
  def get_options(self) -> gcom.Options:
630
648
  """Get the values currently set in the option widgets.
631
649
 
@@ -673,6 +691,16 @@ class OptionPanel(QWidget):
673
691
  options.reread_requested = True
674
692
  self.PushOptions.emit(options, True)
675
693
 
694
+ def get_default_options(self) -> gcom.Options:
695
+ """Return a copy of the default options.
696
+
697
+ Returns
698
+ -------
699
+ audio_tuner_gui.common.Options
700
+ The default options.
701
+ """
702
+ return copy.copy(self._default_options)
703
+
676
704
  def set_default_options(self):
677
705
  """Set the widgets to the values passed in the constructor's
678
706
  `args` parameter. This calls `set_options` with force=True and
@@ -61,6 +61,7 @@ from PyQt6.QtSvgWidgets import QSvgWidget
61
61
  import audio_tuner.error_handling as eh
62
62
  import audio_tuner.common as com
63
63
  import audio_tuner.argument_parser as ap
64
+ import audio_tuner.saved_options as so
64
65
 
65
66
  import audio_tuner_gui.common as gcom
66
67
  import audio_tuner_gui.file_selector as fs
@@ -103,7 +104,10 @@ VERSION_STRING = f'tuner-gui ({APP_TITLE}) {VERSION}\n{ABOUT_TEXT}'
103
104
  DEBUG_BUTTON = False
104
105
 
105
106
  class _AboutWindow(QWidget):
106
- def __init__(self, parent):
107
+ def __init__(self,
108
+ parent,
109
+ config_path: str = ap.CONFIG_PATH,
110
+ savefile_path: str = ap.SAVEFILE_PATH):
107
111
  super().__init__(parent)
108
112
 
109
113
  self.setWindowFlag(Qt.WindowType.Window)
@@ -116,7 +120,8 @@ class _AboutWindow(QWidget):
116
120
  self.text.setTextInteractionFlags(
117
121
  Qt.TextInteractionFlag.TextSelectableByMouse)
118
122
 
119
- self.config_text = QLabel('Config file path:\n' + ap.CONFIG_PATH, self)
123
+ self.config_text = QLabel('Config file path:\n' + config_path
124
+ + '\n\nSave file path:\n' + savefile_path, self)
120
125
  self.config_text.setCursor(Qt.CursorShape.IBeamCursor)
121
126
  self.config_text.setTextInteractionFlags(
122
127
  Qt.TextInteractionFlag.TextSelectableByMouse)
@@ -215,6 +220,28 @@ class _MainUI(QMainWindow):
215
220
  self.exit_act.setStatusTip('Exit application')
216
221
  self.exit_act.triggered.connect(QApplication.instance().quit)
217
222
 
223
+ self.save_act = QAction(QIcon.fromTheme(gcom.ICON_SAVE),
224
+ '&Save options',
225
+ self)
226
+ self.save_act.setShortcut(QKeySequence('Ctrl+S'))
227
+ self.save_act.setStatusTip(
228
+ 'Save the options of the currently selected analyzed audio')
229
+ self.save_act.triggered.connect(self._save)
230
+
231
+ self.reload_saved_act = QAction('&Reload saved options')
232
+ self.reload_saved_act.setStatusTip('Load the saved options for the'
233
+ ' currently selected analyzed'
234
+ ' audio into the option panel')
235
+ self.reload_saved_act.triggered.connect(self._reload_saved)
236
+
237
+ self.delete_saved_act = QAction(QIcon.fromTheme(gcom.ICON_DEL),
238
+ '&Delete saved options',
239
+ self)
240
+ self.delete_saved_act.setStatusTip(
241
+ 'Delete the saved options of the'
242
+ ' currently selected analyzed audio')
243
+ self.delete_saved_act.triggered.connect(self._delete_saved)
244
+
218
245
  self.showhidden_act = QAction('Show &hidden files', self)
219
246
  self.showhidden_act.setCheckable(True)
220
247
  self.showhidden_act.setShortcut(QKeySequence('Ctrl+H'))
@@ -224,19 +251,19 @@ class _MainUI(QMainWindow):
224
251
  self.play_start_end_act.setCheckable(True)
225
252
  self.play_start_end_act.toggled.connect(self._play_start_end_toggled)
226
253
 
227
- self.toggle_options_panel_act = QAction('Show &options panel', self)
254
+ self.toggle_options_panel_act = QAction('Show &option panel', self)
228
255
  self.toggle_options_panel_act.setCheckable(True)
229
256
  self.toggle_options_panel_act.setChecked(False)
230
257
  self.toggle_options_panel_act.setShortcut('F2')
231
258
  self.toggle_options_panel_act.setStatusTip(
232
- 'Switch between file selector and options panel')
259
+ 'Switch between file selector and option panel')
233
260
  self.toggle_options_panel_act.triggered.connect(
234
261
  self._toggle_options_panel)
235
262
 
236
263
  self.toggle_toggle_options_panel_act = QAction(self)
237
264
  self.toggle_toggle_options_panel_act.setIcon(QIcon(gcom.ICON_OPTIONS))
238
265
  self.toggle_toggle_options_panel_act.setStatusTip(
239
- 'Switch between file selector and options panel')
266
+ 'Switch between file selector and option panel')
240
267
  self.toggle_toggle_options_panel_act.triggered.connect(
241
268
  self._toggle_toggle_options_panel)
242
269
 
@@ -245,7 +272,7 @@ class _MainUI(QMainWindow):
245
272
  self.cancel_act.setShortcut('Escape')
246
273
  self.cancel_act.setStatusTip('Cancel')
247
274
 
248
- self.export_act = QAction('&Export Audio...')
275
+ self.export_act = QAction('&Export audio...')
249
276
  self.export_act.triggered.connect(self._export)
250
277
  if com.mpv_error is not None:
251
278
  self.export_act.setEnabled(False)
@@ -293,10 +320,9 @@ class _MainUI(QMainWindow):
293
320
  self.pause_act.triggered.connect(self._pause)
294
321
 
295
322
  self.stop_act = QAction(self)
296
- self.stop_act.setShortcuts([QKeySequence('Ctrl+S'),
297
- Qt.Key.Key_MediaStop])
323
+ self.stop_act.setShortcut(Qt.Key.Key_MediaStop)
298
324
  self.stop_act.setIcon(QIcon.fromTheme(gcom.ICON_STOP))
299
- self.stop_act.setStatusTip('Stop (Ctrl+S)')
325
+ self.stop_act.setStatusTip('Stop')
300
326
  self.stop_act.triggered.connect(self._stop)
301
327
 
302
328
  if DEBUG_BUTTON:
@@ -390,6 +416,10 @@ class _MainUI(QMainWindow):
390
416
  menubar = self.menuBar()
391
417
 
392
418
  file_menu = menubar.addMenu('&File')
419
+ file_menu.addAction(self.reload_saved_act)
420
+ file_menu.addAction(self.save_act)
421
+ file_menu.addAction(self.delete_saved_act)
422
+ file_menu.addSeparator()
393
423
  file_menu.addAction(self.export_act)
394
424
  file_menu.addSeparator()
395
425
  file_menu.addAction(self.exit_act)
@@ -532,6 +562,53 @@ class _MainUI(QMainWindow):
532
562
  self.cancel_button.hide()
533
563
  self.cancel_act.setEnabled(False)
534
564
 
565
+ def _save(self):
566
+ audio_path = self.analyzed_audio.current_selection
567
+ args = self.analyzed_audio.current_options.argparse_namespace()
568
+ if self.saved_options.save(args, audio_path):
569
+ self._update_statusbar('Saved')
570
+ self.delete_saved_act.setEnabled(True)
571
+ self.reload_saved_act.setEnabled(True)
572
+ else:
573
+ self._update_statusbar('Error while saving')
574
+
575
+ def _reload_saved(self):
576
+ path = self.analyzed_audio.current_selection
577
+ options = self.option_panel.get_default_options()
578
+ saved_args = self.saved_options.get_args(path)
579
+ if saved_args:
580
+ options.merge_args(saved_args)
581
+ try:
582
+ options.init_tuning_system()
583
+ except ValueError:
584
+ s = (f'invalid ref_note:'
585
+ f' {options[gcom.OPTION_REF_NOTE]}')
586
+ self._log(s, lv.LOG_LEVEL_ERROR)
587
+ return
588
+ self.option_panel.set_options(options, force=True)
589
+
590
+ def _delete_saved(self):
591
+ audio_path = self.analyzed_audio.current_selection
592
+ message = (f'Delete saved options for'
593
+ f'\n{audio_path}'
594
+ f'\nfrom'
595
+ f'\n{self.saved_options.path}?')
596
+ reply = QMessageBox.question(
597
+ self,
598
+ 'Confirm',
599
+ message,
600
+ QMessageBox.StandardButton.Yes
601
+ | QMessageBox.StandardButton.No,
602
+ QMessageBox.StandardButton.No)
603
+
604
+ if reply == QMessageBox.StandardButton.Yes:
605
+ if self.saved_options.delete(audio_path):
606
+ self._update_statusbar('Deleted')
607
+ self.delete_saved_act.setEnabled(False)
608
+ self.reload_saved_act.setEnabled(False)
609
+ else:
610
+ self._update_statusbar('Error while deleting')
611
+
535
612
  def _export(self):
536
613
  self.export_window.show(self.analyzed_audio.current_selection,
537
614
  self.analyzed_audio.current_listed_title,
@@ -581,6 +658,14 @@ class _MainUI(QMainWindow):
581
658
  self.option_panel.set_apply_enabled(True)
582
659
  self.show_log_plot_act.setEnabled(True)
583
660
  self.show_linear_plot_act.setEnabled(True)
661
+ self.save_act.setEnabled(True)
662
+ selected = self.analyzed_audio.current_selection
663
+ if self.saved_options.has_options(selected):
664
+ self.delete_saved_act.setEnabled(True)
665
+ self.reload_saved_act.setEnabled(True)
666
+ else:
667
+ self.delete_saved_act.setEnabled(False)
668
+ self.reload_saved_act.setEnabled(False)
584
669
  if not self._exporting_in_progress and com.mpv_error is None:
585
670
  self.export_act.setEnabled(True)
586
671
 
@@ -590,6 +675,9 @@ class _MainUI(QMainWindow):
590
675
  self.show_log_plot_act.setEnabled(False)
591
676
  self.show_linear_plot_act.setEnabled(False)
592
677
  self.export_act.setEnabled(False)
678
+ self.save_act.setEnabled(False)
679
+ self.delete_saved_act.setEnabled(False)
680
+ self.reload_saved_act.setEnabled(False)
593
681
 
594
682
  def _play_start_end_toggled(self, checked):
595
683
  if checked:
@@ -726,6 +814,26 @@ class _MainUI(QMainWindow):
726
814
  now = self.player.get_current_position()
727
815
  self.option_panel.set_end(now)
728
816
 
817
+ def _add_audio(self, path: str):
818
+ if self.option_panel.use_options():
819
+ options = self.option_panel.get_options()
820
+ if options is None:
821
+ self.option_panel.ensure_visible()
822
+ return
823
+ else:
824
+ options = self.option_panel.get_default_options()
825
+ saved_args = self.saved_options.get_args(path)
826
+ if saved_args:
827
+ options.merge_args(saved_args)
828
+ try:
829
+ options.init_tuning_system()
830
+ except ValueError:
831
+ s = (f'invalid ref_note:'
832
+ f' {options[gcom.OPTION_REF_NOTE]}')
833
+ self._log(s, lv.LOG_LEVEL_ERROR)
834
+ return
835
+ self.analyzed_audio.add_audio(path, options)
836
+
729
837
  def _initUI(self):
730
838
  self.resize(800, 850)
731
839
  self.setWindowTitle(APP_TITLE)
@@ -775,8 +883,8 @@ class _MainUI(QMainWindow):
775
883
  self.export_window = ex.ExportWindow(self)
776
884
  self.export_window.set_dir(self.file_selector.model.rootDirectory())
777
885
 
778
- file_selector.SelectForAnalysis.connect(analyzed_audio.add_audio)
779
- self.showhidden_act.toggled.connect(file_selector._set_show_hidden)
886
+ file_selector.SelectForAnalysis.connect(self._add_audio)
887
+ self.showhidden_act.toggled.connect(file_selector.set_show_hidden)
780
888
  file_selector.UpdateStatusbar.connect(self._update_statusbar)
781
889
  file_selector.model.rootPathChanged.connect(self.export_window.set_dir)
782
890
  self.analyzed_audio.UpdateStatusbar.connect(self._update_statusbar)
@@ -813,10 +921,15 @@ class _MainUI(QMainWindow):
813
921
  analyzed_audio.DisplayResult.connect(self._update_now_playing)
814
922
  analyzed_audio.PushOptions.connect(option_panel.set_options)
815
923
 
816
- self.about_window = _AboutWindow(self)
817
-
818
924
  self.log_viewer = lv.LogViewer(self)
819
925
 
926
+ self.saved_options = so.SavedOptions(self.args.savefile,
927
+ print_msg=self._log)
928
+
929
+ self.about_window = _AboutWindow(self,
930
+ config_path=self.args.config,
931
+ savefile_path=self.saved_options.path)
932
+
820
933
  self.show()
821
934
 
822
935
  hsplitter.moveSplitter(500, 1)
@@ -21,7 +21,8 @@ readme = "README.rst"
21
21
  Homepage = "https://codeberg.org/bluesloth/audio_tuner"
22
22
  Repository = "https://codeberg.org/bluesloth/audio_tuner.git"
23
23
  Issues = "https://codeberg.org/bluesloth/audio_tuner/issues"
24
- Documentation = "https://audio-tuner.readthedocs.io/en/latest/"
24
+ Documentation = "https://audio-tuner.readthedocs.io/"
25
+ News = "https://codeberg.org/bluesloth/audio_tuner/src/branch/master/NEWS"
25
26
 
26
27
  [project.gui-scripts]
27
28
  tuner-gui = "audio_tuner_gui.scripts.tuner_gui:main"