Fix streaming: bypass ffmpeg for MP3 input, fix PyQt latency, fix routing bugs
- server.py: Add _distribute_mp3() to route MP3 chunks directly to listener queues without a second ffmpeg passthrough (halves pipeline latency, removes the eventlet/subprocess blocking read that caused the Qt client to fail) - server.py: dj_start no longer starts ffmpeg for is_mp3_input=True - server.py: dj_audio routes to _distribute_mp3 vs _feed_transcoder based on format - server.py: _transcoder_watchdog skips MP3-direct mode - server.py: stream_mp3 endpoint no longer waits for ffmpeg proc when MP3 direct - techdj_qt.py: Add -fflags nobuffer + -flush_packets 1 to reduce source latency - techdj_qt.py: bufsize=0 and read(4096) instead of read(8192) for ~260ms chunks - listener.js: Reduce broadcast_started connect delay 800ms -> 300ms
This commit is contained in:
parent
514f9899a3
commit
6027f2e973
|
|
@ -294,9 +294,10 @@ function initSocket() {
|
||||||
updateNowPlaying('Stream is live!');
|
updateNowPlaying('Stream is live!');
|
||||||
|
|
||||||
if (window.listenerAudioEnabled) {
|
if (window.listenerAudioEnabled) {
|
||||||
// Small delay to let the transcoder produce initial data
|
// Brief delay: 300ms is enough for ffmpeg to produce its first output
|
||||||
|
// (was 800ms — reduced to cut perceived startup lag)
|
||||||
resetReconnectBackoff();
|
resetReconnectBackoff();
|
||||||
setTimeout(() => connectStream(), 800);
|
setTimeout(() => connectStream(), 300);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
90
server.py
90
server.py
|
|
@ -235,9 +235,9 @@ def _stop_transcoder():
|
||||||
def _feed_transcoder(data: bytes):
|
def _feed_transcoder(data: bytes):
|
||||||
global _last_audio_chunk_ts
|
global _last_audio_chunk_ts
|
||||||
if _ffmpeg_proc is None or _ffmpeg_proc.poll() is not None:
|
if _ffmpeg_proc is None or _ffmpeg_proc.poll() is not None:
|
||||||
# If active but dead, restart it automatically
|
# If active but dead, restart it automatically (non-MP3 mode only)
|
||||||
if broadcast_state.get('active'):
|
if broadcast_state.get('active'):
|
||||||
_start_transcoder_if_needed(is_mp3_input=broadcast_state.get('is_mp3_input', False))
|
_start_transcoder_if_needed(is_mp3_input=False)
|
||||||
else:
|
else:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|
@ -247,6 +247,28 @@ def _feed_transcoder(data: bytes):
|
||||||
except queue.Full:
|
except queue.Full:
|
||||||
# Drop chunk if overflow to prevent memory bloat
|
# Drop chunk if overflow to prevent memory bloat
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _distribute_mp3(data: bytes):
|
||||||
|
"""Distribute MP3 bytes directly to preroll buffer and all connected listener
|
||||||
|
clients, bypassing the ffmpeg transcoder entirely.
|
||||||
|
|
||||||
|
Used when the DJ is already sending valid MP3 (e.g. the Qt desktop client)
|
||||||
|
to eliminate the unnecessary encode/decode round-trip and cut pipeline latency
|
||||||
|
roughly in half.
|
||||||
|
"""
|
||||||
|
global _transcoder_bytes_out, _last_audio_chunk_ts
|
||||||
|
_last_audio_chunk_ts = time.time()
|
||||||
|
_transcoder_bytes_out += len(data)
|
||||||
|
with _mp3_lock:
|
||||||
|
_mp3_preroll.append(data)
|
||||||
|
for q in list(_mp3_clients):
|
||||||
|
try:
|
||||||
|
q.put_nowait(data)
|
||||||
|
except queue.Full:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
# Load settings to get MUSIC_FOLDER
|
# Load settings to get MUSIC_FOLDER
|
||||||
def _load_settings():
|
def _load_settings():
|
||||||
try:
|
try:
|
||||||
|
|
@ -432,18 +454,21 @@ def setup_shared_routes(app, index_file='index.html'):
|
||||||
'Cache-Control': 'no-cache, no-store',
|
'Cache-Control': 'no-cache, no-store',
|
||||||
})
|
})
|
||||||
|
|
||||||
# If the transcoder isn't ready yet, wait briefly (it may still be starting)
|
# For non-MP3 input the server runs an ffmpeg transcoder; wait for it to start.
|
||||||
waited = 0.0
|
# For MP3 input (e.g. Qt client) chunks are distributed directly — no ffmpeg needed.
|
||||||
while (_ffmpeg_proc is None or _ffmpeg_proc.poll() is not None) and waited < 5.0:
|
is_mp3_direct = broadcast_state.get('is_mp3_input', False)
|
||||||
eventlet.sleep(0.5)
|
if not is_mp3_direct:
|
||||||
waited += 0.5
|
waited = 0.0
|
||||||
|
while (_ffmpeg_proc is None or _ffmpeg_proc.poll() is not None) and waited < 5.0:
|
||||||
|
eventlet.sleep(0.5)
|
||||||
|
waited += 0.5
|
||||||
|
|
||||||
if _ffmpeg_proc is None or _ffmpeg_proc.poll() is not None:
|
if _ffmpeg_proc is None or _ffmpeg_proc.poll() is not None:
|
||||||
return Response(b'', status=503, content_type='audio/mpeg', headers={
|
return Response(b'', status=503, content_type='audio/mpeg', headers={
|
||||||
'Retry-After': '3',
|
'Retry-After': '3',
|
||||||
'Access-Control-Allow-Origin': '*',
|
'Access-Control-Allow-Origin': '*',
|
||||||
'Cache-Control': 'no-cache, no-store',
|
'Cache-Control': 'no-cache, no-store',
|
||||||
})
|
})
|
||||||
|
|
||||||
preroll_count = len(_mp3_preroll)
|
preroll_count = len(_mp3_preroll)
|
||||||
print(f"LISTENER: New listener joined stream (Pre-roll: {preroll_count} chunks)")
|
print(f"LISTENER: New listener joined stream (Pre-roll: {preroll_count} chunks)")
|
||||||
|
|
@ -688,17 +713,21 @@ def dj_start(data=None):
|
||||||
broadcast_state['is_mp3_input'] = is_mp3_input
|
broadcast_state['is_mp3_input'] = is_mp3_input
|
||||||
|
|
||||||
if not was_already_active:
|
if not was_already_active:
|
||||||
# Fresh broadcast start - clear pre-roll and start transcoder cleanly
|
# Fresh broadcast start — clear pre-roll.
|
||||||
|
# For non-MP3 input start the ffmpeg transcoder; for MP3 input chunks are
|
||||||
|
# distributed directly via _distribute_mp3(), no transcoder required.
|
||||||
with _mp3_lock:
|
with _mp3_lock:
|
||||||
_mp3_preroll.clear()
|
_mp3_preroll.clear()
|
||||||
_start_transcoder_if_needed(is_mp3_input=is_mp3_input)
|
if not is_mp3_input:
|
||||||
|
_start_transcoder_if_needed(is_mp3_input=False)
|
||||||
# Tell listeners a new broadcast has begun (triggers audio player reload)
|
# Tell listeners a new broadcast has begun (triggers audio player reload)
|
||||||
listener_socketio.emit('broadcast_started', namespace='/')
|
listener_socketio.emit('broadcast_started', namespace='/')
|
||||||
else:
|
else:
|
||||||
# DJ reconnected mid-broadcast - just ensure transcoder is alive
|
# DJ reconnected mid-broadcast - just ensure transcoder is alive (non-MP3 only)
|
||||||
# Do NOT clear pre-roll or trigger listener reload
|
# Do NOT clear pre-roll or trigger listener reload
|
||||||
print("BROADCAST: DJ reconnected - resuming existing broadcast")
|
print("BROADCAST: DJ reconnected - resuming existing broadcast")
|
||||||
_start_transcoder_if_needed(is_mp3_input=is_mp3_input)
|
if not is_mp3_input:
|
||||||
|
_start_transcoder_if_needed(is_mp3_input=False)
|
||||||
|
|
||||||
# Always send current status so any waiting listeners get unblocked
|
# Always send current status so any waiting listeners get unblocked
|
||||||
listener_socketio.emit('stream_status', {'active': True}, namespace='/')
|
listener_socketio.emit('stream_status', {'active': True}, namespace='/')
|
||||||
|
|
@ -720,15 +749,14 @@ def dj_stop():
|
||||||
|
|
||||||
@dj_socketio.on('audio_chunk')
|
@dj_socketio.on('audio_chunk')
|
||||||
def dj_audio(data):
|
def dj_audio(data):
|
||||||
# MP3-only mode: do not relay raw chunks to listeners; feed transcoder only.
|
if broadcast_state['active'] and isinstance(data, (bytes, bytearray)):
|
||||||
if broadcast_state['active']:
|
if broadcast_state.get('is_mp3_input', False):
|
||||||
# Ensure MP3 fallback transcoder is running (if ffmpeg is installed)
|
# MP3 input (e.g. Qt client): skip ffmpeg, send directly to listeners
|
||||||
if _ffmpeg_proc is None or _ffmpeg_proc.poll() is not None:
|
_distribute_mp3(bytes(data))
|
||||||
# If we don't know the format, default to transcode,
|
else:
|
||||||
# but usually start_broadcast handles this
|
# Other formats (e.g. webm/opus from browser): route through ffmpeg transcoder
|
||||||
_start_transcoder_if_needed()
|
if _ffmpeg_proc is None or _ffmpeg_proc.poll() is not None:
|
||||||
|
_start_transcoder_if_needed(is_mp3_input=False)
|
||||||
if isinstance(data, (bytes, bytearray)):
|
|
||||||
_feed_transcoder(bytes(data))
|
_feed_transcoder(bytes(data))
|
||||||
|
|
||||||
# === LISTENER SERVER ===
|
# === LISTENER SERVER ===
|
||||||
|
|
@ -792,13 +820,15 @@ def listener_get_count():
|
||||||
|
|
||||||
# DJ Panel Routes (No engine commands needed in local mode)
|
# DJ Panel Routes (No engine commands needed in local mode)
|
||||||
def _transcoder_watchdog():
|
def _transcoder_watchdog():
|
||||||
"""Periodic check to ensure the transcoder stays alive during active broadcasts."""
|
"""Periodic check to ensure the ffmpeg transcoder stays alive.
|
||||||
|
Only applies to non-MP3 input; MP3 input (Qt client) is distributed directly.
|
||||||
|
"""
|
||||||
while True:
|
while True:
|
||||||
if broadcast_state.get('active'):
|
is_mp3_direct = broadcast_state.get('is_mp3_input', False)
|
||||||
|
if broadcast_state.get('active') and not is_mp3_direct:
|
||||||
if _ffmpeg_proc is None or _ffmpeg_proc.poll() is not None:
|
if _ffmpeg_proc is None or _ffmpeg_proc.poll() is not None:
|
||||||
# Only log if it's actually dead and supposed to be alive
|
|
||||||
print("WARNING: Watchdog: Transcoder dead during active broadcast, reviving...")
|
print("WARNING: Watchdog: Transcoder dead during active broadcast, reviving...")
|
||||||
_start_transcoder_if_needed(is_mp3_input=broadcast_state.get('is_mp3_input', False))
|
_start_transcoder_if_needed(is_mp3_input=False)
|
||||||
eventlet.sleep(5)
|
eventlet.sleep(5)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
11
techdj_qt.py
11
techdj_qt.py
|
|
@ -852,21 +852,26 @@ class StreamingWorker(QThread):
|
||||||
"ffmpeg",
|
"ffmpeg",
|
||||||
"-hide_banner",
|
"-hide_banner",
|
||||||
"-loglevel", "error",
|
"-loglevel", "error",
|
||||||
|
# Disable input buffering so frames reach the pipe immediately
|
||||||
|
"-fflags", "nobuffer",
|
||||||
"-f", "pulse",
|
"-f", "pulse",
|
||||||
"-i", source,
|
"-i", source,
|
||||||
"-ac", "2",
|
"-ac", "2",
|
||||||
"-ar", "44100",
|
"-ar", "44100",
|
||||||
"-f", "mp3",
|
|
||||||
"-b:a", "128k",
|
"-b:a", "128k",
|
||||||
"-af", "aresample=async=1",
|
"-af", "aresample=async=1",
|
||||||
|
# Flush every packet — critical for low-latency pipe streaming
|
||||||
|
"-flush_packets", "1",
|
||||||
|
"-f", "mp3",
|
||||||
"pipe:1"
|
"pipe:1"
|
||||||
]
|
]
|
||||||
self.ffmpeg_proc = subprocess.Popen(
|
self.ffmpeg_proc = subprocess.Popen(
|
||||||
cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, bufsize=8192
|
cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, bufsize=0
|
||||||
)
|
)
|
||||||
|
|
||||||
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)
|
# 4096 bytes ≈ 10 MP3 frames ≈ ~260ms at 128kbps — low-latency chunks
|
||||||
|
chunk = self.ffmpeg_proc.stdout.read(4096)
|
||||||
if not chunk:
|
if not chunk:
|
||||||
break
|
break
|
||||||
sio = self.sio # Local ref to avoid race with stop_streaming()
|
sio = self.sio # Local ref to avoid race with stop_streaming()
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue