Compare commits

...

2 Commits

Author SHA1 Message Date
3nd3r
da7e1b7276 Add MP3 fallback stream when Opus unsupported 2026-01-02 22:17:45 -06:00
3nd3r
af76717871 Use video element for listener MSE playback 2026-01-02 22:13:13 -06:00
2 changed files with 216 additions and 13 deletions

View File

@@ -1546,6 +1546,11 @@ let autoStartStream = false;
let listenerAudioContext = null; let listenerAudioContext = null;
let listenerGainNode = null; let listenerGainNode = null;
let listenerChunksReceived = 0; let listenerChunksReceived = 0;
let currentStreamMimeType = null;
function getMp3FallbackUrl() {
return `${window.location.protocol}//${window.location.hostname}:5001/stream.mp3`;
}
// Initialize SocketIO connection // Initialize SocketIO connection
function initSocket() { function initSocket() {
@@ -1782,8 +1787,30 @@ function startBroadcast() {
const selectedBitrate = parseInt(qualitySelect.value) * 1000; // Convert kbps to bps const selectedBitrate = parseInt(qualitySelect.value) * 1000; // Convert kbps to bps
console.log(`🎚️ Starting broadcast at ${qualitySelect.value}kbps`); console.log(`🎚️ Starting broadcast at ${qualitySelect.value}kbps`);
const preferredTypes = [
'audio/webm;codecs=opus',
'audio/webm',
'audio/ogg;codecs=opus',
'audio/mp4;codecs=mp4a.40.2',
'audio/mp4',
];
const chosenType = preferredTypes.find((t) => {
try {
return MediaRecorder.isTypeSupported(t);
} catch {
return false;
}
});
if (!chosenType) {
throw new Error('No supported MediaRecorder mimeType found on this browser');
}
currentStreamMimeType = chosenType;
console.log(`🎛️ Using broadcast mimeType: ${currentStreamMimeType}`);
mediaRecorder = new MediaRecorder(stream, { mediaRecorder = new MediaRecorder(stream, {
mimeType: 'audio/webm;codecs=opus', mimeType: currentStreamMimeType,
audioBitsPerSecond: selectedBitrate audioBitsPerSecond: selectedBitrate
}); });
@@ -1906,9 +1933,9 @@ function startBroadcast() {
document.getElementById('broadcast-status').textContent = '🔴 LIVE'; document.getElementById('broadcast-status').textContent = '🔴 LIVE';
document.getElementById('broadcast-status').classList.add('live'); document.getElementById('broadcast-status').classList.add('live');
// Notify server // Notify server (include codec/container so listeners can configure SourceBuffer)
if (!socket) initSocket(); if (!socket) initSocket();
socket.emit('start_broadcast'); socket.emit('start_broadcast', { mimeType: currentStreamMimeType });
socket.emit('get_listener_count'); socket.emit('get_listener_count');
console.log('✅ Broadcasting started successfully!'); console.log('✅ Broadcasting started successfully!');
@@ -2107,12 +2134,17 @@ function initListenerMode() {
audio.load(); // Reset the element audio.load(); // Reset the element
} }
} else { } else {
// Create a new hidden audio element // Create a new hidden media element.
audio = new Audio(); // Note: MSE (MediaSource) support is often more reliable on <video> than <audio>.
audio = document.createElement('video');
audio.autoplay = false; // Don't autoplay - we use the Enable Audio button audio.autoplay = false; // Don't autoplay - we use the Enable Audio button
audio.hidden = true; audio.muted = false;
audio.controls = false;
audio.playsInline = true;
audio.setAttribute('playsinline', '');
audio.style.display = 'none';
document.body.appendChild(audio); document.body.appendChild(audio);
console.log('🆕 Created new audio element'); console.log('🆕 Created new media element (video) for listener');
// AudioContext will be created later on user interaction // AudioContext will be created later on user interaction
} }
@@ -2134,13 +2166,18 @@ function initListenerMode() {
mediaSource.addEventListener('sourceopen', () => { mediaSource.addEventListener('sourceopen', () => {
console.log('📦 MediaSource opened'); console.log('📦 MediaSource opened');
const mimeType = 'audio/webm;codecs=opus'; const mimeType = window.currentStreamMimeType || currentStreamMimeType || 'audio/webm;codecs=opus';
if (!MediaSource.isTypeSupported(mimeType)) { if (!MediaSource.isTypeSupported(mimeType)) {
console.error(`❌ Browser does not support ${mimeType}`); console.error(`❌ Browser does not support ${mimeType}`);
const statusEl = document.getElementById('connection-status'); const statusEl = document.getElementById('connection-status');
if (statusEl) statusEl.textContent = '❌ Error: Browser does not support WebM/Opus audio'; if (statusEl) statusEl.textContent = '⚠️ WebM/Opus not supported - using MP3 fallback stream';
alert('Your browser does not support WebM/Opus audio format. Please try Chrome, Firefox, or Edge.');
// Fallback to MP3 stream served by the backend (requires ffmpeg on server host)
const fallbackUrl = getMp3FallbackUrl();
console.log(`🎧 Switching to MP3 fallback: ${fallbackUrl}`);
audio.src = fallbackUrl;
audio.load();
return; return;
} }
@@ -2198,6 +2235,14 @@ function initListenerMode() {
initSocket(); initSocket();
socket.emit('join_listener'); socket.emit('join_listener');
socket.on('stream_mime', (data) => {
const mt = data && data.mimeType ? String(data.mimeType) : null;
if (mt && mt !== window.currentStreamMimeType) {
console.log(`📡 Stream mimeType announced: ${mt}`);
window.currentStreamMimeType = mt;
}
});
let hasHeader = false; let hasHeader = false;
socket.on('audio_data', (data) => { socket.on('audio_data', (data) => {

164
server.py
View File

@@ -3,7 +3,10 @@ import eventlet
eventlet.monkey_patch() eventlet.monkey_patch()
import os import os
from flask import Flask, send_from_directory, jsonify, request, session import subprocess
import threading
import queue
from flask import Flask, send_from_directory, jsonify, request, session, Response, stream_with_context
from flask_socketio import SocketIO, emit from flask_socketio import SocketIO, emit
from dotenv import load_dotenv from dotenv import load_dotenv
# Load environment variables from .env file # Load environment variables from .env file
@@ -12,10 +15,118 @@ import downloader
# Relay State # Relay State
broadcast_state = { broadcast_state = {
'active': False 'active': False,
'mimeType': None,
} }
listener_sids = set() listener_sids = set()
dj_sids = set() dj_sids = set()
# === Optional MP3 fallback stream (server-side transcoding) ===
# This allows listeners on browsers that don't support WebM/Opus via MediaSource
# (notably some Safari / locked-down environments) to still hear the stream.
_ffmpeg_proc = None
_ffmpeg_in_q = queue.Queue(maxsize=200)
_mp3_clients = set() # set[queue.Queue]
_mp3_lock = threading.Lock()
_transcode_threads_started = False
def _start_transcoder_if_needed():
global _ffmpeg_proc, _transcode_threads_started
if _ffmpeg_proc is not None and _ffmpeg_proc.poll() is None:
return
cmd = [
'ffmpeg',
'-hide_banner',
'-loglevel', 'error',
'-i', 'pipe:0',
'-vn',
'-acodec', 'libmp3lame',
'-b:a', '192k',
'-f', 'mp3',
'pipe:1',
]
try:
_ffmpeg_proc = subprocess.Popen(
cmd,
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
bufsize=0,
)
except FileNotFoundError:
_ffmpeg_proc = None
print('⚠️ ffmpeg not found; /stream.mp3 fallback disabled')
return
def _writer():
while True:
chunk = _ffmpeg_in_q.get()
if chunk is None:
break
proc = _ffmpeg_proc
if proc is None or proc.stdin is None:
continue
try:
proc.stdin.write(chunk)
except Exception:
# If ffmpeg dies or pipe breaks, just stop writing.
break
def _reader():
proc = _ffmpeg_proc
if proc is None or proc.stdout is None:
return
while True:
try:
data = proc.stdout.read(4096)
except Exception:
break
if not data:
break
with _mp3_lock:
clients = list(_mp3_clients)
for q in clients:
try:
q.put_nowait(data)
except Exception:
# Drop if client queue is full or gone.
pass
if not _transcode_threads_started:
threading.Thread(target=_writer, daemon=True).start()
threading.Thread(target=_reader, daemon=True).start()
_transcode_threads_started = True
def _stop_transcoder():
global _ffmpeg_proc
try:
_ffmpeg_in_q.put_nowait(None)
except Exception:
pass
proc = _ffmpeg_proc
_ffmpeg_proc = None
if proc is None:
return
try:
proc.terminate()
except Exception:
pass
def _feed_transcoder(data: bytes):
if _ffmpeg_proc is None or _ffmpeg_proc.poll() is not None:
return
try:
_ffmpeg_in_q.put_nowait(data)
except Exception:
# Queue full; drop to keep latency bounded.
pass
MUSIC_FOLDER = "music" MUSIC_FOLDER = "music"
# Ensure music folder exists # Ensure music folder exists
if not os.path.exists(MUSIC_FOLDER): if not os.path.exists(MUSIC_FOLDER):
@@ -138,6 +249,37 @@ def setup_shared_routes(app):
print(f"❌ Upload error: {e}") print(f"❌ Upload error: {e}")
return jsonify({"success": False, "error": str(e)}), 500 return jsonify({"success": False, "error": str(e)}), 500
@app.route('/stream.mp3')
def stream_mp3():
# Streaming response from the ffmpeg transcoder output.
# If ffmpeg isn't available, return 503.
if _ffmpeg_proc is None or _ffmpeg_proc.poll() is not None:
return jsonify({"success": False, "error": "MP3 stream not available"}), 503
client_q: queue.Queue = queue.Queue(maxsize=200)
with _mp3_lock:
_mp3_clients.add(client_q)
def gen():
try:
while True:
chunk = client_q.get()
if chunk is None:
break
yield chunk
finally:
with _mp3_lock:
_mp3_clients.discard(client_q)
return Response(
stream_with_context(gen()),
mimetype='audio/mpeg',
headers={
'Cache-Control': 'no-store, no-cache, must-revalidate, max-age=0',
'Connection': 'keep-alive',
},
)
# === DJ SERVER (Port 5000) === # === DJ SERVER (Port 5000) ===
dj_app = Flask(__name__, static_folder='.', static_url_path='') dj_app = Flask(__name__, static_folder='.', static_url_path='')
dj_app.config['SECRET_KEY'] = 'dj_panel_secret' dj_app.config['SECRET_KEY'] = 'dj_panel_secret'
@@ -168,20 +310,32 @@ def stop_broadcast_after_timeout():
pass pass
@dj_socketio.on('start_broadcast') @dj_socketio.on('start_broadcast')
def dj_start(): def dj_start(data=None):
mime_type = None
if isinstance(data, dict):
mime_type = data.get('mimeType') or None
broadcast_state['active'] = True broadcast_state['active'] = True
broadcast_state['mimeType'] = mime_type
session['is_dj'] = True session['is_dj'] = True
print("🎙️ Broadcast -> ACTIVE") print("🎙️ Broadcast -> ACTIVE")
_start_transcoder_if_needed()
listener_socketio.emit('broadcast_started', namespace='/') listener_socketio.emit('broadcast_started', namespace='/')
listener_socketio.emit('stream_status', {'active': True}, namespace='/') listener_socketio.emit('stream_status', {'active': True}, namespace='/')
if mime_type:
listener_socketio.emit('stream_mime', {'mimeType': mime_type}, namespace='/')
@dj_socketio.on('stop_broadcast') @dj_socketio.on('stop_broadcast')
def dj_stop(): def dj_stop():
broadcast_state['active'] = False broadcast_state['active'] = False
broadcast_state['mimeType'] = None
session['is_dj'] = False session['is_dj'] = False
print("🛑 DJ stopped broadcasting") print("🛑 DJ stopped broadcasting")
_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='/')
@@ -190,6 +344,8 @@ def dj_audio(data):
# Relay audio chunk to all listeners immediately # Relay audio chunk to all listeners immediately
if broadcast_state['active']: if broadcast_state['active']:
listener_socketio.emit('audio_data', data, namespace='/') listener_socketio.emit('audio_data', data, namespace='/')
if isinstance(data, (bytes, bytearray)):
_feed_transcoder(bytes(data))
# === LISTENER SERVER (Port 5001) === # === LISTENER SERVER (Port 5001) ===
listener_app = Flask(__name__, static_folder='.', static_url_path='') listener_app = Flask(__name__, static_folder='.', static_url_path='')
@@ -229,6 +385,8 @@ def listener_join():
dj_socketio.emit('listener_count', {'count': count}, namespace='/') dj_socketio.emit('listener_count', {'count': count}, namespace='/')
emit('stream_status', {'active': broadcast_state['active']}) emit('stream_status', {'active': broadcast_state['active']})
if broadcast_state.get('mimeType'):
emit('stream_mime', {'mimeType': broadcast_state['mimeType']})
@listener_socketio.on('get_listener_count') @listener_socketio.on('get_listener_count')
def listener_get_count(): def listener_get_count():