import numpy as np from scipy.signal import welch import os import time import threading # --- HOST COMMUNICATION --- import __main__ try: BaseNode = __main__.BaseNode QtGui = __main__.QtGui PA_INSTANCE = getattr(__main__, "PA_INSTANCE", None) except AttributeError: class BaseNode: def get_blended_input(self, name, mode): return None import PyQt6.QtGui as QtGui PA_INSTANCE = None try: import pyaudio HAS_PYAUDIO = True except ImportError: HAS_PYAUDIO = False try: from pydub import AudioSegment HAS_PYDUB = True except ImportError: HAS_PYDUB = False class AudioPlayerNode(BaseNode): """ Audio Player & Analyzer (Auto-Pause Fix). Now intelligently pauses audio if the simulation loop stops. """ NODE_CATEGORY = "Input" NODE_TITLE = "Audio Player (Realtime)" NODE_COLOR = QtGui.QColor(255, 50, 100) def __init__(self): super().__init__() self.inputs = { 'volume': 'signal', 'playback_pos': 'signal', 'pause': 'signal' # Manual pause } self.outputs = { 'spectrum': 'spectrum', 'raw_signal': 'signal', 'band_power': 'image' } self.band_names = ["Sub", "Bass", "Mid", "High", "Air"] for i in range(5): self.outputs[f'band_{i+1}_{self.band_names[i]}'] = 'signal' self.file_path = "music.mp3" self.gain = 1.0 self.audio_data = None self.sample_rate = 44100 self.play_head = 0 self.stream = None self.is_playing = False # --- AUTO-PAUSE LOGIC --- self.last_step_time = time.time() self.last_spectrum = np.zeros(16) self.last_5bands = np.zeros(5) self.spec_edges = np.logspace(np.log10(20), np.log10(20000), 17) self.five_edges = [20, 60, 250, 2000, 6000, 20000] self.load_audio() def update(self): self.load_audio() def load_audio(self): self.stop_stream() if not os.path.exists(self.file_path): print(f"Audio file not found: {self.file_path}") return try: print(f"Loading {self.file_path}...") if HAS_PYDUB: seg = AudioSegment.from_file(self.file_path) self.sample_rate = seg.frame_rate samples = np.array(seg.get_array_of_samples()) if seg.channels == 2: samples = samples.reshape((-1, 2)).mean(axis=1) if seg.sample_width == 2: samples = samples.astype(np.float32) / 32768.0 elif seg.sample_width == 4: samples = samples.astype(np.float32) / 2147483648.0 self.audio_data = samples self.start_stream() else: print("Pydub not installed.") except Exception as e: print(f"Error loading audio: {e}") def start_stream(self): if not HAS_PYAUDIO or PA_INSTANCE is None: return def callback(in_data, frame_count, time_info, status): if self.audio_data is None: return (None, pyaudio.paComplete) # --- AUTO-PAUSE CHECK --- # If step() hasn't been called in > 200ms, assume Host is Paused if time.time() - self.last_step_time > 0.2: # Return silence but keep stream alive (Pause) return (np.zeros(frame_count, dtype=np.float32).tobytes(), pyaudio.paContinue) if self.play_head + frame_count >= len(self.audio_data): self.play_head = 0 data = self.audio_data[self.play_head : self.play_head + frame_count] self.play_head += frame_count out_data = (data * self.gain).astype(np.float32) return (out_data.tobytes(), pyaudio.paContinue) try: self.stream = PA_INSTANCE.open( format=pyaudio.paFloat32, channels=1, rate=self.sample_rate, output=True, stream_callback=callback, frames_per_buffer=1024 ) self.stream.start_stream() self.is_playing = True except Exception as e: print(f"Failed to start stream: {e}") def stop_stream(self): if self.stream: try: self.stream.stop_stream() self.stream.close() except: pass self.stream = None self.is_playing = False def step(self): # Update heartbeat timestamp so audio thread knows we are running self.last_step_time = time.time() vol = self.get_blended_input('volume', 'sum') if vol is not None: self.gain = float(vol) if self.audio_data is None: return # Analysis window_size = int(self.sample_rate * 0.05) current_pos = self.play_head if current_pos + window_size > len(self.audio_data): chunk = self.audio_data[current_pos:] else: chunk = self.audio_data[current_pos : current_pos + window_size] if len(chunk) < 64: return freqs, psd = welch(chunk, fs=self.sample_rate, nperseg=len(chunk)) # 16-Band spec = np.zeros(16) for i in range(16): mask = (freqs >= self.spec_edges[i]) & (freqs < self.spec_edges[i+1]) if np.sum(mask) > 0: spec[i] = np.mean(psd[mask]) if np.max(spec) > 0: spec /= np.max(spec) self.last_spectrum = spec # 5-Band bands = np.zeros(5) for i in range(5): mask = (freqs >= self.five_edges[i]) & (freqs < self.five_edges[i+1]) if np.sum(mask) > 0: bands[i] = np.mean(psd[mask]) bands = np.log1p(bands * 1000) if np.max(bands) > 0: bands /= np.max(bands) self.last_5bands = bands def get_output(self, port_name): if port_name == 'spectrum': return self.last_spectrum elif port_name == 'raw_signal': return float(np.mean(self.last_spectrum)) elif port_name.startswith('band_'): try: idx = int(port_name.split('_')[1]) - 1 return float(self.last_5bands[idx]) except: pass elif port_name == 'band_power': h, w = 64, 128 img = np.zeros((h, w), dtype=np.float32) bar_w = w // 16 for i, val in enumerate(self.last_spectrum): height = int(val * (h-1)) img[h-height:, i*bar_w:(i+1)*bar_w] = 1.0 return img return None def get_config_options(self): return [ ("Audio File", "file_path", self.file_path, "file_open"), ("Gain", "gain", self.gain, "float") ] def close(self): self.stop_stream()