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,563 @@
1
+ # This file is part of Audio Tuner.
2
+ #
3
+ # Copyright 2025, 2026 Jessie Blue Cassell <bluesloth600@gmail.com>
4
+ #
5
+ # This program is free software: you can redistribute it and/or modify
6
+ # it under the terms of the GNU General Public License as published by
7
+ # the Free Software Foundation, either version 3 of the License, or
8
+ # (at your option) any later version.
9
+ #
10
+ # This program is distributed in the hope that it will be useful,
11
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
12
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13
+ # GNU General Public License for more details.
14
+ #
15
+ # You should have received a copy of the GNU General Public License
16
+ # along with this program. If not, see <https://www.gnu.org/licenses/>.
17
+
18
+
19
+ """Audio export functionality for the GUI."""
20
+
21
+
22
+ __author__ = 'Jessie Blue Cassell'
23
+
24
+
25
+ __all__ = [
26
+ 'ExportWindow',
27
+ ]
28
+
29
+
30
+ import os
31
+ from dataclasses import dataclass
32
+ from contextlib import contextmanager
33
+
34
+ from PyQt6.QtCore import (
35
+ Qt,
36
+ pyqtSignal,
37
+ QDir,
38
+ QObject,
39
+ QThread,
40
+ )
41
+ from PyQt6.QtWidgets import (
42
+ QWidget,
43
+ QPushButton,
44
+ QVBoxLayout,
45
+ QHBoxLayout,
46
+ QLineEdit,
47
+ QFileDialog,
48
+ QComboBox,
49
+ QCheckBox,
50
+ QTableView,
51
+ )
52
+ from PyQt6.QtGui import (
53
+ QStandardItemModel,
54
+ QStandardItem,
55
+ )
56
+
57
+ import audio_tuner.analysis as anal
58
+ import audio_tuner.common as com
59
+
60
+ import audio_tuner_gui.common as gcom
61
+ import audio_tuner_gui.log_viewer as lv
62
+
63
+ mpv_error = com.mpv_error
64
+ if mpv_error is None:
65
+ import mpv
66
+
67
+
68
+ _OUTPUT_FORMATS = [
69
+ {'name': 'flac', 'ext': 'flac', 'desc': 'Free Lossless Audio Codec'},
70
+ {'name': 'ogg', 'ext': 'ogg', 'desc': 'Ogg Vorbis'},
71
+ {'name': 'opus', 'ext': 'opus', 'desc': 'Ogg Opus'},
72
+ {'name': 'spx', 'ext': 'spx', 'desc': 'Ogg Speex'},
73
+ {'name': 'aiff', 'ext': 'aiff', 'desc': 'Audio Interchange File Format'},
74
+ {'name': 'au', 'ext': 'au', 'desc': 'Sun Au'},
75
+ {'name': 'mp3', 'ext': 'mp3', 'desc': 'MPEG audio layer 3'},
76
+ {'name': 'wav', 'ext': 'wav', 'desc': 'Waveform Audio'},
77
+ ]
78
+
79
+ _DEFAULT_EXT = 'flac'
80
+
81
+ _ENCODER_TAGS = (
82
+ 'encoder',
83
+ 'encoded_by',
84
+ )
85
+
86
+
87
+ @dataclass
88
+ class _ExportOptions():
89
+ input_file: str
90
+ output_file: str
91
+ output_format: str
92
+ tags: dict = None
93
+ pitch: float = 1.0
94
+ tempo: float = 1.0
95
+ start: str = None
96
+ end: str = None
97
+
98
+
99
+ @contextmanager
100
+ def _in_progress(start_signal, finish_signal):
101
+ try:
102
+ start_signal.emit()
103
+ yield None
104
+ finally:
105
+ finish_signal.emit()
106
+
107
+
108
+ class _Worker(QObject):
109
+ AddToLog = pyqtSignal(str, int)
110
+ Starting = pyqtSignal()
111
+ Finished = pyqtSignal()
112
+ Success = pyqtSignal()
113
+
114
+ def _mpv_error_checker(self, evt):
115
+ self._mpv_err = b'unknown error'
116
+ evt_dict = evt.as_dict()
117
+ self._loaded = evt_dict['event'] == b'file-loaded'
118
+ if not self._loaded:
119
+ try:
120
+ self._mpv_err = evt_dict['file_error']
121
+ except KeyError:
122
+ pass
123
+ return True
124
+
125
+ def _log_handler(self, level, prefix, text):
126
+ if level == 'warn':
127
+ if 'Estimating duration from bitrate' in text:
128
+ return
129
+ self.AddToLog.emit(f'libmpv: {prefix}: {text}'.rstrip('\n'),
130
+ lv.LOG_LEVEL_WARNING)
131
+ if level in ['fatal', 'error']:
132
+ self.AddToLog.emit(f'libmpv: {prefix}: {text}'.rstrip('\n'),
133
+ lv.LOG_LEVEL_ERROR)
134
+
135
+ def export(self, export_options: _ExportOptions):
136
+ with _in_progress(self.Starting, self.Finished):
137
+ s = f'\nExport {export_options.input_file}'
138
+ self.AddToLog.emit(s, lv.LOG_LEVEL_NORMAL)
139
+ s = f' as {export_options.output_file}'
140
+ self.AddToLog.emit(s, lv.LOG_LEVEL_NORMAL)
141
+ if export_options.start:
142
+ s = f'start {export_options.start}'
143
+ self.AddToLog.emit(s, lv.LOG_LEVEL_NORMAL)
144
+ if export_options.end:
145
+ s = f'end {export_options.end}'
146
+ self.AddToLog.emit(s, lv.LOG_LEVEL_NORMAL)
147
+ s = f'pitch {export_options.pitch:.3f}'
148
+ self.AddToLog.emit(s, lv.LOG_LEVEL_NORMAL)
149
+ s = f'tempo {export_options.tempo:.3f}'
150
+ self.AddToLog.emit(s, lv.LOG_LEVEL_NORMAL)
151
+ s = 'Exporting...'
152
+ self.AddToLog.emit(s, lv.LOG_LEVEL_NORMAL)
153
+
154
+ pitch = export_options.pitch
155
+ tempo = export_options.tempo
156
+ pitch_t = pitch / tempo
157
+
158
+ mpv_opts = {
159
+ 'video': 'no',
160
+ 'o': export_options.output_file,
161
+ 'of': export_options.output_format,
162
+ 'audio_pitch_correction': 'no',
163
+ 'ocopy_metadata': 'no',
164
+ }
165
+ if export_options.tags:
166
+ t = export_options.tags
167
+ tagstring = ','.join([f'{k}="{v}"' for k, v in t.items()])
168
+ mpv_opts['oset_metadata'] = tagstring
169
+ if abs(tempo - 1.0) > .0005:
170
+ mpv_opts['speed'] = f'{tempo:.3f}'
171
+ if abs(pitch_t - 1.0) > .0005:
172
+ mpv_opts['af'] = f'@rb:rubberband=pitch-scale={pitch_t:.3f}'
173
+ if export_options.start:
174
+ mpv_opts['start'] = export_options.start
175
+ if export_options.end:
176
+ mpv_opts['end'] = export_options.end
177
+
178
+ out_dir = os.path.dirname(export_options.output_file)
179
+ if not os.path.isdir(out_dir):
180
+ s = f'No such directory: {out_dir}'
181
+ self.AddToLog.emit(s, lv.LOG_LEVEL_ERROR)
182
+ self.AddToLog.emit('Aborting.', lv.LOG_LEVEL_WARNING)
183
+ return
184
+
185
+ # Create the file now instead of letting mpv create it to
186
+ # avoid a race condition in existence checking.
187
+ try:
188
+ with open(export_options.output_file, 'x'):
189
+ pass
190
+ except OSError as err:
191
+ self.AddToLog.emit(str(err), lv.LOG_LEVEL_ERROR)
192
+ self.AddToLog.emit('Aborting.', lv.LOG_LEVEL_WARNING)
193
+ return
194
+
195
+ with anal.mpv_cm(log_handler=self._log_handler,
196
+ **mpv_opts) as (err, player):
197
+ if err is not None:
198
+ self.AddToLog.emit('Aborting.', lv.LOG_LEVEL_WARNING)
199
+ return
200
+ with player.prepare_and_wait_for_event(
201
+ 'file_loaded',
202
+ 'end_file',
203
+ cond=self._mpv_error_checker,
204
+ timeout=5):
205
+ player.command('loadfile',
206
+ com.string_to_raw(export_options.input_file),
207
+ 'replace')
208
+ if self._loaded:
209
+ player.wait_for_playback()
210
+ file = export_options.output_file
211
+ if os.path.exists(file) and os.path.getsize(file) > 0:
212
+ self.AddToLog.emit('Done', lv.LOG_LEVEL_NORMAL)
213
+ self.Success.emit()
214
+ return
215
+ self.AddToLog.emit('Export failed', lv.LOG_LEVEL_ERROR)
216
+
217
+
218
+ def _split_extension(path):
219
+ directory, file = os.path.split(path)
220
+ name, dot, ext = file.rpartition('.')
221
+ return (os.path.join(directory, name if name else file), ext)
222
+
223
+
224
+ class _TagEditor(QWidget):
225
+ def __init__(self, *args, **kwargs):
226
+ super().__init__(*args, **kwargs)
227
+
228
+ model = QStandardItemModel(0, 2, self)
229
+ headers = ('Tag', 'Value')
230
+ model.setHorizontalHeaderLabels(headers)
231
+
232
+ vbox = QVBoxLayout()
233
+ self.setLayout(vbox)
234
+
235
+ view = QTableView()
236
+ view.setModel(model)
237
+ view.setColumnWidth(0, 200)
238
+ view.setColumnWidth(1, 450)
239
+ view.setSelectionBehavior(QTableView.SelectionBehavior.SelectRows)
240
+ view.setSelectionMode(QTableView.SelectionMode.SingleSelection)
241
+
242
+ vbox.addWidget(view)
243
+
244
+ buttons = QWidget()
245
+ hbox = QHBoxLayout()
246
+ buttons.setLayout(hbox)
247
+
248
+ delete_button = QPushButton('Delete selected tag')
249
+ delete_button.clicked.connect(self.delete_tag)
250
+ hbox.addWidget(delete_button)
251
+ add_button = QPushButton('Insert empty tag before selection')
252
+ add_button.clicked.connect(self.insert_before)
253
+ hbox.addWidget(add_button)
254
+ add_button = QPushButton('Insert empty tag after selection')
255
+ add_button.clicked.connect(self.insert_after)
256
+ hbox.addWidget(add_button)
257
+
258
+ vbox.addWidget(buttons)
259
+
260
+ self.model = model
261
+ self.view = view
262
+
263
+ def add_tag(self, tag, value):
264
+ tag_item = QStandardItem(tag)
265
+ value_item = QStandardItem(value)
266
+ self.model.appendRow((tag_item, value_item))
267
+
268
+ def insert_before(self):
269
+ indexes = self.view.selectionModel().selectedRows()
270
+ if indexes:
271
+ index = max([x.row() for x in indexes])
272
+ else:
273
+ index = 0
274
+ self.model.insertRow(index)
275
+
276
+ def insert_after(self):
277
+ indexes = self.view.selectionModel().selectedRows()
278
+ if indexes:
279
+ index = max([x.row() for x in indexes])
280
+ else:
281
+ index = -1
282
+ self.model.insertRow(index + 1)
283
+
284
+ def delete_tag(self):
285
+ indexes = self.view.selectionModel().selectedRows()
286
+ for index in indexes:
287
+ self.model.removeRow(index.row())
288
+
289
+ def clear_tags(self):
290
+ self.model.setRowCount(0)
291
+
292
+ def tags(self):
293
+ n = self.model.rowCount()
294
+ i = 0
295
+ while i < n:
296
+ k = self.model.item(i, 0).text()
297
+ v = self.model.item(i, 1).text()
298
+ i += 1
299
+ yield (k, v)
300
+
301
+
302
+ class ExportWindow(QWidget):
303
+ """A window that handles exporting audio, including editing tags.
304
+ Inherits from QWidget.
305
+
306
+ Parameters
307
+ ----------
308
+ parent
309
+ The parent widget.
310
+ """
311
+
312
+ AddToLog = pyqtSignal(str, int)
313
+ """Signal emitted to request a message be added to the log.
314
+
315
+ Parameters
316
+ ----------
317
+ str
318
+ The message.
319
+ int
320
+ The severity level as defined in audio_tuner_gui.log_viewer
321
+ (LOG_LEVEL_ERROR, LOG_LEVEL_WARNING or LOG_LEVEL_NORMAL).
322
+ """
323
+
324
+ Export = pyqtSignal(_ExportOptions)
325
+ """Signal emitted to tell the worker thread to do an export.
326
+
327
+ Parameters
328
+ ----------
329
+ _ExportOptions
330
+ The export options.
331
+ """
332
+
333
+ Starting = pyqtSignal()
334
+ """Signal emitted when exporting is starting."""
335
+
336
+ Finished = pyqtSignal()
337
+ """Signal emitted when exporting is finished."""
338
+
339
+
340
+ def __init__(self, parent):
341
+ super().__init__(parent)
342
+
343
+ self._ext = _DEFAULT_EXT
344
+ self._line_edit_update_needed = False
345
+ self._thread_started = False
346
+
347
+ self.setWindowFlag(Qt.WindowType.Window)
348
+ self.setWindowTitle('Export')
349
+
350
+ vbox = QVBoxLayout(self)
351
+
352
+ self._path_chooser = QWidget()
353
+ self._path_chooser_layout = QHBoxLayout(self._path_chooser)
354
+ self._path_chooser_layout.setContentsMargins(0, 0, 0, 0)
355
+ self._line_edit = QLineEdit()
356
+ self._path_chooser_layout.addWidget(self._line_edit)
357
+ self._path_button = QPushButton('Choose path...')
358
+ self._path_button.clicked.connect(self._choose_path)
359
+ self._path_chooser_layout.addWidget(self._path_button)
360
+ vbox.addWidget(self._path_chooser)
361
+
362
+ self._format_chooser = QComboBox()
363
+ for item in _OUTPUT_FORMATS:
364
+ s = f'{item["name"]} ({item["desc"]})'
365
+ self._format_chooser.addItem(s, item)
366
+ vbox.addWidget(self._format_chooser)
367
+ self._format_chooser.currentIndexChanged.connect(self._format_selected)
368
+ self._line_edit.editingFinished.connect(self._text_edited)
369
+
370
+ self._option_controls = QWidget()
371
+ self._option_controls_layout = QHBoxLayout(self._option_controls)
372
+ self._start_end = QCheckBox(
373
+ f'Only export between {gcom.OPTION_START} and {gcom.OPTION_END}')
374
+ self._option_controls_layout.addWidget(self._start_end)
375
+
376
+ vbox.addWidget(self._option_controls)
377
+
378
+ self._tag_editor = _TagEditor()
379
+ vbox.addWidget(self._tag_editor)
380
+
381
+ self._buttons = QWidget()
382
+ self._buttons_layout = QHBoxLayout(self._buttons)
383
+ self._buttons_layout.setContentsMargins(0, 0, 0, 0)
384
+ self._close_button = QPushButton('Close')
385
+ self._close_button.setShortcut('Escape')
386
+ self._close_button.clicked.connect(self.hide)
387
+ self._buttons_layout.addWidget(self._close_button)
388
+ self._export_button = QPushButton('Export')
389
+ self._export_button.clicked.connect(self._export,
390
+ Qt.ConnectionType.SingleShotConnection)
391
+ self._buttons_layout.addWidget(self._export_button)
392
+ vbox.addWidget(self._buttons)
393
+
394
+ self._set_format_from_ext(_DEFAULT_EXT)
395
+
396
+ self.resize(720, 400)
397
+
398
+ def show(self, original_path, title, options, metadata: dict={}):
399
+ """Show the window, or try to give it focus if it's already
400
+ shown.
401
+
402
+ Parameters
403
+ ----------
404
+ original_path : str or QDir
405
+ The path of the original audio file to be exported.
406
+ title : str
407
+ The title of the audio. This overrides the `title` or
408
+ `TITLE` tag.
409
+ options : audio_tuner_gui.common.Options
410
+ The options set for the audio.
411
+ metadata : dict
412
+ The tags to fill in the tag editor with. Tags in
413
+ `_ENCODER_TAGS` are ignored, and `title` and `TITLE` tags
414
+ have their value replaced with the value of the title
415
+ parameter.
416
+ """
417
+
418
+ if mpv_error is not None:
419
+ self.AddToLog.emit(mpv_error, lv.LOG_LEVEL_ERROR)
420
+ return
421
+ if isinstance(original_path, QDir):
422
+ self._original_path = original_path.path()
423
+ else:
424
+ self._original_path = original_path
425
+ file = os.path.basename(self._original_path)
426
+ self._file_name, self._orig_ext = _split_extension(file)
427
+ self._options = options
428
+
429
+ self._line_edit_update_needed = True
430
+ self._update_line_edit()
431
+ if not self._thread_started:
432
+ self._start_worker_thread()
433
+
434
+ self._tag_editor.clear_tags()
435
+ if title is not None and not ('title' in metadata
436
+ or 'TITLE' in metadata):
437
+ self._tag_editor.add_tag('title', title)
438
+ for tag, value in metadata.items():
439
+ if tag in ('title', 'TITLE'):
440
+ value = title
441
+ if tag not in _ENCODER_TAGS:
442
+ self._tag_editor.add_tag(tag, value)
443
+
444
+ super().show()
445
+ self.activateWindow()
446
+
447
+ def _start_worker_thread(self):
448
+ worker_thread = QThread(self)
449
+ self._worker_thread = worker_thread
450
+ self._worker = _Worker()
451
+ self._worker.moveToThread(worker_thread)
452
+ self.Export.connect(self._worker.export,
453
+ Qt.ConnectionType.QueuedConnection)
454
+ self._worker.AddToLog.connect(self._handle_log_message,
455
+ Qt.ConnectionType.QueuedConnection)
456
+ self._worker.Starting.connect(self._starting,
457
+ Qt.ConnectionType.QueuedConnection)
458
+ self._worker.Finished.connect(self._finished,
459
+ Qt.ConnectionType.QueuedConnection)
460
+ self._worker.Success.connect(self._success,
461
+ Qt.ConnectionType.QueuedConnection)
462
+ worker_thread.start()
463
+ self._thread_started = True
464
+
465
+ def _handle_log_message(self, message, level):
466
+ self.AddToLog.emit(message, level)
467
+
468
+ def set_dir(self, directory):
469
+ """Set the directory. This does not immediately update the path
470
+ shown in the line edit widget, but does affect what directory is
471
+ shown the next time it's updated.
472
+
473
+ Parameters
474
+ ----------
475
+ directory : str or QDir
476
+ The directory.
477
+ """
478
+
479
+ if isinstance(directory, QDir):
480
+ d = directory.path()
481
+ else:
482
+ d = directory
483
+ self._directory = d
484
+
485
+ def _export(self):
486
+ output_file = self._line_edit.text()
487
+ output_format = self._format_chooser.currentData()['name']
488
+ pitch = self._options[gcom.OPTION_PITCH]
489
+ tempo = self._options[gcom.OPTION_TEMPO]
490
+ tags = {k: v for k, v in self._tag_editor.tags()}
491
+ export_options = _ExportOptions(input_file=self._original_path,
492
+ output_file=output_file,
493
+ output_format=output_format,
494
+ tags=tags,
495
+ pitch=pitch,
496
+ tempo=tempo)
497
+ if self._start_end.isChecked():
498
+ export_options.start = self._options[gcom.OPTION_START]
499
+ export_options.end = self._options[gcom.OPTION_END]
500
+ self.Export.emit(export_options)
501
+
502
+ def _starting(self):
503
+ self._export_button.setEnabled(False)
504
+ self.Starting.emit()
505
+
506
+ def _finished(self):
507
+ self._export_button.clicked.connect(self._export,
508
+ Qt.ConnectionType.SingleShotConnection)
509
+ self.Finished.emit()
510
+ self._export_button.setEnabled(True)
511
+
512
+ def _success(self):
513
+ self.hide()
514
+
515
+ def _set_format_from_ext(self, ext):
516
+ for item in _OUTPUT_FORMATS:
517
+ if item['ext'] == ext:
518
+ s = f'{item["name"]} ({item["desc"]})'
519
+ self._format_chooser.setCurrentText(s)
520
+ return True
521
+ return False
522
+
523
+ def _text_edited(self):
524
+ path = self._line_edit.text()
525
+ spath, ext = _split_extension(path)
526
+ if spath:
527
+ directory, self._file_name = os.path.split(spath)
528
+ self.set_dir(directory)
529
+ self._line_edit_update_needed = True
530
+ self._set_format_from_ext(ext)
531
+ if self._line_edit_update_needed:
532
+ self._update_line_edit()
533
+
534
+ def _format_selected(self, index):
535
+ self._ext = self._format_chooser.itemData(index)['ext']
536
+ self._update_line_edit()
537
+
538
+ def _update_line_edit(self):
539
+ new_file = f'{self._file_name}.{self._ext}'
540
+ new_path = os.path.join(self._directory, new_file)
541
+ self._line_edit.setText(QDir.toNativeSeparators(new_path))
542
+ self._line_edit_update_needed = False
543
+
544
+ def _choose_path(self):
545
+ p = os.path.join(self._directory, f'{self._file_name}.{self._ext}')
546
+ path = QFileDialog.getSaveFileName(parent=self,
547
+ caption='Choose path',
548
+ directory=p)
549
+ spath, ext = _split_extension(path[0])
550
+ if spath:
551
+ directory, self._file_name = os.path.split(spath)
552
+ self.set_dir(directory)
553
+ self._line_edit_update_needed = True
554
+ self._set_format_from_ext(ext)
555
+ if self._line_edit_update_needed:
556
+ self._update_line_edit()
557
+
558
+ def quit_thread(self):
559
+ """Stop the worker thread. Call this before closing the app."""
560
+
561
+ if self._thread_started:
562
+ self._worker_thread.quit()
563
+ self._thread_started = False