From 5ba5c45e64ba4545395abccf7874d3e0fa093731 Mon Sep 17 00:00:00 2001 From: ComputerTech Date: Mon, 9 Mar 2026 20:26:36 +0000 Subject: [PATCH] Fix: audio isolation via PULSE_SINK env var before QApplication MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace fragile PID-based PulseAudioIsolator class with module-level PULSE_SINK approach: create virtual null sink BEFORE QApplication() so Qt routes all audio there automatically - Add verification that the monitor source actually exists before trusting it - Add clear startup diagnostics (✓ confirmations for each step) - get_audio_capture_source() now double-checks monitor still exists - Fails loudly instead of silently falling back to default.monitor - Remove threading import (no longer needed) - Add atexit cleanup for virtual sink teardown --- techdj_qt.py | 306 +++++++++++++++++++++++---------------------------- 1 file changed, 138 insertions(+), 168 deletions(-) diff --git a/techdj_qt.py b/techdj_qt.py index 8d3a3d9..74377e5 100644 --- a/techdj_qt.py +++ b/techdj_qt.py @@ -10,7 +10,7 @@ import requests import re import socketio import subprocess -import threading +import atexit from pathlib import Path import soundfile as sf @@ -110,161 +110,147 @@ QSlider#crossfader::handle:horizontal:hover { """ # --- AUDIO ISOLATION --- -# PulseAudio virtual sink so streaming/recording only captures THIS app's audio, -# not YouTube Music, Spotify, system sounds, etc. +# We create a PulseAudio virtual null sink and set PULSE_SINK *before* Qt +# initialises audio. That forces ALL audio from this process to the virtual +# sink automatically – no fragile PID-based routing needed. +# +# A loopback copies the virtual-sink audio back to the real speakers so the +# DJ still hears their mix. Streaming/recording capture from the virtual +# sink's monitor, which contains ONLY this app's audio. -class PulseAudioIsolator: - """Manages a PulseAudio/PipeWire virtual sink for app-only audio capture. +_AUDIO_SINK_NAME = "techdj_stream" +_AUDIO_MONITOR = f"{_AUDIO_SINK_NAME}.monitor" +_audio_sink_module = None # pactl module ID for the null sink +_audio_lb_module = None # pactl module ID for the loopback +_audio_isolated = False # True when the virtual sink is active - Usage: - source = PulseAudioIsolator.acquire() # "techdj_stream.monitor" or "default.monitor" - ...capture from `source`... - PulseAudioIsolator.release() - Reference-counted: the virtual sink stays alive until the last user releases it. - A loopback is created so the DJ still hears their mix through speakers. +def _cleanup_stale_sinks(): + """Remove leftover techdj_stream modules from a previous crash.""" + try: + result = subprocess.run( + ['pactl', 'list', 'modules', 'short'], + capture_output=True, text=True, timeout=5, + ) + for line in result.stdout.strip().split('\n'): + if _AUDIO_SINK_NAME in line and ( + 'module-null-sink' in line or 'module-loopback' in line): + mod_id = line.split()[0] + subprocess.run(['pactl', 'unload-module', mod_id], + capture_output=True, timeout=5) + print(f"[AUDIO] Cleaned up stale module {mod_id}") + except Exception: + pass + + +def _setup_audio_isolation(): + """Create the virtual sink and set PULSE_SINK. + + **MUST** be called before QApplication() so that Qt's audio output + is automatically routed to the virtual sink. """ - _ref_count = 0 - _sink_module_id = None - _loopback_module_id = None - _lock = threading.Lock() + global _audio_sink_module, _audio_lb_module, _audio_isolated - SINK_NAME = "techdj_stream" + if not shutil.which('pactl'): + print('[AUDIO] pactl not found – audio isolation disabled ' + '(install pulseaudio-utils or pipewire-pulse)') + return - @classmethod - def acquire(cls): - """Create the virtual sink (if needed) and route app audio to it. - Returns the PulseAudio monitor source name to capture from.""" - with cls._lock: - cls._ref_count += 1 - if cls._ref_count == 1: - if not cls._create_sink(): - cls._ref_count -= 1 - return "default.monitor" - cls._route_app_audio(cls.SINK_NAME) - return f"{cls.SINK_NAME}.monitor" + _cleanup_stale_sinks() - @classmethod - def release(cls): - """Release the virtual sink. Removed when last user releases.""" - with cls._lock: - cls._ref_count = max(0, cls._ref_count - 1) - if cls._ref_count == 0: - cls._destroy_sink() + # 1. Create null sink + r = subprocess.run( + ['pactl', 'load-module', 'module-null-sink', + f'sink_name={_AUDIO_SINK_NAME}', + f'sink_properties=device.description="TechDJ_Stream"'], + capture_output=True, text=True, timeout=5, + ) + if r.returncode != 0: + print(f'[AUDIO] Failed to create virtual sink: {r.stderr.strip()}') + return + _audio_sink_module = r.stdout.strip() - @classmethod - def refresh_routes(cls): - """Re-route app audio (call periodically to catch new PulseAudio streams).""" - with cls._lock: - if cls._ref_count > 0 and cls._sink_module_id: - cls._route_app_audio(cls.SINK_NAME) + # 2. Loopback → real speakers so the DJ hears the mix + r = subprocess.run( + ['pactl', 'load-module', 'module-loopback', + f'source={_AUDIO_MONITOR}', + 'latency_msec=50'], + capture_output=True, text=True, timeout=5, + ) + if r.returncode == 0: + _audio_lb_module = r.stdout.strip() + else: + print(f'[AUDIO] Loopback failed (DJ may not hear audio): {r.stderr.strip()}') - # -- internal helpers -- + # 3. Verify the sink and its monitor actually exist + verify = subprocess.run( + ['pactl', 'list', 'sources', 'short'], + capture_output=True, text=True, timeout=5, + ) + if _AUDIO_MONITOR not in verify.stdout: + print(f'[AUDIO] ERROR: monitor source "{_AUDIO_MONITOR}" not found!') + print(f'[AUDIO] Available sources:\n{verify.stdout.strip()}') + # Tear down the sink we just created since the monitor isn't there + if _audio_sink_module: + subprocess.run(['pactl', 'unload-module', _audio_sink_module], + capture_output=True, timeout=5) + _audio_sink_module = None + return - @classmethod - def _create_sink(cls): + # 4. Force this process's audio to the virtual sink + os.environ['PULSE_SINK'] = _AUDIO_SINK_NAME + _audio_isolated = True + print(f'[AUDIO] ✓ Virtual sink "{_AUDIO_SINK_NAME}" active (module {_audio_sink_module})') + print(f'[AUDIO] ✓ Loopback active (module {_audio_lb_module})') + print(f'[AUDIO] ✓ PULSE_SINK={_AUDIO_SINK_NAME} — all app audio routed to virtual sink') + print(f'[AUDIO] ✓ Capture source: {_AUDIO_MONITOR}') + + +def _teardown_audio_isolation(): + """Remove the virtual sink (called at process exit via atexit).""" + global _audio_sink_module, _audio_lb_module, _audio_isolated + for mod_id in (_audio_lb_module, _audio_sink_module): + if mod_id: + try: + subprocess.run(['pactl', 'unload-module', mod_id], + capture_output=True, timeout=5) + except Exception: + pass + _audio_sink_module = None + _audio_lb_module = None + if _audio_isolated: + _audio_isolated = False + os.environ.pop('PULSE_SINK', None) + print('[AUDIO] Virtual sink removed') + + +def get_audio_capture_source(): + """Return the PulseAudio source to capture from. + + If the virtual sink is active this returns its monitor; otherwise + falls back to default.monitor (which captures ALL system audio). + """ + if _audio_isolated: + # Double-check the monitor source still exists (PipeWire can be fussy) try: - if not shutil.which("pactl"): - print("[AUDIO] pactl not found - cannot isolate audio") - return False - - # Clean up any stale sinks from a previous crash - cls._cleanup_stale_sinks() - - # Create a null sink dedicated to this app - result = subprocess.run( - ['pactl', 'load-module', 'module-null-sink', - f'sink_name={cls.SINK_NAME}', - f'sink_properties=device.description="TechDJ_Stream"'], - capture_output=True, text=True, timeout=5 + r = subprocess.run( + ['pactl', 'list', 'sources', 'short'], + capture_output=True, text=True, timeout=5, ) - if result.returncode != 0: - print(f"[AUDIO] Virtual sink failed: {result.stderr.strip()}") - return False - cls._sink_module_id = result.stdout.strip() - - # Loopback: route virtual-sink audio back to speakers so DJ can monitor - result = subprocess.run( - ['pactl', 'load-module', 'module-loopback', - f'source={cls.SINK_NAME}.monitor', - 'latency_msec=50'], - capture_output=True, text=True, timeout=5 - ) - if result.returncode == 0: - cls._loopback_module_id = result.stdout.strip() + if _AUDIO_MONITOR in r.stdout: + print(f'[AUDIO] Capturing from isolated source: {_AUDIO_MONITOR}') + return _AUDIO_MONITOR else: - print(f"[AUDIO] Loopback failed (DJ may not hear audio): {result.stderr.strip()}") - - cls._route_app_audio(cls.SINK_NAME) - print("[AUDIO] Virtual sink active - app audio isolated") - return True + print(f'[AUDIO] ERROR: {_AUDIO_MONITOR} disappeared! ' + f'Available: {r.stdout.strip()}') except Exception as e: - print(f"[AUDIO] Virtual sink error: {e}") - return False + print(f'[AUDIO] pactl check failed: {e}') + print('[AUDIO] WARNING: virtual sink not active – capturing ALL system audio!') + print('[AUDIO] The listener WILL hear all your system audio (YouTube, Spotify, etc).') + return 'default.monitor' - @classmethod - def _cleanup_stale_sinks(cls): - """Remove any leftover techdj_stream sinks from a previous crash.""" - try: - result = subprocess.run( - ['pactl', 'list', 'modules', 'short'], - capture_output=True, text=True, timeout=5 - ) - for line in result.stdout.strip().split('\n'): - if 'module-null-sink' in line and cls.SINK_NAME in line: - module_id = line.split()[0] - subprocess.run(['pactl', 'unload-module', module_id], - capture_output=True, timeout=5) - print(f"[AUDIO] Cleaned up stale sink module {module_id}") - if 'module-loopback' in line and cls.SINK_NAME in line: - module_id = line.split()[0] - subprocess.run(['pactl', 'unload-module', module_id], - capture_output=True, timeout=5) - print(f"[AUDIO] Cleaned up stale loopback module {module_id}") - except Exception: - pass - @classmethod - def _destroy_sink(cls): - try: - cls._route_app_audio('@DEFAULT_SINK@') - except Exception: - pass - for mid in (cls._loopback_module_id, cls._sink_module_id): - if mid: - try: - subprocess.run(['pactl', 'unload-module', mid], - capture_output=True, timeout=5) - except Exception: - pass - cls._sink_module_id = None - cls._loopback_module_id = None - print("[AUDIO] Virtual sink removed") - - @classmethod - def _route_app_audio(cls, target_sink): - """Move this process's PulseAudio sink-inputs to *target_sink*.""" - pid = str(os.getpid()) - try: - result = subprocess.run( - ['pactl', 'list', 'sink-inputs'], - capture_output=True, text=True, timeout=5 - ) - current_idx = None - for line in result.stdout.split('\n'): - s = line.strip() - if s.startswith('Sink Input #'): - current_idx = s.split('#')[1].strip() - elif 'application.process.id' in s and current_idx: - m = re.search(r'"(\d+)"', s) - if m and m.group(1) == pid: - subprocess.run( - ['pactl', 'move-sink-input', current_idx, target_sink], - capture_output=True, timeout=5 - ) - current_idx = None - except Exception: - pass +atexit.register(_teardown_audio_isolation) # --- WORKERS --- @@ -767,7 +753,6 @@ class RecordingWorker(QProcess): def __init__(self, parent=None): super().__init__(parent) self.output_file = "" - self._using_virtual_sink = False self.readyReadStandardError.connect(self.handle_error) def start_recording(self, output_path): @@ -778,13 +763,8 @@ class RecordingWorker(QProcess): self.recording_error.emit("FFmpeg not found. Install with: sudo apt install ffmpeg") return False - print(f"[RECORDING] Starting: {output_path}") - - # Acquire isolated audio source (virtual sink or fallback) - source = PulseAudioIsolator.acquire() - self._using_virtual_sink = (source != "default.monitor") - if not self._using_virtual_sink: - print("[RECORDING] WARNING: Using default.monitor - ALL system audio will be recorded") + source = get_audio_capture_source() + print(f"[RECORDING] Starting: {output_path} (source={source})") args = [ "-f", "pulse", @@ -802,15 +782,13 @@ class RecordingWorker(QProcess): return True def stop_recording(self): - """Stop the recording and release the virtual sink.""" + """Stop the recording.""" if self.state() == QProcess.ProcessState.Running: print("[RECORDING] Stopping...") self.write(b"q") self.waitForFinished(3000) if self.state() == QProcess.ProcessState.Running: self.kill() - PulseAudioIsolator.release() - self._using_virtual_sink = False def handle_error(self): """Handle FFmpeg stderr (which includes progress info)""" @@ -819,10 +797,10 @@ class RecordingWorker(QProcess): print(f"[RECORDING ERROR] {err}") class StreamingWorker(QThread): - """Streams this app's audio output to a server using Socket.IO. + """Streams this app's isolated audio output to a server using Socket.IO. - Uses PulseAudioIsolator to capture ONLY the DJ app's audio, - not YouTube Music, Spotify, or other system sounds. + PULSE_SINK is set at startup so ALL audio from this process goes to the + virtual sink automatically. We just capture from its monitor here. """ streaming_started = pyqtSignal() streaming_error = pyqtSignal(str) @@ -834,7 +812,6 @@ class StreamingWorker(QThread): self.stream_url = "" self.is_running = False self.ffmpeg_proc = None - self._using_virtual_sink = False def on_connect(self): print("[SOCKET] Connected to DJ server") @@ -861,11 +838,8 @@ class StreamingWorker(QThread): self.sio.connect(self.stream_url) - # Acquire isolated audio source (virtual sink or fallback) - source = PulseAudioIsolator.acquire() - self._using_virtual_sink = (source != "default.monitor") - if not self._using_virtual_sink: - print("[STREAM] WARNING: Capturing ALL system audio (pactl unavailable)") + source = get_audio_capture_source() + print(f"[STREAM] Capturing from: {source}") cmd = [ "ffmpeg", @@ -884,18 +858,12 @@ class StreamingWorker(QThread): cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, bufsize=8192 ) - last_reroute = time.time() while self.is_running and self.ffmpeg_proc.poll() is None: chunk = self.ffmpeg_proc.stdout.read(8192) if not chunk: break if self.sio.connected: self.sio.emit('audio_chunk', chunk) - - # Periodically re-route audio (handles new sink-inputs from track changes) - if self._using_virtual_sink and time.time() - last_reroute > 3: - PulseAudioIsolator.refresh_routes() - last_reroute = time.time() except Exception as e: self.streaming_error.emit(f"Streaming thread error: {e}") @@ -914,8 +882,6 @@ class StreamingWorker(QThread): try: self.ffmpeg_proc.terminate() except: pass self.ffmpeg_proc = None - PulseAudioIsolator.release() - self._using_virtual_sink = False if self.sio and self.sio.connected: try: self.sio.emit('stop_broadcast') @@ -2362,6 +2328,10 @@ class DJApp(QMainWindow): event.accept() if __name__ == "__main__": + # Create virtual sink BEFORE QApplication so Qt's audio output + # is automatically routed to it via PULSE_SINK env var. + _setup_audio_isolation() + app = QApplication(sys.argv) app.setApplicationName("TechDJ Pro") app.setDesktopFileName("techdj-pro")