Scripts, scenes, bridges, bots and more

This commit is contained in:
Jakub Zych
2026-04-26 23:08:48 +02:00
parent a34cd5fab7
commit fb5aaed9e7
32 changed files with 2985 additions and 4382 deletions

View File

@@ -1,80 +0,0 @@
#!/usr/bin/env bash
# loading/convert.sh — strip video tracks out of audio files in
# ~/.config/obs-studio/playlist/ and re-encode to AAC m4a in place.
#
# WHY: yt-dlp .webm downloads include 1080p video. Chromium's <audio>
# element decodes the video stream too (just doesn't render it) — pegs
# CPU and stalls playback mid-track. Audio-only m4a is ~95% smaller
# and decodes ~20× faster.
#
# ./convert.sh — convert all video-bearing files
# ./convert.sh --dry-run — show what would happen, do nothing
#
# DO NOT run this while OBS has a playlist track open (between streams
# is fine; mid-stream you'll lock the file ffmpeg is reading from).
#
# After conversion, re-index: ./playlist.sh
set -euo pipefail
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
OBS_DIR="$(cd "$DIR/.." && pwd)"
AUDIO_DIR="$OBS_DIR/playlist"
DRY=0
[[ "${1:-}" == "--dry-run" ]] && DRY=1
if [[ ! -d "$AUDIO_DIR" ]]; then
echo "$AUDIO_DIR does not exist" >&2
exit 1
fi
if ! command -v ffprobe >/dev/null 2>&1 || ! command -v ffmpeg >/dev/null 2>&1; then
echo " ✗ ffmpeg / ffprobe required" >&2
exit 1
fi
shopt -s nullglob nocaseglob
# Files that contain a video stream (and aren't already m4a)
declare -a TODO=()
for f in "$AUDIO_DIR"/*.{webm,mp4,mkv,mov,avi}; do
[[ -f "$f" ]] || continue
# Skip yt-dlp intermediate files
case "$(basename "$f")" in
*.f[0-9]*.*|*.temp.*|*.part) continue ;;
esac
# Only consider files that actually contain a video stream
if ffprobe -v error -select_streams v -show_entries stream=codec_type \
-of csv=p=0 "$f" 2>/dev/null | grep -q video; then
TODO+=("$f")
fi
done
if [[ ${#TODO[@]} -eq 0 ]]; then
echo " ✓ nothing to convert (no video-bearing files in $AUDIO_DIR)"
exit 0
fi
echo "── Converting ${#TODO[@]} file(s) → AAC m4a ──"
for f in "${TODO[@]}"; do
base="${f%.*}"
out="$base.m4a"
if [[ -f "$out" ]]; then
echo " ⊘ skip (m4a exists): $(basename "$f")"
continue
fi
echo "$(basename "$f")"
if [[ $DRY -eq 1 ]]; then
echo " (dry-run: would write $(basename "$out") and remove the source)"
continue
fi
if ffmpeg -nostdin -loglevel error -i "$f" -vn -c:a aac -b:a 192k "$out"; then
rm -f "$f"
echo "${out##*/} ($(du -h "$out" | cut -f1))"
else
echo " ✗ ffmpeg failed; leaving $(basename "$f") in place" >&2
rm -f "$out"
fi
done
echo
echo " → next: ./playlist.sh (re-index for the new filenames)"

View File

