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,605 @@
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
+ """Analysis display widget for the GUI."""
22
+
23
+
24
+ __author__ = 'Jessie Blue Cassell'
25
+
26
+
27
+ __all__ = [
28
+ 'Display',
29
+ ]
30
+
31
+
32
+ import math
33
+
34
+ from PyQt6.QtCore import (
35
+ Qt,
36
+ pyqtSignal,
37
+ QRectF,
38
+ QPointF,
39
+ )
40
+ from PyQt6.QtWidgets import (
41
+ QGraphicsView,
42
+ QGraphicsScene,
43
+ QGraphicsItem,
44
+ QGraphicsTextItem,
45
+ QGraphicsLineItem,
46
+ QGraphicsPolygonItem,
47
+ QGraphicsRectItem,
48
+ )
49
+
50
+ from PyQt6.QtGui import (
51
+ QColor,
52
+ QPainter,
53
+ QPen,
54
+ QBrush,
55
+ QTransform,
56
+ QPolygonF,
57
+ QLinearGradient,
58
+ )
59
+
60
+ import audio_tuner.common as com
61
+
62
+
63
+ DISPLAY_BG_COLOR = QColor(10, 10, 20)
64
+ _DISPLAY_HEADER_COLOR = QColor(200, 200, 200)
65
+ _DISPLAY_BRIGHT_COLOR = QColor(20, 180, 30)
66
+ DISPLAY_DATA_COLOR = QColor(10, 150, 20)
67
+ _METER_SCALE_COLOR = QColor(100, 100, 100)
68
+ _METER_ZERO_COLOR = QColor(240, 240, 240)
69
+
70
+ _TITLE_X = .015
71
+ _TITLE_Y = .033
72
+ _TITLE_H = .045
73
+
74
+ _HEADER_Y = .07
75
+ _HEADER_H = .035
76
+ _HEADER_DATA = (
77
+ ('Note', .025),
78
+ ('Standard', .142),
79
+ ('Measured', .295),
80
+ ('Discrepancy', .445),
81
+ ('Correction', .87),
82
+ )
83
+
84
+
85
+ class _DisplayScene(QGraphicsScene):
86
+ def drawBackground(self, painter, rect):
87
+ w = self.width()
88
+ h = self.height()
89
+ margin = 1
90
+ x = margin
91
+ y = margin
92
+ w -= 2 * margin
93
+ h -= 2 * margin
94
+ painter.setBrush(DISPLAY_BG_COLOR)
95
+ painter.setPen(Qt.PenStyle.NoPen)
96
+ r = .02 * w
97
+ painter.drawRoundedRect(QRectF(x, y, w, h), r, r)
98
+
99
+
100
+ class _Text(QGraphicsTextItem):
101
+ def __init__(self, string, color):
102
+ super().__init__(string)
103
+ self.setDefaultTextColor(color)
104
+ self.setTextInteractionFlags(
105
+ Qt.TextInteractionFlag.TextSelectableByMouse)
106
+ self.setCursor(Qt.CursorShape.IBeamCursor)
107
+
108
+ def set_geo(self, x, y, h=None, right=False):
109
+ self.orig_w = self.boundingRect().width()
110
+ self.orig_h = self.boundingRect().height()
111
+ parent = self.parentItem()
112
+ if parent:
113
+ parent_scale = parent.scale()
114
+ else:
115
+ parent_scale = 1.0
116
+ if h:
117
+ scalefactor = h / (self.orig_h * parent_scale)
118
+ self.setScale(scalefactor)
119
+ self.scalefactor = scalefactor
120
+ else:
121
+ scalefactor = self.scale() * parent_scale
122
+ h = self.orig_h * scalefactor
123
+ if right:
124
+ x -= self.orig_w * scalefactor
125
+ y -= h/2
126
+ self.setPos(x / parent_scale, y)
127
+
128
+ def set_squish(self, squish_factor):
129
+ self.setTransform(QTransform(squish_factor, 0, 0,
130
+ 1, 0, 0),
131
+ combine=False)
132
+
133
+
134
+ class _TitleText():
135
+ def __init__(self, scene, string, color):
136
+ text = _Text(string, color)
137
+ scene.addItem(text)
138
+ self.text = text
139
+
140
+ def update_data(self, string):
141
+ self.text.setPlainText(string)
142
+ self.set_geo(self.x, self.y, self.h, self.max_w)
143
+
144
+ def set_geo(self, x, y, h, max_w=None):
145
+ self.x = x
146
+ self.y = y
147
+ self.h = h
148
+ self.max_w = max_w
149
+ self.text.set_geo(x, y, h)
150
+ if max_w is not None:
151
+ w = self.text.boundingRect().width() * self.text.scale()
152
+ squish_factor = max_w / w
153
+ if squish_factor < 1:
154
+ self.text.set_squish(squish_factor)
155
+ else:
156
+ self.text.set_squish(1)
157
+
158
+
159
+
160
+ class _Headers():
161
+ def __init__(self, scene, color):
162
+ self.data = _HEADER_DATA
163
+
164
+ self.headers = []
165
+ for x in self.data:
166
+ header = _Text(x[0], color)
167
+ self.headers.append(header)
168
+ scene.addItem(header)
169
+ header.setZValue(2)
170
+
171
+ def show(self):
172
+ for header in self.headers:
173
+ header.show()
174
+
175
+ def hide(self):
176
+ for header in self.headers:
177
+ header.hide()
178
+
179
+ def update_size(self, view_w, view_h):
180
+ for i, header in enumerate(self.headers):
181
+ x = self.data[i][1] * view_w
182
+ y = _HEADER_Y * view_w
183
+ h = _HEADER_H * view_w
184
+ header.set_geo(x, y, h)
185
+
186
+
187
+ class _Meter():
188
+ def __init__(self):
189
+ self.main_thickness = .4
190
+ needle_length = 6
191
+ needle_thickness = 2.6
192
+ needle_middle = 1
193
+ minor_tick_length = 1
194
+ major_tick_length = 2
195
+
196
+ main_color = _METER_SCALE_COLOR
197
+ zero_color = _METER_ZERO_COLOR
198
+ needle_color = _DISPLAY_BRIGHT_COLOR
199
+
200
+ main_pen = QPen(main_color, self.main_thickness)
201
+ zero_pen = QPen(zero_color, self.main_thickness)
202
+ needle_brush = QBrush(needle_color)
203
+ self.mainline = QGraphicsLineItem(-50, 0, 50, 0)
204
+ self.mainline.setPen(main_pen)
205
+
206
+ needlepoly = QPolygonF((
207
+ QPointF(-needle_thickness / 2, needle_length / 2),
208
+ QPointF(needle_thickness / 2, needle_length / 2),
209
+ QPointF(needle_middle / 2, 0),
210
+ QPointF(needle_thickness / 2, -needle_length / 2),
211
+ QPointF(-needle_thickness / 2, -needle_length / 2),
212
+ QPointF(-needle_middle / 2, 0),
213
+ ))
214
+ needle = QGraphicsPolygonItem(needlepoly)
215
+ needle.setPen(QPen(Qt.PenStyle.NoPen))
216
+ needle.setBrush(needle_brush)
217
+ needle.setParentItem(self.mainline)
218
+ needle.setFlag(QGraphicsItem.GraphicsItemFlag.ItemStacksBehindParent)
219
+ self.needle = needle
220
+
221
+ ghostneedle = QGraphicsPolygonItem(needlepoly)
222
+ ghostneedle.setPen(QPen(needle_color, self.main_thickness / 2))
223
+ ghostneedle.setBrush(QBrush(Qt.BrushStyle.NoBrush))
224
+ ghostneedle.setParentItem(self.needle)
225
+ ghostneedle.setFlag(
226
+ QGraphicsItem.GraphicsItemFlag.ItemStacksBehindParent)
227
+ self.ghostneedle = ghostneedle
228
+
229
+ for x in range(-50, 51, 25):
230
+ if x == 0:
231
+ pen = zero_pen
232
+ else:
233
+ pen = main_pen
234
+ if x % 50 == 0:
235
+ y = major_tick_length
236
+ else:
237
+ y = minor_tick_length
238
+ QGraphicsLineItem(x, 0,
239
+ x, y,
240
+ self.mainline).setPen(pen)
241
+
242
+ for x in range(975, 1026, 5):
243
+ if x == 1000:
244
+ pen = zero_pen
245
+ else:
246
+ pen = main_pen
247
+ if x % 10 == 0:
248
+ y = major_tick_length
249
+ else:
250
+ y = minor_tick_length
251
+ x = (-1.0) * com.ratio_to_cents(x/1000)
252
+ QGraphicsLineItem(x, 0,
253
+ x, -y,
254
+ self.mainline).setPen(pen)
255
+
256
+ def setParentItem(self, parent):
257
+ self.parent = parent
258
+ self.mainline.setParentItem(parent)
259
+
260
+ def set_data(self, cents):
261
+ self.needle.setPos(cents, 0)
262
+
263
+ def set_ghost_offset(self, cents):
264
+ self.ghostneedle.setPos(cents, 0)
265
+
266
+ def set_geo(self, x, y, w):
267
+ scalefactor = w / 100
268
+ self.mainline.setScale(scalefactor)
269
+ self.mainline.setPos(x, y)
270
+
271
+
272
+ class _Row():
273
+ pass
274
+
275
+
276
+ class _ReferencePoint(QGraphicsItem):
277
+ def boundingRect(self):
278
+ return QRectF(self.x(), self.y(), 0, 0)
279
+
280
+ def paint(self, a, b, c):
281
+ pass
282
+
283
+
284
+ class _ClipRect(QGraphicsRectItem):
285
+ def paint(self, a, b, c):
286
+ pass
287
+
288
+
289
+ class _Rows(list):
290
+ def __init__(self, scene):
291
+ super().__init__()
292
+ self.view_w = None
293
+
294
+ self.color1 = DISPLAY_DATA_COLOR
295
+
296
+ self.vertical_offset = 0.0
297
+
298
+ self.scene = scene
299
+
300
+ self.cliprect = _ClipRect()
301
+ self.cliprect.setFlag(
302
+ QGraphicsItem.GraphicsItemFlag.ItemClipsChildrenToShape)
303
+ self.cliprect.setFlag(
304
+ QGraphicsItem.GraphicsItemFlag.ItemHasNoContents)
305
+ scene.addItem(self.cliprect)
306
+
307
+ self.headers = _Headers(scene, _DISPLAY_HEADER_COLOR)
308
+ self.headers.hide()
309
+
310
+ self.top_fade = QGraphicsRectItem()
311
+ self.bot_fade = QGraphicsRectItem()
312
+ self.top_fade.setZValue(1)
313
+ self.bot_fade.setZValue(1)
314
+ self.top_fade.setPen(QPen(Qt.PenStyle.NoPen))
315
+ self.bot_fade.setPen(QPen(Qt.PenStyle.NoPen))
316
+ top_grad = QLinearGradient()
317
+ top_grad.setCoordinateMode(QLinearGradient.CoordinateMode.ObjectMode)
318
+ transparent = QColor(DISPLAY_BG_COLOR)
319
+ transparent.setAlpha(0)
320
+ top_grad.setColorAt(1, transparent)
321
+ top_grad.setColorAt(0, DISPLAY_BG_COLOR)
322
+ top_grad.setStart(QPointF(0, 0))
323
+ top_grad.setFinalStop(QPointF(0, 1))
324
+ bot_grad = QLinearGradient(top_grad)
325
+ bot_grad.setStart(QPointF(0, 1))
326
+ bot_grad.setFinalStop(QPointF(0, 0))
327
+ self.top_fade.setBrush(QBrush(top_grad))
328
+ self.bot_fade.setBrush(QBrush(bot_grad))
329
+ scene.addItem(self.top_fade)
330
+ scene.addItem(self.bot_fade)
331
+
332
+ self.bar = QGraphicsRectItem()
333
+ self.bar.setZValue(1.5)
334
+ self.bar.setBrush(QBrush(self.color1))
335
+ self.bar.setPen(QPen(Qt.PenStyle.NoPen))
336
+ self.bar.hide()
337
+ scene.addItem(self.bar)
338
+
339
+ def add_row(self):
340
+ row = _Row()
341
+ row.parent = _ReferencePoint()
342
+ row.parent.setFlag(
343
+ QGraphicsItem.GraphicsItemFlag.ItemHasNoContents)
344
+ if len(self) == 0:
345
+ self.headers.show()
346
+ row.parent.setParentItem(self.cliprect)
347
+ row.parent.setFlag(
348
+ QGraphicsItem.GraphicsItemFlag.ItemIsMovable)
349
+ else:
350
+ row.parent.setParentItem(self[0].parent)
351
+ row.note = _Text(' ', self.color1)
352
+ row.note.setParentItem(row.parent)
353
+ row.standard = _Text(' ', self.color1)
354
+ row.standard.setParentItem(row.parent)
355
+ row.measured = _Text(' ', self.color1)
356
+ row.measured.setParentItem(row.parent)
357
+ row.cents = _Text(' ', self.color1)
358
+ row.cents.setParentItem(row.parent)
359
+ row.meter = _Meter()
360
+ row.meter.setParentItem(row.parent)
361
+ row.correction = _Text(' ', self.color1)
362
+ row.correction.setParentItem(row.parent)
363
+ self.append(row)
364
+
365
+ def remove_row(self):
366
+ self.scene.removeItem(self[-1].parent)
367
+ del self[-1]
368
+ if len(self) == 0:
369
+ self.headers.hide()
370
+
371
+ def update_data(self, result_rows):
372
+ """Update the data displayed in the rows.
373
+
374
+ Parameters
375
+ ----------
376
+ result_rows : list[audio_tuner_gui.common.RowData]
377
+ The row data for each row.
378
+ """
379
+
380
+ while len(self) > len(result_rows):
381
+ self.remove_row()
382
+ while len(self) < len(result_rows):
383
+ self.add_row()
384
+
385
+ for i, row in enumerate(result_rows):
386
+ self[i].note.setPlainText(row['note'])
387
+ self[i].standard.setPlainText(f'{row["standard"]:>8.2f} Hz')
388
+ self[i].measured.setPlainText(f'{row["measured"]:>8.2f} Hz')
389
+ self[i].cents.setPlainText(f'{row["cents"]:>+3.0f} c')
390
+ self[i].meter.set_data(row['cents'])
391
+ self[i].correction.setPlainText(f'{row["correction"]:>8.3f}')
392
+ self.update_size(self.view_w, self.view_h, force=True)
393
+
394
+ def update_ghost_offset(self, cents):
395
+ for row in self:
396
+ row.meter.set_ghost_offset(cents)
397
+
398
+ def update_size(self, view_w, view_h, force=False):
399
+ old_view_w = self.view_w
400
+ self.view_w = view_w
401
+ self.view_h = view_h
402
+ clip_x = .023 * view_w
403
+ clip_y = .08 * view_w - 2
404
+ self.clip_y = clip_y
405
+ clip_w = .962 * view_w
406
+ clip_h = view_h - (clip_y + 6)
407
+ self.clip_h = clip_h
408
+ fade_x = clip_x
409
+ fade_w = clip_w - 5
410
+ fade_h = view_w * .015
411
+ self.cliprect.setRect(QRectF(clip_x, clip_y,
412
+ clip_w, clip_h))
413
+ self.top_fade.setRect(QRectF(fade_x, clip_y - 2,
414
+ fade_w, fade_h))
415
+ self.bot_fade.setRect(QRectF(fade_x, clip_y + clip_h - fade_h + 2,
416
+ fade_w, fade_h))
417
+ for i, row in enumerate(self):
418
+ h = .04 * view_w
419
+ if i == 0:
420
+ row.parent.setPos(clip_x,
421
+ .1 * view_w + self.vertical_offset)
422
+ elif old_view_w != view_w or force:
423
+ row.parent.setPos(0, .03 * i * view_w)
424
+ self.headers.update_size(view_w, view_h)
425
+ if old_view_w != view_w or force:
426
+ row.note.set_geo(0, 0, h)
427
+ row.standard.set_geo(.24 * view_w, 0, h, right=True)
428
+ row.measured.set_geo(.4 * view_w, 0, h, right=True)
429
+ row.cents.set_geo(.486 * view_w, 0, h, right=True)
430
+ row.meter.set_geo(.68 * view_w, 0, .37 * view_w)
431
+ row.correction.set_geo(.855 * view_w, 0, h, right=False)
432
+ self.max_drag = (len(self) * .03 * self.view_w
433
+ - self.view_h
434
+ + .11 * self.view_w)
435
+ self.vertical_drag(0)
436
+ self.update_bar()
437
+
438
+ def update_bar(self):
439
+ try:
440
+ bar_h_factor = ((self.view_h - .11 * self.view_w)
441
+ / (len(self) * .03 * self.view_w))
442
+ except ZeroDivisionError:
443
+ bar_h_factor = 2
444
+ if bar_h_factor < 1:
445
+ bar_h = bar_h_factor * self.clip_h
446
+ self.bar_h = bar_h
447
+ bar_offset = ((-self.vertical_offset / self.max_drag)
448
+ * (self.clip_h - bar_h))
449
+ self.bar.setRect(QRectF(.985 * self.view_w,
450
+ self.clip_y + bar_offset,
451
+ .005 * self.view_w,
452
+ bar_h))
453
+ self.bar.show()
454
+ else:
455
+ self.bar.hide()
456
+
457
+ def vertical_drag(self, event, scroll_bar_click=False):
458
+ if scroll_bar_click:
459
+ event = -((event / (self.clip_h - self.bar_h)) * self.max_drag)
460
+ delta = max(event, -self.max_drag - self.vertical_offset)
461
+ delta = min(delta, -self.vertical_offset)
462
+ try:
463
+ self[0].parent.moveBy(0, delta)
464
+ except IndexError:
465
+ return
466
+ self.vertical_offset += delta
467
+ self.update_bar()
468
+
469
+ def one_up(self):
470
+ self.vertical_drag(.03 * self.view_w)
471
+
472
+ def one_down(self):
473
+ self.vertical_drag(-.03 * self.view_w)
474
+
475
+ def page_up(self):
476
+ n = math.floor((self.view_h - .1 * self.view_w) / (.03 * self.view_w))
477
+ self.vertical_drag(.03 * self.view_w * n)
478
+
479
+ def page_down(self):
480
+ n = math.floor((self.view_h - .1 * self.view_w) / (.03 * self.view_w))
481
+ self.vertical_drag(-.03 * self.view_w * n)
482
+
483
+
484
+ class Display(QGraphicsView):
485
+ """A widget that displays the results of the analysis. Inherits
486
+ from QGraphicsView.
487
+ """
488
+
489
+ VerticalDrag = pyqtSignal(float, bool)
490
+ """
491
+ """
492
+
493
+ def __init__(self):
494
+ self._w = 101
495
+ self._h = 101
496
+ self._old_w = 101
497
+ self._old_h = 101
498
+ self._scroll_bar_click = False
499
+
500
+ self._margin = 10
501
+
502
+ self._scene = _DisplayScene(0, 0, self._w, self._h)
503
+
504
+ super().__init__(self._scene)
505
+
506
+ self._init_display(' ')
507
+
508
+ self.setRenderHint(QPainter.RenderHint.Antialiasing)
509
+
510
+ self.setAcceptDrops(False)
511
+
512
+ def _init_display(self, title):
513
+ self._title = _TitleText(self._scene, title, DISPLAY_DATA_COLOR)
514
+
515
+ self._rows = _Rows(self._scene)
516
+ self.VerticalDrag.connect(self._rows.vertical_drag,
517
+ Qt.ConnectionType.QueuedConnection)
518
+
519
+ def mousePressEvent(self, event):
520
+ xpos = ((event.pos().x() - self._margin)
521
+ / (self._total_w - 2 * self._margin))
522
+ self._scroll_bar_click = xpos > .983
523
+ self._prev_mouse_pos = event.globalPosition().y()
524
+ super().mousePressEvent(event)
525
+
526
+ def mouseMoveEvent(self, event):
527
+ if event.buttons() & Qt.MouseButton.LeftButton:
528
+ mouse_pos = event.globalPosition().y()
529
+ mouse_delta = mouse_pos - self._prev_mouse_pos
530
+ self._prev_mouse_pos = mouse_pos
531
+ self.VerticalDrag.emit(mouse_delta, self._scroll_bar_click)
532
+ super().mouseMoveEvent(event)
533
+
534
+ def wheelEvent(self, event):
535
+ self._rows.vertical_drag(event.angleDelta().y())
536
+
537
+ def keyPressEvent(self, event):
538
+ if event.key() == Qt.Key.Key_Up:
539
+ self._rows.one_up()
540
+ if event.key() == Qt.Key.Key_Down:
541
+ self._rows.one_down()
542
+ if event.key() == Qt.Key.Key_PageUp:
543
+ self._rows.page_up()
544
+ if event.key() == Qt.Key.Key_PageDown:
545
+ self._rows.page_down()
546
+ super().keyPressEvent(event)
547
+
548
+ def resizeEvent(self, event):
549
+ margin = self._margin
550
+ self._total_w = event.size().width()
551
+ self._total_h = event.size().height()
552
+ w = self._total_w - 2 * margin
553
+ h = self._total_h - 2 * margin
554
+ # force even numbers to prevent jitter in the position of the
555
+ # display while resizing
556
+ h = (h // 2) * 2
557
+ w = (w // 2) * 2
558
+ self._w = w
559
+ self._h = h
560
+ if self._old_w != w:
561
+ self.setMinimumHeight(int(.12 * event.size().width() + 2 * margin))
562
+ if self._old_w != w or self._old_h != h:
563
+ self._old_w = w
564
+ self._old_h = h
565
+ self.setSceneRect(0, 0, w, h)
566
+ super().resizeEvent(event)
567
+ self.update()
568
+ self._scene.setSceneRect(0, 0, w, h)
569
+ self._title.set_geo(_TITLE_X * w,
570
+ _TITLE_Y * w,
571
+ _TITLE_H * w, w * .98)
572
+ self._rows.update_size(w, h)
573
+ self._scene.update()
574
+
575
+ def _add_row(self):
576
+ pass
577
+
578
+ def update_ghost_offset(self, cents):
579
+ """Set the offset of the ghost needles away from the main
580
+ needles.
581
+
582
+ Parameters
583
+ ----------
584
+ cents : float
585
+ The offset in cents.
586
+ """
587
+
588
+ self._rows.update_ghost_offset(cents)
589
+
590
+ def update_data(self, filename, result_rows):
591
+ """Update the displayed data.
592
+
593
+ Parameters
594
+ ----------
595
+ filename : str
596
+ The name of the song.
597
+ result_rows : list[audio_tuner_gui.common.RowData]
598
+ The row data for each row.
599
+ """
600
+
601
+ try:
602
+ self._title.update_data(filename)
603
+ except AttributeError:
604
+ self._init_display(filename)
605
+ self._rows.update_data(result_rows)