Fix: isolate DJ audio from system audio in streaming/recording
The Qt app was using PulseAudio's 'default.monitor' which captures ALL
system audio (YouTube Music, Spotify, browser, etc.). This caused listeners
to hear whatever was playing on the DJ's system, not just the DJ mix.
Added PulseAudioIsolator class that:
- Creates a virtual PulseAudio null sink ('techdj_stream')
- Routes only this app's audio to the virtual sink
- Creates a loopback so the DJ still hears their mix through speakers
- Captures from the virtual sink's monitor (only DJ audio)
- Reference-counted: shared between streaming and recording workers
- Automatically cleans up stale sinks from previous crashes
- Periodically re-routes audio to catch new tracks/streams
- Falls back to default.monitor if pactl is unavailable
Both StreamingWorker and RecordingWorker now use the isolator.
This commit is contained in:
parent
abf907ddfb
commit
cddce99b29
226
techdj_qt.py
226
techdj_qt.py
|
|
@ -10,6 +10,7 @@ import requests
|
|||
import re
|
||||
import socketio
|
||||
import subprocess
|
||||
import threading
|
||||
from pathlib import Path
|
||||
import soundfile as sf
|
||||
|
||||
|
|
@ -108,6 +109,164 @@ 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.
|
||||
|
||||
class PulseAudioIsolator:
|
||||
"""Manages a PulseAudio/PipeWire virtual sink for app-only audio capture.
|
||||
|
||||
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.
|
||||
"""
|
||||
_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:
|
||||
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
|
||||
|
||||
|
||||
# --- WORKERS ---
|
||||
|
||||
class DownloadThread(QThread):
|
||||
|
|
@ -601,42 +760,40 @@ class YTResultDialog(QDialog):
|
|||
return i.data(Qt.ItemDataRole.UserRole) if i else None
|
||||
|
||||
class RecordingWorker(QProcess):
|
||||
"""Records system audio output using FFmpeg"""
|
||||
"""Records this app's audio output (isolated) using FFmpeg."""
|
||||
recording_started = pyqtSignal()
|
||||
recording_error = pyqtSignal(str)
|
||||
|
||||
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):
|
||||
"""Start recording system audio to file"""
|
||||
"""Start recording this app's audio to file."""
|
||||
self.output_file = output_path
|
||||
|
||||
# Check if FFmpeg is available
|
||||
if not shutil.which("ffmpeg"):
|
||||
self.recording_error.emit("FFmpeg not found. Install with: sudo apt install ffmpeg")
|
||||
return False
|
||||
|
||||
print(f"[RECORDING] Starting: {output_path}")
|
||||
|
||||
# FFmpeg command to record PulseAudio output with high quality
|
||||
# IMPORTANT: Use .monitor to capture OUTPUT (what you hear), not INPUT (microphone)
|
||||
# -f pulse: use PulseAudio
|
||||
# -i default.monitor: capture system audio OUTPUT (not microphone)
|
||||
# -ac 2: stereo
|
||||
# -ar 48000: 48kHz sample rate (higher quality than 44.1kHz)
|
||||
# -acodec pcm_s16le: uncompressed 16-bit PCM (lossless)
|
||||
# -sample_fmt s16: 16-bit samples
|
||||
# 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 = [
|
||||
"-f", "pulse",
|
||||
"-i", "default.monitor", # .monitor captures OUTPUT, not microphone!
|
||||
"-i", source,
|
||||
"-ac", "2",
|
||||
"-ar", "48000", # Higher sample rate for better quality
|
||||
"-acodec", "pcm_s16le", # Lossless PCM codec
|
||||
"-ar", "48000",
|
||||
"-acodec", "pcm_s16le",
|
||||
"-sample_fmt", "s16",
|
||||
"-y", # Overwrite if exists
|
||||
"-y",
|
||||
output_path
|
||||
]
|
||||
|
||||
|
|
@ -645,14 +802,15 @@ class RecordingWorker(QProcess):
|
|||
return True
|
||||
|
||||
def stop_recording(self):
|
||||
"""Stop the recording"""
|
||||
"""Stop the recording and release the virtual sink."""
|
||||
if self.state() == QProcess.ProcessState.Running:
|
||||
print("[RECORDING] Stopping...")
|
||||
# Send 'q' to FFmpeg to gracefully stop
|
||||
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)"""
|
||||
|
|
@ -661,7 +819,11 @@ class RecordingWorker(QProcess):
|
|||
print(f"[RECORDING ERROR] {err}")
|
||||
|
||||
class StreamingWorker(QThread):
|
||||
"""Streams system audio output to a server using Socket.IO Chunks"""
|
||||
"""Streams this app's 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.
|
||||
"""
|
||||
streaming_started = pyqtSignal()
|
||||
streaming_error = pyqtSignal(str)
|
||||
listener_count = pyqtSignal(int)
|
||||
|
|
@ -672,6 +834,7 @@ 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")
|
||||
|
|
@ -689,23 +852,27 @@ class StreamingWorker(QThread):
|
|||
|
||||
def run(self):
|
||||
try:
|
||||
# Create a fresh Socket.IO client for each session to avoid stale state
|
||||
# Create a fresh Socket.IO client for each session
|
||||
self.sio = socketio.Client()
|
||||
self.sio.on('connect', self.on_connect)
|
||||
self.sio.on('disconnect', self.on_disconnect)
|
||||
self.sio.on('listener_count', self.on_listener_count)
|
||||
self.sio.on('connect_error', self.on_connect_error)
|
||||
|
||||
# Connect to socket
|
||||
self.sio.connect(self.stream_url)
|
||||
|
||||
# Start FFmpeg to capture audio and output to pipe
|
||||
# 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)")
|
||||
|
||||
cmd = [
|
||||
"ffmpeg",
|
||||
"-hide_banner",
|
||||
"-loglevel", "error",
|
||||
"-f", "pulse",
|
||||
"-i", "default.monitor",
|
||||
"-i", source,
|
||||
"-ac", "2",
|
||||
"-ar", "44100",
|
||||
"-f", "mp3",
|
||||
|
|
@ -713,13 +880,22 @@ class StreamingWorker(QThread):
|
|||
"-af", "aresample=async=1",
|
||||
"pipe:1"
|
||||
]
|
||||
self.ffmpeg_proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, bufsize=8192)
|
||||
self.ffmpeg_proc = subprocess.Popen(
|
||||
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 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}")
|
||||
|
|
@ -738,6 +914,8 @@ 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')
|
||||
|
|
|
|||
Loading…
Reference in New Issue