11"""
2- Audio Visualizer module for Party Mode easter egg .
2+ Audio Visualizer module.
33Uses WASAPI loopback to capture Windows audio output and perform FFT analysis.
44"""
55
@@ -58,8 +58,11 @@ def __init__(self, num_cpu_cores=8):
5858 # Pre-compute CPU frequency bands (log-spaced from 200Hz to 16kHz)
5959 self ._cpu_freq_bands = self ._compute_cpu_bands ()
6060
61+ # Global amplitude multiplier for fine-tuning
62+ self .amplitude = 2.0
63+
6164 # Smoothing factor (0-1, higher = smoother but less responsive)
62- self ._smoothing = 0.3
65+ self ._smoothing = 0.4
6366
6467 def _compute_cpu_bands (self ):
6568 """Compute log-spaced frequency bands for CPU cores."""
@@ -111,19 +114,40 @@ def _audio_callback(self, indata, frames, time_info, status):
111114 fft = np .fft .rfft (windowed )
112115 magnitudes = np .abs (fft )
113116
114- # Normalize (with some headroom to prevent clipping)
115- max_mag = np .max (magnitudes )
116- if max_mag > 0 :
117- magnitudes = magnitudes / max_mag
117+ # Normalize by block size to get approx 0-1 range
118+ # Increasing the divisor makes it more sensitive (e.g., /12 instead of /4)
119+ magnitudes = magnitudes / (len (audio ) / 12 )
118120
119- # Extract band magnitudes
120- ram_mag = self ._get_band_magnitude (magnitudes , self .BASS_LOW , self .BASS_HIGH ) * 100
121- swap_mag = self ._get_band_magnitude (magnitudes , self .LOW_MID_LOW , self .LOW_MID_HIGH ) * 100
122- disk_mag = self ._get_band_magnitude (magnitudes , self .HIGH_MID_LOW , self .HIGH_MID_HIGH ) * 100
121+ # Extract band magnitudes using a custom scaling
122+ # We don't normalize the whole array anymore, so volume is preserved.
123+
124+ def get_scaled_mag (low , high , boost = 1.0 ):
125+ val = self ._get_band_magnitude (magnitudes , low , high )
126+
127+ # Apply global amplitude and local boost
128+ val = val * self .amplitude * boost
129+
130+ if val <= 0.0001 :
131+ return 0.0
132+
133+ # Sharper log curve to boost low-level signals more aggressively
134+ # val=0.01 -> 0.17
135+ # val=0.1 -> 0.47
136+ # val=1.0 -> 1.0
137+ scaled = np .log10 (1 + 39 * val ) / np .log10 (40 )
138+
139+ return min (100.0 , scaled * 100.0 )
140+
141+ # Apply mild weighting to balance spectrum (bass is naturally strong)
142+ ram_mag = get_scaled_mag (self .BASS_LOW , self .BASS_HIGH , boost = 0.7 ) # Bass
143+ swap_mag = get_scaled_mag (self .LOW_MID_LOW , self .LOW_MID_HIGH , boost = 1.0 ) # Low Mid
144+ disk_mag = get_scaled_mag (self .HIGH_MID_LOW , self .HIGH_MID_HIGH , boost = 1.3 ) # High Mid
123145
124146 cpu_mags = []
125- for low , high in self ._cpu_freq_bands :
126- mag = self ._get_band_magnitude (magnitudes , low , high ) * 100
147+ for i , (low , high ) in enumerate (self ._cpu_freq_bands ):
148+ # Progressive boost for higher cpu bands
149+ freq_boost = 1.0 + (i / len (self ._cpu_freq_bands )) * 2.5
150+ mag = get_scaled_mag (low , high , boost = freq_boost )
127151 cpu_mags .append (mag )
128152
129153 # Apply smoothing and update shared state
@@ -141,139 +165,113 @@ def _smooth(self, old_val, new_val):
141165 return old_val * self ._smoothing + new_val * (1 - self ._smoothing )
142166
143167 def _find_loopback_device (self ):
144- """Pick the best available loopback / virtual output device.
145-
146- Priority order:
147- 1. Voicemeeter B1 virtual output bus (captures all audio sent to B1)
148- 2. Voicemeeter B2/B3 buses
149- 3. Voicemeeter Input / AUX Input (capture what's being sent to VM)
150- 4. Stereo Mix / Loopback (Windows default capture)
151- 5. Any other WASAPI input
168+ """
169+ Pick the best audio source:
170+ 1. Virtual Output (Voicemeeter B1/B2, VB-Cable) - Direct Capture
171+ 2. System Default Output - WASAPI Loopback
152172 """
153173 try :
154174 devices = sd .query_devices ()
175+ host_apis = sd .query_hostapis ()
176+
177+ # Get default output info for fallback matching
178+ def_out = None
179+ try :
180+ idx = sd .default .device [1 ]
181+ if idx is not None :
182+ def_out = sd .query_devices (idx )
183+ except : pass
184+
155185 candidates = []
156-
186+
157187 for i , dev in enumerate (devices ):
158- if dev ['max_input_channels' ] <= 0 :
188+ # Must be WASAPI for compatibility/loopback support
189+ if 'wasapi' not in host_apis [dev ['hostapi' ]]['name' ].lower ():
159190 continue
160-
191+
161192 name = dev ['name' ].lower ()
162- api = sd .query_hostapis (dev ['hostapi' ])['name' ].lower ()
163-
164- # Only prioritize WASAPI devices (best quality on Windows)
165- if 'wasapi' not in api :
166- # WDM/DirectSound fallback - low priority
167- if 'wdm' in api or 'directsound' in api :
168- if 'voicemeeter' in name or 'stereo mix' in name :
169- candidates .append ((100 , i , dev ))
170- continue
171-
172- # ---- Voicemeeter B buses (virtual outputs) ----
173- # These capture mixed audio output, B1 is typically the main bus
174- if 'voicemeeter' in name and 'out' in name :
175- if 'out b1' in name or ('out b' not in name and 'out a' not in name and 'aux' not in name and 'vaio3' not in name ):
176- # B1 is highest priority (or generic "Voicemeeter Out" which is B1)
177- candidates .append ((0 , i , dev ))
178- elif 'out b2' in name :
179- candidates .append ((1 , i , dev ))
180- elif 'out b3' in name :
181- candidates .append ((2 , i , dev ))
182- # Skip A buses (hardware out, not what we want)
183- continue
184-
185- # ---- Voicemeeter Inputs (capture what's sent to VM) ----
186- if 'voicemeeter' in name and ('input' in name or 'aux' in name ):
187- if 'aux' in name :
188- candidates .append ((3 , i , dev )) # AUX input
189- else :
190- candidates .append ((4 , i , dev )) # Main input
191- continue
192-
193- # ---- Stereo Mix / Loopback (good generic source) ----
194- if 'loopback' in name or 'stereo mix' in name or 'what u hear' in name :
195- candidates .append ((5 , i , dev ))
196- continue
197-
198- # ---- Other WASAPI inputs (last resort) ----
199- candidates .append ((50 , i , dev ))
200-
201- if not candidates :
202- return None
203-
204- # Pick lowest priority value (highest priority device)
205- candidates .sort (key = lambda x : x [0 ])
206- _ , index , dev = candidates [0 ]
207- return index
193+
194+ # Priority 0: Known Virtual Outputs (Capture devices)
195+ # These are "Input" devices in Windows (in_ch > 0) but carry output audio
196+ if dev ['max_input_channels' ] > 0 :
197+ if 'voicemeeter' in name and 'out' in name :
198+ # Prioritize B1/Main mix
199+ if 'out b1' in name :
200+ candidates .append ((0 , i , False , dev ['name' ]))
201+ elif 'out b' in name :
202+ candidates .append ((1 , i , False , dev ['name' ]))
203+ else :
204+ candidates .append ((2 , i , False , dev ['name' ]))
205+
206+ elif 'virtual cable' in name and 'out' in name :
207+ candidates .append ((0 , i , False , dev ['name' ]))
208+
209+ # Priority 10: System Default Output (Loopback)
210+ # This ensures we get what the user is actually hearing if they aren't using Voicemeeter capture
211+ if def_out and dev ['max_output_channels' ] > 0 :
212+ # Relaxed name matching to find the WASAPI version of the default output
213+ if def_out ['name' ] in dev ['name' ] or dev ['name' ] in def_out ['name' ]:
214+ candidates .append ((10 , i , True , dev ['name' ]))
215+
216+ # Sort by score
217+ if candidates :
218+ candidates .sort (key = lambda x : x [0 ])
219+ return candidates [0 ][1 ], candidates [0 ][2 ], candidates [0 ][3 ]
220+
221+ # Fallback: If nothing matched, try to force the default output index as a loopback source
222+ # This is a last resort for systems where name matching fails completely
223+ if def_out :
224+ is_wasapi = 'wasapi' in host_apis [def_out ['hostapi' ]]['name' ].lower ()
225+ return sd .default .device [1 ], is_wasapi , def_out ['name' ]
226+
227+ return None , False , None
208228
209229 except Exception :
210- return None
230+ return None , False , None
211231
212232 def start (self ):
213233 """Start audio capture."""
214234 if self ._running :
215235 return True
216236
217- device_id = self ._find_loopback_device ()
218-
219- # Common sample rates to try (in order of preference)
220- # Start with device default, then try common rates
221- sample_rates_to_try = [48000 , 44100 , 96000 ]
222-
223- # Get device's default sample rate and put it first
224- if device_id is not None :
225- try :
226- dev_info = sd .query_devices (device_id )
227- default_rate = int (dev_info .get ('default_samplerate' , 48000 ))
228- if default_rate not in sample_rates_to_try :
229- sample_rates_to_try .insert (0 , default_rate )
230- else :
231- # Move default to front
232- sample_rates_to_try .remove (default_rate )
233- sample_rates_to_try .insert (0 , default_rate )
234- except Exception :
235- pass
237+ device_id , loopback_required , device_name = self ._find_loopback_device ()
236238
239+ if device_id is None :
240+ return False
241+
237242 # Build list of configurations to try
238243 configs_to_try = []
239244
240- if device_id is not None :
241- for sample_rate in sample_rates_to_try :
242- # Try with WASAPI settings, 2 channels
243- configs_to_try .append ({
244- 'device' : device_id ,
245- 'samplerate' : sample_rate ,
246- 'blocksize' : self .block_size ,
247- 'channels' : 2 ,
248- 'extra_settings' : self ._get_wasapi_settings ()
249- })
250- # Try without WASAPI settings, 2 channels
251- configs_to_try .append ({
252- 'device' : device_id ,
253- 'samplerate' : sample_rate ,
254- 'blocksize' : self .block_size ,
255- 'channels' : 2 ,
256- 'extra_settings' : None
257- })
258- # Try with 1 channel
259- configs_to_try .append ({
260- 'device' : device_id ,
261- 'samplerate' : sample_rate ,
262- 'blocksize' : self .block_size ,
263- 'channels' : 1 ,
264- 'extra_settings' : None
265- })
266-
267- # Also try default device as last fallback
268- for sample_rate in sample_rates_to_try :
245+ sample_rates = [48000 , 44100 , 96000 ]
246+ try :
247+ dev_info = sd .query_devices (device_id )
248+ default_rate = int (dev_info .get ('default_samplerate' , 48000 ))
249+ if default_rate not in sample_rates :
250+ sample_rates .insert (0 , default_rate )
251+ except : pass
252+
253+ for rate in sample_rates :
254+ # Config 1: Stereo
269255 configs_to_try .append ({
270- 'device' : None ,
271- 'samplerate' : sample_rate ,
256+ 'device' : device_id ,
257+ 'samplerate' : rate ,
258+ 'blocksize' : self .block_size ,
259+ 'channels' : 2 ,
260+ 'extra_settings' : self ._get_wasapi_settings (loopback = loopback_required )
261+ })
262+
263+ # Config 2: Mono (Fallback)
264+ configs_to_try .append ({
265+ 'device' : device_id ,
266+ 'samplerate' : rate ,
272267 'blocksize' : self .block_size ,
273268 'channels' : 1 ,
274- 'extra_settings' : None
269+ 'extra_settings' : self . _get_wasapi_settings ( loopback = loopback_required )
275270 })
276271
272+ # CRITICAL: We DO NOT fall back to device=None here.
273+ # device=None opens the Default Input (Mic), which we strictly want to avoid.
274+
277275 for config in configs_to_try :
278276 try :
279277 extra = config .pop ('extra_settings' )
@@ -282,19 +280,20 @@ def start(self):
282280 self ._stream = sd .InputStream (callback = self ._audio_callback , extra_settings = extra , ** config )
283281 else :
284282 self ._stream = sd .InputStream (callback = self ._audio_callback , ** config )
283+
285284 self ._stream .start ()
286285 self ._running = True
287- self .sample_rate = actual_rate # Update for FFT calculations
286+ self .sample_rate = actual_rate
288287 return True
289288 except Exception :
290289 continue
291290
292291 return False
293292
294- def _get_wasapi_settings (self ):
295- """Get WASAPI settings if available ."""
293+ def _get_wasapi_settings (self , loopback = False ):
294+ """Get WASAPI settings."""
296295 try :
297- return sd .WasapiSettings (exclusive = False )
296+ return sd .WasapiSettings (exclusive = False , loopback = loopback )
298297 except Exception :
299298 return None
300299
@@ -334,6 +333,4 @@ def get_magnitudes(self):
334333 @property
335334 def is_running (self ):
336335 """Check if audio capture is active."""
337- return self ._running
338-
339- # TODO: make this audio amplitude sensitive (ergo the audio in being louder should make the bars taller)
336+ return self ._running
0 commit comments