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,757 @@
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
+ """Audio player for the GUI."""
22
+
23
+
24
+ __author__ = 'Jessie Blue Cassell'
25
+
26
+
27
+ __all__ = [
28
+ 'Player',
29
+ ]
30
+
31
+
32
+ import atexit
33
+
34
+ from PyQt6.QtCore import (
35
+ Qt,
36
+ QObject,
37
+ pyqtSignal,
38
+ )
39
+ from PyQt6.QtWidgets import (
40
+ QWidget,
41
+ QVBoxLayout,
42
+ QHBoxLayout,
43
+ QPushButton,
44
+ QTableView,
45
+ )
46
+ from PyQt6.QtGui import (
47
+ QStandardItemModel,
48
+ QStandardItem,
49
+ )
50
+
51
+ import audio_tuner_gui.log_viewer as lv
52
+ import audio_tuner.common as com
53
+
54
+ mpv_error = com.mpv_error
55
+ if mpv_error is None:
56
+ import mpv
57
+
58
+
59
+ class _DeviceView(QTableView):
60
+ def __init__(self):
61
+ super().__init__()
62
+
63
+ self.setSelectionBehavior(QTableView.SelectionBehavior.SelectRows)
64
+ self.setSelectionMode(QTableView.SelectionMode.SingleSelection)
65
+ self.setShowGrid(False)
66
+
67
+
68
+ class _DeviceWindow(QWidget):
69
+ Select = pyqtSignal(str)
70
+
71
+ def __init__(self, parent):
72
+ super().__init__(parent)
73
+
74
+ self.setWindowFlag(Qt.WindowType.Window)
75
+ self.setWindowTitle('Audio Devices')
76
+
77
+ self.vbox = QVBoxLayout(self)
78
+
79
+ self._init_device_view()
80
+
81
+ self.vbox.addWidget(self.view)
82
+
83
+ self._init_button_panel()
84
+ self.vbox.addWidget(self.button_panel)
85
+
86
+ self.resize(600, 400)
87
+
88
+ def _init_device_view(self):
89
+ model = QStandardItemModel(0, 2, self)
90
+ headers = ('Name', 'Description')
91
+ model.setHorizontalHeaderLabels(headers)
92
+
93
+ view = _DeviceView()
94
+ view.setModel(model)
95
+
96
+ view.setColumnWidth(0, 200)
97
+ view.setColumnWidth(1, 400)
98
+
99
+ self.model = model
100
+ self.view = view
101
+
102
+ def _init_button_panel(self):
103
+ self.button_panel = QWidget()
104
+ self.hbox = QHBoxLayout(self.button_panel)
105
+
106
+ self.cancel_button = QPushButton('Cancel')
107
+ self.cancel_button.setShortcut('Esc')
108
+ self.hbox.addWidget(self.cancel_button)
109
+ self.cancel_button.clicked.connect(self.close)
110
+
111
+ self.select_button = QPushButton('OK')
112
+ self.select_button.setShortcut('Return')
113
+ self.hbox.addWidget(self.select_button)
114
+ self.select_button.clicked.connect(self.select)
115
+
116
+ def set_device_list(self, devices, current_device):
117
+ self.model.setRowCount(0)
118
+ selection = None
119
+ for i, device in enumerate(devices):
120
+ name = device['name']
121
+ description = device['description']
122
+ if name == current_device:
123
+ selection = i
124
+ name_item = QStandardItem(name)
125
+ description_item = QStandardItem(description)
126
+ self.model.appendRow((name_item, description_item))
127
+ if selection is not None:
128
+ self.view.selectRow(selection)
129
+
130
+ def select(self):
131
+ row = self.view.selectedIndexes()[0].row()
132
+ name = self.model.item(row, 0).text()
133
+ self.Select.emit(name)
134
+ self.close()
135
+
136
+ def close(self):
137
+ self.hide()
138
+
139
+
140
+
141
+ def _hms_to_seconds(time):
142
+ hrmin, c, sec = time.rpartition(':')
143
+ hr, c, minute = hrmin.rpartition(':')
144
+
145
+ if hr:
146
+ hrf = float(hr)
147
+ else:
148
+ hrf = 0
149
+ if minute:
150
+ minutef = float(minute)
151
+ else:
152
+ minutef = 0
153
+ if sec:
154
+ secf = float(sec)
155
+ else:
156
+ secf = 0
157
+
158
+ minutef += hrf * 60
159
+ secf += minutef * 60
160
+
161
+ return secf
162
+
163
+
164
+ class Player(QObject):
165
+ """Audio player that uses libmpv as a backend. Inherits from
166
+ QObject. It plays audio from the start position to the end position
167
+ with pitch and tempo correction, with the `start`, `end`, `pitch`
168
+ and `tempo` parameters set by the `play` and `update_corrections`
169
+ methods.
170
+
171
+ Parameters
172
+ ----------
173
+ parent
174
+ The parent widget.
175
+
176
+ Attributes
177
+ ----------
178
+ time_number : int
179
+ The total number of times a TickPos or TickRem signal has been
180
+ emitted. This can be used for diagnostic purposes. If it
181
+ increases too much faster than 60 times per second, libmpv may
182
+ be misbehaving.
183
+ """
184
+
185
+ TickPos = pyqtSignal(bool, int)
186
+ """Signal emitted when the time from the beginning of the audio
187
+ changes. Although it has one second resolution, it may be emitted
188
+ more than once per second. The exact rate is controlled by libmpv.
189
+ Note that this is the time from the beginning of the audio, not the
190
+ `start` position. If tempo correction is applied, it won't change
191
+ the time, but it will tick at a rate other than 1 second per second.
192
+
193
+ Parameters
194
+ ----------
195
+ bool
196
+ True if audio is loaded and the time is valid, False otherwise.
197
+ int
198
+ The time in seconds.
199
+ """
200
+
201
+ TickRem = pyqtSignal(bool, int)
202
+ """Signal emitted when the time remaining to the end of the audio
203
+ changes. Although it has one second resolution, it may be emitted
204
+ more than once per second. The exact rate is controlled by libmpv.
205
+ Note that this is the time to the end of the audio, not the `end`
206
+ position. If tempo correction is applied, it won't change the time,
207
+ but it will tick at a rate other than 1 second per second.
208
+
209
+ Parameters
210
+ ----------
211
+ bool
212
+ True if audio is loaded and the time is valid, False otherwise.
213
+ int
214
+ The time in seconds.
215
+ """
216
+
217
+ Percent = pyqtSignal(int)
218
+ """Signal emitted to indicate the position in the audio as a
219
+ percentage. Unlike TickPos and TickRem, this does take into account
220
+ the `start` and `end` positions, so it will progresses from 0% to
221
+ 100% even if `start` to `end` only covers a small portion of the
222
+ audio.
223
+
224
+ Parameters
225
+ ----------
226
+ int
227
+ The percentage
228
+ """
229
+
230
+ PauseStatus = pyqtSignal(bool)
231
+ """Signal emitted when the pause status changes.
232
+
233
+ Parameters
234
+ ----------
235
+ bool
236
+ True if paused, False otherwise.
237
+ """
238
+
239
+ AddToLog = pyqtSignal(str, int)
240
+ """Signal emitted to request a message be added to the log.
241
+
242
+ Parameters
243
+ ----------
244
+ str
245
+ The message.
246
+ int
247
+ The severity level as defined in audio_tuner_gui.log_viewer
248
+ (LOG_LEVEL_ERROR, LOG_LEVEL_WARNING or LOG_LEVEL_NORMAL).
249
+ """
250
+
251
+ def __init__(self, parent):
252
+ super().__init__()
253
+
254
+ self._player = None
255
+
256
+ self._time_pos = None
257
+ self._time_rem = None
258
+ self.time_number = 0
259
+
260
+ self._pitch_t = 1.0
261
+ self._pitch = 1.0
262
+ self._tempo = 1.0
263
+ self._start = None
264
+ self._end = None
265
+ self._duration = None
266
+
267
+ self._start_percent = 0
268
+
269
+ self._device = 'auto'
270
+
271
+ self._device_window = _DeviceWindow(parent)
272
+ self._device_window.Select.connect(self.set_device)
273
+
274
+ atexit.register(self._stop_player)
275
+
276
+ def _player_time_to_percent(self, time: str, endmode: bool=False):
277
+ p = None
278
+
279
+ if time is None:
280
+ time = 'none'
281
+
282
+ time = time.strip(' ')
283
+ if time.startswith('-'):
284
+ time = time.lstrip('-')
285
+ negative = True
286
+ else:
287
+ negative = False
288
+
289
+ if time == 'none':
290
+ return 100.0 if endmode else 0.0
291
+
292
+ if time.endswith('%'):
293
+ try:
294
+ p = float(time.rstrip('%'))
295
+ except ValueError:
296
+ return None
297
+
298
+ elif d := self.get_duration():
299
+ try:
300
+ secf = _hms_to_seconds(time)
301
+ except ValueError:
302
+ return None
303
+ p = 100.0 * (secf / d)
304
+
305
+ if p:
306
+ if negative:
307
+ return 100 - p
308
+ else:
309
+ return p
310
+
311
+ return None
312
+
313
+ def _player_time_to_seconds(self, time: str, endmode: bool=False):
314
+ p = None
315
+ d = self.get_duration()
316
+
317
+ if time is None:
318
+ time = 'none'
319
+
320
+ time = time.strip(' ')
321
+ if time.startswith('-'):
322
+ time = time.lstrip('-')
323
+ negative = True
324
+ else:
325
+ negative = False
326
+
327
+ if time == 'none':
328
+ return d if endmode else 0.0
329
+
330
+ if time.endswith('%'):
331
+ try:
332
+ p = float(time.rstrip('%'))
333
+ except ValueError:
334
+ return None
335
+ secf = (p / 100.0) * d
336
+
337
+ else:
338
+ try:
339
+ secf = _hms_to_seconds(time)
340
+ except ValueError:
341
+ return None
342
+
343
+ if secf:
344
+ if negative:
345
+ return d - secf
346
+ else:
347
+ return secf
348
+
349
+ return None
350
+
351
+ def _percent_player_to_slider(self, percent):
352
+ if self._player is not None:
353
+ start = self._start
354
+ end = self._end
355
+
356
+ startf = self._player_time_to_percent(start)
357
+ endf = self._player_time_to_percent(end, endmode=True)
358
+ if startf is None or endf is None:
359
+ return percent
360
+
361
+ ret = (percent - startf) * (100.0 / (endf - startf))
362
+ return max(0.0, min(100.0, ret))
363
+
364
+ def _percent_slider_to_player(self, percent):
365
+ if self._player is not None:
366
+ start = self._start
367
+ end = self._end
368
+
369
+ startf = self._player_time_to_percent(start)
370
+ endf = self._player_time_to_percent(end, endmode=True)
371
+ if startf is None or endf is None:
372
+ return percent
373
+
374
+ ret = (percent * (endf - startf)) / 100 + startf
375
+ return max(0.0, min(100.0, ret))
376
+
377
+ def _ensure_player_is_running(self,
378
+ pitch=1.0,
379
+ tempo=1.0,
380
+ start=None,
381
+ end=None):
382
+ if mpv_error is not None:
383
+ self.AddToLog.emit(mpv_error, lv.LOG_LEVEL_ERROR)
384
+ raise OSError
385
+
386
+ if self._player is not None and self._player.core_shutdown:
387
+ self._unobserve()
388
+ self._player.terminate()
389
+ self._player = None
390
+ if self._player is None:
391
+ pitch_t = pitch / tempo
392
+ self._pitch_t = pitch_t
393
+ self._pitch = pitch
394
+ self._tempo = tempo
395
+ mpv_opts = {
396
+ 'audio_pitch_correction': 'no',
397
+ 'keep_open': 'yes',
398
+ 'audio_device': self._device,
399
+ 'audio_fallback_to_null': 'yes',
400
+ 'af': f'@rb:rubberband=pitch-scale={pitch_t:.3f}',
401
+ 'speed': f'{tempo:.3f}'
402
+ }
403
+ if start:
404
+ mpv_opts['start'] = start
405
+ self._start = start
406
+ if end:
407
+ mpv_opts['end'] = end
408
+ self._end = end
409
+ self._player = mpv.MPV(**mpv_opts)
410
+
411
+ if self._player.audio_device == 'null':
412
+ s = f'Unable to open output device {self._device}'
413
+ self.AddToLog.emit(s, lv.LOG_LEVEL_ERROR)
414
+
415
+ self._player.observe_property('time-pos',
416
+ self._time_pos_observer)
417
+ self._player.observe_property('time-remaining',
418
+ self._time_rem_observer)
419
+ self._player.observe_property('percent-pos',
420
+ self._percent_observer)
421
+ self._player.observe_property('pause',
422
+ self._pause_observer)
423
+
424
+ def _unobserve(self):
425
+ if self._player is not None:
426
+ try:
427
+ self._player.unobserve_property('time-pos',
428
+ self._time_pos_observer)
429
+ except ValueError as err:
430
+ self.AddToLog.emit(str(err), lv.LOG_LEVEL_ERROR)
431
+ try:
432
+ self._player.unobserve_property('time-remaining',
433
+ self._time_rem_observer)
434
+ except ValueError as err:
435
+ self.AddToLog.emit(str(err), lv.LOG_LEVEL_ERROR)
436
+ try:
437
+ self._player.unobserve_property('percent-pos',
438
+ self._percent_observer)
439
+ except ValueError as err:
440
+ self.AddToLog.emit(str(err), lv.LOG_LEVEL_ERROR)
441
+ try:
442
+ self._player.unobserve_property('pause',
443
+ self._pause_observer)
444
+ except ValueError as err:
445
+ self.AddToLog.emit(str(err), lv.LOG_LEVEL_ERROR)
446
+
447
+ def _restart_mpv(self, pitch=1.0, tempo=1.0, start=None, end=None):
448
+ if self._player is None:
449
+ self._ensure_player_is_running(pitch, tempo, start, end)
450
+ else:
451
+ path = com.raw_to_string(self._player.raw.path)
452
+ pause = self._player.pause
453
+ time_pos = self._player.time_pos
454
+
455
+ self._stop_player()
456
+
457
+ if path is not None:
458
+ self.play(path, pitch, tempo, start, end)
459
+ self._player.pause = pause
460
+ self._player.time_pos = time_pos
461
+ else:
462
+ self._ensure_player_is_running(pitch, tempo, start, end)
463
+ self._player.pause = pause
464
+
465
+ def update_corrections(self, pitch=None, tempo=None, start=None, end=None):
466
+ """Update the pitch correction, tempo correction, start time and
467
+ end time. See the `--start` option in the `mpv` manpage to find
468
+ out how to format the `start` and `end` parameters.
469
+
470
+ Parameters
471
+ ----------
472
+ pitch : float, optional
473
+ Pitch correction factor. Default None.
474
+ tempo : float, optional
475
+ Tempo correction factor. Default None.
476
+ start : str, optional
477
+ The position to start playing at. If None, start at the
478
+ beginning. This causes a seek iff the starting position set
479
+ is after the current position. Default None.
480
+ end : str, optional
481
+ The position to stop playing at. If None, end at the end.
482
+ Default None.
483
+ """
484
+
485
+ if self._player is not None:
486
+ if pitch is None and tempo is None:
487
+ self._update_start_end(start, end)
488
+ return
489
+
490
+ if pitch is None:
491
+ pitch = self._pitch
492
+ else:
493
+ self._pitch = pitch
494
+ if tempo is None:
495
+ tempo = self._tempo
496
+ pitch_t = pitch / tempo
497
+ try:
498
+ if abs(pitch_t - self._pitch_t) > .0011:
499
+ self._player.af_command('rb',
500
+ 'set-pitch',
501
+ f'{pitch_t:.3f}')
502
+ self._pitch_t = pitch_t
503
+ if abs(tempo - self._tempo) > .0011:
504
+ self._player.speed = f'{tempo:.3f}'
505
+ self._tempo = tempo
506
+ self._update_start_end(start, end)
507
+ except SystemError:
508
+ self._restart_mpv(pitch, tempo, start, end)
509
+
510
+ def _update_start_end(self, start, end):
511
+ if self._player is not None:
512
+ try:
513
+ if start:
514
+ self._player.start = start
515
+ self._start = start
516
+ else:
517
+ self._player.start = 'none'
518
+ self._start = None
519
+ if end:
520
+ self._player.end = end
521
+ self._end = end
522
+ else:
523
+ self._player.end = 'none'
524
+ self._end = None
525
+ except SystemError:
526
+ self._restart_mpv(self._pitch, self._tempo, start, end)
527
+
528
+ minimum = self._player_time_to_seconds(self._start)
529
+ try:
530
+ if self._player.time_pos < minimum:
531
+ self._player.time_pos = minimum
532
+ except TypeError:
533
+ pass
534
+
535
+ def play(self,
536
+ path: str,
537
+ duration=None,
538
+ pitch=1.0,
539
+ tempo=1.0,
540
+ start=None,
541
+ end=None):
542
+ """Load an audio file and play it.
543
+
544
+ Parameters
545
+ ----------
546
+ path : str
547
+ The path to the audio file.
548
+ duration : int, optional
549
+ The duration of the audio in the file. If this is not set,
550
+ the duration will be estimated by libmpv, which may be
551
+ inaccurate.
552
+ pitch : float, optional
553
+ Pitch correction factor. Default 1.0.
554
+ tempo : float, optional
555
+ Tempo correction factor. Default 1.0.
556
+ start : str, optional
557
+ The position to start playing at. If None, start at the
558
+ beginning. Default None.
559
+ end : str, optional
560
+ The position to stop playing at. If None, end at the end.
561
+ Default None.
562
+ """
563
+
564
+ self._ensure_player_is_running(pitch, tempo, start, end)
565
+
566
+ self.update_corrections(pitch, tempo, start, end)
567
+
568
+ if self.get_currently_playing() is not None:
569
+ self.stop()
570
+
571
+ self._duration = duration
572
+
573
+ if self._start_percent == 0:
574
+ self._player.command('loadfile',
575
+ com.string_to_raw(path),
576
+ 'replace')
577
+ else:
578
+ p = self._percent_slider_to_player(self._start_percent)
579
+ self._player.command('loadfile',
580
+ com.string_to_raw(path),
581
+ 'replace',
582
+ f'start={p}%')
583
+ self._start_percent = 0
584
+
585
+ def stop(self):
586
+ """Stop playing and unload the file."""
587
+
588
+ if self._player is not None:
589
+ self._player.stop()
590
+
591
+ def toggle_pause(self):
592
+ """Pause if unpaused, unpause if paused."""
593
+
594
+ if self._player is not None:
595
+ self._player.cycle('pause')
596
+
597
+ def set_pause(self, pause):
598
+ """Set the pause state.
599
+
600
+ Parameters
601
+ ----------
602
+ pause : bool
603
+ Pause if True, unpause if False.
604
+ """
605
+
606
+ if self._player is not None:
607
+ self._player.pause = pause
608
+
609
+ def _pause_observer(self, name, value):
610
+ self.PauseStatus.emit(value)
611
+
612
+ def get_currently_playing(self):
613
+ """Get the path of the currently loaded file.
614
+
615
+ Returns
616
+ -------
617
+ str
618
+ The path of the file.
619
+ """
620
+
621
+ if self._player is None:
622
+ return None
623
+ else:
624
+ return com.raw_to_string(self._player.raw.path)
625
+
626
+ def get_duration(self):
627
+ """Get the duration of the audio in the currently loaded file.
628
+
629
+ Returns
630
+ -------
631
+ float
632
+ The duration in seconds.
633
+ """
634
+
635
+ if self._player is None:
636
+ return None
637
+ if not self._duration:
638
+ self._duration = self._player.duration
639
+ return self._duration
640
+
641
+ def _time_pos_observer(self, name, value):
642
+ self.time_number += 1
643
+ try:
644
+ cur_time = int(value)
645
+ if cur_time != self._time_pos:
646
+ self._time_pos = cur_time
647
+ self.TickPos.emit(True, cur_time)
648
+ except TypeError:
649
+ self._time_pos = None
650
+ self.TickPos.emit(False, 0)
651
+
652
+ def _time_rem_observer(self, name, value):
653
+ self.time_number += 1
654
+ try:
655
+ cur_time = int(value + .9)
656
+ if cur_time != self._time_rem:
657
+ self._time_rem = cur_time
658
+ self.TickRem.emit(True, cur_time)
659
+ except TypeError:
660
+ self._time_rem = None
661
+ self.TickRem.emit(False, 0)
662
+
663
+ def _percent_observer(self, name, value):
664
+ if value is None:
665
+ self.Percent.emit(0)
666
+ else:
667
+ self.Percent.emit(int(self._percent_player_to_slider(value)))
668
+
669
+ def set_percent(self, percent):
670
+ """Set the playback position as a percentage. The percentage
671
+ works the same as in the `Percent` signal, so 0% sets it to
672
+ whatever position `start` is set to and 100% sets it to whatever
673
+ position `end` is set to.
674
+
675
+ Parameters
676
+ ----------
677
+ percent : numeric type
678
+ The percentage.
679
+ """
680
+
681
+ if self._player is not None and self._player.raw.path is not None:
682
+ self._player.percent_pos = self._percent_slider_to_player(percent)
683
+ else:
684
+ self._start_percent = percent
685
+
686
+ def back(self, amount=10.0):
687
+ """Seek backwards. If that would put the play position before
688
+ `start`, it goes to `start` instead.
689
+
690
+ Parameters
691
+ ----------
692
+ amount : float, optional
693
+ The amount of time to seek in seconds. Default 10.
694
+ """
695
+
696
+ if self._player is not None:
697
+ minimum = self._player_time_to_seconds(self._start)
698
+ if minimum is not None:
699
+ new = max(self._time_pos - amount, minimum)
700
+ self._player.time_pos = new
701
+
702
+ def forward(self, amount=10.0):
703
+ """Seek forwards. If that would put the play position after
704
+ `end`, it goes to `end` instead.
705
+
706
+ Parameters
707
+ ----------
708
+ amount : float, optional
709
+ The amount of time to seek in seconds. Default 10.
710
+ """
711
+
712
+ if self._player is not None:
713
+ self._player.seek(amount)
714
+
715
+ def get_current_position(self):
716
+ """Get the current play position, or None if the player is not
717
+ running.
718
+
719
+ Returns
720
+ -------
721
+ float
722
+ The play position.
723
+ """
724
+
725
+ if self._player is not None:
726
+ return self._player.time_pos
727
+
728
+ def show_device_window(self):
729
+ """Open a window where the user can select an audio output
730
+ device.
731
+ """
732
+
733
+ self._ensure_player_is_running()
734
+ self._device_window.set_device_list(self._player.audio_device_list,
735
+ self._player.audio_device)
736
+ self._device_window.show()
737
+
738
+ def set_device(self, device):
739
+ """Set the audio output device.
740
+
741
+ Parameters
742
+ ----------
743
+ device : str
744
+ The device. See `--audio-device` in the mpv manpage for a
745
+ description of what to put here.
746
+ """
747
+
748
+ self._device = device
749
+ if self._player is not None:
750
+ self._player.audio_device = device
751
+
752
+ def _stop_player(self):
753
+ if self._player is not None:
754
+ self._unobserve()
755
+ self._player.command('quit')
756
+ self._player.terminate()
757
+ self._player = None