Music daemon + cross-source sync via OBS WebSocket
Music persistence across scene switches by extracting playback into a
separate browser source (music/index.html) — single source instance
shared across scenes via OBS's "Add Existing", so it doesn't restart on
scene change.
Loading and Game overlays subscribe to mpd:state CustomEvents over OBS
WebSocket; the daemon broadcasts at 1 Hz plus on every track change.
File-based command pipeline lets bash control playback without a
WebSocket client of its own:
- vendor/obs-ws-mini.js — minimal WS v5 client with HMAC-SHA256 auth,
hard timeout, close-rejection, and a pure-JS SHA-256 fallback for
CEF file:// origins where crypto.subtle is unavailable.
- setup.sh — generates vendor/obs-config.js from the OBS plugin config;
checks the live socket instead of the (lagging) config file.
- music/index.html — invisible audio worker. Plays through a shuffled
queue with hardened error/stall recovery, polls loading/cmd.js for
bash-issued commands, broadcasts mpd:state via WS. Includes a
diagnostic status block (visible if the source's eye-icon is on).
- loading/index.html — strips local audio playback; subscribes to
mpd:state to drive the now-playing terminal line live. Removes the
redundant trailing prompt; terminal lines anchor at the bottom.
- loading/playlist.sh — rebuilt as subcommand API:
sync (default), skip, prev, pause, resume, status
control commands write loading/cmd.js (id, type, ts), which the
daemon polls every 250ms.
- loading/convert.sh — strips video tracks from .webm music downloads
to audio-only m4a (~95% smaller, ~20× faster decode); fixes the
CPU pegging from CEF decoding 1080p VP9 video for music playback.
- .gitignore — track manifests, ignore generated wrappers + secrets +
the audio cache.
This commit is contained in:
176
loading/playlist.sh
Executable file
176
loading/playlist.sh
Executable file
@@ -0,0 +1,176 @@
|
||||
#!/usr/bin/env bash
|
||||
# loading/playlist.sh — playlist sync + control API for the music daemon.
|
||||
#
|
||||
# Usage:
|
||||
# ./playlist.sh → sync (default; index audio in ../playlist/)
|
||||
# ./playlist.sh sync → explicit sync
|
||||
# ./playlist.sh skip → next track
|
||||
# ./playlist.sh prev → previous track
|
||||
# ./playlist.sh pause → pause playback
|
||||
# ./playlist.sh resume → resume playback
|
||||
# ./playlist.sh status → show last queued command
|
||||
# ./playlist.sh help → this help
|
||||
#
|
||||
# Control commands (skip/prev/pause/resume) write a tiny `cmd.js` that the
|
||||
# Music Daemon polls every 250ms. The daemon picks up the new command,
|
||||
# acts on it, and broadcasts the resulting state via OBS WebSocket so the
|
||||
# loading overlay updates live.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
OBS_DIR="$(cd "$DIR/.." && pwd)"
|
||||
AUDIO_DIR="$OBS_DIR/playlist"
|
||||
JSON="$DIR/playlist.json"
|
||||
JS="$DIR/playlist.js"
|
||||
CMD_JS="$DIR/cmd.js"
|
||||
|
||||
usage() {
|
||||
sed -n 's/^# \?//p' "$0" | head -20
|
||||
}
|
||||
|
||||
# ─────────────── sync ───────────────
|
||||
cmd_sync() {
|
||||
if [[ ! -d "$AUDIO_DIR" ]]; then
|
||||
echo " ✗ $AUDIO_DIR does not exist" >&2
|
||||
echo " Drop your audio files there first." >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! command -v ffprobe >/dev/null 2>&1; then
|
||||
echo " ✗ ffprobe not found (install ffmpeg: sudo pacman -S ffmpeg)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "── Indexing $AUDIO_DIR ──"
|
||||
|
||||
local TMP="$JSON.tmp.$$"
|
||||
python3 - "$AUDIO_DIR" "$TMP" <<'PY'
|
||||
import json, subprocess, sys, os, datetime, re
|
||||
|
||||
audio_dir, out = sys.argv[1], sys.argv[2]
|
||||
exts = ('.m4a', '.mp3', '.opus', '.ogg', '.flac', '.webm', '.aac', '.wav')
|
||||
|
||||
def ffprobe_meta(path):
|
||||
try:
|
||||
r = subprocess.run(
|
||||
['ffprobe', '-v', 'error',
|
||||
'-show_entries', 'format=duration:format_tags=title,artist',
|
||||
'-of', 'json', path],
|
||||
capture_output=True, text=True, timeout=10,
|
||||
)
|
||||
if r.returncode != 0: return {}
|
||||
fmt = (json.loads(r.stdout) or {}).get('format', {}) or {}
|
||||
tags = {k.lower(): v for k, v in (fmt.get('tags') or {}).items()}
|
||||
return {
|
||||
'title': tags.get('title'),
|
||||
'artist': tags.get('artist'),
|
||||
'durationSec': float(fmt['duration']) if fmt.get('duration') else None,
|
||||
}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
def clean_title(t):
|
||||
if not t: return t
|
||||
# PRIMARY rule: keep only what's before the first vertical bar
|
||||
# (fullwidth | U+FF5C or ASCII |). Everything after is genre/label noise.
|
||||
t = re.sub(r'\s*[||].*$', '', t)
|
||||
# Strip trailing YouTube-ID brackets, e.g. " [-XxZTgMWKV0]"
|
||||
t = re.sub(r'\s*\[[A-Za-z0-9_-]{11}\]\s*$', '', t)
|
||||
# Strip "[NCS Release]" / "(NCS10 Release)" suffix variants
|
||||
t = re.sub(r'\s*[\[(](?:NCS\d*|No Copyright Sounds)(?:\s+Release)?[\])]\s*$', '', t, flags=re.I)
|
||||
return t.strip()
|
||||
|
||||
INTERMEDIATE = re.compile(r'\.(?:f\d+|temp|part)$', re.I)
|
||||
def is_audio_file(name):
|
||||
base, ext = os.path.splitext(name)
|
||||
if ext.lower() not in exts: return False
|
||||
if INTERMEDIATE.search(base): return False
|
||||
return True
|
||||
|
||||
YT_ID_RX = re.compile(r'\[([A-Za-z0-9_-]{11})\]')
|
||||
files = sorted(f for f in os.listdir(audio_dir) if is_audio_file(f))
|
||||
|
||||
tracks, seen_ids = [], set()
|
||||
for fname in files:
|
||||
m = YT_ID_RX.search(fname)
|
||||
yt_id = m.group(1) if m else None
|
||||
if yt_id:
|
||||
if yt_id in seen_ids: continue
|
||||
seen_ids.add(yt_id)
|
||||
path = os.path.join(audio_dir, fname)
|
||||
meta = ffprobe_meta(path)
|
||||
display = clean_title(os.path.splitext(fname)[0])
|
||||
tracks.append({
|
||||
'title': display,
|
||||
'file': f"../playlist/{fname}",
|
||||
'durationSec': int(meta['durationSec']) if meta.get('durationSec') else None,
|
||||
})
|
||||
|
||||
doc = {
|
||||
'syncedAt': datetime.datetime.now(datetime.timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ'),
|
||||
'sourceDir': audio_dir,
|
||||
'trackCount': len(tracks),
|
||||
'tracks': tracks,
|
||||
}
|
||||
with open(out, 'w') as f:
|
||||
json.dump(doc, f, indent=2, ensure_ascii=False)
|
||||
PY
|
||||
|
||||
if ! python3 -m json.tool "$TMP" >/dev/null 2>&1; then
|
||||
echo " ✗ manifest invalid, leaving $TMP" >&2
|
||||
exit 1
|
||||
fi
|
||||
mv -f "$TMP" "$JSON"
|
||||
{ printf 'window.__PLAYLIST = '; cat "$JSON"; printf ';\n'; } > "$JS.tmp.$$"
|
||||
mv -f "$JS.tmp.$$" "$JS"
|
||||
|
||||
local count
|
||||
count=$(python3 -c "import json; print(json.load(open('$JSON'))['trackCount'])")
|
||||
echo
|
||||
echo " ✓ wrote $JSON ($count tracks)"
|
||||
echo " ✓ wrote $JS"
|
||||
echo " → refresh the Music Daemon browser source in OBS to reload the queue."
|
||||
}
|
||||
|
||||
# ─────────────── control commands ───────────────
|
||||
# Writes a unique-per-call cmd.js that the daemon polls. The id is
|
||||
# nanosecond-precise so the daemon's "did this id change?" check always passes
|
||||
# even on rapid-fire commands.
|
||||
send_cmd() {
|
||||
local type="$1"
|
||||
local id ts
|
||||
id=$(date +%s%N)
|
||||
ts=$(date +%s)
|
||||
printf 'window.__CMD = {"id":%s,"type":"%s","ts":%s};\n' "$id" "$type" "$ts" > "$CMD_JS"
|
||||
echo " → ${type} (id=$id)"
|
||||
}
|
||||
|
||||
cmd_skip() { send_cmd skip; }
|
||||
cmd_prev() { send_cmd prev; }
|
||||
cmd_pause() { send_cmd pause; }
|
||||
cmd_resume() { send_cmd resume; }
|
||||
|
||||
cmd_status() {
|
||||
if [[ -f "$CMD_JS" ]]; then
|
||||
echo "── last queued command ──"
|
||||
cat "$CMD_JS"
|
||||
else
|
||||
echo " (no command file yet — daemon hasn't been signalled this session)"
|
||||
fi
|
||||
}
|
||||
|
||||
# ─────────────── dispatch ───────────────
|
||||
case "${1:-sync}" in
|
||||
sync) cmd_sync ;;
|
||||
skip|next) cmd_skip ;;
|
||||
prev|back) cmd_prev ;;
|
||||
pause) cmd_pause ;;
|
||||
resume|play) cmd_resume ;;
|
||||
status) cmd_status ;;
|
||||
help|-h|--help) usage ;;
|
||||
*)
|
||||
echo " ✗ unknown command: $1" >&2
|
||||
usage
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
Reference in New Issue
Block a user