Compare commits

...

2 Commits

Author SHA1 Message Date
ComputerTech 80a4286bc0 fix: defer broadcast_started for Qt MP3-direct mode until first audio chunk
Previously, start_broadcast immediately emitted broadcast_started and
stream_status: active to listeners, even before the DJ pressed play.
Listeners would connect to /stream.mp3, wait 60s with no data, then
disconnect — so music never reached them from the PyQt client.

The web DJ panel was unaffected because it only sends audio_chunk while
music is actively playing, so start_broadcast and data arrive together.

Fix: in MP3-direct mode (Qt client, format=mp3), hold back broadcast_started
and stream_status: active until the very first audio_chunk arrives. The
first chunk guarantees the preroll buffer has data and listeners will
immediately receive audio when they connect.

- _mp3_broadcast_announced flag tracks whether the deferred announcement
  has been sent in the current session
- Resets on stop_broadcast, grace-period auto-stop, and fresh start_broadcast
- Browser (webm/opus) mode is unaffected — announces immediately as before
2026-04-03 13:44:09 +01:00
ComputerTech 35adfa7feb Tauri v2 migration: asset protocol, desktop CSS polish, absolutePath in library 2026-03-28 11:49:00 +00:00
10 changed files with 202 additions and 33 deletions

View File

@ -19,10 +19,18 @@
* { * {
-webkit-overflow-scrolling: touch; -webkit-overflow-scrolling: touch;
box-sizing: border-box; box-sizing: border-box;
user-select: none;
-webkit-user-select: none;
} }
html { html {
scroll-behavior: smooth; scroll-behavior: smooth;
overflow: hidden;
scrollbar-width: none;
}
html::-webkit-scrollbar {
display: none;
} }
body { body {

View File

@ -1274,6 +1274,22 @@ function updateCrossfader(val) {
} }
// Library Functions // Library Functions
// ---------------------------------------------------------------------------
// Tauri v2 asset-protocol helper
// When running inside Tauri (window.__TAURI__ is injected via withGlobalTauri)
// and the server has provided an absolutePath for the track, we convert it to
// an asset:// URL so the WebView reads the file directly from disk — no Flask
// round-trip, works with any folder under $HOME.
// Falls back to the ordinary server URL when not in Tauri or no absolutePath.
// ---------------------------------------------------------------------------
function tauriResolve(track) {
const cvt = window.__TAURI__?.core?.convertFileSrc;
if (cvt && track.absolutePath) {
return cvt(track.absolutePath);
}
return track.file;
}
async function fetchLibrary() { async function fetchLibrary() {
try { try {
const res = await fetch('library.json?t=' + new Date().getTime()); const res = await fetch('library.json?t=' + new Date().getTime());
@ -1303,7 +1319,7 @@ function renderLibrary(songs) {
// Drag data // Drag data
item.ondragstart = (e) => { item.ondragstart = (e) => {
e.dataTransfer.setData('trackFile', t.file); e.dataTransfer.setData('trackFile', tauriResolve(t));
e.dataTransfer.setData('trackTitle', t.title); e.dataTransfer.setData('trackTitle', t.title);
e.dataTransfer.setData('source', 'library'); e.dataTransfer.setData('source', 'library');
item.classList.add('dragging'); item.classList.add('dragging');
@ -1324,25 +1340,25 @@ function renderLibrary(songs) {
const btnA = document.createElement('button'); const btnA = document.createElement('button');
btnA.className = 'load-btn btn-a'; btnA.className = 'load-btn btn-a';
btnA.textContent = 'LOAD A'; btnA.textContent = 'LOAD A';
btnA.addEventListener('click', () => loadFromServer('A', t.file, t.title)); btnA.addEventListener('click', () => loadFromServer('A', tauriResolve(t), t.title));
const btnB = document.createElement('button'); const btnB = document.createElement('button');
btnB.className = 'load-btn btn-b'; btnB.className = 'load-btn btn-b';
btnB.textContent = 'LOAD B'; btnB.textContent = 'LOAD B';
btnB.addEventListener('click', () => loadFromServer('B', t.file, t.title)); btnB.addEventListener('click', () => loadFromServer('B', tauriResolve(t), t.title));
// QUEUE buttons // QUEUE buttons
const queueA = document.createElement('button'); const queueA = document.createElement('button');
queueA.className = 'load-btn queue-btn-a'; queueA.className = 'load-btn queue-btn-a';
queueA.textContent = 'Q-A'; queueA.textContent = 'Q-A';
queueA.title = 'Add to Queue A'; queueA.title = 'Add to Queue A';
queueA.addEventListener('click', () => addToQueue('A', t.file, t.title)); queueA.addEventListener('click', () => addToQueue('A', tauriResolve(t), t.title));
const queueB = document.createElement('button'); const queueB = document.createElement('button');
queueB.className = 'load-btn queue-btn-b'; queueB.className = 'load-btn queue-btn-b';
queueB.textContent = 'Q-B'; queueB.textContent = 'Q-B';
queueB.title = 'Add to Queue B'; queueB.title = 'Add to Queue B';
queueB.addEventListener('click', () => addToQueue('B', t.file, t.title)); queueB.addEventListener('click', () => addToQueue('B', tauriResolve(t), t.title));
loadActions.appendChild(btnA); loadActions.appendChild(btnA);
loadActions.appendChild(queueA); loadActions.appendChild(queueA);
@ -1351,8 +1367,9 @@ function renderLibrary(songs) {
item.appendChild(trackName); item.appendChild(trackName);
item.appendChild(loadActions); item.appendChild(loadActions);
// Add data attribute for highlighting // Add data attribute for highlighting — store the resolved URL so
item.dataset.file = t.file; // updateLibraryHighlighting() matches decks.X.currentFile correctly.
item.dataset.file = tauriResolve(t);
list.appendChild(item); list.appendChild(item);
}); });

View File

@ -54,6 +54,15 @@ broadcast_state = {
listener_sids = set() listener_sids = set()
dj_sids = set() dj_sids = set()
# Set to True once the first audio chunk arrives in MP3-direct mode.
# broadcast_started is held back until then so listeners don't connect
# to /stream.mp3 before any data is flowing (e.g. before the DJ presses play).
_mp3_broadcast_announced = False
# Current listener glow intensity — persisted so new/reloaded listeners get the
# correct level immediately without waiting for the DJ to move the slider again.
_current_glow_intensity = 30
# Grace-period greenlet: auto-stop broadcast if DJ doesn't reconnect in time # Grace-period greenlet: auto-stop broadcast if DJ doesn't reconnect in time
_dj_grace_greenlet = None _dj_grace_greenlet = None
DJ_GRACE_PERIOD_SECS = 20 # seconds to wait before auto-stopping DJ_GRACE_PERIOD_SECS = 20 # seconds to wait before auto-stopping
@ -136,7 +145,9 @@ def _start_transcoder_if_needed(is_mp3_input=False):
try: _ffmpeg_in_q.get_nowait() try: _ffmpeg_in_q.get_nowait()
except: break except: break
# Define threads INSIDE so they close over THIS specific 'proc' # Define greenlets INSIDE so they close over THIS specific 'proc'.
# Blocking subprocess pipe I/O is delegated to eventlet.tpool so it runs
# in a real OS thread, preventing it from stalling the eventlet hub.
def _writer(proc): def _writer(proc):
global _transcoder_last_error global _transcoder_last_error
print(f"[THREAD] Transcoder writer started (PID: {proc.pid})") print(f"[THREAD] Transcoder writer started (PID: {proc.pid})")
@ -146,8 +157,9 @@ def _start_transcoder_if_needed(is_mp3_input=False):
if chunk is None: break if chunk is None: break
if proc.stdin: if proc.stdin:
proc.stdin.write(chunk) # Run blocking pipe-write in a real thread via tpool
proc.stdin.flush() eventlet.tpool.execute(proc.stdin.write, chunk)
eventlet.tpool.execute(proc.stdin.flush)
except queue.Empty: except queue.Empty:
continue continue
except (BrokenPipeError, ConnectionResetError): except (BrokenPipeError, ConnectionResetError):
@ -158,7 +170,7 @@ def _start_transcoder_if_needed(is_mp3_input=False):
_transcoder_last_error = str(e) _transcoder_last_error = str(e)
break break
# Ensure process is killed if thread exits unexpectedly # Ensure process is killed if greenlet exits unexpectedly
if proc.poll() is None: if proc.poll() is None:
try: proc.terminate() try: proc.terminate()
except: pass except: pass
@ -169,9 +181,9 @@ def _start_transcoder_if_needed(is_mp3_input=False):
print(f"[THREAD] Transcoder reader started (PID: {proc.pid})") print(f"[THREAD] Transcoder reader started (PID: {proc.pid})")
while proc.poll() is None: while proc.poll() is None:
try: try:
# Smaller read for smoother delivery (1KB) # Run blocking pipe-read in a real thread via tpool (1 KB chunks
# This prevents buffering delays at lower bitrates # for smooth delivery; prevents buffering delays at lower bitrates)
data = proc.stdout.read(1024) data = eventlet.tpool.execute(proc.stdout.read, 1024)
if not data: break if not data: break
_transcoder_bytes_out += len(data) _transcoder_bytes_out += len(data)
@ -183,20 +195,20 @@ def _start_transcoder_if_needed(is_mp3_input=False):
try: try:
q.put_nowait(data) q.put_nowait(data)
except queue.Full: except queue.Full:
# Client is too slow, skip this chunk for them # Client is too slow skip this chunk for them
pass pass
except Exception as e: except Exception as e:
print(f"WARNING: Transcoder reader error: {e}") print(f"WARNING: Transcoder reader error: {e}")
_transcoder_last_error = str(e) _transcoder_last_error = str(e)
break break
# Ensure process is killed if thread exits unexpectedly # Ensure process is killed if greenlet exits unexpectedly
if proc.poll() is None: if proc.poll() is None:
try: proc.terminate() try: proc.terminate()
except: pass except: pass
print(f"[THREAD] Transcoder reader finished (PID: {proc.pid})") print(f"[THREAD] Transcoder reader finished (PID: {proc.pid})")
# Start greenlets/threads for THIS process specifically # Spawn as greenlets — tpool handles the blocking subprocess I/O internally
eventlet.spawn(_writer, _ffmpeg_proc) eventlet.spawn(_writer, _ffmpeg_proc)
eventlet.spawn(_reader, _ffmpeg_proc) eventlet.spawn(_reader, _ffmpeg_proc)
@ -306,7 +318,10 @@ def setup_shared_routes(app, index_file='index.html'):
rel_path = os.path.relpath(os.path.join(root, filename), MUSIC_FOLDER) rel_path = os.path.relpath(os.path.join(root, filename), MUSIC_FOLDER)
library.append({ library.append({
"title": os.path.splitext(filename)[0], "title": os.path.splitext(filename)[0],
"file": f"music_proxy/{rel_path}" "file": f"music_proxy/{rel_path}",
# Absolute path exposed for Tauri's asset protocol (convertFileSrc).
# The web fallback keeps using the music_proxy route above.
"absolutePath": os.path.abspath(os.path.join(root, filename))
}) })
break # Top-level only break # Top-level only
return jsonify(library) return jsonify(library)
@ -661,12 +676,14 @@ def dj_connect():
def _dj_disconnect_grace(): def _dj_disconnect_grace():
"""Auto-stop broadcast if no DJ reconnects within the grace period.""" """Auto-stop broadcast if no DJ reconnects within the grace period."""
global _dj_grace_greenlet global _dj_grace_greenlet, _mp3_broadcast_announced
print(f"INFO: Grace period started — {DJ_GRACE_PERIOD_SECS}s for DJ to reconnect") print(f"INFO: Grace period started — {DJ_GRACE_PERIOD_SECS}s for DJ to reconnect")
eventlet.sleep(DJ_GRACE_PERIOD_SECS) eventlet.sleep(DJ_GRACE_PERIOD_SECS)
if broadcast_state.get('active') and not dj_sids: if broadcast_state.get('active') and not dj_sids:
print("WARNING: Grace period expired, no DJ reconnected — auto-stopping broadcast") print("WARNING: Grace period expired, no DJ reconnected — auto-stopping broadcast")
broadcast_state['active'] = False broadcast_state['active'] = False
broadcast_state['is_mp3_input'] = False
_mp3_broadcast_announced = False
_stop_transcoder() _stop_transcoder()
listener_socketio.emit('broadcast_stopped', namespace='/') listener_socketio.emit('broadcast_stopped', namespace='/')
listener_socketio.emit('stream_status', {'active': False}, namespace='/') listener_socketio.emit('stream_status', {'active': False}, namespace='/')
@ -691,11 +708,11 @@ def dj_disconnect():
@dj_socketio.on('start_broadcast') @dj_socketio.on('start_broadcast')
def dj_start(data=None): def dj_start(data=None):
global _dj_grace_greenlet global _dj_grace_greenlet, _mp3_broadcast_announced
# Cancel any pending auto-stop grace period (DJ reconnected in time) # Cancel any pending auto-stop grace period (DJ reconnected in time)
if _dj_grace_greenlet is not None: if _dj_grace_greenlet is not None:
_dj_grace_greenlet.kill() _dj_grace_greenlet.kill(block=True) # block=True prevents a GreenletExit race
_dj_grace_greenlet = None _dj_grace_greenlet = None
print("INFO: DJ reconnected within grace period — broadcast continues") print("INFO: DJ reconnected within grace period — broadcast continues")
@ -715,14 +732,22 @@ 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. # Fresh broadcast start — clear pre-roll and reset announcement flag.
# 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()
if not is_mp3_input:
if is_mp3_input:
# MP3-direct mode (Qt client): the DJ still needs to press play before
# audio flows. Firing broadcast_started now would cause listeners to
# connect to /stream.mp3 before any data exists, wait 60 s, then
# disconnect. Instead we hold back the announcement and send it on
# the very first audio_chunk so listeners connect when data is ready.
_mp3_broadcast_announced = False
print("BROADCAST: MP3-direct mode — deferring broadcast_started until first chunk")
else:
# Non-MP3 (browser webm/opus): ffmpeg transcoder starts immediately,
# so listeners can connect right away.
_start_transcoder_if_needed(is_mp3_input=False) _start_transcoder_if_needed(is_mp3_input=False)
# 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 (non-MP3 only) # DJ reconnected mid-broadcast - just ensure transcoder is alive (non-MP3 only)
@ -731,7 +756,11 @@ def dj_start(data=None):
if not is_mp3_input: if not is_mp3_input:
_start_transcoder_if_needed(is_mp3_input=False) _start_transcoder_if_needed(is_mp3_input=False)
# Always send current status so any waiting listeners get unblocked # For MP3-direct fresh broadcast, hold back stream_status active=true too
# so listeners don't auto-connect before audio data is flowing.
# It will be sent alongside broadcast_started on the first audio_chunk.
# For non-MP3 (browser) mode or DJ reconnects, send immediately.
if not (is_mp3_input and not was_already_active):
listener_socketio.emit('stream_status', {'active': True}, namespace='/') listener_socketio.emit('stream_status', {'active': True}, namespace='/')
@dj_socketio.on('get_listener_count') @dj_socketio.on('get_listener_count')
@ -741,8 +770,10 @@ def dj_get_listener_count():
@dj_socketio.on('listener_glow') @dj_socketio.on('listener_glow')
def dj_listener_glow(data): def dj_listener_glow(data):
"""DJ sets the glow intensity on the listener page.""" """DJ sets the glow intensity on the listener page."""
global _current_glow_intensity
intensity = int(data.get('intensity', 30)) if isinstance(data, dict) else 30 intensity = int(data.get('intensity', 30)) if isinstance(data, dict) else 30
intensity = max(0, min(100, intensity)) intensity = max(0, min(100, intensity))
_current_glow_intensity = intensity
listener_socketio.emit('listener_glow', {'intensity': intensity}, namespace='/') listener_socketio.emit('listener_glow', {'intensity': intensity}, namespace='/')
@dj_socketio.on('deck_glow') @dj_socketio.on('deck_glow')
@ -755,9 +786,22 @@ def dj_deck_glow(data):
'B': bool(data.get('B', False)), 'B': bool(data.get('B', False)),
}, namespace='/') }, namespace='/')
@dj_socketio.on('now_playing')
def dj_now_playing(data):
"""Relay the currently playing track title to all listener pages."""
if not isinstance(data, dict):
return
listener_socketio.emit('now_playing', {
'title': str(data.get('title', '')),
'deck': str(data.get('deck', '')),
}, namespace='/')
@dj_socketio.on('stop_broadcast') @dj_socketio.on('stop_broadcast')
def dj_stop(): def dj_stop():
global _mp3_broadcast_announced
broadcast_state['active'] = False broadcast_state['active'] = False
broadcast_state['is_mp3_input'] = False
_mp3_broadcast_announced = False
session['is_dj'] = False session['is_dj'] = False
print("STOPPED: DJ stopped broadcasting") print("STOPPED: DJ stopped broadcasting")
@ -768,9 +812,17 @@ def dj_stop():
@dj_socketio.on('audio_chunk') @dj_socketio.on('audio_chunk')
def dj_audio(data): def dj_audio(data):
global _mp3_broadcast_announced
if broadcast_state['active'] and isinstance(data, (bytes, bytearray)): if broadcast_state['active'] and isinstance(data, (bytes, bytearray)):
if broadcast_state.get('is_mp3_input', False): if broadcast_state.get('is_mp3_input', False):
# MP3 input (e.g. Qt client): skip ffmpeg, send directly to listeners # MP3 input (e.g. Qt client): skip ffmpeg, send directly to listeners.
# Fire broadcast_started on the very first chunk so listeners connect
# only once actual audio data is flowing (the DJ has pressed play).
if not _mp3_broadcast_announced:
_mp3_broadcast_announced = True
print("BROADCAST: First MP3 chunk received — announcing broadcast_started to listeners")
listener_socketio.emit('broadcast_started', namespace='/')
listener_socketio.emit('stream_status', {'active': True}, namespace='/')
_distribute_mp3(bytes(data)) _distribute_mp3(bytes(data))
else: else:
# Other formats (e.g. webm/opus from browser): route through ffmpeg transcoder # Other formats (e.g. webm/opus from browser): route through ffmpeg transcoder
@ -845,8 +897,9 @@ def listener_disconnect():
@listener_socketio.on('join_listener') @listener_socketio.on('join_listener')
def listener_join(): def listener_join():
# SID already added in listener_connect(); just send stream status back # SID already added in listener_connect(); send stream status and current glow
emit('stream_status', {'active': broadcast_state['active']}) emit('stream_status', {'active': broadcast_state['active']})
emit('listener_glow', {'intensity': _current_glow_intensity})
@listener_socketio.on('get_listener_count') @listener_socketio.on('get_listener_count')
def listener_get_count(): def listener_get_count():

21
src-tauri/Cargo.toml Normal file
View File

@ -0,0 +1,21 @@
[package]
name = "techdj"
version = "0.1.0"
edition = "2021"
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = [] }
tauri-plugin-fs = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
# Release optimisations — keep the binary small on the 4 GB machine
[profile.release]
panic = "abort"
codegen-units = 1
lto = true
opt-level = "s"
strip = true

3
src-tauri/build.rs Normal file
View File

@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}

View File

@ -0,0 +1,11 @@
{
"$schema": "https://schema.tauri.app/config/2/acl/capability.json",
"identifier": "default",
"description": "TechDJ default permissions — read-only access to home directory for local audio",
"windows": ["main"],
"permissions": [
"core:default",
"fs:read-all",
"fs:scope-home-recursive"
]
}

10
src-tauri/src/lib.rs Normal file
View File

@ -0,0 +1,10 @@
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
// Grant the WebView direct read access to local audio files so
// convertFileSrc() can serve tracks from $HOME without going
// through the Flask proxy.
.plugin(tauri_plugin_fs::init())
.run(tauri::generate_context!())
.expect("error while running TechDJ");
}

6
src-tauri/src/main.rs Normal file
View File

@ -0,0 +1,6 @@
// Hides the console window on Windows release builds; harmless on Linux.
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
techdj_lib::run()
}

38
src-tauri/tauri.conf.json Normal file
View File

@ -0,0 +1,38 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "TechDJ",
"version": "0.1.0",
"identifier": "dev.computertech.techdj",
"build": {
"frontendDist": "../"
},
"app": {
"withGlobalTauri": true,
"windows": [
{
"title": "TechDJ",
"width": 1280,
"height": 800,
"minWidth": 1024,
"minHeight": 600,
"decorations": true,
"fullscreen": false,
"resizable": true
}
],
"security": {
"assetProtocol": {
"enable": true,
"scope": [
"$HOME/**",
"$HOME/Music/**"
]
}
}
},
"bundle": {
"active": true,
"targets": ["deb"],
"icon": ["../icon.png"]
}
}

View File

@ -20,6 +20,8 @@
/* Smooth scrolling for all scrollable elements */ /* Smooth scrolling for all scrollable elements */
* { * {
-webkit-overflow-scrolling: touch; -webkit-overflow-scrolling: touch;
user-select: none;
-webkit-user-select: none;
} }
html { html {