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 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")