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."""
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 global _audio_sink_module, _audio_lb_module, _audio_isolated
_sink_module_id = None
_loopback_module_id = None
_lock = threading.Lock()
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 _cleanup_stale_sinks()
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 # 1. Create null sink
def release(cls): r = subprocess.run(
"""Release the virtual sink. Removed when last user releases.""" ['pactl', 'load-module', 'module-null-sink',
with cls._lock: f'sink_name={_AUDIO_SINK_NAME}',
cls._ref_count = max(0, cls._ref_count - 1) f'sink_properties=device.description="TechDJ_Stream"'],
if cls._ref_count == 0: capture_output=True, text=True, timeout=5,
cls._destroy_sink() )
if r.returncode != 0:
print(f'[AUDIO] Failed to create virtual sink: {r.stderr.strip()}')
return
_audio_sink_module = r.stdout.strip()
@classmethod # 2. Loopback → real speakers so the DJ hears the mix
def refresh_routes(cls): r = subprocess.run(
"""Re-route app audio (call periodically to catch new PulseAudio streams).""" ['pactl', 'load-module', 'module-loopback',
with cls._lock: f'source={_AUDIO_MONITOR}',
if cls._ref_count > 0 and cls._sink_module_id: 'latency_msec=50'],
cls._route_app_audio(cls.SINK_NAME) 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 # 4. Force this process's audio to the virtual sink
def _create_sink(cls): 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: try:
if not shutil.which("pactl"): r = subprocess.run(
print("[AUDIO] pactl not found - cannot isolate audio") ['pactl', 'list', 'sources', 'short'],
return False capture_output=True, text=True, timeout=5,
# 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: if _AUDIO_MONITOR in r.stdout:
print(f"[AUDIO] Virtual sink failed: {result.stderr.strip()}") print(f'[AUDIO] Capturing from isolated source: {_AUDIO_MONITOR}')
return False return _AUDIO_MONITOR
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: else:
print(f"[AUDIO] Loopback failed (DJ may not hear audio): {result.stderr.strip()}") print(f'[AUDIO] ERROR: {_AUDIO_MONITOR} disappeared! '
f'Available: {r.stdout.strip()}')
cls._route_app_audio(cls.SINK_NAME)
print("[AUDIO] Virtual sink active - app audio isolated")
return True
except Exception as e: except Exception as e:
print(f"[AUDIO] Virtual sink error: {e}") print(f'[AUDIO] pactl check failed: {e}')
return False 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 atexit.register(_teardown_audio_isolation)
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
# --- 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,18 +858,12 @@ 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:
break break
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}")
@ -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")