forked from computertech/techdj
Add MP3 fallback stream when Opus unsupported
This commit is contained in:
52
script.js
52
script.js
@@ -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!');
|
||||||
@@ -2139,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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2203,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
164
server.py
@@ -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,19 +310,31 @@ 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():
|
||||||
|
|||||||
Reference in New Issue
Block a user