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:
parent
cddce99b29
commit
5ba5c45e64
306
techdj_qt.py
306
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")
|
||||
|
|
|
|||
Loading…
Reference in New Issue