@@ -17,7 +17,7 @@
--term-fg-bright: #97f99a;
--term-fg-dim: #2c8d2f;
--term-glow: rgba(80, 220, 100, 0.55);
--term-edge: rgba(80, 220, 100, 0.30);
--term-edge: rgba(80, 220, 100, 0.45);
--mono: 'DejaVu Sans Mono', 'Liberation Mono', 'Consolas', monospace;
--display: 'DejaVu Sans', 'Liberation Sans', 'Helvetica', 'Arial', sans-serif;
@@ -58,16 +58,25 @@ body {
.hud > .group:nth-child(2) { justify-self: center; }
.hud > .group:nth-child(3) { justify-self: end; }
.hud .dim { color: var(--hud-dim); }
.hud .group > span { display: inline-flex; align-items: center; gap: 12px; }
.hud .led {
display: inline-block;
width: 11px; height: 11px;
border-radius: 50%;
background: var(--accent);
box-shadow: 0 0 10px var(--accent);
margin-right: 12px;
vertical-align: middle;
/* Small upward nudge: flex centers on line-box mid, but caps-only text
reads centered around cap-height mid which sits a touch higher. */
margin-top: -0.08em;
animation: rec-blink 1.6s ease-in-out infinite;
}
/* The ▮/▯ glyphs in DejaVu Sans Mono are designed centered on x-height,
not cap-height, so against all-caps SIGNAL their visual middle sits low.
Lift by ~(cap-mid x-mid) ≈ 0.10em to put the glyph center on cap center. */
#signal {
display: inline-block;
transform: translateY(-0.10em);
}
@keyframes rec-blink {
0%, 55% { opacity: 1; }
65%, 100% { opacity: 0.2; }
@@ -81,16 +90,17 @@ body {
}
.terminal {
width: 64vw;
max-width: 1500px;
height: 100%;
min-height: 380px;
width: 60vw;
max-width: 1380px;
height: 58vh;
max-height: 740px;
min-height: 400px;
position: relative;
overflow: hidden;
background: var(--term-bg);
border: 1px solid var(--term-edge);
border: 2px solid var(--term-edge);
border-radius: 6px;
padding: 32px 40px;
padding: 28px 36px;
font-family: var(--mono);
font-size: 26px;
line-height: 1.55;
@@ -158,11 +168,15 @@ body {
}
/* ───────── Big countdown ───────── */
/* text-indent compensates for trailing letter-spacing on the last char,
which would otherwise be included in the centered inline box and shift
visible text left by half the letter-spacing value. */
.countdown { text-align: center; }
.countdown .label {
font-family: var(--mono);
font-size: 22px;
letter-spacing: 0.45em;
text-indent: 0.45em;
color: var(--hud-dim);
margin-bottom: 12px;
}
@@ -171,6 +185,7 @@ body {
font-size: 132px;
font-weight: 900;
letter-spacing: 0.10em;
text-indent: 0.10em;
line-height: 1;
color: var(--ink);
text-shadow: 0 0 22px rgba(79, 210, 255, 0.18);
@@ -181,6 +196,7 @@ body {
font-family: var(--mono);
font-size: 22px;
letter-spacing: 0.45em;
text-indent: 0.45em;
color: var(--hud-dim);
}
@keyframes cd-pulse {
@@ -191,6 +207,7 @@ body {
color: var(--term-fg);
text-shadow: 0 0 32px var(--term-glow);
letter-spacing: 0.18em;
text-indent: 0.18em;
}
.countdown.ready .sub { color: var(--term-fg-bright); }
.countdown.ready .label { color: var(--term-fg-dim); }
@@ -301,9 +318,12 @@ body {
<div class="vignette"></div>
<div class="flicker"></div>
<!-- Manifest data written by loading.sh — script-tag-loaded global to dodge CEF file:// fetch CORS -->
<!-- Rig + hardware telemetry written by scripts/telemetry.sh — exposes window.__TEL.rig
for the "detected rig :: …" terminal line below. Same wrapper trick. -->
<script src="../landing/telemetry.js"></script>
<!-- Manifest data written by scripts/loading.sh — script-tag-loaded global to dodge CEF file:// fetch CORS -->
<script src="loading.js"></script>
<!-- Playlist manifest written by playlist.sh — same wrapper trick. Used here
<!-- Playlist manifest written by scripts/playlist.sh — same wrapper trick. Used here
just for the track count in the terminal output; actual playback runs
in the separate Music Daemon source. -->
<script src="playlist.js"></script>
@@ -356,9 +376,10 @@ body {
const cdMin = data.countdownMin ?? 5;
const PROMPT = 'OPHI-118://> ';
const rigName = (window.__TEL?.rig || 'unknown').toUpperCase();
const lines = [
{ kind: 'cmd', text: 'loadproject --manifest' },
{ kind: 'out', text: 'initializing transmission envelope...' },
{ kind: 'out', text: `detected rig :: ${rigName} - initializing transmission...` },
{ kind: 'gap' },
{ kind: 'out', text: `target :: ${upper(data.game) || '—'}` },
...(data.subtitle ? [{ kind: 'out', text: `mode :: ${upper(data.subtitle)}` }] : []),
@@ -450,7 +471,7 @@ body {
let lastTrackIndex = null;
async function connectMusic() {
if (!window.__OBSWS) {
logBeforeNp('[audio] vendor/obs-config.js missing — run setup.sh', 'term-dim');
logBeforeNp('[audio] vendor/obs-config.js missing — run scripts/setup.sh', 'term-dim');
return;
}
try {
@@ -482,7 +503,7 @@ body {
await typeCommand('mpd --queue ~/playlists/stream-mix');
if (allTracks.length === 0) {
append('> ', '[audio] no tracks queued — run loading/playlist.sh', 'term-dim');
append('> ', '[audio] no tracks queued — run scripts/playlist.sh', 'term-dim');
return;
}

View File

@@ -1,119 +0,0 @@
#!/usr/bin/env bash
# Interactive manifest builder for the Project Loading scene.
#
# ./loading.sh
#
# Walks you through 5 prompts, writes loading.json + loading.js, then
# refresh the Project Loading browser source in OBS. Re-running uses
# previous answers as defaults — press Enter to keep them.
set -euo pipefail
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
JSON="$DIR/loading.json"
JS="$DIR/loading.js"
# Reads a top-level field from the previous loading.json. Always exits 0;
# prints empty string if file/field is missing.
prev() {
[[ -f "$JSON" ]] || return 0
python3 - "$JSON" "$1" <<'PY' 2>/dev/null || true
import json, sys
try:
d = json.load(open(sys.argv[1]))
v = d.get(sys.argv[2])
if v is None: print('')
elif isinstance(v, bool): print('y' if v else 'n')
else: print(v)
except Exception:
pass
PY
}
ask() {
# ask "question" "default" → echoes user input or default if blank
local q="$1" def="${2:-}"
local prompt=" $q"
[[ -n "$def" ]] && prompt+=" [$def]"
prompt+=": "
local v
read -rp "$prompt" v
[[ -z "$v" && -n "$def" ]] && v="$def"
printf '%s' "$v"
}
ask_yn() {
local v; v=$(ask "$1" "${2:-y}")
case "${v,,}" in
y|yes|on|true|1) printf 'true' ;;
n|no|off|false|0) printf 'false' ;;
*) printf 'true' ;;
esac
}
ask_int() {
local v
while :; do
v=$(ask "$1" "${2:-}")
[[ "$v" =~ ^[0-9]+$ ]] && { printf '%s' "$v"; return; }
echo " ✗ must be a whole number" >&2
done
}
sanitize() {
printf '%s' "${1:-}" \
| tr -d '"\\' \
| tr '\n\r\t' ' ' \
| sed 's/ */ /g; s/^ //; s/ $//'
}
emit_str() { [[ -z "${1:-}" ]] && printf 'null' || printf '"%s"' "$1"; }
# ── Pull previous answers as defaults ───────────────
prev_game=$(prev game)
prev_subtitle=$(prev subtitle)
prev_count=$(prev countdownMin); [[ -z "$prev_count" ]] && prev_count="5"
prev_camera=$(prev camera); [[ -z "$prev_camera" ]] && prev_camera="y"
prev_mic=$(prev microphone); [[ -z "$prev_mic" ]] && prev_mic="y"
echo "── Project Loading manifest ──"
echo " (press Enter to keep [defaults])"
echo
game=$(ask "Game" "$prev_game")
while [[ -z "$game" ]]; do
echo " ✗ Game is required" >&2
game=$(ask "Game" "")
done
subtitle=$(ask "Subtitle (mode / episode / note)" "$prev_subtitle")
countdown=$(ask_int "Countdown (minutes)" "$prev_count")
camera=$(ask_yn "Camera" "$prev_camera")
mic=$(ask_yn "Microphone" "$prev_mic")
game=$(sanitize "$game")
subtitle=$(sanitize "$subtitle")
now_iso=$(date -u +%FT%TZ)
tmp="$JSON.tmp.$$"
cat > "$tmp" <<JSON
{
"compiledAt": "$now_iso",
"game": $(emit_str "$game"),
"subtitle": $(emit_str "$subtitle"),
"countdownMin": $countdown,
"camera": $camera,
"microphone": $mic
}
JSON
if ! python3 -m json.tool "$tmp" >/dev/null 2>&1; then
echo " ✗ produced invalid JSON, leaving $tmp for inspection" >&2
exit 1
fi
mv -f "$tmp" "$JSON"
{ printf 'window.__LOADING = '; cat "$JSON"; printf ';\n'; } > "$JS.tmp.$$"
mv -f "$JS.tmp.$$" "$JS"
echo
echo " ✓ wrote $JSON"
echo " ✓ wrote $JS"
echo " → refresh the Project Loading browser source in OBS."

View File

@@ -1,176 +0,0 @@
#!/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