1
+ import os
2
+ import struct
3
+ import subprocess
4
+ import tempfile
5
+ import textwrap
6
+ import logging
7
+ import shutil
8
+ import threading
9
+ from core .widgets .base import BaseWidget
10
+ from core .validation .widgets .yasb .cava import VALIDATION_SCHEMA
11
+ from PyQt6 .QtWidgets import QHBoxLayout ,QWidget ,QLabel ,QApplication
12
+ from PyQt6 .QtGui import QLinearGradient ,QPainter ,QColor
13
+ from PyQt6 .QtCore import QTimer ,pyqtSignal
14
+
15
+ class CavaBar (QWidget ):
16
+ def __init__ (self ,cava_widget ):
17
+ super ().__init__ ()
18
+ self ._cava_widget = cava_widget
19
+ self .setFixedHeight (self ._cava_widget ._height )
20
+ self .setFixedWidth (self ._cava_widget ._bars_number * (self ._cava_widget ._bar_width + self ._cava_widget ._bar_spacing ))
21
+ self .setContentsMargins (0 ,0 ,0 ,0 )
22
+
23
+ def paintEvent (self ,event ):
24
+ painter = QPainter (self )
25
+ left_margin = self ._cava_widget ._bar_spacing // 2
26
+ for i ,sample in enumerate (self ._cava_widget .samples ):
27
+ x = left_margin + i * (self ._cava_widget ._bar_width + self ._cava_widget ._bar_spacing )
28
+ height = int (sample * self ._cava_widget ._height )
29
+ y = self ._cava_widget ._height - height
30
+ if height > 0 :
31
+ if self ._cava_widget ._gradient == 1 and self ._cava_widget .colors :
32
+ gradient = QLinearGradient (0 ,1 ,0 ,0 )
33
+ gradient .setCoordinateMode (QLinearGradient .CoordinateMode .ObjectBoundingMode )
34
+ stop_step = 1.0 / (len (self ._cava_widget .colors )- 1 )
35
+ for idx ,color in enumerate (self ._cava_widget .colors ):
36
+ gradient .setColorAt (idx * stop_step ,color )
37
+ painter .fillRect (x ,y ,self ._cava_widget ._bar_width ,height ,gradient )
38
+ else :
39
+ painter .fillRect (x ,y ,self ._cava_widget ._bar_width ,height ,self ._cava_widget .foreground_color )
40
+
41
+ class CavaWidget (BaseWidget ):
42
+ validation_schema = VALIDATION_SCHEMA
43
+ samplesUpdated = pyqtSignal (list )
44
+
45
+ def __init__ (
46
+ self ,
47
+ bar_height :int ,
48
+ bars_number :int ,
49
+ output_bit_format :str ,
50
+ bar_spacing :int ,
51
+ bar_width :int ,
52
+ sleep_timer :int ,
53
+ sensitivity :int ,
54
+ lower_cutoff_freq :int ,
55
+ higher_cutoff_freq :int ,
56
+ framerate :int ,
57
+ noise_reduction :float ,
58
+ mono_option :str ,
59
+ reverse :int ,
60
+ channels :str ,
61
+ foreground :str ,
62
+ gradient :bool ,
63
+ gradient_color_1 :str ,
64
+ gradient_color_2 :str ,
65
+ gradient_color_3 :str ,
66
+ hide_empty :bool ,
67
+ container_padding :dict [str ,int ],
68
+ ):
69
+ super ().__init__ (class_name = "cava-widget" )
70
+ # Widget configuration
71
+ self ._height = bar_height
72
+ self ._bars_number = bars_number
73
+ self ._output_bit_format = output_bit_format
74
+ self ._bar_spacing = bar_spacing
75
+ self ._bar_width = bar_width
76
+ self ._sleep_timer = sleep_timer
77
+ self ._sensitivity = sensitivity
78
+ self ._lower_cutoff_freq = lower_cutoff_freq
79
+ self ._higher_cutoff_freq = higher_cutoff_freq
80
+ self ._framerate = framerate
81
+ self ._noise_reduction = noise_reduction
82
+ self ._mono_option = mono_option
83
+ self ._reverse = reverse
84
+ self ._channels = channels
85
+ self ._foreground = foreground
86
+ self ._gradient = gradient
87
+ self ._gradient_color_1 = gradient_color_1
88
+ self ._gradient_color_2 = gradient_color_2
89
+ self ._gradient_color_3 = gradient_color_3
90
+ self ._hide_empty = hide_empty
91
+ self ._padding = container_padding
92
+ self ._hide_cava_widget = True
93
+ self ._stop_cava = False
94
+
95
+ # Set up samples and colors
96
+ self .samples = [0 ]* self ._bars_number
97
+ self .colors = []
98
+
99
+ # Construct container layout
100
+ self ._widget_container_layout :QHBoxLayout = QHBoxLayout ()
101
+ self ._widget_container_layout .setSpacing (0 )
102
+ self ._widget_container_layout .setContentsMargins (
103
+ self ._padding ['left' ],
104
+ self ._padding ['top' ],
105
+ self ._padding ['right' ],
106
+ self ._padding ['bottom' ]
107
+ )
108
+ self ._widget_container = QWidget ()
109
+ self ._widget_container .setLayout (self ._widget_container_layout )
110
+ self ._widget_container .setProperty ("class" ,"widget-container" )
111
+ self .widget_layout .addWidget (self ._widget_container )
112
+
113
+ # Check if cava is available
114
+ if shutil .which ("cava" )is None :
115
+ error_label = QLabel ("Cava not installed" )
116
+ self ._widget_container_layout .addWidget (error_label )
117
+ return
118
+
119
+ # Add the custom bar frame
120
+ self ._bar_frame = CavaBar (self )
121
+ self ._widget_container_layout .addWidget (self ._bar_frame )
122
+
123
+ # Connect signal and start audio processing
124
+ self .samplesUpdated .connect (self .on_samples_updated )
125
+ self .start_cava ()
126
+
127
+
128
+ # Set up auto-hide timer for silence
129
+ if self ._hide_empty and self ._sleep_timer > 0 :
130
+ self .hide ()
131
+ self ._hide_timer = QTimer (self )
132
+ self ._hide_timer .setInterval (self ._sleep_timer * 1000 )
133
+ self ._hide_timer .timeout .connect (self .hide_bar_frame )
134
+
135
+ if QApplication .instance ():
136
+ QApplication .instance ().aboutToQuit .connect (self .stop_cava )
137
+
138
+ def stop_cava (self )-> None :
139
+ self ._stop_cava = True
140
+ if hasattr (self ,"thread_cava" )and self .thread_cava .is_alive ():
141
+ self .thread_cava .join ()
142
+
143
+ def initialize_colors (self )-> None :
144
+ self .foreground_color = QColor (self ._foreground )
145
+ if self ._gradient == 1 :
146
+ for color_str in [self ._gradient_color_1 ,self ._gradient_color_2 ,self ._gradient_color_3 ]:
147
+ try :
148
+ self .colors .append (QColor (color_str ))
149
+ except Exception as e :
150
+ logging .error (f"Error setting gradient color '{ color_str } ':{ e } " )
151
+
152
+ def on_samples_updated (self ,new_samples :list )-> None :
153
+ try :
154
+ self .samples = new_samples
155
+ except Exception :
156
+ return
157
+ if any (val != 0 for val in new_samples ):
158
+ try :
159
+ if self ._hide_empty and self ._sleep_timer > 0 :
160
+ if self ._hide_cava_widget :
161
+ self .show ()
162
+ self ._hide_cava_widget = False
163
+ self ._hide_timer .start ()
164
+ self ._bar_frame .update ()
165
+ except Exception as e :
166
+ logging .error (f"Error updating cava widget:{ e } " )
167
+
168
+ def hide_bar_frame (self )-> None :
169
+ self .hide ()
170
+ self ._hide_cava_widget = True
171
+
172
+ def start_cava (self )-> None :
173
+ # Build configuration file, temp config file will be created in %temp% directory
174
+ config_template = textwrap .dedent (f"""\
175
+ # Cava config auto-generated by YASB
176
+ [general]
177
+ bars ={ self ._bars_number }
178
+ bar_spacing ={ self ._bar_spacing }
179
+ bar_width ={ self ._bar_width }
180
+ sleep_timer ={ self ._sleep_timer }
181
+ sensitivity ={ self ._sensitivity }
182
+ lower_cutoff_freq ={ self ._lower_cutoff_freq }
183
+ higher_cutoff_freq ={ self ._higher_cutoff_freq }
184
+ framerate ={ self ._framerate }
185
+ noise_reduction ={ self ._noise_reduction }
186
+ [output]
187
+ method = raw
188
+ bit_format ={ self ._output_bit_format }
189
+ channels ={ self ._channels }
190
+ mono_option ={ self ._mono_option }
191
+ reverse ={ self ._reverse }
192
+ [color]
193
+ foreground = '{ self ._foreground } '
194
+ gradient ={ self ._gradient }
195
+ gradient_color_1 = '{ self ._gradient_color_1 } '
196
+ gradient_color_2 = '{ self ._gradient_color_2 } '
197
+ gradient_color_3 = '{ self ._gradient_color_3 } '
198
+ """ )
199
+
200
+ self .initialize_colors ()
201
+
202
+ # Determine byte type settings for reading audio data
203
+ if self ._output_bit_format == "16bit" :
204
+ bytetype ,bytesize ,bytenorm = ("H" ,2 ,65535 )
205
+ else :
206
+ bytetype ,bytesize ,bytenorm = ("B" ,1 ,255 )
207
+
208
+ def process_audio ():
209
+ try :
210
+ cava_config_path = os .path .join (tempfile .gettempdir (),"yasb_cava_config" )
211
+ with open (cava_config_path ,"w" )as config_file :
212
+ config_file .write (config_template )
213
+ config_file .flush ()
214
+ process = subprocess .Popen (
215
+ ["cava" ,"-p" ,cava_config_path ],
216
+ stdout = subprocess .PIPE ,
217
+ creationflags = subprocess .CREATE_NO_WINDOW
218
+ )
219
+ chunk = bytesize * self ._bars_number
220
+ fmt = bytetype * self ._bars_number
221
+ while True :
222
+ try :
223
+ data = process .stdout .read (chunk )
224
+ except Exception as e :
225
+ return
226
+ if len (data )< chunk :
227
+ break
228
+ samples = [val / bytenorm for val in struct .unpack (fmt ,data )]
229
+ if self ._stop_cava :
230
+ break
231
+ self .samplesUpdated .emit (samples )
232
+ except Exception as e :
233
+ logging .error (f"Error processing audio in Cava:{ e } " )
234
+ finally :
235
+ process .terminate ()
236
+
237
+ self .thread_cava = threading .Thread (target = process_audio ,daemon = True )
238
+ self .thread_cava .start ()