Commit e32c45b4 by PLN (Algolia)

rec-up: one button for stems + screen, verified, cleanly stopped

start: pre-checks (disk, gig-log, autoroute service, routing, portal
token), screen capture on the default sink's monitor, arm, then a
per-orbit verify that never kills the video. State written before
verify, values quoted (the session path has a space). stop: SIGINT
sweep by output filename for the sandboxed recorder, transport halt,
take summary. Camera opt-in (--cam); no notification on the happy path.
parent 90e2cea6
#!/usr/bin/env bash
# rec-up — THE ParVagues REC button: stems (Ardour) + screen + camera + logs.
#
# What it kills: four capture legs started by hand, and the Take103 lesson —
# D8 silently never recorded and nobody knew until mastering. Here nothing is
# trusted on a self-report: `take-rec.py verify` reads the STEM FILES (the
# same truth take-lens reads after the fact) and screams per orbit.
#
# start pre-checks, then camera → screen → arm+verify. A leg that fails
# stops the launch: a half-REC is worse than no REC.
# status the legs, the stem growth, the video growth.
# stop SIGINT to both captureurs (mp4s finalise), Ardour transport stop,
# take summary printed.
#
# NOT a systemd unit, on purpose: a recorder must never restart (a restart
# silently splits the files). rig policy: launched on purpose, like SuperDirt.
set -u
DIR="$(cd "$(dirname "$0")" && pwd)"
SESSION="${PARVAGUES_ARDOUR_SESSION:-$HOME/Work/Sound/Ardour/Tidal Live}"
VIDEO_DIR="$SESSION/interchange/$(basename "$SESSION")/video"
STATE="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/parvagues-rec.state"
GSR_FLATPAK="com.dec05eba.gpu_screen_recorder"
CAM_DEV="${REC_CAM_DEV:-/dev/video0}"
MIN_FREE_GB=25 # disk-hygiene floor
C_OK=$'\e[32m'; C_WARN=$'\e[33m'; C_REC=$'\e[41;1;97m'; C_DIM=$'\e[2m'; C_RST=$'\e[0m'
ok(){ printf '%s✓ %s%s\n' "$C_OK" "$*" "$C_RST"; }
warn(){ printf '%s! %s%s\n' "$C_WARN" "$*" "$C_RST"; }
die(){ warn "$*"; notify-send -u critical "rec-up" "$*" 2>/dev/null; exit 1; }
usage(){
cat <<'USAGE'
rec-up — one-button REC: Ardour stems + screen (+ gig-log, already on)
rec-up.sh start screen + sound + stems, armed and verified
rec-up.sh start --cam also record the webcam (default OFF — the hands
montage is a post-production concern, not a take one)
rec-up.sh status legs + growth, no state changes
rec-up.sh stop finalise the mp4s, stop the transport, print summary
USAGE
exit 2
}
gsr(){ flatpak run --command=gpu-screen-recorder "$GSR_FLATPAK" "$@"; }
# ---- stop ------------------------------------------------------------------
cmd_stop(){
[ -f "$STATE" ] || { warn "not recording (no $STATE)"; exit 0; }
# shellcheck disable=SC1090
source "$STATE"
warn "stopping REC started at $T0_ISO"
for pid in "$CAM_PID" "$SCREEN_PID"; do
[ -n "${pid:-}" ] && kill -INT "$pid" 2>/dev/null \
&& ok "finalising capture pid $pid" \
|| true
done
# The zombie sweep (first live test, 2026-09-28): SIGINT to the flatpak
# wrapper does not always reach the recorder INSIDE the sandbox, and an
# orphaned recorder keeps filming for an hour unnoticed. The output file
# name is unique per run, so matching on it cannot self-match.
for f in "$SCREEN_OUT" "$CAM_OUT"; do
[ -n "${f:-}" ] && pkill -INT -f "$f" 2>/dev/null && \
warn "zombie capture finalised: $f"
done
# And the leg the state file can miss: the AUDIO transport. A rolling
# transport with armed tracks IS still recording.
python3 "$DIR/tools/take-rec.py" halt || true
for f in "$SCREEN_OUT" "$CAM_OUT"; do
[ -n "${f:-}" ] && [ -f "$f" ] \
&& printf ' %s %s\n' "$(du -h "$f" | cut -f1)" "$f"
done
python3 "$DIR/tools/take-rec.py" summary --session "$SESSION" || true
rm -f "$STATE"
pkill -f "rec-tray.py" 2>/dev/null || true
ok "REC stopped. Ardour stays armed — disarm in the Mixer when you like."
}
# ---- status ----------------------------------------------------------------
cmd_status(){
if [ -f "$STATE" ]; then
# shellcheck disable=SC1090
source "$STATE"
echo "recording since $T0_ISO ($(( $(date +%s) - T0 ))s):"
for f in "$SCREEN_OUT" "$CAM_OUT"; do
[ -f "$f" ] && printf ' %s %s\n' "$(du -h "$f" | cut -f1)" "$(basename "$f")"
done
else
echo "not recording"
fi
python3 "$DIR/tools/take-rec.py" verify --session "$SESSION" --delta 1.0
}
# ---- start -----------------------------------------------------------------
cmd_start(){
WANT_CAM=0
for arg in "${@:2}"; do
[ "$arg" = "--cam" ] && WANT_CAM=1
done
[ -f "$STATE" ] && die "already recording ($STATE) — stop first"
# 1. Pre-checks. Each one is a thing that has actually broken a take.
free_gb=$(df -BG --output=avail "$SESSION" | tail -1 | tr -dc 0-9)
[ "$free_gb" -ge "$MIN_FREE_GB" ] \
|| die "only ${free_gb}G free (<${MIN_FREE_GB}G) — clean up before recording"
ok "disk: ${free_gb}G free"
systemctl --user is-active --quiet gig-log.service \
|| die "gig-log.service is not active — no MIDI/track timeline for this take"
ok "gig-log active (MIDI + track timeline)"
# Routing leg (Take105's lesson): armed tracks with no SuperDirt input
# record 255s of digital silence and nothing screams. Reconcile first —
# the autoroute script is idempotent — then count the live inputs.
# NEVER call tidal-ardour-autoroute.sh inline: it is a `while :` daemon
# (the first REC attempt hung on it). The service IS the reconciler.
systemctl --user is-active --quiet tidal-ardour-autoroute.service \
&& ok "autoroute service active" \
|| warn "autoroute service NOT active — orbits may not reach Ardour"
wired=$(pw-link -i 2>/dev/null | grep -oE 'ardour:Tidal [0-9]+' | sort -u | wc -l)
[ "$wired" -ge 12 ] \
&& ok "routing: $wired Tidal tracks fed by SuperDirt" \
|| warn "routing: only $wired/12 Tidal tracks fed — silent stems ahead?"
flatpak info "$GSR_FLATPAK" >/dev/null 2>&1 \
|| die "gpu-screen-recorder flatpak missing: flatpak install --system flathub $GSR_FLATPAK"
[ "$WANT_CAM" = 0 ] || [ -c "$CAM_DEV" ] || die "camera $CAM_DEV absent"
[ -f "$HOME/.config/gsr-portal-token" ] \
&& ok "portal token present (no dialog)" \
|| warn "no portal token — accept the share dialog once; it is saved for next time"
mkdir -p "$VIDEO_DIR"
TS=$(date +%Y%m%d-%H%M%S)
SCREEN_OUT="$VIDEO_DIR/REC-$TS-screen.mp4"
CAM_OUT=""
# 2. Camera (opt-in). The dumb leg, started first so it can never block.
CAM_PID=""
if [ "$WANT_CAM" = 1 ]; then
CAM_OUT="$VIDEO_DIR/REC-$TS-cam.mp4"
stdbuf -oL ffmpeg -v error -y -f v4l2 -i "$CAM_DEV" \
-c:v libx264 -preset veryfast -crf 23 -pix_fmt yuv420p -r 30 \
"$CAM_OUT" >/tmp/rec-up-cam.log 2>&1 &
CAM_PID=$!
sleep 1
kill -0 "$CAM_PID" 2>/dev/null || die "camera ffmpeg died — see /tmp/rec-up-cam.log"
ok "camera → $(basename "$CAM_OUT")"
fi
# 3. Screen. Audio = the DEFAULT sink's monitor, resolved NOW: the DP1/
# Speaker trap (a silent take is not a hypothesis, it was measured).
SINK=$(pactl get-default-sink 2>/dev/null) || die "pactl failed — no audio system?"
# stdbuf cannot exec a shell FUNCTION (`gsr`) — Permission denied — and gsr
# logs per line on its own, so the flatpak call goes in directly.
flatpak run --command=gpu-screen-recorder "$GSR_FLATPAK" -w portal \
-restore-portal-session yes \
-portal-session-token-filepath "$HOME/.config/gsr-portal-token" \
-f 30 -a "$SINK.monitor" -c mp4 -k h264 -q medium \
-o "$SCREEN_OUT" >/tmp/rec-up-screen.log 2>&1 &
SCREEN_PID=$!
sleep 2
kill -0 "$SCREEN_PID" 2>/dev/null || die "screen recorder died — see /tmp/rec-up-screen.log"
ok "screen → $(basename "$SCREEN_OUT") (audio: $SINK.monitor)"
# 4. State file BEFORE verify, so `stop` can always find the video legs.
# Values QUOTED — the session path contains a space ("Tidal Live"), and
# `source` splits on it otherwise (first live REC test, 2026-09-28).
{
echo "T0=$(date +%s)"
echo "T0_ISO=\"$(date --iso-8601=seconds)\""
echo "CAM_PID=\"$CAM_PID\""
echo "SCREEN_PID=\"$SCREEN_PID\""
echo "SCREEN_OUT=\"$SCREEN_OUT\""
echo "CAM_OUT=\"$CAM_OUT\""
} > "$STATE"
chmod 600 "$STATE"
# 5. Arm Ardour, then VERIFY against the files. A failed verify NEVER
# costs the video: the screen is a deliverable on its own, and the
# fix for a bad arm is one click in Ardour while it keeps rolling.
# The 5s settle is Ardour's: capture files exist at roll start, but
# the first seconds are buffered, not written.
python3 "$DIR/tools/take-rec.py" arm --session "$SESSION" || true
sleep 5
if ! python3 "$DIR/tools/take-rec.py" verify --session "$SESSION" --delta 4.0; then
notify-send -u critical "rec-up" "Stems NOT recording — arm by hand in Ardour (video keeps rolling)" 2>/dev/null
warn "NOT all stems recording — hit record in Ardour by hand; the video keeps rolling."
fi
# 6. The big visual: tray + notification + banner.
nohup python3 "$DIR/tools/rec-tray.py" >/tmp/rec-tray.log 2>&1 &
# no notification on the happy path: the screen IS the recording
printf '\n%s ●●● REC — stems + screen ●●●%s\n\n' "$C_REC" "$C_RST"
echo "stop: rec-up.sh stop"
}
case "${1:-}" in
start) cmd_start "$@" ;;
status) cmd_status ;;
stop) cmd_stop ;;
*) usage ;;
esac
#!/usr/bin/env python3
"""rec-tray — the red dot in the tray while rec-up is recording.
WHY A TRAY AND NOT ONLY A TERMINAL
----------------------------------
The set is played in windows that are NOT the terminal rec-up ran in. The
one surface that is always visible on this rig is the system tray (midiviz
already lives there — same pattern, same Qt). The dot is the "REC is
actually running" signal; Stop calls rec-up.sh stop, which finalises the
mp4s and prints the take summary in ITS terminal — so Stop only exists as
a safety net, not as the routine.
Meets the same bar as midiviz's tray: a painted pixmap (no theme/icon-pack
dependency), a poll of the state file instead of a daemon, and death when
the state file goes — the tray is decoration and never outlives the fact.
"""
from __future__ import annotations
import os
import subprocess
import sys
import time
from pathlib import Path
from PySide6 import QtCore, QtGui, QtWidgets
DIR = Path(__file__).resolve().parent
STATE = Path(os.environ.get(
"XDG_RUNTIME_DIR", f"/run/user/{os.getuid()}")) / "parvagues-rec.state"
def rec_icon(active: bool) -> QtGui.QIcon:
"""A filled dot — red while recording, grey while not. Painted, not
themed: the tray must read on any panel, like midiviz's badge."""
pm = QtGui.QPixmap(24, 24)
pm.fill(QtCore.Qt.GlobalColor.transparent)
p = QtGui.QPainter(pm)
p.setRenderHint(QtGui.QPainter.RenderHint.Antialiasing)
p.setBrush(QtGui.QColor("#d5223a" if active else "#666666"))
p.setPen(QtCore.Qt.PenStyle.NoPen)
p.drawEllipse(4, 4, 16, 16)
p.end()
return QtGui.QIcon(pm)
class RecTray(QtWidgets.QSystemTrayIcon):
def __init__(self):
super().__init__()
self.setIcon(rec_icon(True))
self.setToolTip("rec-up: starting")
menu = QtWidgets.QMenu()
act_stop = menu.addAction("Stop REC")
act_stop.triggered.connect(self.stop)
act_status = menu.addAction("Status")
act_status.triggered.connect(self.status)
self.setContextMenu(menu)
self.activated.connect(self._activated)
self.timer = QtCore.QTimer()
self.timer.timeout.connect(self.tick)
self.timer.start(2000)
self.tick()
def _activated(self, reason):
if reason == QtWidgets.QSystemTrayIcon.ActivationReason.Trigger:
self.status()
def tick(self):
"""The state file is the truth; its T0 is the elapsed clock. No
file = recording is over — a decoration never outlives the fact."""
if not STATE.is_file():
self.setToolTip("rec-up: not recording")
QtWidgets.QApplication.quit()
return
try:
for line in STATE.read_text().splitlines():
if line.startswith("T0="):
s = int(time.time()) - int(line[3:])
h, rem = divmod(s, 3600)
m, sec = divmod(rem, 60)
self.setToolTip(f"rec-up: REC {h}:{m:02d}:{sec:02d}")
break
except (OSError, ValueError):
pass
def status(self):
subprocess.Popen(["notify-send", "rec-up", self.toolTip()])
def stop(self):
# rec-up.sh stop owns the shutdown order (SIGINT → finalise →
# summary); the tray only rings the bell and dies with the state.
subprocess.Popen([str(DIR.parent / "rec-up.sh"), "stop"])
def main() -> int:
app = QtWidgets.QApplication(sys.argv)
app.setQuitOnLastWindowClosed(False)
if not QtWidgets.QSystemTrayIcon.isSystemTrayAvailable():
print("rec-tray: no system tray — skipping (recording is unaffected)",
file=sys.stderr)
return 0
tray = RecTray()
tray.show()
return app.exec()
if __name__ == "__main__":
sys.exit(main())
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment