Fix: audio isolation via PULSE_SINK env var before QApplication

- 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
This commit is contained in:
ComputerTech 2026-03-09 20:26:36 +00:00
parent cddce99b29
commit 5ba5c45e64
1 changed files with 138 additions and 168 deletions

View File

@ -10,7 +10,7 @@ import requests
import re import re
import socketio import socketio
import subprocess import subprocess
import threading import atexit
from pathlib import Path from pathlib import Path
import soundfile as sf import soundfile as sf
@ -110,161 +110,147 @@ QSlider#crossfader::handle:horizontal:hover {
""" """
# --- AUDIO ISOLATION --- # --- AUDIO ISOLATION ---
# PulseAudio virtual sink so streaming/recording only captures THIS app's audio, # We create a PulseAudio virtual null sink and set PULSE_SINK *before* Qt
# not YouTube Music, Spotify, system sounds, etc. # 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: _AUDIO_SINK_NAME = "techdj_stream"
"""Manages a PulseAudio/PipeWire virtual sink for app-only audio capture. _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. def _cleanup_stale_sinks():
A loopback is created so the DJ still hears their mix through speakers. """Remove leftover techdj_stream modules from a previous crash."""
"""
_ref_count = 0
_sink_module_id = None
_loopback_module_id = None
_lock = threading.Lock()
SINK_NAME = "techdj_stream"
@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"
@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()
@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)
# -- internal helpers --
@classmethod
def _create_sink(cls):
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
)
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()
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
except Exception as e:
print(f"[AUDIO] Virtual sink error: {e}")
return False
@classmethod
def _cleanup_stale_sinks(cls):
"""Remove any leftover techdj_stream sinks from a previous crash."""
try: try:
result = subprocess.run( result = subprocess.run(
['pactl', 'list', 'modules', 'short'], ['pactl', 'list', 'modules', 'short'],
capture_output=True, text=True, timeout=5 capture_output=True, text=True, timeout=5,
) )
for line in result.stdout.strip().split('\n'): for line in result.stdout.strip().split('\n'):
if 'module-null-sink' in line and cls.SINK_NAME in line: if _AUDIO_SINK_NAME in line and (
module_id = line.split()[0] 'module-null-sink' in line or 'module-loopback' in line):
subprocess.run(['pactl', 'unload-module', module_id], mod_id = line.split()[0]
subprocess.run(['pactl', 'unload-module', mod_id],
capture_output=True, timeout=5) capture_output=True, timeout=5)
print(f"[AUDIO] Cleaned up stale sink module {module_id}") print(f"[AUDIO] Cleaned up stale module {mod_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: except Exception:
pass pass
@classmethod
def _destroy_sink(cls): 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.
"""
global _audio_sink_module, _audio_lb_module, _audio_isolated
if not shutil.which('pactl'):
print('[AUDIO] pactl not found audio isolation disabled '
'(install pulseaudio-utils or pipewire-pulse)')
return
_cleanup_stale_sinks()
# 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()
# 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()}')
# 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
# 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: try:
cls._route_app_audio('@DEFAULT_SINK@') subprocess.run(['pactl', 'unload-module', mod_id],
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) capture_output=True, timeout=5)
except Exception: except Exception:
pass pass
cls._sink_module_id = None _audio_sink_module = None
cls._loopback_module_id = None _audio_lb_module = None
print("[AUDIO] Virtual sink removed") if _audio_isolated:
_audio_isolated = False
os.environ.pop('PULSE_SINK', None)
print('[AUDIO] Virtual sink removed')
@classmethod
def _route_app_audio(cls, target_sink): def get_audio_capture_source():
"""Move this process's PulseAudio sink-inputs to *target_sink*.""" """Return the PulseAudio source to capture from.
pid = str(os.getpid())
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: try:
result = subprocess.run( r = subprocess.run(
['pactl', 'list', 'sink-inputs'], ['pactl', 'list', 'sources', 'short'],
capture_output=True, text=True, timeout=5 capture_output=True, text=True, timeout=5,
) )
current_idx = None if _AUDIO_MONITOR in r.stdout:
for line in result.stdout.split('\n'): print(f'[AUDIO] Capturing from isolated source: {_AUDIO_MONITOR}')
s = line.strip() return _AUDIO_MONITOR
if s.startswith('Sink Input #'): else:
current_idx = s.split('#')[1].strip() print(f'[AUDIO] ERROR: {_AUDIO_MONITOR} disappeared! '
elif 'application.process.id' in s and current_idx: f'Available: {r.stdout.strip()}')
m = re.search(r'"(\d+)"', s) except Exception as e:
if m and m.group(1) == pid: print(f'[AUDIO] pactl check failed: {e}')
subprocess.run( print('[AUDIO] WARNING: virtual sink not active capturing ALL system audio!')
['pactl', 'move-sink-input', current_idx, target_sink], print('[AUDIO] The listener WILL hear all your system audio (YouTube, Spotify, etc).')
capture_output=True, timeout=5 return 'default.monitor'
)
current_idx = None
except Exception: atexit.register(_teardown_audio_isolation)
pass
# --- WORKERS --- # --- WORKERS ---
@ -767,7 +753,6 @@ class RecordingWorker(QProcess):
def __init__(self, parent=None): def __init__(self, parent=None):
super().__init__(parent) super().__init__(parent)
self.output_file = "" self.output_file = ""
self._using_virtual_sink = False
self.readyReadStandardError.connect(self.handle_error) self.readyReadStandardError.connect(self.handle_error)
def start_recording(self, output_path): 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") self.recording_error.emit("FFmpeg not found. Install with: sudo apt install ffmpeg")
return False return False
print(f"[RECORDING] Starting: {output_path}") source = get_audio_capture_source()
print(f"[RECORDING] Starting: {output_path} (source={source})")
# 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")
args = [ args = [
"-f", "pulse", "-f", "pulse",
@ -802,15 +782,13 @@ class RecordingWorker(QProcess):
return True return True
def stop_recording(self): def stop_recording(self):
"""Stop the recording and release the virtual sink.""" """Stop the recording."""
if self.state() == QProcess.ProcessState.Running: if self.state() == QProcess.ProcessState.Running:
print("[RECORDING] Stopping...") print("[RECORDING] Stopping...")
self.write(b"q") self.write(b"q")
self.waitForFinished(3000) self.waitForFinished(3000)
if self.state() == QProcess.ProcessState.Running: if self.state() == QProcess.ProcessState.Running:
self.kill() self.kill()
PulseAudioIsolator.release()
self._using_virtual_sink = False
def handle_error(self): def handle_error(self):
"""Handle FFmpeg stderr (which includes progress info)""" """Handle FFmpeg stderr (which includes progress info)"""
@ -819,10 +797,10 @@ class RecordingWorker(QProcess):
print(f"[RECORDING ERROR] {err}") print(f"[RECORDING ERROR] {err}")
class StreamingWorker(QThread): 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, PULSE_SINK is set at startup so ALL audio from this process goes to the
not YouTube Music, Spotify, or other system sounds. virtual sink automatically. We just capture from its monitor here.
""" """
streaming_started = pyqtSignal() streaming_started = pyqtSignal()
streaming_error = pyqtSignal(str) streaming_error = pyqtSignal(str)
@ -834,7 +812,6 @@ class StreamingWorker(QThread):
self.stream_url = "" self.stream_url = ""
self.is_running = False self.is_running = False
self.ffmpeg_proc = None self.ffmpeg_proc = None
self._using_virtual_sink = False
def on_connect(self): def on_connect(self):
print("[SOCKET] Connected to DJ server") print("[SOCKET] Connected to DJ server")
@ -861,11 +838,8 @@ class StreamingWorker(QThread):
self.sio.connect(self.stream_url) self.sio.connect(self.stream_url)
# Acquire isolated audio source (virtual sink or fallback) source = get_audio_capture_source()
source = PulseAudioIsolator.acquire() print(f"[STREAM] Capturing from: {source}")
self._using_virtual_sink = (source != "default.monitor")
if not self._using_virtual_sink:
print("[STREAM] WARNING: Capturing ALL system audio (pactl unavailable)")
cmd = [ cmd = [
"ffmpeg", "ffmpeg",
@ -884,7 +858,6 @@ class StreamingWorker(QThread):
cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, bufsize=8192 cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, bufsize=8192
) )
last_reroute = time.time()
while self.is_running and self.ffmpeg_proc.poll() is None: while self.is_running and self.ffmpeg_proc.poll() is None:
chunk = self.ffmpeg_proc.stdout.read(8192) chunk = self.ffmpeg_proc.stdout.read(8192)
if not chunk: if not chunk:
@ -892,11 +865,6 @@ class StreamingWorker(QThread):
if self.sio.connected: if self.sio.connected:
self.sio.emit('audio_chunk', chunk) 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: except Exception as e:
self.streaming_error.emit(f"Streaming thread error: {e}") self.streaming_error.emit(f"Streaming thread error: {e}")
finally: finally:
@ -914,8 +882,6 @@ class StreamingWorker(QThread):
try: self.ffmpeg_proc.terminate() try: self.ffmpeg_proc.terminate()
except: pass except: pass
self.ffmpeg_proc = None self.ffmpeg_proc = None
PulseAudioIsolator.release()
self._using_virtual_sink = False
if self.sio and self.sio.connected: if self.sio and self.sio.connected:
try: try:
self.sio.emit('stop_broadcast') self.sio.emit('stop_broadcast')
@ -2362,6 +2328,10 @@ class DJApp(QMainWindow):
event.accept() event.accept()
if __name__ == "__main__": 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 = QApplication(sys.argv)
app.setApplicationName("TechDJ Pro") app.setApplicationName("TechDJ Pro")
app.setDesktopFileName("techdj-pro") app.setDesktopFileName("techdj-pro")