mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-09-11 06:24:31 +00:00
Compare commits
24
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
523feb161e | ||
|
|
4abdfb5f5c | ||
|
|
99812b8ed3 | ||
|
|
9d8fda6d46 | ||
|
|
663e8ff4a1 | ||
|
|
f362c9a083 | ||
|
|
be78b4f29e | ||
|
|
f9a57ba044 | ||
|
|
c83fec267e | ||
|
|
c73ff96dfe | ||
|
|
286a24214f | ||
|
|
9178959dbd | ||
|
|
de10e81259 | ||
|
|
1172bc30cb | ||
|
|
5e5d892a63 | ||
|
|
cab652d145 | ||
|
|
78dbb039b7 | ||
|
|
70dbe45e27 | ||
|
|
1cef01d02c | ||
|
|
756588678b | ||
|
|
bd830328f0 | ||
|
|
09f7e450a5 | ||
|
|
8bec8d2466 | ||
|
|
8d0e270345 |
@@ -0,0 +1,378 @@
|
||||
"""Builtin content seeding: the calibration/diagnostic sloppaks and the starter library.
|
||||
|
||||
Carved VERBATIM out of server.py (R3b) — with ONE deliberate signature change, and it is
|
||||
the whole reason this module is safe.
|
||||
|
||||
━━━ WHY THE ROOT IS A PARAMETER ━━━
|
||||
|
||||
server.py had `_feedBack_server_root()` = `Path(__file__).resolve().parent`. That is
|
||||
correct *in server.py*: the repo root in dev, resources/feedBack when bundled — the tree
|
||||
that actually holds docs/ and data/.
|
||||
|
||||
Move that body here unchanged and it keeps working, silently, and returns `lib/`. There is
|
||||
no docs/diagnostics under lib/, so every seed would quietly find nothing and log "source
|
||||
missing" — a verbatim move whose meaning changed because `__file__` did. Nothing would
|
||||
fail; the starter library would just never appear.
|
||||
|
||||
So this module CANNOT compute a root: it takes `server_root` as a parameter, and server.py
|
||||
— the only place that legitimately knows where it lives — passes it in. The trap is now
|
||||
structurally impossible rather than merely avoided. (_copy_builtin_packs already took the
|
||||
root this way; the two seed helpers now do too.)
|
||||
|
||||
Everything else is byte-identical. `log` is this module's own logger under the same
|
||||
`feedBack.` hierarchy, and CONFIG_DIR is read late as `appstate.config_dir` — see appstate.py
|
||||
for why those reads must be late-bound (tests monkeypatch it).
|
||||
"""
|
||||
import logging
|
||||
import os
|
||||
import secrets
|
||||
import shutil
|
||||
import stat
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import appstate
|
||||
from dlc_paths import _get_dlc_dir
|
||||
|
||||
log = logging.getLogger("feedBack.builtin_content")
|
||||
|
||||
|
||||
BUILTIN_DIAGNOSTIC_SUBDIR = "diagnostics-builtin"
|
||||
|
||||
|
||||
BUILTIN_DIAGNOSTIC_SOURCES: list[tuple[str, str]] = [
|
||||
(
|
||||
"feedBack-diagnostic-basic-guitar.sloppak",
|
||||
"docs/diagnostics/feedBack-diagnostic-basic-guitar.sloppak",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def builtin_diagnostic_filename() -> str:
|
||||
"""Library filename (DLC-relative POSIX path) of the calibration sloppak —
|
||||
the onboarding challenge target (spec 010)."""
|
||||
return f"{BUILTIN_DIAGNOSTIC_SUBDIR}/{BUILTIN_DIAGNOSTIC_SOURCES[0][0]}"
|
||||
|
||||
|
||||
def _copy_builtin_packs(
|
||||
root: Path,
|
||||
dest_dir: Path,
|
||||
sources: list[tuple[str, str]],
|
||||
label: str,
|
||||
update_existing: bool = True,
|
||||
) -> int:
|
||||
"""Symlink-safe, mtime-aware copy of bundled packs into ``dest_dir``.
|
||||
|
||||
``sources`` is a list of ``(dest_name, rel_source)`` pairs; each source is
|
||||
resolved under ``root`` (the repo root in dev, ``resources/feedBack`` when
|
||||
bundled). A pack is copied when its destination is missing. Never deletes
|
||||
user files; refuses to follow a symlinked seed directory or destination and
|
||||
refuses to clobber a non-regular destination (any would let a copy escape
|
||||
``dest_dir`` or destroy user data). Logs and continues on error. ``label``
|
||||
prefixes every log line.
|
||||
|
||||
``update_existing`` controls what happens when a *regular* destination file
|
||||
already exists: when True (diagnostic seed) a bundle copy newer than the
|
||||
destination refreshes it; when False (one-time starter content) an existing
|
||||
file is always left as-is so the user's copy is never overwritten.
|
||||
|
||||
Returns the number of ``sources`` that are present at their destination
|
||||
afterwards (freshly seeded, refreshed, or already current) — so callers can
|
||||
tell whether every pack made it. A skip (missing source, symlink/non-regular
|
||||
refusal, copy error) does not count.
|
||||
"""
|
||||
# Refuse a symlinked seed directory: mkdir(exist_ok=True) would accept it
|
||||
# and copies would land at the link target, outside the DLC tree. The
|
||||
# per-file symlink guard below cannot catch this.
|
||||
if dest_dir.is_symlink():
|
||||
log.warning("%s: %s is a symlink, skipping all seeding", label, dest_dir.name)
|
||||
return 0
|
||||
dest_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Pin the seed directory by an O_NOFOLLOW fd so a symlink swapped in for
|
||||
# dest_dir *after* the check above cannot redirect the per-file stat /
|
||||
# temp-create / replace outside the DLC tree (parent-directory TOCTOU).
|
||||
# os.replace accepts dir_fd on POSIX even though it isn't listed in
|
||||
# os.supports_dir_fd, so gate on os.rename (the reliable proxy); platforms
|
||||
# without dir_fd/O_NOFOLLOW (e.g. Windows) fall back to path-based ops.
|
||||
dir_fd = None
|
||||
if (
|
||||
hasattr(os, "O_NOFOLLOW")
|
||||
and hasattr(os, "O_DIRECTORY")
|
||||
and os.open in os.supports_dir_fd
|
||||
and os.rename in os.supports_dir_fd
|
||||
):
|
||||
try:
|
||||
dir_fd = os.open(dest_dir, os.O_RDONLY | os.O_NOFOLLOW | os.O_DIRECTORY)
|
||||
except OSError as exc:
|
||||
log.warning("%s: cannot open seed dir %s: %s", label, dest_dir, exc)
|
||||
return 0
|
||||
|
||||
try:
|
||||
present = 0
|
||||
for dest_name, rel_source in sources:
|
||||
source = root / rel_source
|
||||
if not source.is_file():
|
||||
log.warning("%s: source missing, skipping %s (%s)", label, dest_name, source)
|
||||
continue
|
||||
|
||||
# lstat the destination without following symlinks. Pinned by dir_fd
|
||||
# this resolves within the real seed dir, immune to a parent swap.
|
||||
try:
|
||||
if dir_fd is not None:
|
||||
dstat = os.lstat(dest_name, dir_fd=dir_fd)
|
||||
else:
|
||||
dstat = os.lstat(dest_dir / dest_name)
|
||||
dest_exists = True
|
||||
dest_islink = stat.S_ISLNK(dstat.st_mode)
|
||||
except FileNotFoundError:
|
||||
dest_exists = False
|
||||
dest_islink = False
|
||||
except OSError as exc:
|
||||
log.warning("%s: cannot stat %s: %s", label, dest_name, exc)
|
||||
continue
|
||||
|
||||
# Refuse to seed through a symlink at the destination name.
|
||||
if dest_islink:
|
||||
log.warning("%s: destination is a symlink, skipping %s", label, dest_name)
|
||||
continue
|
||||
|
||||
# A non-regular destination (directory, fifo, …) the user placed
|
||||
# there: never clobber it, and never count it as present — otherwise
|
||||
# a one-time seed would mark itself done without a real pack on disk.
|
||||
if dest_exists and not stat.S_ISREG(dstat.st_mode):
|
||||
log.warning("%s: destination is not a regular file, skipping %s", label, dest_name)
|
||||
continue
|
||||
|
||||
if dest_exists:
|
||||
# A regular file is already there. One-time seeds (starter
|
||||
# content) must never overwrite the user's copy; refreshing
|
||||
# seeds (diagnostics) replace it only when the bundle is newer.
|
||||
if not update_existing:
|
||||
log.info("%s: already present %s", label, dest_name)
|
||||
present += 1
|
||||
continue
|
||||
try:
|
||||
src_mtime = source.stat().st_mtime
|
||||
except OSError as exc:
|
||||
log.warning("%s: cannot stat source %s: %s", label, source, exc)
|
||||
continue
|
||||
if src_mtime <= dstat.st_mtime:
|
||||
log.info("%s: already present %s", label, dest_name)
|
||||
present += 1
|
||||
continue
|
||||
action = "updated"
|
||||
else:
|
||||
action = "seeded"
|
||||
|
||||
if _write_builtin_pack(source, dest_dir, dest_name, dir_fd):
|
||||
present += 1
|
||||
log.info("%s: %s %s -> %s", label, action, source.name, dest_name)
|
||||
else:
|
||||
log.warning("%s: failed to copy %s -> %s/%s", label, source, dest_dir.name, dest_name)
|
||||
|
||||
return present
|
||||
finally:
|
||||
if dir_fd is not None:
|
||||
os.close(dir_fd)
|
||||
|
||||
|
||||
def _write_builtin_pack(
|
||||
source: Path,
|
||||
dest_dir: Path,
|
||||
dest_name: str,
|
||||
dir_fd: int | None,
|
||||
) -> bool:
|
||||
"""Atomically write ``source`` to ``dest_name`` inside ``dest_dir``.
|
||||
|
||||
Writes to a temp file then ``os.replace()``s onto the final name so a
|
||||
symlink raced in at the destination is overwritten (rename semantics), not
|
||||
followed, and a crash never leaves a half-written pack. When ``dir_fd`` is
|
||||
given, every step is anchored to that fd (O_NOFOLLOW temp create + dir_fd
|
||||
replace), closing the parent-directory TOCTOU; otherwise falls back to
|
||||
path-based temp+replace. Returns True on success. Never raises.
|
||||
"""
|
||||
# Unique per-attempt name (O_EXCL create) so a crash that orphans a temp
|
||||
# can't permanently block later seeds via an EEXIST collision.
|
||||
tmp_name = f".seed-{dest_name}.{os.getpid()}.{secrets.token_hex(4)}.tmp"
|
||||
try:
|
||||
src_stat = source.stat()
|
||||
except OSError as exc:
|
||||
log.debug("builtin pack: cannot stat source %s: %s", source, exc)
|
||||
return False
|
||||
if dir_fd is not None:
|
||||
tmp_fd = None
|
||||
try:
|
||||
tmp_fd = os.open(
|
||||
tmp_name,
|
||||
os.O_CREAT | os.O_EXCL | os.O_WRONLY | os.O_NOFOLLOW,
|
||||
0o644,
|
||||
dir_fd=dir_fd,
|
||||
)
|
||||
with open(source, "rb") as sf, os.fdopen(tmp_fd, "wb") as tf:
|
||||
tmp_fd = None # fdopen now owns the descriptor
|
||||
shutil.copyfileobj(sf, tf)
|
||||
os.replace(tmp_name, dest_name, src_dir_fd=dir_fd, dst_dir_fd=dir_fd)
|
||||
# Preserve the bundle mtime (copyfileobj doesn't) so the mtime-based
|
||||
# refresh check matches the shutil.copy2 fallback path. Best-effort.
|
||||
try:
|
||||
os.utime(
|
||||
dest_name,
|
||||
ns=(src_stat.st_atime_ns, src_stat.st_mtime_ns),
|
||||
dir_fd=dir_fd,
|
||||
follow_symlinks=False,
|
||||
)
|
||||
except OSError as exc:
|
||||
log.debug("builtin pack: could not set mtime on %s: %s", dest_name, exc)
|
||||
return True
|
||||
except OSError as exc:
|
||||
log.debug("builtin pack write (dir_fd) failed for %s: %s", dest_name, exc)
|
||||
if tmp_fd is not None:
|
||||
try:
|
||||
os.close(tmp_fd)
|
||||
except OSError:
|
||||
pass
|
||||
try:
|
||||
os.unlink(tmp_name, dir_fd=dir_fd)
|
||||
except OSError:
|
||||
pass
|
||||
return False
|
||||
|
||||
tmp = None
|
||||
try:
|
||||
fd, tmp = tempfile.mkstemp(dir=dest_dir, prefix=".seed-", suffix=".tmp")
|
||||
os.close(fd)
|
||||
shutil.copy2(source, tmp)
|
||||
os.replace(tmp, dest_dir / dest_name)
|
||||
tmp = None
|
||||
return True
|
||||
except OSError as exc:
|
||||
log.debug("builtin pack write failed for %s: %s", dest_name, exc)
|
||||
return False
|
||||
finally:
|
||||
if tmp is not None:
|
||||
try:
|
||||
os.unlink(tmp)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def seed_builtin_diagnostic_sloppaks(server_root: Path, dlc: Path | None = None) -> None:
|
||||
"""Copy bundled diagnostic sloppaks into DLC before library scan.
|
||||
|
||||
Creates ``DLC_DIR/diagnostics-builtin/`` and copies each bundled sloppak
|
||||
when the destination is missing or older than the repo/bundle source.
|
||||
Never deletes user files or touches manually copied paths (e.g.
|
||||
``diagnostics-test/``). Re-seeds whenever the destination is missing so the
|
||||
diagnostic target is always available. Logs and continues on errors.
|
||||
"""
|
||||
try:
|
||||
if dlc is None:
|
||||
dlc = _get_dlc_dir()
|
||||
if dlc is None:
|
||||
log.debug("Builtin diagnostic seed: no DLC folder configured, skipping")
|
||||
return
|
||||
_copy_builtin_packs(
|
||||
server_root,
|
||||
dlc / BUILTIN_DIAGNOSTIC_SUBDIR,
|
||||
BUILTIN_DIAGNOSTIC_SOURCES,
|
||||
"Builtin diagnostic seed",
|
||||
)
|
||||
except Exception:
|
||||
log.warning("Builtin diagnostic seed: unexpected error", exc_info=True)
|
||||
|
||||
|
||||
# Starter content: bundled songs copied into ``DLC_DIR/starter/`` exactly ONCE,
|
||||
# on first run, as a welcome library so a fresh install isn't empty. Unlike the
|
||||
# diagnostic seed this is one-time — guarded by a marker in CONFIG_DIR — so if
|
||||
# the user deletes the starter song it stays gone. ``starter/`` is NOT in the
|
||||
# library scan carve-out (unlike diagnostics-builtin/ / tutorials-builtin/), so
|
||||
# seeded packs surface as ordinary library songs.
|
||||
BUILTIN_STARTER_SUBDIR = "starter"
|
||||
|
||||
|
||||
BUILTIN_STARTER_SOURCES: list[tuple[str, str]] = [
|
||||
(
|
||||
"beethoven-fur_elise.feedpak",
|
||||
"content/starter/beethoven-fur_elise.feedpak",
|
||||
),
|
||||
(
|
||||
"star_spangled_banner.feedpak",
|
||||
"content/starter/star_spangled_banner.feedpak",
|
||||
),
|
||||
(
|
||||
"the_adicts-ode-to-joy_vst_cover.feedpak",
|
||||
"content/starter/the_adicts-ode-to-joy_vst_cover.feedpak",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
STARTER_SEED_MARKER = ".starter-content-seeded"
|
||||
|
||||
|
||||
def seed_builtin_starter_content(server_root: Path, dlc: Path | None = None) -> None:
|
||||
"""Copy bundled starter songs into ``DLC_DIR/starter/`` exactly once.
|
||||
|
||||
Guarded by ``CONFIG_DIR/.starter-content-seeded``: the first run with a DLC
|
||||
folder configured seeds the packs and writes the marker; subsequent runs are
|
||||
no-ops, so a user who deletes the starter song does not get it back on the
|
||||
next launch. Symlink-safe; never deletes user files. Logs, never raises.
|
||||
"""
|
||||
try:
|
||||
marker = appstate.config_dir / STARTER_SEED_MARKER
|
||||
# Already seeded? The marker is a sentinel: any existing path there
|
||||
# (regular file, or a symlink/dir a user deliberately planted to opt
|
||||
# out) means "done" — lstat so we detect it without following a symlink.
|
||||
# Worst case of a planted marker is simply no starter content, never a
|
||||
# data write; the O_EXCL|O_NOFOLLOW create below refuses to write
|
||||
# *through* a symlink regardless.
|
||||
try:
|
||||
os.lstat(marker)
|
||||
return
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
except OSError as exc:
|
||||
log.warning("Starter content seed: cannot stat marker %s: %s", marker, exc)
|
||||
return
|
||||
if dlc is None:
|
||||
dlc = _get_dlc_dir()
|
||||
if dlc is None:
|
||||
# No DLC yet — leave the marker unwritten so we retry once a
|
||||
# library folder is configured.
|
||||
log.debug("Starter content seed: no DLC folder configured, skipping")
|
||||
return
|
||||
present = _copy_builtin_packs(
|
||||
server_root,
|
||||
dlc / BUILTIN_STARTER_SUBDIR,
|
||||
BUILTIN_STARTER_SOURCES,
|
||||
"Starter content seed",
|
||||
update_existing=False,
|
||||
)
|
||||
# Only mark seeding complete once every starter pack is actually in
|
||||
# place. If a source was missing or a copy failed, leave the marker
|
||||
# unwritten so the next launch retries rather than permanently skipping.
|
||||
if present < len(BUILTIN_STARTER_SOURCES):
|
||||
log.info(
|
||||
"Starter content seed: %d/%d packs present, will retry next launch",
|
||||
present,
|
||||
len(BUILTIN_STARTER_SOURCES),
|
||||
)
|
||||
return
|
||||
# Record completion with an exclusive, no-follow create so a planted or
|
||||
# raced symlink at the marker path can't redirect the write outside
|
||||
# CONFIG_DIR. O_EXCL fails (EEXIST) on any existing path including a
|
||||
# symlink, so we never write through one.
|
||||
try:
|
||||
appstate.config_dir.mkdir(parents=True, exist_ok=True)
|
||||
flags = os.O_CREAT | os.O_EXCL | os.O_WRONLY | getattr(os, "O_NOFOLLOW", 0)
|
||||
fd = os.open(marker, flags, 0o644)
|
||||
try:
|
||||
os.write(fd, b"1\n")
|
||||
finally:
|
||||
os.close(fd)
|
||||
except FileExistsError:
|
||||
pass # already marked (or a non-regular path is squatting) — fine
|
||||
except OSError as exc:
|
||||
log.warning("Starter content seed: could not write marker %s: %s", marker, exc)
|
||||
except Exception:
|
||||
log.warning("Starter content seed: unexpected error", exc_info=True)
|
||||
@@ -6,6 +6,7 @@ import json
|
||||
import logging
|
||||
import mimetypes
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
@@ -2384,6 +2385,53 @@ def register_plugin_api(app: FastAPI):
|
||||
return _plugin_file_response(request, script_file, "application/javascript")
|
||||
return Response("", status_code=404)
|
||||
|
||||
# ── Module-graph cache busting (#879) ────────────────────────────────
|
||||
#
|
||||
# ES modules are evaluated ONCE PER URL PER DOCUMENT. Re-inserting a
|
||||
# <script type="module"> whose src the module map has already seen fires
|
||||
# `load` but does NOT re-run the body. So re-loading a plugin — a rollback,
|
||||
# and (see below) an upgrade too — silently kept the OLD module live while
|
||||
# the loader recorded success: a no-op that reported it worked.
|
||||
#
|
||||
# Busting the ENTRY url does not help. A module plugin's screen.js is a
|
||||
# one-line `import './src/main.js'`, and a relative specifier resolves
|
||||
# against the base URL WITH THE QUERY DROPPED — so a ?v= token never reaches
|
||||
# the graph. Driving a real browser through install -> upgrade -> rollback and
|
||||
# counting evaluations of src/main.js gives ONE. The upgrade re-runs the shim
|
||||
# at its new ?v= URL; the shim imports './src/main.js'; that resolves to the
|
||||
# same URL; the module map returns the already-evaluated old module.
|
||||
#
|
||||
# So the token goes in the PATH: /api/plugins/<id>/g/<n>/screen.js. Every
|
||||
# relative import inherits it at every depth — for free, with no
|
||||
# import-specifier rewriting (which could never see `import(expr)` anyway).
|
||||
#
|
||||
# WHY A PATH REWRITE AND NOT TWO MIRRORED ROUTES. The token shifts the base
|
||||
# URL, so EVERYTHING a module resolves relatively moves with it — not just
|
||||
# imports. `new URL('../assets/worklet.js', import.meta.url)` from
|
||||
# /api/plugins/x/g/1/src/main.js resolves to /api/plugins/x/g/1/assets/... .
|
||||
# Mirroring only screen.js and src/ would fix imports and 404 every asset,
|
||||
# worklet and wasm file the graph reaches — and would silently break again the
|
||||
# next time someone adds a plugin route. Stripping the segment before routing
|
||||
# makes every plugin route, present and future, work under the prefix.
|
||||
#
|
||||
# The token is opaque: it is never joined into a filesystem path (and is gone
|
||||
# by the time any handler runs), so containment still rests entirely on the
|
||||
# same safe_join the un-prefixed routes use.
|
||||
_GEN_PREFIX = re.compile(r"^(/api/plugins/[^/]+)/g/[^/]+(/.+)$")
|
||||
|
||||
@app.middleware("http")
|
||||
async def _strip_plugin_generation_prefix(request: Request, call_next):
|
||||
m = _GEN_PREFIX.match(request.scope.get("path", ""))
|
||||
if m:
|
||||
# Starlette routes on scope["path"] alone. raw_path is deliberately left
|
||||
# ALONE: it is informational, and re-encoding the rewritten str back to
|
||||
# bytes would have to guess a codec — `.encode("latin-1")` raises
|
||||
# UnicodeEncodeError on a perfectly valid plugin file like src/工具.js,
|
||||
# 500ing a request the un-prefixed route serves fine. Leaving raw_path as
|
||||
# the client actually sent it is also simply more truthful for logs.
|
||||
request.scope["path"] = m.group(1) + m.group(2)
|
||||
return await call_next(request)
|
||||
|
||||
@app.get("/api/plugins/{plugin_id}/settings.html")
|
||||
def plugin_settings_html(plugin_id: str):
|
||||
with PLUGINS_LOCK:
|
||||
|
||||
@@ -2418,6 +2418,13 @@
|
||||
let _venueSceneAssetsLoaded = false;
|
||||
let _venueSceneLoadFailed = false;
|
||||
const _venueTextureCache = new Map();
|
||||
// Crowd video layers (career mode). venue-crowd.js owns the <video>
|
||||
// elements and the crossfade timing; the renderer only maps them onto
|
||||
// two planes in front of the static plate. _venueCrowdRev bumps on any
|
||||
// element (re)assignment so update() knows to rebind textures.
|
||||
const _venueCrowdVideos = [null, null];
|
||||
let _venueCrowdMix = 0;
|
||||
let _venueCrowdRev = 0;
|
||||
|
||||
function _bgVenueMoodCoeffs(state) {
|
||||
const s = String(state || 'idle').toLowerCase();
|
||||
@@ -2909,6 +2916,20 @@
|
||||
window.h3dVenueSceneSetMood = (state) => {
|
||||
_venueMoodState = String(state || 'idle').toLowerCase();
|
||||
};
|
||||
// Crowd video layers (career mode) — see venue-crowd.js. Layer 0/1 are
|
||||
// two coplanar backdrop planes; mix selects between them (0 → layer 0,
|
||||
// 1 → layer 1) so the caller can crossfade loop videos.
|
||||
window.h3dVenueBackdropSetVideo = (layer, videoEl) => {
|
||||
const i = layer ? 1 : 0;
|
||||
const el = videoEl || null;
|
||||
if (_venueCrowdVideos[i] === el) return;
|
||||
_venueCrowdVideos[i] = el;
|
||||
_venueCrowdRev++;
|
||||
};
|
||||
window.h3dVenueBackdropSetMix = (mix) => {
|
||||
const v = Number(mix);
|
||||
_venueCrowdMix = Number.isFinite(v) ? Math.max(0, Math.min(1, v)) : 0;
|
||||
};
|
||||
window.h3dVenueSceneSetInstrumentPov = (input) => {
|
||||
const next = _venueResolvePovFromInput(input);
|
||||
if (_venueInstrumentPov === next) return;
|
||||
@@ -3371,6 +3392,40 @@
|
||||
() => _venueMarkFailed('failed to load small-club bg plate'),
|
||||
);
|
||||
|
||||
// Crowd video planes (career mode): two crossfading layers
|
||||
// just in front of the static plate (which stays mounted as
|
||||
// the no-pack / load-failure fallback). Textures bind lazily
|
||||
// in update() when venue-crowd.js assigns video elements.
|
||||
state.crowd = { layers: [], rev: -1 };
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const geo = new T.PlaneGeometry(1, 1);
|
||||
const mat = new T.MeshBasicMaterial({
|
||||
color: 0xffffff, transparent: true, opacity: 0,
|
||||
depthWrite: false, fog: false,
|
||||
});
|
||||
const mesh = new T.Mesh(geo, mat);
|
||||
mesh.visible = false;
|
||||
// Layer 1 sits nearest so three.js's back-to-front
|
||||
// transparent sort draws it after layer 0.
|
||||
const layer = {
|
||||
mesh, geo, mat, tex: null, videoEl: null,
|
||||
cam: settings.cam,
|
||||
distance: BG_BACKDROP_DISTANCE * (i === 0 ? 1.04 : 1.03),
|
||||
lastAspect: 0, lastVisibleHeight: 0,
|
||||
};
|
||||
layer.applyCoverCrop = function () {
|
||||
if (!layer.videoEl || !layer.tex) return;
|
||||
_bgCoverCrop(
|
||||
layer.tex,
|
||||
layer.videoEl.videoWidth || 0,
|
||||
layer.videoEl.videoHeight || 0,
|
||||
layer.cam.aspect,
|
||||
);
|
||||
};
|
||||
scene.add(mesh);
|
||||
state.crowd.layers.push(layer);
|
||||
}
|
||||
|
||||
const hazeGeo = new T.PlaneGeometry(280 * K, 40 * K);
|
||||
const hazeMat = new T.MeshBasicMaterial({
|
||||
color: 0x101820, transparent: true, opacity: coeffs.haze,
|
||||
@@ -3402,6 +3457,64 @@
|
||||
s.haze.mat.opacity = (s.haze.baseOp || VENUE_HAZE_STEADY)
|
||||
* (coeffs.haze / VENUE_HAZE_STEADY);
|
||||
}
|
||||
if (s.crowd) {
|
||||
// Rebind VideoTextures when venue-crowd.js (re)assigns
|
||||
// elements. VideoTexture samples the element every frame,
|
||||
// so a src change on the same element needs no rebind.
|
||||
if (s.crowd.rev !== _venueCrowdRev) {
|
||||
s.crowd.rev = _venueCrowdRev;
|
||||
s.crowd.layers.forEach((layer, i) => {
|
||||
const el = _venueCrowdVideos[i];
|
||||
if (layer.videoEl === el) return;
|
||||
if (layer.tex) { layer.mat.map = null; layer.tex.dispose(); layer.tex = null; }
|
||||
layer.videoEl = el;
|
||||
layer.lastAspect = 0; // force refit + recrop
|
||||
if (el) {
|
||||
const tex = new T.VideoTexture(el);
|
||||
tex.colorSpace = T.SRGBColorSpace;
|
||||
tex.wrapS = T.ClampToEdgeWrapping;
|
||||
tex.wrapT = T.ClampToEdgeWrapping;
|
||||
tex.minFilter = T.LinearFilter;
|
||||
tex.magFilter = T.LinearFilter;
|
||||
tex.generateMipmaps = false;
|
||||
layer.tex = tex;
|
||||
layer.mat.map = tex;
|
||||
}
|
||||
layer.mat.needsUpdate = true;
|
||||
});
|
||||
}
|
||||
const warm = coeffs.warmth;
|
||||
s.crowd.layers.forEach((layer, i) => {
|
||||
const el = layer.videoEl;
|
||||
// videoWidth === 0 until metadata lands — showing the
|
||||
// plane before that paints a black flash over the plate.
|
||||
const ready = !!el && el.videoWidth > 0;
|
||||
// venue-crowd.js swaps src on the same element (loop ↔
|
||||
// stinger); a new intrinsic size needs a fresh
|
||||
// cover-crop, which _bgFitBackdropPlane only reapplies
|
||||
// on camera aspect changes.
|
||||
if (ready && (layer.lastVidW !== el.videoWidth ||
|
||||
layer.lastVidH !== el.videoHeight)) {
|
||||
layer.lastVidW = el.videoWidth;
|
||||
layer.lastVidH = el.videoHeight;
|
||||
layer.applyCoverCrop();
|
||||
}
|
||||
// Layer 0 (rear) stays fully opaque whenever any of the
|
||||
// fade involves it: two half-transparent layers would
|
||||
// let the static plate behind bleed through (~25% at
|
||||
// mid-fade). The crossfade is therefore layer 1 (front)
|
||||
// fading over an opaque layer 0 — in both directions.
|
||||
const opacity = i === 0
|
||||
? (_venueCrowdMix < 0.999 ? 1 : 0)
|
||||
: _venueCrowdMix;
|
||||
layer.mat.opacity = opacity;
|
||||
layer.mesh.visible = ready && opacity > 0.01;
|
||||
if (layer.mesh.visible) {
|
||||
layer.mat.color.setRGB(warm, warm * 0.98, warm * 0.95);
|
||||
_bgFitBackdropPlane(layer);
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
teardown(s) {
|
||||
if (!s) return;
|
||||
@@ -3416,6 +3529,19 @@
|
||||
p.mat.dispose?.();
|
||||
}
|
||||
}
|
||||
// Crowd planes: this style owns the VideoTextures; the
|
||||
// <video> elements belong to venue-crowd.js and survive.
|
||||
if (s.crowd) {
|
||||
for (const layer of s.crowd.layers) {
|
||||
layer.mesh?.parent?.remove(layer.mesh);
|
||||
layer.geo?.dispose?.();
|
||||
if (layer.mat) {
|
||||
layer.mat.map = null;
|
||||
layer.mat.dispose?.();
|
||||
}
|
||||
layer.tex?.dispose?.();
|
||||
}
|
||||
}
|
||||
// Dispose the cached plate textures too — the module-level cache
|
||||
// otherwise keeps every loaded POV plate GPU-resident for the
|
||||
// page lifetime (steady VRAM growth across POV/arrangement swaps).
|
||||
|
||||
@@ -44,6 +44,7 @@ from dlc_paths import _get_dlc_dir, _resolve_dlc_path
|
||||
# `appstate.configure(...)` below publishes into the same namespace routers read.
|
||||
# Lives in lib/ because that is the one core dir every packaging path copies.
|
||||
import appstate
|
||||
import builtin_content
|
||||
# Extracted route modules. They import `appstate`, never `server` — one-way graph.
|
||||
from routers import audio_effects, artist_aliases, loops, playlists, ws_highway, chart, wanted, library_extras, shop, progression, profile, stats, version, diagnostics
|
||||
from routers import tunings as tunings_router
|
||||
@@ -645,13 +646,6 @@ def _make_scan_executor():
|
||||
)
|
||||
|
||||
|
||||
_BUILTIN_DIAGNOSTIC_SUBDIR = "diagnostics-builtin"
|
||||
_BUILTIN_DIAGNOSTIC_SOURCES: list[tuple[str, str]] = [
|
||||
(
|
||||
"feedBack-diagnostic-basic-guitar.sloppak",
|
||||
"docs/diagnostics/feedBack-diagnostic-basic-guitar.sloppak",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def _feedBack_server_root() -> Path:
|
||||
@@ -659,10 +653,6 @@ def _feedBack_server_root() -> Path:
|
||||
return Path(__file__).resolve().parent
|
||||
|
||||
|
||||
def _builtin_diagnostic_filename() -> str:
|
||||
"""Library filename (DLC-relative POSIX path) of the calibration sloppak —
|
||||
the onboarding challenge target (spec 010)."""
|
||||
return f"{_BUILTIN_DIAGNOSTIC_SUBDIR}/{_BUILTIN_DIAGNOSTIC_SOURCES[0][0]}"
|
||||
|
||||
|
||||
# Progression content (spec 010): bundled JSON under data/progression/ (paths,
|
||||
@@ -694,329 +684,19 @@ def _get_progression_content() -> dict:
|
||||
# path is unchanged; routers call `appstate.get_progression_content()`.
|
||||
appstate.configure(
|
||||
get_progression_content=_get_progression_content,
|
||||
builtin_diagnostic_filename=_builtin_diagnostic_filename,
|
||||
builtin_diagnostic_filename=builtin_content.builtin_diagnostic_filename,
|
||||
tuning_providers=tuning_providers,
|
||||
)
|
||||
|
||||
|
||||
def _copy_builtin_packs(
|
||||
root: Path,
|
||||
dest_dir: Path,
|
||||
sources: list[tuple[str, str]],
|
||||
label: str,
|
||||
update_existing: bool = True,
|
||||
) -> int:
|
||||
"""Symlink-safe, mtime-aware copy of bundled packs into ``dest_dir``.
|
||||
|
||||
``sources`` is a list of ``(dest_name, rel_source)`` pairs; each source is
|
||||
resolved under ``root`` (the repo root in dev, ``resources/feedBack`` when
|
||||
bundled). A pack is copied when its destination is missing. Never deletes
|
||||
user files; refuses to follow a symlinked seed directory or destination and
|
||||
refuses to clobber a non-regular destination (any would let a copy escape
|
||||
``dest_dir`` or destroy user data). Logs and continues on error. ``label``
|
||||
prefixes every log line.
|
||||
|
||||
``update_existing`` controls what happens when a *regular* destination file
|
||||
already exists: when True (diagnostic seed) a bundle copy newer than the
|
||||
destination refreshes it; when False (one-time starter content) an existing
|
||||
file is always left as-is so the user's copy is never overwritten.
|
||||
|
||||
Returns the number of ``sources`` that are present at their destination
|
||||
afterwards (freshly seeded, refreshed, or already current) — so callers can
|
||||
tell whether every pack made it. A skip (missing source, symlink/non-regular
|
||||
refusal, copy error) does not count.
|
||||
"""
|
||||
# Refuse a symlinked seed directory: mkdir(exist_ok=True) would accept it
|
||||
# and copies would land at the link target, outside the DLC tree. The
|
||||
# per-file symlink guard below cannot catch this.
|
||||
if dest_dir.is_symlink():
|
||||
log.warning("%s: %s is a symlink, skipping all seeding", label, dest_dir.name)
|
||||
return 0
|
||||
dest_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Pin the seed directory by an O_NOFOLLOW fd so a symlink swapped in for
|
||||
# dest_dir *after* the check above cannot redirect the per-file stat /
|
||||
# temp-create / replace outside the DLC tree (parent-directory TOCTOU).
|
||||
# os.replace accepts dir_fd on POSIX even though it isn't listed in
|
||||
# os.supports_dir_fd, so gate on os.rename (the reliable proxy); platforms
|
||||
# without dir_fd/O_NOFOLLOW (e.g. Windows) fall back to path-based ops.
|
||||
dir_fd = None
|
||||
if (
|
||||
hasattr(os, "O_NOFOLLOW")
|
||||
and hasattr(os, "O_DIRECTORY")
|
||||
and os.open in os.supports_dir_fd
|
||||
and os.rename in os.supports_dir_fd
|
||||
):
|
||||
try:
|
||||
dir_fd = os.open(dest_dir, os.O_RDONLY | os.O_NOFOLLOW | os.O_DIRECTORY)
|
||||
except OSError as exc:
|
||||
log.warning("%s: cannot open seed dir %s: %s", label, dest_dir, exc)
|
||||
return 0
|
||||
|
||||
try:
|
||||
present = 0
|
||||
for dest_name, rel_source in sources:
|
||||
source = root / rel_source
|
||||
if not source.is_file():
|
||||
log.warning("%s: source missing, skipping %s (%s)", label, dest_name, source)
|
||||
continue
|
||||
|
||||
# lstat the destination without following symlinks. Pinned by dir_fd
|
||||
# this resolves within the real seed dir, immune to a parent swap.
|
||||
try:
|
||||
if dir_fd is not None:
|
||||
dstat = os.lstat(dest_name, dir_fd=dir_fd)
|
||||
else:
|
||||
dstat = os.lstat(dest_dir / dest_name)
|
||||
dest_exists = True
|
||||
dest_islink = stat.S_ISLNK(dstat.st_mode)
|
||||
except FileNotFoundError:
|
||||
dest_exists = False
|
||||
dest_islink = False
|
||||
except OSError as exc:
|
||||
log.warning("%s: cannot stat %s: %s", label, dest_name, exc)
|
||||
continue
|
||||
|
||||
# Refuse to seed through a symlink at the destination name.
|
||||
if dest_islink:
|
||||
log.warning("%s: destination is a symlink, skipping %s", label, dest_name)
|
||||
continue
|
||||
|
||||
# A non-regular destination (directory, fifo, …) the user placed
|
||||
# there: never clobber it, and never count it as present — otherwise
|
||||
# a one-time seed would mark itself done without a real pack on disk.
|
||||
if dest_exists and not stat.S_ISREG(dstat.st_mode):
|
||||
log.warning("%s: destination is not a regular file, skipping %s", label, dest_name)
|
||||
continue
|
||||
|
||||
if dest_exists:
|
||||
# A regular file is already there. One-time seeds (starter
|
||||
# content) must never overwrite the user's copy; refreshing
|
||||
# seeds (diagnostics) replace it only when the bundle is newer.
|
||||
if not update_existing:
|
||||
log.info("%s: already present %s", label, dest_name)
|
||||
present += 1
|
||||
continue
|
||||
try:
|
||||
src_mtime = source.stat().st_mtime
|
||||
except OSError as exc:
|
||||
log.warning("%s: cannot stat source %s: %s", label, source, exc)
|
||||
continue
|
||||
if src_mtime <= dstat.st_mtime:
|
||||
log.info("%s: already present %s", label, dest_name)
|
||||
present += 1
|
||||
continue
|
||||
action = "updated"
|
||||
else:
|
||||
action = "seeded"
|
||||
|
||||
if _write_builtin_pack(source, dest_dir, dest_name, dir_fd):
|
||||
present += 1
|
||||
log.info("%s: %s %s -> %s", label, action, source.name, dest_name)
|
||||
else:
|
||||
log.warning("%s: failed to copy %s -> %s/%s", label, source, dest_dir.name, dest_name)
|
||||
|
||||
return present
|
||||
finally:
|
||||
if dir_fd is not None:
|
||||
os.close(dir_fd)
|
||||
|
||||
|
||||
def _write_builtin_pack(
|
||||
source: Path,
|
||||
dest_dir: Path,
|
||||
dest_name: str,
|
||||
dir_fd: int | None,
|
||||
) -> bool:
|
||||
"""Atomically write ``source`` to ``dest_name`` inside ``dest_dir``.
|
||||
|
||||
Writes to a temp file then ``os.replace()``s onto the final name so a
|
||||
symlink raced in at the destination is overwritten (rename semantics), not
|
||||
followed, and a crash never leaves a half-written pack. When ``dir_fd`` is
|
||||
given, every step is anchored to that fd (O_NOFOLLOW temp create + dir_fd
|
||||
replace), closing the parent-directory TOCTOU; otherwise falls back to
|
||||
path-based temp+replace. Returns True on success. Never raises.
|
||||
"""
|
||||
# Unique per-attempt name (O_EXCL create) so a crash that orphans a temp
|
||||
# can't permanently block later seeds via an EEXIST collision.
|
||||
tmp_name = f".seed-{dest_name}.{os.getpid()}.{secrets.token_hex(4)}.tmp"
|
||||
try:
|
||||
src_stat = source.stat()
|
||||
except OSError as exc:
|
||||
log.debug("builtin pack: cannot stat source %s: %s", source, exc)
|
||||
return False
|
||||
if dir_fd is not None:
|
||||
tmp_fd = None
|
||||
try:
|
||||
tmp_fd = os.open(
|
||||
tmp_name,
|
||||
os.O_CREAT | os.O_EXCL | os.O_WRONLY | os.O_NOFOLLOW,
|
||||
0o644,
|
||||
dir_fd=dir_fd,
|
||||
)
|
||||
with open(source, "rb") as sf, os.fdopen(tmp_fd, "wb") as tf:
|
||||
tmp_fd = None # fdopen now owns the descriptor
|
||||
shutil.copyfileobj(sf, tf)
|
||||
os.replace(tmp_name, dest_name, src_dir_fd=dir_fd, dst_dir_fd=dir_fd)
|
||||
# Preserve the bundle mtime (copyfileobj doesn't) so the mtime-based
|
||||
# refresh check matches the shutil.copy2 fallback path. Best-effort.
|
||||
try:
|
||||
os.utime(
|
||||
dest_name,
|
||||
ns=(src_stat.st_atime_ns, src_stat.st_mtime_ns),
|
||||
dir_fd=dir_fd,
|
||||
follow_symlinks=False,
|
||||
)
|
||||
except OSError as exc:
|
||||
log.debug("builtin pack: could not set mtime on %s: %s", dest_name, exc)
|
||||
return True
|
||||
except OSError as exc:
|
||||
log.debug("builtin pack write (dir_fd) failed for %s: %s", dest_name, exc)
|
||||
if tmp_fd is not None:
|
||||
try:
|
||||
os.close(tmp_fd)
|
||||
except OSError:
|
||||
pass
|
||||
try:
|
||||
os.unlink(tmp_name, dir_fd=dir_fd)
|
||||
except OSError:
|
||||
pass
|
||||
return False
|
||||
|
||||
tmp = None
|
||||
try:
|
||||
fd, tmp = tempfile.mkstemp(dir=dest_dir, prefix=".seed-", suffix=".tmp")
|
||||
os.close(fd)
|
||||
shutil.copy2(source, tmp)
|
||||
os.replace(tmp, dest_dir / dest_name)
|
||||
tmp = None
|
||||
return True
|
||||
except OSError as exc:
|
||||
log.debug("builtin pack write failed for %s: %s", dest_name, exc)
|
||||
return False
|
||||
finally:
|
||||
if tmp is not None:
|
||||
try:
|
||||
os.unlink(tmp)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _seed_builtin_diagnostic_sloppaks(dlc: Path | None = None) -> None:
|
||||
"""Copy bundled diagnostic sloppaks into DLC before library scan.
|
||||
|
||||
Creates ``DLC_DIR/diagnostics-builtin/`` and copies each bundled sloppak
|
||||
when the destination is missing or older than the repo/bundle source.
|
||||
Never deletes user files or touches manually copied paths (e.g.
|
||||
``diagnostics-test/``). Re-seeds whenever the destination is missing so the
|
||||
diagnostic target is always available. Logs and continues on errors.
|
||||
"""
|
||||
try:
|
||||
if dlc is None:
|
||||
dlc = _get_dlc_dir()
|
||||
if dlc is None:
|
||||
log.debug("Builtin diagnostic seed: no DLC folder configured, skipping")
|
||||
return
|
||||
_copy_builtin_packs(
|
||||
_feedBack_server_root(),
|
||||
dlc / _BUILTIN_DIAGNOSTIC_SUBDIR,
|
||||
_BUILTIN_DIAGNOSTIC_SOURCES,
|
||||
"Builtin diagnostic seed",
|
||||
)
|
||||
except Exception:
|
||||
log.warning("Builtin diagnostic seed: unexpected error", exc_info=True)
|
||||
|
||||
|
||||
# Starter content: bundled songs copied into ``DLC_DIR/starter/`` exactly ONCE,
|
||||
# on first run, as a welcome library so a fresh install isn't empty. Unlike the
|
||||
# diagnostic seed this is one-time — guarded by a marker in CONFIG_DIR — so if
|
||||
# the user deletes the starter song it stays gone. ``starter/`` is NOT in the
|
||||
# library scan carve-out (unlike diagnostics-builtin/ / tutorials-builtin/), so
|
||||
# seeded packs surface as ordinary library songs.
|
||||
_BUILTIN_STARTER_SUBDIR = "starter"
|
||||
_BUILTIN_STARTER_SOURCES: list[tuple[str, str]] = [
|
||||
(
|
||||
"beethoven-fur_elise.feedpak",
|
||||
"content/starter/beethoven-fur_elise.feedpak",
|
||||
),
|
||||
(
|
||||
"star_spangled_banner.feedpak",
|
||||
"content/starter/star_spangled_banner.feedpak",
|
||||
),
|
||||
(
|
||||
"the_adicts-ode-to-joy_vst_cover.feedpak",
|
||||
"content/starter/the_adicts-ode-to-joy_vst_cover.feedpak",
|
||||
),
|
||||
]
|
||||
_STARTER_SEED_MARKER = ".starter-content-seeded"
|
||||
|
||||
|
||||
def _seed_builtin_starter_content(dlc: Path | None = None) -> None:
|
||||
"""Copy bundled starter songs into ``DLC_DIR/starter/`` exactly once.
|
||||
|
||||
Guarded by ``CONFIG_DIR/.starter-content-seeded``: the first run with a DLC
|
||||
folder configured seeds the packs and writes the marker; subsequent runs are
|
||||
no-ops, so a user who deletes the starter song does not get it back on the
|
||||
next launch. Symlink-safe; never deletes user files. Logs, never raises.
|
||||
"""
|
||||
try:
|
||||
marker = CONFIG_DIR / _STARTER_SEED_MARKER
|
||||
# Already seeded? The marker is a sentinel: any existing path there
|
||||
# (regular file, or a symlink/dir a user deliberately planted to opt
|
||||
# out) means "done" — lstat so we detect it without following a symlink.
|
||||
# Worst case of a planted marker is simply no starter content, never a
|
||||
# data write; the O_EXCL|O_NOFOLLOW create below refuses to write
|
||||
# *through* a symlink regardless.
|
||||
try:
|
||||
os.lstat(marker)
|
||||
return
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
except OSError as exc:
|
||||
log.warning("Starter content seed: cannot stat marker %s: %s", marker, exc)
|
||||
return
|
||||
if dlc is None:
|
||||
dlc = _get_dlc_dir()
|
||||
if dlc is None:
|
||||
# No DLC yet — leave the marker unwritten so we retry once a
|
||||
# library folder is configured.
|
||||
log.debug("Starter content seed: no DLC folder configured, skipping")
|
||||
return
|
||||
present = _copy_builtin_packs(
|
||||
_feedBack_server_root(),
|
||||
dlc / _BUILTIN_STARTER_SUBDIR,
|
||||
_BUILTIN_STARTER_SOURCES,
|
||||
"Starter content seed",
|
||||
update_existing=False,
|
||||
)
|
||||
# Only mark seeding complete once every starter pack is actually in
|
||||
# place. If a source was missing or a copy failed, leave the marker
|
||||
# unwritten so the next launch retries rather than permanently skipping.
|
||||
if present < len(_BUILTIN_STARTER_SOURCES):
|
||||
log.info(
|
||||
"Starter content seed: %d/%d packs present, will retry next launch",
|
||||
present,
|
||||
len(_BUILTIN_STARTER_SOURCES),
|
||||
)
|
||||
return
|
||||
# Record completion with an exclusive, no-follow create so a planted or
|
||||
# raced symlink at the marker path can't redirect the write outside
|
||||
# CONFIG_DIR. O_EXCL fails (EEXIST) on any existing path including a
|
||||
# symlink, so we never write through one.
|
||||
try:
|
||||
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
||||
flags = os.O_CREAT | os.O_EXCL | os.O_WRONLY | getattr(os, "O_NOFOLLOW", 0)
|
||||
fd = os.open(marker, flags, 0o644)
|
||||
try:
|
||||
os.write(fd, b"1\n")
|
||||
finally:
|
||||
os.close(fd)
|
||||
except FileExistsError:
|
||||
pass # already marked (or a non-regular path is squatting) — fine
|
||||
except OSError as exc:
|
||||
log.warning("Starter content seed: could not write marker %s: %s", marker, exc)
|
||||
except Exception:
|
||||
log.warning("Starter content seed: unexpected error", exc_info=True)
|
||||
|
||||
|
||||
def _background_scan():
|
||||
@@ -1038,8 +718,8 @@ def _background_scan():
|
||||
log.warning("Scan: no DLC folder configured")
|
||||
return
|
||||
|
||||
_seed_builtin_diagnostic_sloppaks(dlc)
|
||||
_seed_builtin_starter_content(dlc)
|
||||
builtin_content.seed_builtin_diagnostic_sloppaks(_feedBack_server_root(), dlc)
|
||||
builtin_content.seed_builtin_starter_content(_feedBack_server_root(), dlc)
|
||||
|
||||
# Listing can fail on macOS without Full Disk Access, or on Docker if the
|
||||
# path isn't shared. Report the failure explicitly rather than silently
|
||||
|
||||
+103
-3379
File diff suppressed because it is too large
Load Diff
+13
-13
@@ -20,7 +20,7 @@
|
||||
// See ./host.js: reading an unwired hook THROWS, and tests/js/host_contract.test.js
|
||||
// fails CI if the hooks used here and the hooks app.js wires ever drift apart.
|
||||
import { audio } from './audio-el.js';
|
||||
import { host } from './host.js';
|
||||
import { _audioSeek, _songEventPayload, jucePlayer, setPlayButtonState, togglePlay } from './transport.js';
|
||||
import { loopA, loopB, setLoop } from './loops.js';
|
||||
import { S } from './player-state.js';
|
||||
|
||||
@@ -182,7 +182,7 @@ export async function startCountIn(opts = {}) {
|
||||
const gen = _countInGen;
|
||||
const immediate = !!opts.immediate;
|
||||
if (window._juceMode) {
|
||||
await host.jucePlayer().pause().catch((err) => console.error('[app] host.jucePlayer().pause error in count-in:', err));
|
||||
await jucePlayer.pause().catch((err) => console.error('[app] jucePlayer.pause error in count-in:', err));
|
||||
} else {
|
||||
audio.pause();
|
||||
}
|
||||
@@ -225,7 +225,7 @@ export async function startCountIn(opts = {}) {
|
||||
// Rewind done — set final position and start count.
|
||||
// Await the JUCE seek so the engine has repositioned before
|
||||
// we start the click track (HTML5 path is synchronous).
|
||||
host._audioSeek(loopA, 'loop-wrap').then((r) => {
|
||||
_audioSeek(loopA, 'loop-wrap').then((r) => {
|
||||
if (gen !== _countInGen) return; // teardown during seek
|
||||
// Abort the loop restart in two cases:
|
||||
// 1. Cancelled (player torn down): don't beginCount on a
|
||||
@@ -247,10 +247,10 @@ export async function startCountIn(opts = {}) {
|
||||
_countingIn = false;
|
||||
if (S.isPlaying) {
|
||||
S.isPlaying = false;
|
||||
host.setPlayButtonState(false);
|
||||
setPlayButtonState(false);
|
||||
if (window.feedBack) {
|
||||
window.feedBack.isPlaying = false;
|
||||
window.feedBack.emit('song:pause', host._songEventPayload());
|
||||
window.feedBack.emit('song:pause', _songEventPayload());
|
||||
}
|
||||
}
|
||||
return;
|
||||
@@ -282,21 +282,21 @@ export async function startCountIn(opts = {}) {
|
||||
hideCountOverlay();
|
||||
_countingIn = false;
|
||||
if (window._juceMode) {
|
||||
host.jucePlayer().play().then((started) => {
|
||||
jucePlayer.play().then((started) => {
|
||||
if (gen !== _countInGen) return; // teardown during play start
|
||||
if (!started) return;
|
||||
S.isPlaying = true;
|
||||
host.setPlayButtonState(true);
|
||||
setPlayButtonState(true);
|
||||
window.feedBack.isPlaying = true;
|
||||
const payload = host._songEventPayload();
|
||||
const payload = _songEventPayload();
|
||||
window.feedBack.emit('song:play', payload);
|
||||
window.feedBack.emit('song:resume', payload);
|
||||
}).catch((err) => console.error('[app] host.jucePlayer().play error:', err));
|
||||
}).catch((err) => console.error('[app] jucePlayer.play error:', err));
|
||||
} else {
|
||||
audio.play().then(() => {
|
||||
if (gen !== _countInGen) return;
|
||||
S.isPlaying = true;
|
||||
host.setPlayButtonState(true);
|
||||
setPlayButtonState(true);
|
||||
}).catch((err) => {
|
||||
if (gen !== _countInGen) return;
|
||||
// An engine reroute's deliberate pause aborts this play()
|
||||
@@ -307,7 +307,7 @@ export async function startCountIn(opts = {}) {
|
||||
// started if the Promise rejected.
|
||||
console.error('[app] audio.play() rejected after count-in:', err);
|
||||
S.isPlaying = false;
|
||||
host.setPlayButtonState(false);
|
||||
setPlayButtonState(false);
|
||||
});
|
||||
}
|
||||
return;
|
||||
@@ -333,7 +333,7 @@ export async function startSongCountIn() {
|
||||
// bumps it and every delayed callback below bails.
|
||||
const gen = _countInGen;
|
||||
if (window._juceMode) {
|
||||
await host.jucePlayer().pause().catch((err) => console.error('[app] host.jucePlayer().pause error in song count-in:', err));
|
||||
await jucePlayer.pause().catch((err) => console.error('[app] jucePlayer.pause error in song count-in:', err));
|
||||
} else {
|
||||
audio.pause();
|
||||
}
|
||||
@@ -352,7 +352,7 @@ export async function startSongCountIn() {
|
||||
_countingIn = false;
|
||||
// Hand off to the normal play path — togglePlay() flips isPlaying,
|
||||
// updates the button, and emits song:play/resume for plugins.
|
||||
Promise.resolve(host.togglePlay()).catch((err) => console.warn('[app] play after count-in failed:', err));
|
||||
Promise.resolve(togglePlay()).catch((err) => console.warn('[app] play after count-in failed:', err));
|
||||
return;
|
||||
}
|
||||
showCountOverlay(count);
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
// Display formatters. A LEAF module: imports nothing.
|
||||
//
|
||||
// WHY THIS EXISTS FOR ONE FUNCTION. formatTime was a HOST HOOK — loops.js and
|
||||
// section-practice.js both reached back through the seam for it. It was also, by pure
|
||||
// accident of who calls it, inside the dependency closure of the library carve. Leaving
|
||||
// it there would have made loops.js and section-practice.js import the LIBRARY to format
|
||||
// a timestamp, which is nonsense, and a cycle waiting to happen.
|
||||
//
|
||||
// A hook is a cycle you agreed to live with. This one has a real owner — it just isn't
|
||||
// app.js, and it certainly isn't the library. Give it a home of its own and both
|
||||
// consumers import it directly.
|
||||
//
|
||||
// It is a leaf on purpose. Anything else that turns out to be a shared pure formatter
|
||||
// belongs here too; nothing does yet, so nothing else is here.
|
||||
|
||||
/** Seconds -> `M:SS`. */
|
||||
export function formatTime(s) { return `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(2, '0')}`; }
|
||||
@@ -0,0 +1,994 @@
|
||||
// The desktop (JUCE) audio integration — three self-installing shims.
|
||||
//
|
||||
// The largest single slice out of app.js's core: 938 lines, ~12% of what was left.
|
||||
//
|
||||
// _installJuceEngineRoutingWatcher routes a song to the JUCE engine or HTML5 as the
|
||||
// desktop output device enters/leaves exclusive/ASIO
|
||||
// _installRendererBusFeeder feeds the highway renderer bus from whichever
|
||||
// transport is actually running
|
||||
// _installJuceAudioElementShim patches audio.play/pause so the rest of the app
|
||||
// can keep talking to the <audio> element while JUCE
|
||||
// owns the transport
|
||||
//
|
||||
// They EXPORT NOTHING. All three are IIFEs that publish through `window.*`
|
||||
// (_juceMode, _reevaluateJuceRouting, _reevaluateRendererBus, …) — which is why app.js
|
||||
// only needs a side-effect import for two of them, plus _resetJuceAudioShimChain.
|
||||
//
|
||||
// ORDERING, CHECKED: importing this module runs the IIFEs EARLIER than before —
|
||||
// imports evaluate ahead of app.js's body, and therefore ahead of configureHost().
|
||||
// That is safe because none of them touches a hook at execution depth: they only
|
||||
// register listeners and patch audio.play/pause (and `audio` is itself an imported
|
||||
// module now). Verified by walking the AST at IIFE-body depth. If a hook were ever
|
||||
// read there it would THROW loudly — see ./host.js — rather than silently misbehave.
|
||||
//
|
||||
// See ./host.js: reading an unwired hook THROWS, and tests/js/host_contract.test.js
|
||||
// fails CI if the hooks used here and the hooks app.js wires ever drift apart.
|
||||
import { audio } from './audio-el.js';
|
||||
import { _audioSeek, _songEventPayload, jucePlayer, setPlayButtonState } from './transport.js';
|
||||
import { setSpeed } from './player-controls.js';
|
||||
import { S } from './player-state.js';
|
||||
|
||||
(function _installJuceEngineRoutingWatcher() {
|
||||
const juceApi = window.feedBackDesktop?.audio;
|
||||
if (!juceApi || typeof juceApi.isAudioRunning !== 'function') {
|
||||
// Desktop bridge present but audio API incomplete — the whole
|
||||
// exclusive reroute chain is dead and this line is the only witness.
|
||||
// (Docker sphere has no bridge at all: stay silent, nothing to
|
||||
// diagnose there and no debug flag to gate on.)
|
||||
if (window.feedBackDesktop) {
|
||||
console.log('[asio-diag] routing watcher NOT installed (audio api incomplete)');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let _rerouteInFlight = false;
|
||||
// URL that JUCE's loadBackingTrack *explicitly rejected* (ok === false —
|
||||
// e.g. a codec it can't read). The poll below would otherwise retry the
|
||||
// same doomed track every 350 ms; remember it and skip until the song
|
||||
// changes. Only a hard JUCE reject is memoised here — transient failures
|
||||
// (a network blip on /api/audio-local-path, an isAudioRunning() race
|
||||
// during a device restart) are deliberately NOT memoised so they retry.
|
||||
let _rerouteRejectedUrl = null;
|
||||
// Exclusive-style output backends silence every other client on the
|
||||
// endpoint — including our own <audio> element. The share mode IS the
|
||||
// JUCE output device type: "Windows Audio (Exclusive Mode)" is a
|
||||
// hardcoded, unlocalised JUCE type name; ASIO drivers typically hold
|
||||
// the endpoint exclusively too. "Windows Audio (Low Latency Mode)" is
|
||||
// shared and must NOT match.
|
||||
function _isExclusiveOutputType(t) {
|
||||
return t === 'Windows Audio (Exclusive Mode)' || t === 'ASIO';
|
||||
}
|
||||
// [feedpak-route] diagnostics: log the raw outputType string once per
|
||||
// value change (this runs on a 350ms poll — logging every tick would
|
||||
// flood the diagnostics buffer).
|
||||
let _loggedOutputType;
|
||||
// [asio-diag] verbose diagnostics, gated on --debug (preload exposes
|
||||
// audio.debugEnabled). Resolved once at install; until it resolves the
|
||||
// flag stays false and verbose lines are skipped. Shared with the
|
||||
// renderer-bus feeder below via window._asioDiagEnabled.
|
||||
let _asioDiag = false;
|
||||
if (typeof juceApi.debugEnabled === 'function') {
|
||||
juceApi.debugEnabled().then((v) => {
|
||||
_asioDiag = !!v;
|
||||
// Deferred install line: the flag resolves async, so logging at
|
||||
// IIFE entry would race it. Change-detection isn't needed — this
|
||||
// runs once per page load.
|
||||
if (_asioDiag) console.log('[asio-diag] routing watcher installed');
|
||||
}).catch(() => {});
|
||||
}
|
||||
window._asioDiagEnabled = () => _asioDiag;
|
||||
async function _outputIsExclusive() {
|
||||
if (typeof juceApi.getCurrentDevice !== 'function') {
|
||||
if (_loggedOutputType !== '<no-getCurrentDevice>') {
|
||||
_loggedOutputType = '<no-getCurrentDevice>';
|
||||
console.warn('[feedpak-route] juceApi.getCurrentDevice missing — cannot detect exclusive output');
|
||||
}
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const dev = await juceApi.getCurrentDevice();
|
||||
const t = dev?.outputType || dev?.type || '';
|
||||
const excl = _isExclusiveOutputType(t);
|
||||
if (t !== _loggedOutputType) {
|
||||
_loggedOutputType = t;
|
||||
console.log('[feedpak-route] outputType=', JSON.stringify(t), '→ exclusive=', excl);
|
||||
// [asio-diag] full device object on every type change — shows
|
||||
// the exact strings the predicate saw (inputType vs outputType,
|
||||
// device names, duplex), so a driver reporting a non-'ASIO'
|
||||
// type name is visible in tester logs.
|
||||
if (_asioDiag) {
|
||||
try {
|
||||
console.log('[asio-diag] getCurrentDevice=', JSON.stringify(dev));
|
||||
} catch (_) { /* circular/hostile object — skip */ }
|
||||
}
|
||||
}
|
||||
return excl;
|
||||
} catch (e) {
|
||||
if (_loggedOutputType !== '<getCurrentDevice-failed>') {
|
||||
_loggedOutputType = '<getCurrentDevice-failed>';
|
||||
console.warn('[feedpak-route] getCurrentDevice failed:', e);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
// highway.js's initial song-load routing consults this for the same
|
||||
// feedpak-under-exclusive decision the watcher makes below.
|
||||
window._juceOutputIsExclusive = _outputIsExclusive;
|
||||
// Returns true when window._currentSongAudio no longer references the exact
|
||||
// snapshot object captured at reroute entry — i.e. the song was swapped (or
|
||||
// cleared) mid-flight. Staleness is detected by object-reference identity,
|
||||
// not by URL value.
|
||||
function _isStale(songAudio) {
|
||||
return window._currentSongAudio !== songAudio;
|
||||
}
|
||||
|
||||
// Migrates the loaded song from the HTML5 element onto the JUCE backing
|
||||
// transport. Throws only on transient/unexpected failures.
|
||||
// `songAudio` is the snapshot captured at reroute entry; if it stops being
|
||||
// the current song mid-flight we abort without mutating global routing.
|
||||
// Returns a distinct string outcome — the caller must NOT conflate them:
|
||||
// 'switched' — song now plays via JUCE.
|
||||
// 'rejected' — JUCE hard-rejected the track (codec). Caller memoises it.
|
||||
// 'stale' — the loaded song changed mid-flight; aborted, NOT memoised.
|
||||
// (a transient transport-start failure throws instead — also not memoised.)
|
||||
async function _switchHtml5ToJuce(songAudio) {
|
||||
const url = songAudio.url;
|
||||
const wasPlaying = S.isPlaying;
|
||||
const pos = audio.currentTime || 0;
|
||||
window.feedBack?.playback?.recordRouteChange?.({
|
||||
routeKind: 'desktop-native',
|
||||
state: 'switching',
|
||||
preservedTime: true,
|
||||
safeReason: 'desktop audio engine became active',
|
||||
requesterId: 'core.juce-route',
|
||||
});
|
||||
// Mark a reroute in progress so the <audio> 'play'/'pause' listeners
|
||||
// suppress their song:play / song:pause emissions: the migration is
|
||||
// transparent — playback genuinely continues — so plugin state and
|
||||
// window.feedBack.isPlaying must NOT flip. This also silences the
|
||||
// "Audio paused unexpectedly" diagnostic. A REFCOUNT (not a boolean)
|
||||
// lets an overlapping reroute's deferred release coexist: each switch
|
||||
// increments on entry and decrements after its own timeout; listeners
|
||||
// treat any count > 0 as "reroute active".
|
||||
window._juceRerouteInProgress = (window._juceRerouteInProgress || 0) + 1;
|
||||
audio.pause();
|
||||
try {
|
||||
const res = await fetch(`/api/audio-local-path?url=${encodeURIComponent(url)}`);
|
||||
if (!res.ok) {
|
||||
console.warn('[feedpak-route] audio-local-path HTTP', res.status, 'for', url);
|
||||
throw new Error('HTTP ' + res.status);
|
||||
}
|
||||
const { path } = await res.json();
|
||||
console.log('[feedpak-route] audio-local-path resolved:', (typeof path === 'string' && path.split(/[\\/]/).pop()) || '<missing>');
|
||||
if (_isStale(songAudio)) return 'stale'; // song changed mid-fetch
|
||||
const ok = await juceApi.loadBackingTrack(path);
|
||||
if (ok === false) {
|
||||
// JUCE rejected the track — stay on HTML5, resume if needed.
|
||||
console.warn('[juce-reroute] loadBackingTrack rejected; staying on HTML5');
|
||||
// Only resume if the element still has a source. In the normal
|
||||
// flow audio.src is intact here, but a prior HTML5→JUCE switch
|
||||
// clears it — re-point + load before resuming so a bounced
|
||||
// reroute doesn't try to play() an empty element.
|
||||
if (S.isPlaying && !_isStale(songAudio)) {
|
||||
if (!audio.src) { audio.src = url; audio.load(); }
|
||||
try { await audio.play(); } catch (_) { /* ignore */ }
|
||||
}
|
||||
window.feedBack?.playback?.recordRouteChange?.({
|
||||
routeKind: 'browser-media',
|
||||
state: 'degraded',
|
||||
preservedTime: true,
|
||||
safeReason: 'desktop audio route rejected track; kept browser media route',
|
||||
requesterId: 'core.juce-route',
|
||||
});
|
||||
return 'rejected';
|
||||
}
|
||||
if (_isStale(songAudio)) return 'stale';
|
||||
const dur = await juceApi.getBackingDuration();
|
||||
await juceApi.seekBacking(pos);
|
||||
// Start the new transport BEFORE committing global routing state, so
|
||||
// a play() failure can't leave us in "JUCE mode, nothing playing"
|
||||
// (the silent-song state this watcher exists to prevent).
|
||||
// jucePlayer.play() RETURNS false (it does not throw) when
|
||||
// startBacking fails — check the result, don't just await it.
|
||||
// A play() failure is a TRANSIENT transport-start issue, not a hard
|
||||
// codec reject: throw (rather than returning 'rejected') so the
|
||||
// caller's catch path handles it WITHOUT memoising the URL, leaving
|
||||
// it free to retry on the next poll. Only 'rejected' is memoised.
|
||||
// Re-read isPlaying as late as possible: the user can press Pause
|
||||
// during the multi-await fetch/IPC chain above. Starting the JUCE
|
||||
// transport off a stale `wasPlaying` snapshot would resume a song
|
||||
// the user just paused. Only start it if playback is still wanted.
|
||||
if (S.isPlaying) {
|
||||
const started = await jucePlayer.play();
|
||||
if (started === false) {
|
||||
if (!_isStale(songAudio) && S.isPlaying) {
|
||||
try { await audio.play(); } catch (_) { /* ignore */ }
|
||||
}
|
||||
throw new Error('jucePlayer.play() failed (transient transport start)');
|
||||
}
|
||||
}
|
||||
if (_isStale(songAudio)) {
|
||||
// Song changed while JUCE was spinning up — undo and bail.
|
||||
await jucePlayer.pause().catch(() => {});
|
||||
return 'stale';
|
||||
}
|
||||
if (window.jucePlayer) {
|
||||
jucePlayer._dur = dur;
|
||||
jucePlayer._pos = pos;
|
||||
jucePlayer._pollAt = performance.now();
|
||||
}
|
||||
window._juceMode = true;
|
||||
window._juceAudioUrl = url;
|
||||
const _spSlider = document.getElementById?.('speed-slider');
|
||||
if (_spSlider) setSpeed(_spSlider.value / 100);
|
||||
audio.src = '';
|
||||
try {
|
||||
const apply = window.feedBack?.audio?.applySongVolume;
|
||||
if (typeof apply === 'function') await apply();
|
||||
} catch (_) { /* best-effort */ }
|
||||
console.log('[juce-reroute] HTML5 → JUCE @', pos.toFixed(2), 's playing=', wasPlaying);
|
||||
window.feedBack?.playback?.recordRouteChange?.({
|
||||
routeKind: 'desktop-native',
|
||||
state: 'active',
|
||||
preservedTime: true,
|
||||
safeReason: 'desktop audio route active',
|
||||
requesterId: 'core.juce-route',
|
||||
});
|
||||
return 'switched';
|
||||
} catch (err) {
|
||||
// Path lookup, JSON parse, or a JUCE IPC call threw partway through.
|
||||
// audio.pause() already ran above; restore HTML5 playback so a
|
||||
// previously playing song isn't left silently paused, then re-throw
|
||||
// so the caller logs it. The caller does NOT memoise this URL —
|
||||
// transient failures must retry on the next poll.
|
||||
if (S.isPlaying && !window._juceMode && !_isStale(songAudio)) {
|
||||
if (!audio.src) { audio.src = url; audio.load(); }
|
||||
try { await audio.play(); } catch (_) { /* ignore */ }
|
||||
}
|
||||
window.feedBack?.playback?.recordRouteChange?.({
|
||||
routeKind: 'browser-media',
|
||||
state: 'degraded',
|
||||
preservedTime: true,
|
||||
safeReason: 'desktop audio route failed; kept browser media route',
|
||||
requesterId: 'core.juce-route',
|
||||
});
|
||||
throw err;
|
||||
} finally {
|
||||
// Clearing audio.src above dispatches a 'pause' event in a later
|
||||
// task, after this synchronous finally. Defer the refcount
|
||||
// decrement so that trailing event is still suppressed; a 0ms
|
||||
// timeout lands after the pending pause-event task. Decrementing
|
||||
// (rather than zeroing) leaves any overlapping reroute's own
|
||||
// suppression intact.
|
||||
setTimeout(() => {
|
||||
window._juceRerouteInProgress = Math.max(
|
||||
0, (window._juceRerouteInProgress || 1) - 1);
|
||||
}, 0);
|
||||
}
|
||||
}
|
||||
|
||||
async function _switchJuceToHtml5(songAudio) {
|
||||
const url = songAudio.url;
|
||||
const wasPlaying = S.isPlaying;
|
||||
const pos = (window.jucePlayer ? jucePlayer.currentTime : 0) || 0;
|
||||
window.feedBack?.playback?.recordRouteChange?.({
|
||||
routeKind: 'browser-media',
|
||||
state: 'switching',
|
||||
preservedTime: true,
|
||||
safeReason: 'desktop audio engine stopped',
|
||||
requesterId: 'core.juce-route',
|
||||
});
|
||||
// Mark a reroute in progress (refcount) so the <audio> 'play' listener
|
||||
// suppresses its song:play emission — the migration is transparent and
|
||||
// playback genuinely continues, so plugin state must not flip. Held
|
||||
// until after the (possibly deferred) audio.play() event has fired.
|
||||
window._juceRerouteInProgress = (window._juceRerouteInProgress || 0) + 1;
|
||||
let _suppressionReleased = false;
|
||||
const _releaseSuppression = () => {
|
||||
if (_suppressionReleased) return;
|
||||
_suppressionReleased = true;
|
||||
// Defer so the 'play' (or 'pause') event task fires while still
|
||||
// suppressed; a 0ms timeout lands after it.
|
||||
setTimeout(() => {
|
||||
window._juceRerouteInProgress = Math.max(
|
||||
0, (window._juceRerouteInProgress || 1) - 1);
|
||||
}, 0);
|
||||
};
|
||||
let _resumeScheduled = false;
|
||||
try {
|
||||
await jucePlayer.pause().catch(() => {});
|
||||
if (_isStale(songAudio)) return; // song changed mid-pause
|
||||
window._juceMode = false;
|
||||
window._juceAudioUrl = null;
|
||||
audio.src = url;
|
||||
audio.load();
|
||||
const _spSlider = document.getElementById?.('speed-slider');
|
||||
if (_spSlider) setSpeed(_spSlider.value / 100);
|
||||
// Resume only AFTER the seek so playback starts at `pos`, not at 0
|
||||
// with an audible jump once metadata arrives.
|
||||
const resumeAtPos = () => {
|
||||
try {
|
||||
// The metadata event can land after a fast song switch —
|
||||
// bail before touching currentTime so a stale callback
|
||||
// doesn't seek the newly loaded song to the old position.
|
||||
if (_isStale(songAudio)) return;
|
||||
try { audio.currentTime = pos; } catch (_) { /* ignore */ }
|
||||
// Re-read isPlaying (not the entry snapshot): the user may
|
||||
// have pressed Pause during jucePlayer.pause()/metadata
|
||||
// load — don't resume a song they just paused.
|
||||
if (S.isPlaying) {
|
||||
audio.play().catch(() => { /* ignore */ });
|
||||
}
|
||||
} finally {
|
||||
_releaseSuppression();
|
||||
}
|
||||
};
|
||||
_resumeScheduled = true;
|
||||
if (audio.readyState >= 1) {
|
||||
resumeAtPos();
|
||||
} else {
|
||||
// Wait for metadata to resume at `pos`. But metadata may never
|
||||
// arrive (bad URL, network error) — that would leak the
|
||||
// suppression refcount and permanently silence song:play /
|
||||
// song:pause. Guard with the element's 'error' event AND a
|
||||
// backstop timeout; whichever fires first wins, the others are
|
||||
// detached. _releaseSuppression is idempotent regardless.
|
||||
let _settled = false;
|
||||
const _onMeta = () => { finish(true); };
|
||||
const _onErr = () => { finish(false); };
|
||||
let _backstop;
|
||||
function finish(reachedMetadata) {
|
||||
if (_settled) return;
|
||||
_settled = true;
|
||||
clearTimeout(_backstop);
|
||||
audio.removeEventListener('loadedmetadata', _onMeta);
|
||||
audio.removeEventListener('error', _onErr);
|
||||
if (reachedMetadata) {
|
||||
resumeAtPos(); // resumeAtPos releases suppression
|
||||
} else {
|
||||
_releaseSuppression(); // no resume — just release
|
||||
}
|
||||
}
|
||||
audio.addEventListener('loadedmetadata', _onMeta, { once: true });
|
||||
audio.addEventListener('error', _onErr, { once: true });
|
||||
// 10s is well beyond a normal local-file metadata load.
|
||||
_backstop = setTimeout(() => { finish(false); }, 10000);
|
||||
}
|
||||
} finally {
|
||||
// resumeAtPos owns the release once scheduled; if we returned
|
||||
// early (stale, before scheduling) release here instead.
|
||||
// _releaseSuppression is idempotent so an overlap is harmless.
|
||||
if (!_resumeScheduled) _releaseSuppression();
|
||||
}
|
||||
try {
|
||||
const apply = window.feedBack?.audio?.applySongVolume;
|
||||
if (typeof apply === 'function') await apply();
|
||||
} catch (_) { /* best-effort */ }
|
||||
console.log('[juce-reroute] JUCE → HTML5 @', pos.toFixed(2), 's playing=', wasPlaying);
|
||||
window.feedBack?.playback?.recordRouteChange?.({
|
||||
routeKind: 'browser-media',
|
||||
state: 'active',
|
||||
preservedTime: true,
|
||||
safeReason: 'browser media route active',
|
||||
requesterId: 'core.juce-route',
|
||||
});
|
||||
}
|
||||
|
||||
async function _reevaluateJuceRouting() {
|
||||
if (_rerouteInFlight) return;
|
||||
const songAudio = window._currentSongAudio;
|
||||
// /audio/ songs are always JUCE-routable. A feedpak full-mix
|
||||
// (single-mix pack, no stems) is routable ONLY under an
|
||||
// exclusive-style output — in shared mode it must stay on HTML5 so
|
||||
// the stem mixer / WebAudio path keeps working. Sloppak stem URLs
|
||||
// are never routable (per-stem mix can't ride a single transport).
|
||||
if (!songAudio || (!songAudio.juceEligible && !songAudio.feedpakFullMix)) return;
|
||||
// Don't race highway.js's own initial song-load routing: it owns
|
||||
// _juceMode until _juceRoutingPromise settles. Re-running our switch
|
||||
// concurrently would double-call loadBackingTrack for the same URL.
|
||||
if (window._highwayJuceRoutingPending) return;
|
||||
|
||||
// Claim the in-flight guard SYNCHRONOUSLY, before the first await. The
|
||||
// watcher is driven by a 350ms setInterval; if isAudioRunning() (or any
|
||||
// later await) stalls past the poll period, a second tick would
|
||||
// otherwise pass the `if (_rerouteInFlight) return` check above and run
|
||||
// a concurrent switch — duplicate loadBackingTrack IPCs racing on
|
||||
// _juceMode / audio.src. Setting it here closes that window.
|
||||
_rerouteInFlight = true;
|
||||
try {
|
||||
let running;
|
||||
try { running = await juceApi.isAudioRunning(); }
|
||||
catch (_) { return; }
|
||||
if (_isStale(songAudio)) return; // song changed during IPC
|
||||
// Eligibility is evaluated per tick, not snapshotted at song load:
|
||||
// the output share mode can change mid-song (device switch in the
|
||||
// Audio Engine panel), and a feedpak full-mix must follow it —
|
||||
// exclusive → ride the engine; back to shared → return to HTML5.
|
||||
let eligible = !!songAudio.juceEligible;
|
||||
if (!eligible && songAudio.feedpakFullMix && running) {
|
||||
eligible = await _outputIsExclusive();
|
||||
if (_isStale(songAudio)) return; // song changed during IPC
|
||||
}
|
||||
const wantJuce = !!(running && eligible);
|
||||
// [feedpak-route] diagnostics: one line per decision change (the
|
||||
// watcher polls at 350ms; steady state must not spam the buffer).
|
||||
const _decision = 'running=' + running + ' eligible=' + eligible
|
||||
+ ' feedpakFullMix=' + !!songAudio.feedpakFullMix
|
||||
+ ' juceMode=' + !!window._juceMode + ' url=' + songAudio.url;
|
||||
if (_decision !== window._lastFeedpakRouteDecision) {
|
||||
window._lastFeedpakRouteDecision = _decision;
|
||||
console.log('[feedpak-route] watcher:', _decision);
|
||||
}
|
||||
if (wantJuce === !!window._juceMode) return; // routing already consistent
|
||||
// Don't keep retrying a track JUCE explicitly rejected.
|
||||
if (wantJuce && songAudio.url === _rerouteRejectedUrl) return;
|
||||
|
||||
if (wantJuce) {
|
||||
const outcome = await _switchHtml5ToJuce(songAudio);
|
||||
// Memoise ONLY an explicit hard JUCE reject. A successful
|
||||
// switch clears the memo; a 'stale' abort (song changed
|
||||
// mid-flight) leaves it untouched — it must never be
|
||||
// misclassified as a reject, even if the song object was
|
||||
// swapped and then restored before this point.
|
||||
if (outcome === 'rejected') {
|
||||
_rerouteRejectedUrl = songAudio.url;
|
||||
} else if (outcome === 'switched') {
|
||||
_rerouteRejectedUrl = null;
|
||||
}
|
||||
// outcome === 'stale': leave _rerouteRejectedUrl as-is.
|
||||
} else {
|
||||
await _switchJuceToHtml5(songAudio);
|
||||
// The engine stopped (or a feedpak's output left exclusive
|
||||
// mode). Clear any hard-reject memo so a later engine restart
|
||||
// or mode change re-evaluates the track at least once — the
|
||||
// rejection may have been a transient device/decoder state.
|
||||
_rerouteRejectedUrl = null;
|
||||
}
|
||||
} catch (e) {
|
||||
// Transient failure — log but do NOT memoise, so the next poll retries.
|
||||
console.warn('[juce-reroute] re-route failed (will retry):', e);
|
||||
} finally {
|
||||
_rerouteInFlight = false;
|
||||
}
|
||||
}
|
||||
window._reevaluateJuceRouting = _reevaluateJuceRouting;
|
||||
|
||||
// Clears the hard-reject memo. Called from the song-teardown sites that
|
||||
// null window._currentSongAudio (showScreen, playSong) so that reloading
|
||||
// the same file later gets a fresh routing attempt — a prior reject may
|
||||
// have been a transient JUCE/device state, not a permanent codec issue.
|
||||
window._clearJuceRerouteMemo = function () { _rerouteRejectedUrl = null; };
|
||||
|
||||
// The engine can be started/stopped from several places (the desktop Audio
|
||||
// Engine panel, the audio_engine plugin, note_detect) and via setDevice
|
||||
// restarts — and the contextBridge api object is frozen, so its methods
|
||||
// can't be wrapped. Poll isAudioRunning() while a song is loaded; the check
|
||||
// is a cheap IPC boolean and no-ops once routing is already consistent.
|
||||
// Skip the poll while the document is hidden (background tab / minimised
|
||||
// window) — engine toggles there will be reconciled on the first poll
|
||||
// after the tab is visible again.
|
||||
setInterval(() => {
|
||||
if (document.hidden) return;
|
||||
if (window._currentSongAudio) void _reevaluateJuceRouting();
|
||||
}, 350);
|
||||
})();
|
||||
|
||||
// Renderer-audio bus feeder (desktop Phase 2): when the engine holds the
|
||||
// output endpoint in an exclusive-style mode, Chromium cannot reach the
|
||||
// device, so any song audio still played by the renderer goes silent. The
|
||||
// Phase 1 watcher above already migrates what a single-file transport can
|
||||
// carry (loose /audio/ songs, feedpak full-mixes) onto the native backing
|
||||
// transport. This feeder covers the rest — the stems plugin's multi-stem
|
||||
// WebAudio graph, plus <audio>-element songs the native transport could not
|
||||
// take (e.g. a codec loadBackingTrack rejected).
|
||||
//
|
||||
// Mechanism: capture the renderer-side master with an AudioWorklet tap,
|
||||
// re-point the owning AudioContext at a null sink so it keeps rendering
|
||||
// without a device, and push ~10 ms chunks over IPC into the engine's
|
||||
// renderer bus, where they are mixed into the exclusive output like a
|
||||
// backing track (~10-20 ms added latency on song audio only; the guitar
|
||||
// monitoring path is untouched). Validated by the fix12 tester spike:
|
||||
// null-sink rendering works, clocks hold (drift → 0), no overflow.
|
||||
//
|
||||
// Docker sphere: window.feedBackDesktop is undefined → this whole block is
|
||||
// inert. Shared-mode desktop: the bus stays disabled (no double audio) and
|
||||
// captured contexts keep/regain their default sink.
|
||||
(function _installRendererBusFeeder() {
|
||||
const api = window.feedBackDesktop?.audio;
|
||||
if (!api || typeof api.setRendererBus !== 'function'
|
||||
|| typeof api.pushRendererAudio !== 'function') {
|
||||
// Silent in the Docker sphere (no bridge, no debug flag); a desktop
|
||||
// bridge missing the bus API is the diagnostic case.
|
||||
if (window.feedBackDesktop) {
|
||||
console.log('[asio-diag] renderer-bus feeder NOT installed (api=' + !!api
|
||||
+ ' setRendererBus=' + typeof api?.setRendererBus
|
||||
+ ' pushRendererAudio=' + typeof api?.pushRendererAudio + ')');
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Deferred like the watcher's install line: gate on the async debug flag.
|
||||
if (typeof api.debugEnabled === 'function') {
|
||||
api.debugEnabled().then((v) => {
|
||||
if (v) console.log('[asio-diag] renderer-bus feeder installed (loopback-capable='
|
||||
+ (typeof window.navigator?.mediaDevices?.getDisplayMedia === 'function') + ')');
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
const TAP_WORKLET = `
|
||||
class FeedbackBusTap extends AudioWorkletProcessor {
|
||||
process(inputs) {
|
||||
const inp = inputs[0];
|
||||
if (inp && inp[0]) {
|
||||
const L = inp[0], R = inp[1] || inp[0];
|
||||
const out = new Float32Array(L.length * 2);
|
||||
for (let i = 0; i < L.length; i++) { out[i*2] = L[i]; out[i*2+1] = R[i]; }
|
||||
this.port.postMessage(out, [out.buffer]);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
registerProcessor('feedback-bus-tap', FeedbackBusTap);
|
||||
`;
|
||||
const _tapModuleUrl = URL.createObjectURL(new Blob([TAP_WORKLET], { type: 'application/javascript' }));
|
||||
const _tapModuleLoaded = new WeakSet(); // AudioContexts with the module added
|
||||
|
||||
// One tap per captured graph. `active` gates the push (the worklet keeps
|
||||
// running when inactive — it's silent bookkeeping, not audio).
|
||||
function _makeTap(ctx) {
|
||||
const state = { node: null, active: false, batch: [], batchFrames: 0 };
|
||||
state.attach = async (sourceNode) => {
|
||||
if (!_tapModuleLoaded.has(ctx)) {
|
||||
await ctx.audioWorklet.addModule(_tapModuleUrl);
|
||||
_tapModuleLoaded.add(ctx);
|
||||
}
|
||||
if (!state.node) {
|
||||
state.node = new AudioWorkletNode(ctx, 'feedback-bus-tap', { numberOfInputs: 1, channelCount: 2 });
|
||||
const BATCH = Math.round(ctx.sampleRate / 100); // ~10 ms
|
||||
state.node.port.onmessage = (e) => {
|
||||
if (!state.active) { state.batch = []; state.batchFrames = 0; return; }
|
||||
state.batch.push(e.data);
|
||||
state.batchFrames += e.data.length / 2;
|
||||
if (state.batchFrames >= BATCH) {
|
||||
const merged = new Float32Array(state.batchFrames * 2);
|
||||
let o = 0;
|
||||
for (const c of state.batch) { merged.set(c, o); o += c.length; }
|
||||
api.pushRendererAudio(merged, ctx.sampleRate);
|
||||
state.batch = []; state.batchFrames = 0;
|
||||
}
|
||||
};
|
||||
}
|
||||
sourceNode.connect(state.node);
|
||||
// No onward connection: the tap is a sink-side observer; audibility
|
||||
// in shared mode comes from the graph's own destination path.
|
||||
};
|
||||
state.detach = (sourceNode) => {
|
||||
state.active = false;
|
||||
state.batch = []; state.batchFrames = 0;
|
||||
if (state.node && sourceNode) {
|
||||
try { sourceNode.disconnect(state.node); } catch (_) { /* already gone */ }
|
||||
}
|
||||
};
|
||||
return state;
|
||||
}
|
||||
|
||||
// ── Core <audio> element capture ─────────────────────────────────────────
|
||||
// createMediaElementSource permanently reroutes the element into its
|
||||
// context, so it is created lazily — only the first time an exclusive
|
||||
// device actually needs it — and never torn down. From then on the element
|
||||
// always plays through _elCtx; sink toggling routes it to the speakers
|
||||
// (shared mode) or the null sink + bus (exclusive mode).
|
||||
let _elCtx = null, _elSource = null, _elTap = null;
|
||||
async function _ensureElementCapture() {
|
||||
if (_elCtx) return;
|
||||
const el = document.getElementById('audio');
|
||||
if (!el) throw new Error('no core audio element');
|
||||
// Assign the module state ONLY after the whole chain succeeded.
|
||||
// createMediaElementSource throws InvalidStateError when another
|
||||
// consumer (highway_3d's analyser tap) already owns the element's
|
||||
// one-shot source — assigning _elCtx before that throw poisoned every
|
||||
// later tick into `_elTap.active` TypeErrors (tester log 2026-07-11)
|
||||
// while the song kept playing on the default device.
|
||||
const ctx = new AudioContext();
|
||||
let source, tap;
|
||||
try {
|
||||
source = ctx.createMediaElementSource(el);
|
||||
source.connect(ctx.destination);
|
||||
tap = _makeTap(ctx);
|
||||
await tap.attach(source);
|
||||
} catch (e) {
|
||||
try { await ctx.close(); } catch (_) { /* already closed */ }
|
||||
throw e;
|
||||
}
|
||||
_elCtx = ctx; _elSource = source; _elTap = tap;
|
||||
}
|
||||
|
||||
// ── Whole-app loopback capture ───────────────────────────────────────────
|
||||
// Preferred mode: one getDisplayMedia frame-audio capture covers EVERY
|
||||
// sound the app makes (song, previews, UI) — no per-surface taps, so
|
||||
// plugin-private AudioContexts (song-preview, future plugins) survive
|
||||
// exclusive/ASIO output too. The desktop main process answers the request
|
||||
// with this window's own frame (frame-scoped — no other apps' audio).
|
||||
// Local playback is silenced via the suppressLocalAudioPlayback track
|
||||
// constraint, with a page-mute IPC fallback (capture taps frame audio
|
||||
// before the output mute, so a muted page still feeds the stream).
|
||||
let _lbStream = null, _lbCtx = null, _lbTap = null, _lbPageMuted = false;
|
||||
let _loopbackUnavailable = false; // sticky: probe once, then fall back
|
||||
async function _engageLoopback() {
|
||||
const stream = await navigator.mediaDevices.getDisplayMedia({
|
||||
video: true,
|
||||
audio: { suppressLocalAudioPlayback: true },
|
||||
});
|
||||
for (const t of stream.getVideoTracks()) t.stop(); // required, unused
|
||||
const track = stream.getAudioTracks()[0];
|
||||
if (!track) {
|
||||
for (const t of stream.getTracks()) t.stop();
|
||||
throw new Error('no loopback audio track');
|
||||
}
|
||||
try {
|
||||
// Fresh context per session (not reused) so teardown's close()
|
||||
// fully releases the tap worklet node — see _teardownLoopback.
|
||||
_lbCtx = new AudioContext();
|
||||
if (_lbCtx.state !== 'running') await _lbCtx.resume().catch(() => {});
|
||||
const source = _lbCtx.createMediaStreamSource(stream);
|
||||
const tap = _makeTap(_lbCtx);
|
||||
await tap.attach(source);
|
||||
const suppressed = track.getSettings?.().suppressLocalAudioPlayback === true;
|
||||
if (!suppressed && typeof api.setPageMuted === 'function') {
|
||||
_lbPageMuted = (await api.setPageMuted(true)) === true;
|
||||
}
|
||||
if (window._asioDiagEnabled?.()) {
|
||||
console.log('[asio-diag] loopback: suppressed=', suppressed,
|
||||
'pageMuted=', _lbPageMuted, 'rate=', _lbCtx.sampleRate);
|
||||
}
|
||||
await api.setRendererBus(true, 1.0);
|
||||
tap.active = true;
|
||||
_lbStream = stream; _lbTap = tap;
|
||||
_mode = 'loopback';
|
||||
console.log('[renderer-bus] engaged: app loopback → engine bus');
|
||||
} catch (e) {
|
||||
for (const t of stream.getTracks()) t.stop();
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
async function _teardownLoopback() {
|
||||
if (_lbTap) _lbTap.active = false;
|
||||
if (_lbStream) for (const t of _lbStream.getTracks()) t.stop();
|
||||
_lbStream = null; _lbTap = null;
|
||||
// Close the capture context so its tap worklet node is released. The
|
||||
// context is per-session (not reused): without this, each exclusive⇄
|
||||
// shared switch orphaned a live worklet on a long-lived context.
|
||||
if (_lbCtx) {
|
||||
try { await _lbCtx.close(); } catch (_) { /* already closed */ }
|
||||
_lbCtx = null;
|
||||
}
|
||||
if (_lbPageMuted && typeof api.setPageMuted === 'function') {
|
||||
try { await api.setPageMuted(false); } catch (_) { /* engine gone */ }
|
||||
}
|
||||
_lbPageMuted = false;
|
||||
}
|
||||
|
||||
// ── Engagement state machine ─────────────────────────────────────────────
|
||||
// 'off' | 'loopback' | 'element' | 'stems' (element/stems = fallback when
|
||||
// loopback capture is unavailable: old desktop main, denied capture)
|
||||
let _mode = 'off';
|
||||
let _stemsGraph = null; // { context, masterNode } snapshot while engaged
|
||||
let _stemsTap = null;
|
||||
const _stemsTaps = new WeakMap(); // context → tap (stems ctx is reused across songs)
|
||||
let _busy = false;
|
||||
|
||||
async function _setSink(ctx, exclusive) {
|
||||
if (typeof ctx.setSinkId !== 'function') throw new Error('setSinkId unsupported');
|
||||
await ctx.setSinkId(exclusive ? { type: 'none' } : '');
|
||||
if (ctx.state !== 'running') await ctx.resume().catch(() => {});
|
||||
// [asio-diag] a context left on the default sink while the bus is
|
||||
// engaged is exactly the "song on the wrong device" symptom — record
|
||||
// every successful sink flip (failures throw and are logged upstream).
|
||||
if (window._asioDiagEnabled?.()) {
|
||||
console.log('[asio-diag] setSink:', exclusive ? 'null-sink' : 'default',
|
||||
'state=', ctx.state, 'rate=', ctx.sampleRate);
|
||||
}
|
||||
}
|
||||
|
||||
async function _disengage() {
|
||||
if (_mode === 'off') return;
|
||||
const prev = _mode;
|
||||
_mode = 'off';
|
||||
try { await api.setRendererBus(false, 0); } catch (_) { /* engine gone */ }
|
||||
if (prev === 'loopback') {
|
||||
await _teardownLoopback();
|
||||
} else if (prev === 'element' && _elCtx) {
|
||||
_elTap.active = false;
|
||||
await _setSink(_elCtx, false).catch(() => {});
|
||||
} else if (prev === 'stems' && _stemsGraph) {
|
||||
if (_stemsTap) _stemsTap.detach(_stemsGraph.masterNode);
|
||||
await _setSink(_stemsGraph.context, false).catch(() => {});
|
||||
_stemsGraph = null; _stemsTap = null;
|
||||
}
|
||||
console.log('[renderer-bus] disengaged (' + prev + ')');
|
||||
}
|
||||
|
||||
async function _engageStems(graph) {
|
||||
await _setSink(graph.context, true);
|
||||
let tap = _stemsTaps.get(graph.context);
|
||||
if (!tap) { tap = _makeTap(graph.context); _stemsTaps.set(graph.context, tap); }
|
||||
await tap.attach(graph.masterNode);
|
||||
await api.setRendererBus(true, 1.0);
|
||||
tap.active = true;
|
||||
_stemsGraph = graph; _stemsTap = tap;
|
||||
_mode = 'stems';
|
||||
console.log('[renderer-bus] engaged: stems graph → engine bus');
|
||||
}
|
||||
|
||||
async function _engageElement() {
|
||||
await _ensureElementCapture();
|
||||
await _setSink(_elCtx, true);
|
||||
await api.setRendererBus(true, 1.0);
|
||||
_elTap.active = true;
|
||||
_mode = 'element';
|
||||
console.log('[renderer-bus] engaged: <audio> element → engine bus');
|
||||
}
|
||||
|
||||
async function _reevaluate() {
|
||||
if (_busy) return;
|
||||
_busy = true;
|
||||
try {
|
||||
let running = false, exclusive = false;
|
||||
try {
|
||||
running = await api.isAudioRunning();
|
||||
} catch (_) { /* engine unreachable → treat as not running */ }
|
||||
if (running) {
|
||||
// Reuse the Phase 1 predicate installed by the routing watcher
|
||||
// (getCurrentDevice + exclusive-type check with change-logged
|
||||
// diagnostics). Fail closed if it is somehow absent.
|
||||
exclusive = !!(await window._juceOutputIsExclusive?.());
|
||||
}
|
||||
|
||||
// The stems plugin publishes its live graph while a multi-stem
|
||||
// song is loaded (and removes it on teardown).
|
||||
const stems = (window.feedBack || window.slopsmith)?.stems?.audioGraph || null;
|
||||
// Element songs: a song is loaded, it is NOT riding the native
|
||||
// transport (Phase 1 owns those), and the stems graph is not the
|
||||
// player. Covers native-transport rejects (codec) in exclusive
|
||||
// mode — without this they would be silent.
|
||||
const songAudio = window._currentSongAudio;
|
||||
const elementSong = !!songAudio && !window._juceMode && !stems;
|
||||
|
||||
let want = 'off';
|
||||
if (running && exclusive) {
|
||||
// Loopback covers ALL app audio (song, previews, UI), so it
|
||||
// engages for the whole exclusive session — not just while a
|
||||
// song is loaded. Per-surface modes remain as fallback when
|
||||
// loopback capture is unavailable (old desktop main without
|
||||
// the display-media handler, capture denied).
|
||||
if (!_loopbackUnavailable) want = 'loopback';
|
||||
else if (stems) want = 'stems';
|
||||
else if (elementSong) want = 'element';
|
||||
}
|
||||
// Song audio riding the native transport must not ALSO ride the
|
||||
// loopback (double-carry into the same engine output). The native
|
||||
// transport plays from the engine, not the page, so page loopback
|
||||
// never hears it — no conflict; loopback stays engaged for
|
||||
// previews/UI while the transport owns the song.
|
||||
|
||||
// [asio-diag] full decision vector, change-gated (500ms poll —
|
||||
// steady state must not flood the buffer). This is the feeder-side
|
||||
// counterpart of the watcher's [feedpak-route] decision line: it
|
||||
// shows WHY the bus did or didn't engage (exclusive predicate,
|
||||
// stems graph presence, native transport ownership, element song).
|
||||
if (window._asioDiagEnabled?.()) {
|
||||
const d = 'running=' + running + ' exclusive=' + exclusive
|
||||
+ ' stems=' + !!stems + ' songAudio=' + !!songAudio
|
||||
+ ' juceMode=' + !!window._juceMode
|
||||
+ ' elementSong=' + elementSong
|
||||
+ ' loopbackUnavailable=' + _loopbackUnavailable
|
||||
+ ' want=' + want + ' mode=' + _mode;
|
||||
if (d !== window._lastRendererBusDecision) {
|
||||
window._lastRendererBusDecision = d;
|
||||
console.log('[asio-diag] renderer-bus:', d);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const stemsGraphChanged = _mode === 'stems' && stems !== _stemsGraph;
|
||||
if (want !== _mode || stemsGraphChanged) {
|
||||
await _disengage();
|
||||
try {
|
||||
if (want === 'loopback') await _engageLoopback();
|
||||
else if (want === 'stems') await _engageStems(stems);
|
||||
else if (want === 'element') await _engageElement();
|
||||
} catch (e) {
|
||||
if (want === 'loopback') {
|
||||
// Capture unavailable (no handler in an old desktop
|
||||
// main, permission denied) — remember and fall back to
|
||||
// the per-surface modes on the next tick.
|
||||
_loopbackUnavailable = true;
|
||||
console.warn('[renderer-bus] loopback capture unavailable — falling back to surface taps:', e);
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Explicit name/message/stack head — the console-message forward
|
||||
// stringifies a DOMException to the useless "[object DOMException]".
|
||||
console.warn('[renderer-bus] reevaluate failed (will retry):',
|
||||
(e && e.name ? e.name + ': ' + e.message : String(e)),
|
||||
(e && e.stack ? '| ' + String(e.stack).split('\n')[1] : ''));
|
||||
_mode = 'off';
|
||||
// A partial engage may have left the bus enabled with no producer
|
||||
// and the page muted — undo both so a failed tick can't strand
|
||||
// audio in silence until the next successful engage.
|
||||
try { await api.setRendererBus(false, 0); } catch (_) { /* engine gone */ }
|
||||
await _teardownLoopback().catch(() => {});
|
||||
} finally {
|
||||
_busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Same cadence/rationale as the routing watcher above. Also re-check on
|
||||
// visibility return so a device switch made while hidden is reconciled.
|
||||
setInterval(() => { if (!document.hidden) void _reevaluate(); }, 500);
|
||||
document.addEventListener('visibilitychange', () => { if (!document.hidden) void _reevaluate(); });
|
||||
window._reevaluateRendererBus = _reevaluate;
|
||||
})();
|
||||
|
||||
// Desktop JUCE backing uses an empty <audio> element; plugins such as Section Map
|
||||
// still seek via audio.currentTime / pause / play. Mirror those onto jucePlayer
|
||||
// while _juceMode is active. Same-tick pause+seek coalesce into a single seek
|
||||
// (no stopBacking before seek — HTML5 needed that for buffering; JUCE does not).
|
||||
export let _resetJuceAudioShimChain = function () {};
|
||||
(function _installJuceAudioElementShim() {
|
||||
if (!window.feedBackDesktop?.audio) return;
|
||||
|
||||
const mediaProto = HTMLMediaElement.prototype;
|
||||
const ctDesc = Object.getOwnPropertyDescriptor(mediaProto, 'currentTime');
|
||||
const pausedDesc = Object.getOwnPropertyDescriptor(mediaProto, 'paused');
|
||||
if (!ctDesc?.get || !ctDesc?.set || !pausedDesc?.get) return;
|
||||
|
||||
const nativePlay = mediaProto.play;
|
||||
const nativePause = mediaProto.pause;
|
||||
|
||||
let chain = Promise.resolve();
|
||||
/** Same-tick pause + seek (Section Map): coalesce to one seek — no stopBacking before seek. */
|
||||
let _juceShimBatch = null;
|
||||
let _juceShimBatchFlushScheduled = false;
|
||||
let _juceShimGen = 0;
|
||||
function enqueue(fn) {
|
||||
const gen = _juceShimGen;
|
||||
const p = chain.then(async () => {
|
||||
if (gen !== _juceShimGen) return;
|
||||
return fn(gen);
|
||||
});
|
||||
chain = p.catch((e) => {
|
||||
console.warn('[juce-audio-shim]', e);
|
||||
});
|
||||
return p;
|
||||
}
|
||||
// forUpcomingPlay: caller will enqueue a play() right after, so don't
|
||||
// emit pause-state side effects for a wantsPause batch — play() will
|
||||
// overwrite them anyway.
|
||||
function flushJuceShimBatchNow({ forUpcomingPlay = false } = {}) {
|
||||
_juceShimBatchFlushScheduled = false;
|
||||
const batch = _juceShimBatch;
|
||||
_juceShimBatch = null;
|
||||
if (!batch || !window._juceMode) return;
|
||||
const wantsPause = !!batch.wantsPause;
|
||||
const seekTime = batch.seekTime;
|
||||
if (wantsPause && seekTime !== undefined) {
|
||||
enqueue(async (gen) => {
|
||||
const r = await _audioSeek(seekTime, 'audio-element-shim');
|
||||
if (!r.completed) return; // seek cancelled by teardown
|
||||
if (gen !== _juceShimGen) return;
|
||||
if (!forUpcomingPlay) {
|
||||
await jucePlayer.pause();
|
||||
if (gen !== _juceShimGen) return;
|
||||
S.isPlaying = false;
|
||||
setPlayButtonState(false);
|
||||
const sm = window.feedBack;
|
||||
if (sm) {
|
||||
sm.isPlaying = false;
|
||||
sm.emit('song:pause', _songEventPayload());
|
||||
}
|
||||
}
|
||||
audio.dispatchEvent(new Event('seeked'));
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (wantsPause) {
|
||||
enqueue(async (gen) => {
|
||||
await jucePlayer.pause();
|
||||
if (gen !== _juceShimGen) return;
|
||||
S.isPlaying = false;
|
||||
setPlayButtonState(false);
|
||||
const sm = window.feedBack;
|
||||
if (sm) {
|
||||
sm.isPlaying = false;
|
||||
sm.emit('song:pause', _songEventPayload());
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (seekTime !== undefined) {
|
||||
enqueue(async (gen) => {
|
||||
const r = await _audioSeek(seekTime, 'audio-element-shim');
|
||||
if (!r.completed) return; // seek cancelled by teardown
|
||||
if (gen !== _juceShimGen) return;
|
||||
audio.dispatchEvent(new Event('seeked'));
|
||||
});
|
||||
}
|
||||
}
|
||||
function scheduleJuceShimBatchFlush() {
|
||||
if (_juceShimBatchFlushScheduled) return;
|
||||
_juceShimBatchFlushScheduled = true;
|
||||
const flushGen = _juceShimGen;
|
||||
queueMicrotask(() => {
|
||||
if (flushGen !== _juceShimGen) {
|
||||
_juceShimBatchFlushScheduled = false;
|
||||
return;
|
||||
}
|
||||
flushJuceShimBatchNow();
|
||||
});
|
||||
}
|
||||
_resetJuceAudioShimChain = function () {
|
||||
chain = Promise.resolve();
|
||||
_juceShimBatch = null;
|
||||
_juceShimBatchFlushScheduled = false;
|
||||
_juceShimGen++;
|
||||
};
|
||||
|
||||
Object.defineProperty(audio, 'currentTime', {
|
||||
get() {
|
||||
if (window._juceMode) return jucePlayer.currentTime;
|
||||
return ctDesc.get.call(this);
|
||||
},
|
||||
set(v) {
|
||||
if (window._juceMode) {
|
||||
const t = Math.max(0, Number(v) || 0);
|
||||
_juceShimBatch = _juceShimBatch || {};
|
||||
_juceShimBatch.seekTime = t;
|
||||
scheduleJuceShimBatchFlush();
|
||||
return;
|
||||
}
|
||||
ctDesc.set.call(this, v);
|
||||
},
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
Object.defineProperty(audio, 'paused', {
|
||||
get() {
|
||||
if (window._juceMode) return !S.isPlaying;
|
||||
return pausedDesc.get.call(this);
|
||||
},
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
audio.pause = function () {
|
||||
if (window._juceMode) {
|
||||
_juceShimBatch = _juceShimBatch || {};
|
||||
_juceShimBatch.wantsPause = true;
|
||||
scheduleJuceShimBatchFlush();
|
||||
return;
|
||||
}
|
||||
nativePause.call(audio);
|
||||
};
|
||||
|
||||
audio.play = function () {
|
||||
if (window._juceMode) {
|
||||
if (_juceShimBatch != null) flushJuceShimBatchNow({ forUpcomingPlay: true });
|
||||
const p = enqueue(async (gen) => {
|
||||
const started = await jucePlayer.play();
|
||||
if (gen !== _juceShimGen || !started) return;
|
||||
S.isPlaying = true;
|
||||
setPlayButtonState(true);
|
||||
const sm = window.feedBack;
|
||||
if (sm) {
|
||||
sm.isPlaying = true;
|
||||
const payload = _songEventPayload();
|
||||
sm.emit('song:play', payload);
|
||||
sm.emit('song:resume', payload);
|
||||
}
|
||||
});
|
||||
return p.then(() => undefined);
|
||||
}
|
||||
return nativePlay.call(audio);
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,29 @@
|
||||
// Shared, MUTABLE library state.
|
||||
//
|
||||
// WHY A CONTAINER AND NOT PLAIN EXPORTS. An imported binding is READ-ONLY:
|
||||
// `import { _treeStats }; _treeStats = x` throws. Of the library module's 28 outward
|
||||
// bindings, 23 are only ever READ from outside, so they stay plain exports. These five
|
||||
// are genuinely WRITTEN from outside — by showScreen (session teardown bumps the epoch,
|
||||
// resets the page), deleteSongFromModal, and syncLibrarySong, none of which can move into
|
||||
// the library module because they reach the playSong/showScreen core.
|
||||
//
|
||||
// So exactly these five move onto an object, and no more. `L.treeStats = x` is a property
|
||||
// write, which works from any module holding the same `L`. Same shape as ./player-state.js.
|
||||
//
|
||||
// Add to it when a carve actually needs it, not before — a container is a shared mutable
|
||||
// global with better manners, and every field on it is a coupling you have to keep true.
|
||||
export const L = {
|
||||
/** Library tree stats (artist -> counts), cached from /api/library/tree-stats. */
|
||||
treeStats: null,
|
||||
/** Same, for the favourites tree. */
|
||||
favTreeStats: null,
|
||||
/** Tuning names, cached from /api/library/tuning-names. */
|
||||
tuningNames: null,
|
||||
/**
|
||||
* Session generation for the library. Bumped on teardown so an in-flight page fetch
|
||||
* that resolves against a stale library can't render into the new one.
|
||||
*/
|
||||
libEpoch: 0,
|
||||
/** Current grid page (0-based). */
|
||||
currentPage: 0,
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
+9
-7
@@ -19,6 +19,8 @@
|
||||
// See ./host.js: reading an unwired hook THROWS, and tests/js/host_contract.test.js
|
||||
// fails CI if the hooks used here and the hooks app.js wires ever drift apart.
|
||||
import { esc, uiPrompt } from './dom.js';
|
||||
import { _audioSeek, _audioTime } from './transport.js';
|
||||
import { formatTime } from './format.js';
|
||||
import { host } from './host.js';
|
||||
import {
|
||||
_setSectionPracticeMode,
|
||||
@@ -39,14 +41,14 @@ export let loopB = null;
|
||||
export let _loopMutationGen = 0;
|
||||
|
||||
export function setLoopStart() {
|
||||
loopA = host._audioTime();
|
||||
loopA = _audioTime();
|
||||
document.getElementById('btn-loop-a').className = 'px-3 py-1.5 bg-green-900/50 rounded-lg text-xs text-green-300 transition';
|
||||
updateLoopUI();
|
||||
}
|
||||
|
||||
export function setLoopEnd() {
|
||||
if (loopA === null) return;
|
||||
loopB = host._audioTime();
|
||||
loopB = _audioTime();
|
||||
if (loopB <= loopA) { loopB = null; return; }
|
||||
document.getElementById('btn-loop-b').className = 'px-3 py-1.5 bg-green-900/50 rounded-lg text-xs text-green-300 transition';
|
||||
updateLoopUI();
|
||||
@@ -72,7 +74,7 @@ export function clearLoop(options) {
|
||||
document.getElementById('loop-label').textContent = '';
|
||||
document.getElementById('saved-loops').value = '';
|
||||
resetSelection();
|
||||
_updateSectionPracticeHighlight(host._audioTime());
|
||||
_updateSectionPracticeHighlight(_audioTime());
|
||||
if (hadLoop && emitTransportEvent && typeof window !== 'undefined') {
|
||||
window.feedBack?.playback?.transportEvent?.('loop-cleared', {
|
||||
requesterId: 'core.loop',
|
||||
@@ -125,7 +127,7 @@ export async function setLoop(a, b, options) {
|
||||
// Don't arm loopA/loopB before the seek lands — the 60Hz tick's wrap
|
||||
// detector (`ct >= loopB`) would trigger startCountIn against
|
||||
// half-applied state.
|
||||
const r = await host._audioSeek(aNum, 'loop-set');
|
||||
const r = await _audioSeek(aNum, 'loop-set');
|
||||
if (!r.completed || Math.abs(r.to - aNum) > 0.05) return false;
|
||||
// Caller-owned staleness gate, re-checked after the awaited seek and before
|
||||
// we commit loopA/loopB. practiceSection() passes this so a superseded retry
|
||||
@@ -166,11 +168,11 @@ export function updateLoopUI() {
|
||||
const label = document.getElementById('loop-label');
|
||||
const hasLoop = loopA !== null && loopB !== null;
|
||||
if (hasLoop) {
|
||||
label.textContent = `${host.formatTime(loopA)} → ${host.formatTime(loopB)}`;
|
||||
label.textContent = `${formatTime(loopA)} → ${formatTime(loopB)}`;
|
||||
document.getElementById('btn-loop-clear').classList.remove('hidden');
|
||||
document.getElementById('btn-loop-save').classList.remove('hidden');
|
||||
} else if (loopA !== null) {
|
||||
label.textContent = `${host.formatTime(loopA)} → ?`;
|
||||
label.textContent = `${formatTime(loopA)} → ?`;
|
||||
document.getElementById('btn-loop-clear').classList.add('hidden');
|
||||
document.getElementById('btn-loop-save').classList.add('hidden');
|
||||
} else {
|
||||
@@ -189,7 +191,7 @@ export async function loadSavedLoops() {
|
||||
|
||||
sel.innerHTML = '<option value="">Saved Loops</option>';
|
||||
for (const l of loops) {
|
||||
sel.innerHTML += `<option value="${l.id}" data-start="${l.start}" data-end="${l.end}">${esc(l.name)} (${host.formatTime(l.start)}→${host.formatTime(l.end)})</option>`;
|
||||
sel.innerHTML += `<option value="${l.id}" data-start="${l.start}" data-end="${l.end}">${esc(l.name)} (${formatTime(l.start)}→${formatTime(l.end)})</option>`;
|
||||
}
|
||||
if (loops.length > 0) {
|
||||
sel.classList.remove('hidden');
|
||||
|
||||
@@ -31,4 +31,12 @@ export const S = {
|
||||
* land where it was asked to (JUCE can clamp; HTML5 can round).
|
||||
*/
|
||||
lastAudioTime: 0,
|
||||
|
||||
/**
|
||||
* A resume request armed by playSong({ resume }) and consumed on song:ready.
|
||||
* Written by app.js (playSong, and the song:ready listener that consumes it) and
|
||||
* read by the resume-session module — so, like the two above, it cannot be a plain
|
||||
* export.
|
||||
*/
|
||||
pendingResume: null,
|
||||
};
|
||||
|
||||
@@ -654,7 +654,8 @@ export async function loadPlugins() {
|
||||
// of a cached copy keyed only by path (matches the art
|
||||
// URL ?v=mtime convention elsewhere in this file).
|
||||
const v = encodeURIComponent(wantedVersion);
|
||||
script.src = `/api/plugins/${plugin.id}/screen.js${v ? `?v=${v}` : ''}`;
|
||||
const query = v ? `?v=${v}` : '';
|
||||
script.src = _pluginScriptUrl(plugin, wantedVersion, query);
|
||||
// Module-migration (R0): a migrated plugin declares
|
||||
// scriptType:"module" and its screen.js is `import
|
||||
// './src/main.js'`. A <script type="module"> fires load
|
||||
@@ -844,6 +845,56 @@ export async function checkPluginUpdates() {
|
||||
btn.textContent = 'Check for Updates';
|
||||
}
|
||||
|
||||
// ── Module re-evaluation (#879) ─────────────────────────────────────────────
|
||||
//
|
||||
// ES modules are evaluated ONCE PER URL PER DOCUMENT. Re-inserting a
|
||||
// <script type="module"> whose src the module map has already seen fires `load` but
|
||||
// does NOT re-run the body. So a ROLLBACK — reloading a version already evaluated
|
||||
// this session — silently kept the OLD module live, while onload fired and
|
||||
// loadedScripts recorded the rollback as applied. A no-op that reported success.
|
||||
// (Upgrades were fine: a new version means a new ?v=, hence a new URL.)
|
||||
//
|
||||
// Busting the ENTRY url alone does NOT fix it. A module plugin's screen.js is a
|
||||
// one-line `import './src/main.js'`, and a relative specifier resolves against the
|
||||
// base URL WITH THE QUERY STRING DROPPED — so ?v= never reaches the graph, and
|
||||
// src/main.js (where the plugin actually lives) stays cached no matter what we hang
|
||||
// off screen.js.
|
||||
//
|
||||
// So the token goes in the PATH. From /api/plugins/x/g/7/screen.js, './src/main.js'
|
||||
// resolves to /api/plugins/x/g/7/src/main.js — every relative import in the graph
|
||||
// inherits it, at every depth, with no import-specifier rewriting (which could not
|
||||
// see `import(expr)` anyway). The server ignores the token and serves identical
|
||||
// bytes.
|
||||
//
|
||||
// ─── AND THE UPGRADE PATH WAS BROKEN TOO ────────────────────────────────────
|
||||
//
|
||||
// #879 says "upgrades are fine — a new version yields a new URL". That is true of
|
||||
// screen.js and FALSE of the plugin. Driving a real browser through
|
||||
// install(1.0.0) -> upgrade(1.1.0) -> rollback(1.0.0) and counting evaluations of
|
||||
// src/main.js gives ONE. Not two, not three: ONE. The upgrade re-evaluates the
|
||||
// one-line screen.js shim at its new ?v= URL, that shim imports './src/main.js',
|
||||
// that resolves to the same URL as before, and the module map hands back the
|
||||
// ALREADY-EVALUATED v1.0.0 module. The plugin's actual code never re-ran.
|
||||
//
|
||||
// So the generation token is not a rollback special case. EVERY re-load of a module
|
||||
// plugin needs it — the key is the plugin id, NOT id@version. Only the first load of
|
||||
// a given plugin in this document takes the stable URL, which is what keeps the
|
||||
// ETag/304 live-edit contract the R0 rails depend on.
|
||||
const _evaluatedModules = new Set(); // plugin ids whose module graph is live in this document
|
||||
let _moduleReloadSeq = 0;
|
||||
|
||||
function _pluginScriptUrl(plugin, wantedVersion, query) {
|
||||
const base = `/api/plugins/${plugin.id}/screen.js${query}`;
|
||||
if (plugin.script_type !== 'module') return base; // classic scripts always re-run
|
||||
if (!_evaluatedModules.has(plugin.id)) {
|
||||
_evaluatedModules.add(plugin.id);
|
||||
return base; // first load: stable URL, 304-able
|
||||
}
|
||||
// Re-load of a module plugin — upgrade OR rollback. Its graph is already in the
|
||||
// module map, so it needs an entirely fresh path or nothing below screen.js re-runs.
|
||||
return `/api/plugins/${plugin.id}/g/${++_moduleReloadSeq}/screen.js${query}`;
|
||||
}
|
||||
|
||||
export async function updatePlugin(pluginId, btn) {
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Updating...';
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
// Resume last session — the snapshot taken when you leave a song, and the pill that
|
||||
// offers it back.
|
||||
//
|
||||
// The fifth slice out of app.js's strongly-connected core. Small and self-contained:
|
||||
// ONE hook (playSong) plus a currentFilename getter.
|
||||
//
|
||||
// The armed resume request itself lives on the shared container as S.pendingResume,
|
||||
// not here, because app.js WRITES it — playSong({ resume }) arms it and the song:ready
|
||||
// listener consumes it — while this module reads it. An imported binding is read-only,
|
||||
// so shared mutable state has to live on the container. Same reason isPlaying does.
|
||||
//
|
||||
// See ./host.js: reading an unwired hook THROWS, and tests/js/host_contract.test.js
|
||||
// fails CI if the hooks used here and the hooks app.js wires ever drift apart.
|
||||
import { host } from './host.js';
|
||||
import { _curPlaybackSpeed } from './player-controls.js';
|
||||
import { S } from './player-state.js';
|
||||
|
||||
// ── Resume last session ────────────────────────────────────────────────────
|
||||
// Leaving a song snapshots where you were — song, arrangement, position, and
|
||||
// speed — so an exit (especially an accidental one, now that Escape reliably
|
||||
// leaves regardless of focus) is recoverable instead of restarting from bar 1.
|
||||
// The snapshot is offered back through a non-blocking "Resume" pill; it never
|
||||
// gates, blocks, or auto-acts. Cleared on natural song-end and once consumed.
|
||||
// (This is the player-session slice; the broader nav/state-resume work — e.g.
|
||||
// returning to a song after wandering into Settings → Tone Builder — is a
|
||||
// separate, larger track.)
|
||||
const _RESUME_KEY = 'feedBack.resumeSession';
|
||||
const _RESUME_MAX_AGE_MS = 24 * 60 * 60 * 1000; // a day-old snapshot is stale
|
||||
const _RESUME_MIN_POSITION_S = 3; // ignore barely-started songs
|
||||
const _RESUME_END_GUARD_S = 5; // ignore basically-finished songs
|
||||
let _resumePillDismissed = false; // per-session: user waved off the current snapshot
|
||||
|
||||
// Snapshot the live session. Called from showScreen()'s teardown before
|
||||
// highway.stop()/audio unload, while getSongInfo() + position are still valid.
|
||||
export function _snapshotResumeSession(position) {
|
||||
try {
|
||||
if (!host.currentFilename()) return;
|
||||
const si = (window.highway && typeof highway.getSongInfo === 'function')
|
||||
? (highway.getSongInfo() || {}) : {};
|
||||
const dur = Number(si.duration) || 0;
|
||||
const pos = Number(position) || 0;
|
||||
// Only worth resuming a song you were genuinely mid-way through — not a
|
||||
// glance at the first seconds, and not one that already basically ended.
|
||||
if (pos < _RESUME_MIN_POSITION_S) { _clearResumeSession(); return; }
|
||||
if (dur && pos > dur - _RESUME_END_GUARD_S) { _clearResumeSession(); return; }
|
||||
const snap = {
|
||||
f: host.currentFilename(),
|
||||
a: (typeof si.arrangement_index === 'number' && si.arrangement_index >= 0)
|
||||
? si.arrangement_index : undefined,
|
||||
t: pos,
|
||||
sp: _curPlaybackSpeed(),
|
||||
title: si.title || '',
|
||||
artist: si.artist || '',
|
||||
ts: Date.now(),
|
||||
};
|
||||
localStorage.setItem(_RESUME_KEY, JSON.stringify(snap));
|
||||
// A fresh snapshot earns one offer — undo any earlier dismissal.
|
||||
_resumePillDismissed = false;
|
||||
} catch (_) { /* storage unavailable — resume is best-effort */ }
|
||||
}
|
||||
|
||||
export function _readResumeSession() {
|
||||
try {
|
||||
const raw = localStorage.getItem(_RESUME_KEY);
|
||||
if (!raw) return null;
|
||||
const snap = JSON.parse(raw);
|
||||
if (!snap || !snap.f || !(Number(snap.t) > 0)) return null;
|
||||
if (!snap.ts || Date.now() - snap.ts > _RESUME_MAX_AGE_MS) { _clearResumeSession(); return null; }
|
||||
return snap;
|
||||
} catch (_) { return null; }
|
||||
}
|
||||
|
||||
export function _clearResumeSession() {
|
||||
try { localStorage.removeItem(_RESUME_KEY); } catch (_) {}
|
||||
}
|
||||
|
||||
// Re-enter the snapshotted song and restore arrangement + position + speed.
|
||||
export async function resumeLastSession() {
|
||||
const snap = _readResumeSession();
|
||||
if (!snap) { _hideResumePill(); return false; }
|
||||
_hideResumePill();
|
||||
try {
|
||||
await host.playSong(snap.f, snap.a, {
|
||||
resume: { position: Number(snap.t) || 0, speed: Number(snap.sp) || 1 },
|
||||
});
|
||||
} catch (err) {
|
||||
// A transient load/connect failure must not strand the user: keep the
|
||||
// snapshot so the pill can re-offer it on the next non-player screen,
|
||||
// rather than consuming the only copy before the song actually loaded.
|
||||
console.warn('[app] resume failed to load; keeping snapshot:', err);
|
||||
S.pendingResume = null;
|
||||
return false;
|
||||
}
|
||||
_clearResumeSession(); // consumed only after a successful load
|
||||
return true;
|
||||
}
|
||||
|
||||
// ── Resume pill (non-blocking "continue where you left off") ────────────────
|
||||
// Self-contained, inline-styled, body-appended so it works identically in the
|
||||
// classic (v2) and v3 shells with no Tailwind rebuild. It only ever appears off
|
||||
// the player screen, never blocks, and a dismiss forgets the current snapshot
|
||||
// for the session.
|
||||
export function _hideResumePill() {
|
||||
const el = document.getElementById('fb-resume-pill');
|
||||
if (el) el.remove();
|
||||
}
|
||||
|
||||
export function _maybeShowResumePill() {
|
||||
const active = document.querySelector('.screen.active');
|
||||
if (active && active.id === 'player') { _hideResumePill(); return; }
|
||||
if (_resumePillDismissed) return;
|
||||
const snap = _readResumeSession();
|
||||
if (!snap) { _hideResumePill(); return; }
|
||||
if (document.getElementById('fb-resume-pill')) return; // already shown
|
||||
|
||||
const label = (snap.title || decodeURIComponent(snap.f || 'your last song')).toString();
|
||||
const pill = document.createElement('div');
|
||||
pill.id = 'fb-resume-pill';
|
||||
pill.setAttribute('role', 'status');
|
||||
pill.style.cssText = [
|
||||
'position:fixed', 'left:16px', 'bottom:16px', 'z-index:120',
|
||||
'display:flex', 'align-items:center', 'gap:10px',
|
||||
'max-width:min(90vw,360px)', 'padding:10px 12px',
|
||||
'background:rgba(17,24,39,0.96)', 'color:#e5e7eb',
|
||||
'border:1px solid rgba(148,163,184,0.25)', 'border-radius:10px',
|
||||
'box-shadow:0 6px 24px rgba(0,0,0,0.4)',
|
||||
'font:13px/1.3 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif',
|
||||
].join(';');
|
||||
|
||||
const text = document.createElement('div');
|
||||
text.style.cssText = 'flex:1;min-width:0';
|
||||
const t1 = document.createElement('div');
|
||||
t1.textContent = 'Resume practice';
|
||||
t1.style.cssText = 'font-weight:600;color:#fff';
|
||||
const t2 = document.createElement('div');
|
||||
t2.textContent = label;
|
||||
t2.style.cssText = 'opacity:0.7;white-space:nowrap;overflow:hidden;text-overflow:ellipsis';
|
||||
text.appendChild(t1); text.appendChild(t2);
|
||||
|
||||
const resumeBtn = document.createElement('button');
|
||||
resumeBtn.type = 'button';
|
||||
resumeBtn.textContent = 'Resume ▸';
|
||||
resumeBtn.style.cssText = 'flex:none;padding:6px 10px;border:0;border-radius:7px;background:#4080e0;color:#fff;font-weight:600;cursor:pointer';
|
||||
resumeBtn.addEventListener('click', () => { resumeLastSession(); });
|
||||
|
||||
const dismissBtn = document.createElement('button');
|
||||
dismissBtn.type = 'button';
|
||||
dismissBtn.setAttribute('aria-label', 'Dismiss');
|
||||
dismissBtn.textContent = '✕';
|
||||
dismissBtn.style.cssText = 'flex:none;padding:4px 6px;border:0;border-radius:7px;background:transparent;color:#9ca3af;cursor:pointer;font-size:14px';
|
||||
dismissBtn.addEventListener('click', () => { _resumePillDismissed = true; _hideResumePill(); });
|
||||
|
||||
pill.appendChild(text);
|
||||
pill.appendChild(resumeBtn);
|
||||
pill.appendChild(dismissBtn);
|
||||
(document.body || document.documentElement).appendChild(pill);
|
||||
}
|
||||
@@ -27,6 +27,8 @@
|
||||
// layer that catches it on the paths a smoke test never runs.
|
||||
import { audio } from './audio-el.js';
|
||||
import { esc } from './dom.js';
|
||||
import { _audioDuration, _audioTime, audioSeekGen } from './transport.js';
|
||||
import { formatTime } from './format.js';
|
||||
import { host } from './host.js';
|
||||
|
||||
export function _sectionPracticeBarContains(el) {
|
||||
@@ -85,7 +87,7 @@ export function _setSectionPracticeMode(on, opts = {}) {
|
||||
if (opts.defaultWholeOn) {
|
||||
_sectionPracticeWholeSection = true;
|
||||
}
|
||||
_updateSectionPracticeHighlight(host._audioTime());
|
||||
_updateSectionPracticeHighlight(_audioTime());
|
||||
if (opts.defaultWholeOn) {
|
||||
_syncSectionPracticePieceUi();
|
||||
}
|
||||
@@ -104,7 +106,7 @@ export function _setSectionPracticeMode(on, opts = {}) {
|
||||
_sectionPracticeSelected = -1;
|
||||
_sectionPracticeWholeSection = false;
|
||||
_sectionPracticeSavedPartIndex = 0;
|
||||
_updateSectionPracticeHighlight(host._audioTime());
|
||||
_updateSectionPracticeHighlight(_audioTime());
|
||||
if (!opts.skipClearLoop && (host.loopA() !== null || host.loopB() !== null)) {
|
||||
host.clearLoop();
|
||||
}
|
||||
@@ -129,7 +131,7 @@ function _sectionPracticeHighway() {
|
||||
}
|
||||
|
||||
function _sectionPracticeDuration() {
|
||||
const d = host._audioDuration();
|
||||
const d = _audioDuration();
|
||||
if (d && Number.isFinite(d) && d > 0) return d;
|
||||
const cd = window.feedBack?.currentSong?.duration;
|
||||
return (cd && Number.isFinite(cd) && cd > 0) ? cd : 0;
|
||||
@@ -901,7 +903,7 @@ export function renderSectionPracticeBar() {
|
||||
_showSectionPracticeBar(bar);
|
||||
scroll.innerHTML = parents.map((p, i) => {
|
||||
const label = _formatSectionPracticeName(p.name);
|
||||
const tip = `${label} (${host.formatTime(p.start)}–${host.formatTime(p.end)})`;
|
||||
const tip = `${label} (${formatTime(p.start)}–${formatTime(p.end)})`;
|
||||
const kindClass = _sectionPracticeChipKindClass(p.name, i);
|
||||
return `<button type="button" class="section-practice-chip${kindClass}" data-parent-idx="${i}" title="${esc(tip)}" onclick="onSectionParentClick(${i})">${esc(label)}</button>`;
|
||||
}).join('');
|
||||
@@ -914,7 +916,7 @@ export function renderSectionPracticeBar() {
|
||||
// matching one; run it before the piece UI so that reflects the result.
|
||||
_syncSectionPracticeFromLoop();
|
||||
_syncSectionPracticePieceUi();
|
||||
_updateSectionPracticeHighlight(host._audioTime());
|
||||
_updateSectionPracticeHighlight(_audioTime());
|
||||
}
|
||||
|
||||
export async function onSectionParentClick(parentIdx) {
|
||||
@@ -927,7 +929,7 @@ export async function onSectionParentClick(parentIdx) {
|
||||
_sectionPracticeSavedPartIndex = 0;
|
||||
_sectionPracticeWholeSection = true;
|
||||
_syncSectionPracticePieceUi();
|
||||
_updateSectionPracticeHighlight(host._audioTime());
|
||||
_updateSectionPracticeHighlight(_audioTime());
|
||||
if (_sectionPracticeActiveParentRange() || _sectionPracticeRanges.length) {
|
||||
await practiceSection(0, { whole: true });
|
||||
}
|
||||
@@ -1019,7 +1021,7 @@ function _blurSectionPracticeFocusIfNeeded() {
|
||||
|
||||
export async function practiceSection(index, opts = {}) {
|
||||
const requestGen = ++_sectionPracticeRequestGen;
|
||||
const seekGen = host._audioSeekGen();
|
||||
const seekGen = audioSeekGen();
|
||||
const loopGen = host._loopMutationGen();
|
||||
const whole = !!opts.whole;
|
||||
const r = _sectionPracticeResolveLoopTarget(index, opts);
|
||||
@@ -1045,7 +1047,7 @@ export async function practiceSection(index, opts = {}) {
|
||||
let ok = false;
|
||||
for (let attempt = 0; attempt < 5; attempt++) {
|
||||
// A newer click or a song/arrangement change supersedes this retry.
|
||||
if (requestGen !== _sectionPracticeRequestGen || seekGen !== host._audioSeekGen() || loopGen !== host._loopMutationGen()) return;
|
||||
if (requestGen !== _sectionPracticeRequestGen || seekGen !== audioSeekGen() || loopGen !== host._loopMutationGen()) return;
|
||||
try {
|
||||
// skipSectionSync: this function owns the section-practice state and
|
||||
// applies it below under the request-gen guard, so a stale retry
|
||||
@@ -1055,7 +1057,7 @@ export async function practiceSection(index, opts = {}) {
|
||||
// after its internal seek await, so a stale loop is never armed.
|
||||
ok = await host.setLoop(start, end, {
|
||||
skipSectionSync: true,
|
||||
commitGuard: () => requestGen === _sectionPracticeRequestGen && seekGen === host._audioSeekGen() && loopGen === host._loopMutationGen(),
|
||||
commitGuard: () => requestGen === _sectionPracticeRequestGen && seekGen === audioSeekGen() && loopGen === host._loopMutationGen(),
|
||||
});
|
||||
} catch (err) {
|
||||
ok = false;
|
||||
@@ -1064,7 +1066,7 @@ export async function practiceSection(index, opts = {}) {
|
||||
await new Promise(res => setTimeout(res, 60 + attempt * 90));
|
||||
}
|
||||
// Re-check after the awaited retries before applying any loop/count-in state.
|
||||
if (requestGen !== _sectionPracticeRequestGen || seekGen !== host._audioSeekGen() || loopGen !== host._loopMutationGen()) return;
|
||||
if (requestGen !== _sectionPracticeRequestGen || seekGen !== audioSeekGen() || loopGen !== host._loopMutationGen()) return;
|
||||
|
||||
if (ok) {
|
||||
_sectionPracticeWholeSection = whole;
|
||||
@@ -1073,7 +1075,7 @@ export async function practiceSection(index, opts = {}) {
|
||||
_sectionPracticeSavedPartIndex = index;
|
||||
}
|
||||
_blurSectionPracticeFocusIfNeeded();
|
||||
_updateSectionPracticeHighlight(host._audioTime());
|
||||
_updateSectionPracticeHighlight(_audioTime());
|
||||
host.startCountIn({ immediate: true });
|
||||
} else {
|
||||
_setSectionPracticeMode(false, { skipClearLoop: true });
|
||||
@@ -1120,7 +1122,7 @@ export function _syncSectionPracticeFromLoop() {
|
||||
} else if (_sectionPracticeMode) {
|
||||
_setSectionPracticeMode(false, { skipClearLoop: true });
|
||||
}
|
||||
_updateSectionPracticeHighlight(host._audioTime());
|
||||
_updateSectionPracticeHighlight(_audioTime());
|
||||
}
|
||||
|
||||
function _sectionPracticeIndexAtTime(t) {
|
||||
|
||||
@@ -0,0 +1,377 @@
|
||||
// The playback transport — the play/pause/seek core, and the two clocks it reads.
|
||||
//
|
||||
// WHY THIS IS A MODULE AND NOT A HOOK BUNDLE. Every carve before this one ADDED host
|
||||
// hooks: a module pulled out of app.js still had to call back into it. This one SUBTRACTS
|
||||
// them. count-in, juce-audio, loops, and section-practice were all reaching through the
|
||||
// seam for the same handful of names — _audioSeek, _audioTime, setPlayButtonState,
|
||||
// _songEventPayload, jucePlayer. Those names have an owner, and it isn't app.js. Give
|
||||
// them one and the four consumers import them directly:
|
||||
//
|
||||
// count-in.js 5 hooks -> 0 juce-audio.js 4 hooks -> 0
|
||||
// loops.js 6 hooks -> 4 section-practice.js 10 hooks -> 7
|
||||
//
|
||||
// A hook is a cycle you agreed to live with. An import is a dependency you actually have.
|
||||
// Prefer the import whenever the name has a real owner.
|
||||
//
|
||||
// TWO THINGS DELIBERATELY LEFT IN app.js, both for the same reason — they would close a
|
||||
// cycle, and app.js is the root, so it can import from both sides for free:
|
||||
//
|
||||
// * _currentPlaybackSnapshot reads loopA/loopB from ./loops.js, and loops.js imports
|
||||
// this module. The dependency scan MISSED this at first: it
|
||||
// only walked app.js's own top-level decls, and loopA stopped
|
||||
// being one the moment loops.js was carved out. Any scan of a
|
||||
// partly-carved monolith has to resolve the imports too.
|
||||
// * restartCurrentSong calls _cancelCountIn() from ./count-in.js, which imports
|
||||
// this module.
|
||||
//
|
||||
// The seek generation (_audioSeekGen) stays PRIVATE. It has exactly one writer —
|
||||
// _resetAudioSeekState(), right here — so readers get audioSeekGen() and nobody outside
|
||||
// can desync it. That is strictly better than the host hook it replaces, which handed out
|
||||
// a getter and left the writer in app.js.
|
||||
import { audio } from './audio-el.js';
|
||||
import { S } from './player-state.js';
|
||||
|
||||
// Sync the play/pause button's icon and accessible state in one place so
|
||||
// screen readers, tooltips, and aria-pressed stay aligned with playback.
|
||||
// Updates the existing <img> child's src in place rather than rewriting
|
||||
// innerHTML, so any future children (fallback label, loading spinner, …)
|
||||
// survive state changes.
|
||||
export function setPlayButtonState(isPlaying) {
|
||||
const btn = document.getElementById('btn-play');
|
||||
if (!btn) return;
|
||||
const label = isPlaying ? 'Pause' : 'Play';
|
||||
const icon = isPlaying ? 'pause' : 'play';
|
||||
let img = btn.querySelector('img.button-icon-svg');
|
||||
if (!img) {
|
||||
img = document.createElement('img');
|
||||
img.className = 'button-icon-svg';
|
||||
img.alt = '';
|
||||
img.setAttribute('aria-hidden', 'true');
|
||||
btn.appendChild(img);
|
||||
}
|
||||
img.src = `/static/svg/${icon}.svg`;
|
||||
btn.setAttribute('aria-label', label);
|
||||
btn.setAttribute('aria-pressed', isPlaying ? 'true' : 'false');
|
||||
btn.title = label;
|
||||
}
|
||||
|
||||
// ── Player ───────────────────────────────────────────────────────────────
|
||||
// `audio` now lives in ./js/audio-el.js so carved-out modules can reach the
|
||||
// player without importing app.js back (which would close a cycle). Same
|
||||
// element, same handle, same lookup — just imported instead of declared here.
|
||||
let _lastSongPositionEventAt = 0;
|
||||
|
||||
export function _emitSongPositionChanged(time, duration) {
|
||||
const now = Date.now();
|
||||
if (now - _lastSongPositionEventAt < 250) return;
|
||||
_lastSongPositionEventAt = now;
|
||||
const payload = (typeof _songEventPayload === 'function') ? _songEventPayload() : { time };
|
||||
window.feedBack.emit('song:position-changed', Object.assign(payload, { duration }));
|
||||
}
|
||||
|
||||
export const jucePlayer = {
|
||||
_timer: null,
|
||||
_pos: 0,
|
||||
_dur: 0,
|
||||
_pollAt: 0, // performance.now() when _pos was last set
|
||||
_polling: false,
|
||||
_speed: 1,
|
||||
get currentTime() {
|
||||
if (!this._polling) return this._pos;
|
||||
// Interpolate between IPC polls so highway motion is smooth at 60fps
|
||||
// Scale by _speed so at 0.7x the interpolated clock advances 0.7s/s
|
||||
const elapsed = (performance.now() - this._pollAt) / 1000;
|
||||
return Math.min(this._pos + elapsed * this._speed, this._dur > 0 ? this._dur : Infinity);
|
||||
},
|
||||
get duration() { return this._dur; },
|
||||
async play() {
|
||||
try {
|
||||
await window.feedBackDesktop.audio.startBacking();
|
||||
} catch (err) {
|
||||
console.warn('[jucePlayer] startBacking failed:', err);
|
||||
return false;
|
||||
}
|
||||
this._startPolling();
|
||||
return true;
|
||||
},
|
||||
async pause() {
|
||||
// Snapshot the interpolated position before stopping the poll so
|
||||
// _pos stays at the visible pause point rather than jumping back
|
||||
// to the last raw IPC sample (which can be up to 100ms behind).
|
||||
this._pos = this.currentTime;
|
||||
this._pollAt = performance.now();
|
||||
this._stopPolling();
|
||||
try {
|
||||
await window.feedBackDesktop.audio.stopBacking();
|
||||
} catch (err) {
|
||||
console.warn('[jucePlayer] stopBacking failed:', err);
|
||||
}
|
||||
},
|
||||
async seek(s) {
|
||||
const prev = this._pos;
|
||||
this._pos = s;
|
||||
this._pollAt = performance.now();
|
||||
try {
|
||||
await window.feedBackDesktop.audio.seekBacking(s);
|
||||
} catch (err) {
|
||||
console.warn('[jucePlayer] seekBacking failed:', err);
|
||||
this._pos = prev;
|
||||
this._pollAt = performance.now();
|
||||
}
|
||||
},
|
||||
_startPolling() {
|
||||
this._stopPolling();
|
||||
this._polling = true;
|
||||
this._pollAt = performance.now();
|
||||
const self = this;
|
||||
function scheduleNext() {
|
||||
self._timer = setTimeout(async () => {
|
||||
if (!self._polling) return;
|
||||
try {
|
||||
self._pos = await window.feedBackDesktop.audio.getBackingPosition();
|
||||
self._pollAt = performance.now();
|
||||
_emitSongPositionChanged(self.currentTime, self.duration || null);
|
||||
} catch (err) {
|
||||
console.warn('[jucePlayer] position poll failed:', err);
|
||||
} finally {
|
||||
if (self._polling) scheduleNext();
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
scheduleNext();
|
||||
},
|
||||
_stopPolling() {
|
||||
this._polling = false;
|
||||
if (this._timer) { clearTimeout(this._timer); this._timer = null; }
|
||||
},
|
||||
setRate(rate) {
|
||||
this._pos = this.currentTime;
|
||||
this._pollAt = performance.now();
|
||||
this._speed = rate;
|
||||
},
|
||||
async stop() {
|
||||
await this.pause();
|
||||
this._pos = 0;
|
||||
this._dur = 0;
|
||||
this._pollAt = 0;
|
||||
this._speed = 1;
|
||||
},
|
||||
};
|
||||
|
||||
export function _audioTime() { return window._juceMode ? jucePlayer.currentTime : audio.currentTime; }
|
||||
|
||||
export function _audioDuration() { return window._juceMode ? jucePlayer.duration : audio.duration; }
|
||||
|
||||
// Canonical payload for song:play/song:pause/song:ended. Plugins anchor
|
||||
// their own clocks against `perfNow` (a monotonic timestamp at the same
|
||||
// moment audio reports `audioT`) so they don't have to chase the chart
|
||||
// clock with a follow-up call. `time` is kept as an alias for `audioT`
|
||||
// because pre-existing plugins read e.detail.time.
|
||||
export function _songEventPayload() {
|
||||
const audioT = _audioTime();
|
||||
return {
|
||||
time: audioT,
|
||||
audioT,
|
||||
chartT: highway.getTime(),
|
||||
perfNow: performance.now(),
|
||||
};
|
||||
}
|
||||
|
||||
export function _markPlaybackPaused() {
|
||||
S.isPlaying = false;
|
||||
setPlayButtonState(false);
|
||||
if (window.feedBack) {
|
||||
window.feedBack.isPlaying = false;
|
||||
window.feedBack.emit('song:pause', _songEventPayload());
|
||||
}
|
||||
}
|
||||
|
||||
export function _markPlaybackResumed() {
|
||||
S.isPlaying = true;
|
||||
setPlayButtonState(true);
|
||||
if (window.feedBack) {
|
||||
window.feedBack.isPlaying = true;
|
||||
const payload = _songEventPayload();
|
||||
window.feedBack.emit('song:play', payload);
|
||||
window.feedBack.emit('song:resume', payload);
|
||||
}
|
||||
}
|
||||
|
||||
export function _emitPlaybackStopped(time, screen = 'playback-command') {
|
||||
if (window.feedBack) window.feedBack.emit('song:stop', { time: time || 0, screen });
|
||||
}
|
||||
|
||||
export function _waitForSongReady(expectedSeekGen, timeoutMs = 10000) {
|
||||
if (!window.feedBack || typeof window.feedBack.on !== 'function') return Promise.resolve(false);
|
||||
return new Promise(resolve => {
|
||||
let timer = null;
|
||||
const done = value => {
|
||||
if (timer !== null) clearTimeout(timer);
|
||||
window.feedBack.off('song:ready', onReady);
|
||||
resolve(value);
|
||||
};
|
||||
const onReady = () => done(expectedSeekGen == null || expectedSeekGen === _audioSeekGen);
|
||||
window.feedBack.on('song:ready', onReady);
|
||||
timer = setTimeout(() => done(false), timeoutMs);
|
||||
});
|
||||
}
|
||||
|
||||
// Serializes seeks so concurrent callers (e.g. user ⏪ during a loop wrap)
|
||||
// don't interleave their from/to reads — each call captures `from` only
|
||||
// once the previous seek + emit have completed. The generation token
|
||||
// lets session teardown invalidate queued seeks so they don't run against
|
||||
// the new player and emit a stale song:seek.
|
||||
let _audioSeekChain = Promise.resolve();
|
||||
|
||||
let _audioSeekGen = 0;
|
||||
|
||||
export function _resetAudioSeekState() {
|
||||
// Bump the generation — in-flight chain callbacks see the mismatch on
|
||||
// their next guard check and short-circuit (no emit, no further state
|
||||
// mutation by us). Don't reset the chain head: new seeks must still
|
||||
// queue behind the in-flight old seek's IPC so two `jucePlayer.seek()`
|
||||
// calls can't race in the JUCE backing engine. The queue drains
|
||||
// quickly because each subsequent old-gen step bails on the first
|
||||
// guard the moment its predecessor resolves.
|
||||
_audioSeekGen++;
|
||||
}
|
||||
|
||||
// Time-box the JUCE IPC so a single hung seek can't block the global
|
||||
// _audioSeekChain forever (which would freeze every subsequent reposition
|
||||
// path: seekBy, loop-wrap, jump-fix, shimmed audio.currentTime).
|
||||
const _JUCE_SEEK_TIMEOUT_MS = 2000;
|
||||
|
||||
function _juceSeekWithTimeout(s) {
|
||||
let timer;
|
||||
const seekP = jucePlayer.seek(s);
|
||||
const timeoutP = new Promise((_, reject) => {
|
||||
timer = setTimeout(() => reject(new Error('JUCE seek timed out')), _JUCE_SEEK_TIMEOUT_MS);
|
||||
});
|
||||
// Clear the timer once the race settles either way; without this the
|
||||
// pending timeout keeps the event loop alive (and eventually rejects
|
||||
// an unawaited promise) even after a successful seek.
|
||||
return Promise.race([seekP, timeoutP]).finally(() => clearTimeout(timer));
|
||||
}
|
||||
|
||||
// Resolves to `{ completed, from, to }`:
|
||||
// - completed: true if the seek ran to completion and emitted song:seek;
|
||||
// false if cancelled by a teardown gen bump (or threw).
|
||||
// - from: chart clock just before the seek (NaN on cancel before from-read).
|
||||
// - to: verified post-seek clock (NaN on cancel/throw).
|
||||
// Callers that fire follow-up work after the seek (count-in, arrangement
|
||||
// restore, etc.) should check `completed` so they don't act on a torn-down
|
||||
// session. Callers that need the actual landed position (because JUCE may
|
||||
// clamp or HTML5 may snap to the seekable range) should read `to` rather
|
||||
// than re-using the requested `s`.
|
||||
export async function _audioSeek(s, reason) {
|
||||
// Single funnel for every audio repositioning. Emits song:seek so
|
||||
// plugins (notedetect detection-suppression during seek transients,
|
||||
// practice-journal segment tracking) can react to any chart-time
|
||||
// jump regardless of which UI path triggered it. `reason` is a
|
||||
// free-form short string ('seek-by', 'loop-wrap', 'loop-set',
|
||||
// 'arrangement-restore', 'jump-fix') so subscribers can filter.
|
||||
const gen = _audioSeekGen;
|
||||
_audioSeekChain = _audioSeekChain.then(async () => {
|
||||
if (gen !== _audioSeekGen) return { completed: false, from: NaN, to: NaN };
|
||||
const from = _audioTime();
|
||||
if (window._juceMode) await _juceSeekWithTimeout(s);
|
||||
else audio.currentTime = s;
|
||||
if (gen !== _audioSeekGen) return { completed: false, from, to: NaN };
|
||||
// Read the verified post-seek position rather than the requested `s`
|
||||
// so plugins observe the actual clock — JUCE may clamp or roll back,
|
||||
// and HTML5 may snap to the nearest seekable range.
|
||||
const to = _audioTime();
|
||||
// Sync the jump-fix tracker so the next 60Hz tick doesn't see a
|
||||
// legitimate far seek (e.g. saved-loop jump > 30s) as a browser
|
||||
// bug and revert it.
|
||||
S.lastAudioTime = to;
|
||||
// Sync the chart clock too so any song:* emit fired right after
|
||||
// _audioSeek resolves (e.g. the auto-resume song:play in
|
||||
// changeArrangement) sees an in-sync chartT via _songEventPayload.
|
||||
// Without this, chartT lags by one 60Hz tick after a seek.
|
||||
if (typeof highway !== 'undefined' && highway && typeof highway.setTime === 'function') {
|
||||
highway.setTime(to);
|
||||
}
|
||||
window.feedBack.emit('song:seek', { from, to, reason: reason || null });
|
||||
return { completed: true, from, to };
|
||||
}).catch((err) => {
|
||||
// Don't let one failed seek poison subsequent ones.
|
||||
console.warn('[_audioSeek]', err);
|
||||
return { completed: false, from: NaN, to: NaN };
|
||||
});
|
||||
return _audioSeekChain;
|
||||
}
|
||||
|
||||
// Per-attempt counter for HTML5 audio.play() invocations. Bumped on
|
||||
// every play branch entry so a slow rejection from attempt N can't
|
||||
// clobber the UI of a newer attempt N+1 within the same session.
|
||||
let _playAttemptGen = 0;
|
||||
|
||||
export async function togglePlay() {
|
||||
if (window._juceMode) {
|
||||
if (S.isPlaying) {
|
||||
await jucePlayer.pause();
|
||||
S.isPlaying = false;
|
||||
setPlayButtonState(false);
|
||||
window.feedBack.isPlaying = false;
|
||||
window.feedBack.emit('song:pause', _songEventPayload());
|
||||
} else {
|
||||
const started = await jucePlayer.play();
|
||||
if (!started) return; // startBacking() failed — IPC error already logged
|
||||
S.isPlaying = true;
|
||||
setPlayButtonState(true);
|
||||
window.feedBack.isPlaying = true;
|
||||
const payload = _songEventPayload();
|
||||
window.feedBack.emit('song:play', payload);
|
||||
window.feedBack.emit('song:resume', payload);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (S.isPlaying) {
|
||||
audio.pause(); S.isPlaying = false;
|
||||
setPlayButtonState(false);
|
||||
} else {
|
||||
// Flip the UI optimistically before awaiting the play() Promise so
|
||||
// a quick second click during a slow start (buffering, device
|
||||
// wake, etc.) still enters the pause branch above. Two stale-
|
||||
// resolution guards:
|
||||
// - _audioSeekGen: bumped in showScreen() teardown and
|
||||
// playSong(), so a rejection from a torn-down session can't
|
||||
// touch new-session UI. Survives same-URL reloads.
|
||||
// - _playAttemptGen: bumped on every play branch entry, so
|
||||
// within a single session a slow rejection from attempt N
|
||||
// can't clobber a faster attempt N+1 (Play → Pause → Play).
|
||||
const sessionGen = _audioSeekGen;
|
||||
const attempt = ++_playAttemptGen;
|
||||
S.isPlaying = true;
|
||||
setPlayButtonState(true);
|
||||
try {
|
||||
await audio.play();
|
||||
} catch (err) {
|
||||
if (sessionGen !== _audioSeekGen) return;
|
||||
if (attempt !== _playAttemptGen) return;
|
||||
// An engine reroute (HTML5 -> JUCE) deliberately pauses the <audio>
|
||||
// element mid-migration, which rejects this in-flight play() with an
|
||||
// AbortError even though playback continues on the JUCE transport.
|
||||
// The reroute owns isPlaying / the button while it runs (same guard
|
||||
// the <audio> 'play'/'pause' listeners use); resetting here would
|
||||
// leave the button showing Play while the song keeps playing — the
|
||||
// "two clicks to pause on the first song after a fresh load" bug.
|
||||
if (window._juceRerouteInProgress) return;
|
||||
console.error('[app] audio.play() rejected:', err);
|
||||
S.isPlaying = false;
|
||||
setPlayButtonState(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function seekBy(s) {
|
||||
await _audioSeek(Math.max(0, _audioTime() + s), 'seek-by');
|
||||
}
|
||||
|
||||
/**
|
||||
* Read-only view of the seek generation. Bumped by _resetAudioSeekState() on session
|
||||
* teardown; callers capture it before an await and compare after, so a resolution from a
|
||||
* torn-down session can't touch new-session state.
|
||||
*/
|
||||
export function audioSeekGen() { return _audioSeekGen; }
|
||||
@@ -1275,6 +1275,7 @@
|
||||
saved 'off'/'full' motion preference on first paint. -->
|
||||
<script defer src="/static/v3/venue-mood-fx.js"></script>
|
||||
<script defer src="/static/v3/venue-scene-3d.js"></script>
|
||||
<script defer src="/static/v3/venue-crowd.js"></script>
|
||||
<script defer src="/static/v3/playlists.js"></script>
|
||||
<script defer src="/static/v3/audio-routing.js"></script>
|
||||
<script defer src="/static/v3/live-guitar-tone-source.js"></script>
|
||||
|
||||
@@ -0,0 +1,637 @@
|
||||
/*
|
||||
* fee[dB]ack — Venue crowd video layer (career mode PR1).
|
||||
*
|
||||
* Crossfades pre-rendered crowd-state loop videos behind the highway based on
|
||||
* v3:live-performance-state, plus one-shot reaction stingers. Renders through
|
||||
* two video backdrop planes owned by the highway_3d venue background style
|
||||
* (window.h3dVenueBackdropSetVideo / window.h3dVenueBackdropSetMix).
|
||||
*
|
||||
* Inert unless a venue pack manifest is set — by the career plugin via
|
||||
* v3VenueCrowd.setManifest(), or (dev only) a JSON manifest in localStorage
|
||||
* under feedBack-venue-crowd-dev. With no manifest the static bg plate
|
||||
* behaves exactly as before.
|
||||
*/
|
||||
(function (root) {
|
||||
'use strict';
|
||||
|
||||
// live-performance-hud state → crowd state.
|
||||
const CROWD_OF_PERF = {
|
||||
smoke: 'bored',
|
||||
recovery: 'bored',
|
||||
idle: 'neutral',
|
||||
steady: 'neutral',
|
||||
strong: 'engaged',
|
||||
fire: 'ecstatic',
|
||||
};
|
||||
const CROWD_STATES = ['bored', 'neutral', 'engaged', 'ecstatic'];
|
||||
const CROWD_RANK = { bored: 0, neutral: 1, engaged: 2, ecstatic: 3 };
|
||||
|
||||
const STABLE_MS = 3000; // target must hold this long before a switch
|
||||
const DWELL_MS = 8000; // min time between committed switches
|
||||
const FADE_MS = 1200; // loop crossfade
|
||||
const STINGER_FADE_MS = 400; // stinger fade-in/out
|
||||
const STINGER_MIN_GAP_MS = 20000;
|
||||
const STREAK_MILESTONES = [25, 50, 100];
|
||||
const CANPLAY_TIMEOUT_MS = 4000;
|
||||
const DEV_FLAG_KEY = 'feedBack-venue-crowd-dev';
|
||||
const SFX_KEY = 'feedBack-venue-crowd-sfx'; // 'on' | 'off' (default off)
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Pure, clock-injected decision logic (unit-tested in
|
||||
// tests/js/venue_crowd.test.js — keep DOM-free).
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
function crowdStateOfPerf(perfState) {
|
||||
return CROWD_OF_PERF[String(perfState || '').toLowerCase()] || 'neutral';
|
||||
}
|
||||
|
||||
// Hysteresis: a new target must be observed continuously for STABLE_MS,
|
||||
// and at least DWELL_MS must have passed since the last committed switch.
|
||||
function createCrowdMachine() {
|
||||
let current = 'neutral';
|
||||
let candidate = null;
|
||||
let candidateSince = 0;
|
||||
let lastSwitchAt = -Infinity;
|
||||
return {
|
||||
get current() { return current; },
|
||||
reset() {
|
||||
current = "neutral";
|
||||
candidate = null;
|
||||
lastSwitchAt = -Infinity;
|
||||
},
|
||||
// Feed the latest perf state; returns the new crowd state when a
|
||||
// transition commits, else null.
|
||||
update(perfState, nowMs) {
|
||||
const target = crowdStateOfPerf(perfState);
|
||||
if (target === current) {
|
||||
candidate = null;
|
||||
return null;
|
||||
}
|
||||
if (target !== candidate) {
|
||||
candidate = target;
|
||||
candidateSince = nowMs;
|
||||
return null;
|
||||
}
|
||||
if (nowMs - candidateSince < STABLE_MS) return null;
|
||||
if (nowMs - lastSwitchAt < DWELL_MS) return null;
|
||||
current = target;
|
||||
candidate = null;
|
||||
lastSwitchAt = nowMs;
|
||||
return current;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Cheer when the streak crosses a milestone (rising edge only).
|
||||
function stingerForStreak(prevStreak, streak) {
|
||||
for (const m of STREAK_MILESTONES) {
|
||||
if (prevStreak < m && streak >= m) return 'cheer';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// End-of-song reaction from final accuracy.
|
||||
function stingerForAccuracy(accuracyPct) {
|
||||
const a = Number(accuracyPct);
|
||||
if (!Number.isFinite(a)) return null;
|
||||
if (a >= 90) return 'cheer';
|
||||
if (a >= 75) return 'clap';
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Video layer controller (browser only).
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
const machine = createCrowdMachine();
|
||||
let _manifest = null; // { loops: {state: url}, stingers: {name: url} }
|
||||
let _venueActive = false;
|
||||
let _videos = [null, null];
|
||||
let _activeLayer = 0; // layer currently showing the loop
|
||||
let _mix = 0; // 0 → layer0 visible, 1 → layer1 visible
|
||||
let _fadeRaf = 0;
|
||||
let _stopGen = 0; // bumped by stop(): invalidates ALL in-flight loads
|
||||
let _boundToRenderer = false;
|
||||
let _pendingLoop = null; // loop switch deferred by an active stinger
|
||||
let _loadingLoop = null; // loop currently waiting on canplaythrough
|
||||
let _fadingLoop = null; // loop currently crossfading in (not yet active)
|
||||
let _stingerUntilEnded = false;
|
||||
let _stingerGen = 0; // identity for ended/timeout handlers
|
||||
let _introActive = false;
|
||||
let _introGen = 0;
|
||||
let _audioEl = null; // crowd ambience during the intro flyover
|
||||
let _audioFadeTimer = 0;
|
||||
let _lastStingerAt = -Infinity;
|
||||
let _prevStreak = 0;
|
||||
let _lastAccuracyPct = null; // from perf events; stats:recorded carries none
|
||||
let _bound = false;
|
||||
|
||||
function now() { return Date.now(); }
|
||||
|
||||
function h3d(name) {
|
||||
return root && typeof root[name] === 'function' ? root[name] : null;
|
||||
}
|
||||
|
||||
function normalizeManifest(m) {
|
||||
if (!m || typeof m !== 'object' || !m.loops) return null;
|
||||
const base = typeof m.base === 'string' ? m.base : '';
|
||||
const abs = (u) => (typeof u === 'string' && u ? base + u : '');
|
||||
const loops = {};
|
||||
for (const s of CROWD_STATES) loops[s] = abs(m.loops[s]);
|
||||
if (!CROWD_STATES.every((s) => loops[s])) return null;
|
||||
const stingers = {};
|
||||
for (const k of ['clap', 'cheer']) stingers[k] = abs(m.stingers && m.stingers[k]);
|
||||
const intro = {
|
||||
video: abs(m.intro && m.intro.video),
|
||||
audio: abs(m.intro && m.intro.audio),
|
||||
};
|
||||
const sfx = {
|
||||
up: abs(m.sfx && m.sfx.up),
|
||||
down: abs(m.sfx && m.sfx.down),
|
||||
};
|
||||
return { loops, stingers, intro, sfx };
|
||||
}
|
||||
|
||||
function ensureVideos() {
|
||||
if (!_videos[0] && typeof document !== 'undefined') {
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const v = document.createElement('video');
|
||||
// Same autoplay-safe recipe as the highway_3d video bg style:
|
||||
// muted + playsInline bypasses gesture requirements; same-origin
|
||||
// URLs so VideoTexture never taints.
|
||||
v.muted = true;
|
||||
v.playsInline = true;
|
||||
v.preload = 'auto';
|
||||
v.loop = true;
|
||||
v.style.display = 'none';
|
||||
document.body.appendChild(v);
|
||||
_videos[i] = v;
|
||||
}
|
||||
}
|
||||
bindVideosToRenderer();
|
||||
}
|
||||
|
||||
// The highway_3d plugin (and its globals) can register after the venue
|
||||
// pack starts — e.g. Venue selected at page load, renderer ready later.
|
||||
// Idempotent and retried from start() and the perf-event path so a late
|
||||
// renderer still picks the videos up.
|
||||
function bindVideosToRenderer() {
|
||||
if (_boundToRenderer || !_videos[0]) return;
|
||||
const setVideo = h3d('h3dVenueBackdropSetVideo');
|
||||
if (!setVideo) return;
|
||||
setVideo(0, _videos[0]);
|
||||
setVideo(1, _videos[1]);
|
||||
_boundToRenderer = true;
|
||||
setMix(_mix); // re-push mix the renderer missed while unregistered
|
||||
}
|
||||
|
||||
function setMix(v) {
|
||||
_mix = Math.max(0, Math.min(1, v));
|
||||
const fn = h3d('h3dVenueBackdropSetMix');
|
||||
if (fn) fn(_mix);
|
||||
}
|
||||
|
||||
function cancelFade() {
|
||||
if (_fadeRaf && typeof cancelAnimationFrame === 'function') {
|
||||
cancelAnimationFrame(_fadeRaf);
|
||||
}
|
||||
_fadeRaf = 0;
|
||||
}
|
||||
|
||||
function fadeMixTo(target, durationMs, done) {
|
||||
cancelFade();
|
||||
if (typeof requestAnimationFrame !== 'function') {
|
||||
setMix(target);
|
||||
if (done) done();
|
||||
return;
|
||||
}
|
||||
const from = _mix;
|
||||
const t0 = now();
|
||||
const step = () => {
|
||||
const k = Math.min(1, (now() - t0) / durationMs);
|
||||
setMix(from + (target - from) * k);
|
||||
if (k < 1) {
|
||||
_fadeRaf = requestAnimationFrame(step);
|
||||
} else {
|
||||
_fadeRaf = 0;
|
||||
if (done) done();
|
||||
}
|
||||
};
|
||||
_fadeRaf = requestAnimationFrame(step);
|
||||
}
|
||||
|
||||
// Load url into the video, resolve when it can play through (or after a
|
||||
// timeout — a stalled fetch must not wedge the crowd forever). Tokens are
|
||||
// per-element: a later load on the SAME video (a stinger preempting the
|
||||
// idle layer) cancels this one, but loads on the other layer don't.
|
||||
function loadAndPlay(video, url, loop, cb) {
|
||||
const token = (video._fbCrowdToken = (video._fbCrowdToken || 0) + 1);
|
||||
const gen = _stopGen;
|
||||
let settled = false;
|
||||
const settle = (ok) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
// Cleanup must run even for superseded loads or stale listeners
|
||||
// accumulate on the two persistent elements; only the callback
|
||||
// is gated on still being the current load.
|
||||
video.removeEventListener('canplaythrough', onReady);
|
||||
video.removeEventListener('error', onError);
|
||||
if (token !== video._fbCrowdToken || gen !== _stopGen) return;
|
||||
cb(ok);
|
||||
};
|
||||
const onReady = () => settle(true);
|
||||
const onError = () => settle(false);
|
||||
video.addEventListener('canplaythrough', onReady);
|
||||
video.addEventListener('error', onError);
|
||||
video.loop = loop;
|
||||
video.src = url;
|
||||
video.play().catch(() => { /* browser retries on visibility/gesture */ });
|
||||
setTimeout(() => settle(video.readyState >= 3), CANPLAY_TIMEOUT_MS);
|
||||
}
|
||||
|
||||
function idleLayer() { return _activeLayer === 0 ? 1 : 0; }
|
||||
|
||||
// Crossfade the loop for `state` in on the idle layer.
|
||||
function showLoop(state, fadeMs) {
|
||||
if (!_manifest || !_videos[0]) return;
|
||||
const layer = idleLayer();
|
||||
const video = _videos[layer];
|
||||
_loadingLoop = state;
|
||||
loadAndPlay(video, _manifest.loops[state], true, (ok) => {
|
||||
if (_loadingLoop === state) _loadingLoop = null;
|
||||
if (!ok || !_venueActive) return;
|
||||
_fadingLoop = state;
|
||||
fadeMixTo(layer === 1 ? 1 : 0, fadeMs, () => {
|
||||
// Preempted mid-fade (stinger claimed this layer while we
|
||||
// were still ramping): the layer no longer holds this loop —
|
||||
// promoting it would pause the real loop and hand fade-back
|
||||
// the wrong target.
|
||||
if (_fadingLoop !== state) return;
|
||||
_fadingLoop = null;
|
||||
const old = _videos[_activeLayer];
|
||||
_activeLayer = layer;
|
||||
if (old && !old.paused) old.pause();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function playStinger(name) {
|
||||
if (!_manifest || !_manifest.stingers[name] || !_videos[0]) return;
|
||||
if (_stingerUntilEnded) return;
|
||||
const t = now();
|
||||
if (t - _lastStingerAt < STINGER_MIN_GAP_MS) return;
|
||||
_lastStingerAt = t;
|
||||
_stingerUntilEnded = true;
|
||||
const layer = idleLayer();
|
||||
const video = _videos[layer];
|
||||
// The stinger reuses the idle layer's element, cancelling any loop
|
||||
// load still in flight there — and idleLayer() is still the fading-in
|
||||
// layer while a crossfade runs (_activeLayer flips on completion), so
|
||||
// a mid-fade loop gets overwritten too. Requeue either for when the
|
||||
// stinger ends (the machine already advanced, nothing re-fires it).
|
||||
const interrupted = _loadingLoop || _fadingLoop;
|
||||
if (interrupted) {
|
||||
// Freeze any in-flight crossfade: its ramp would keep pushing the
|
||||
// mix toward this layer while the stinger replaces the src (loop
|
||||
// vanishing / stinger popping in at full opacity).
|
||||
cancelFade();
|
||||
_pendingLoop = interrupted;
|
||||
_loadingLoop = null;
|
||||
_fadingLoop = null;
|
||||
}
|
||||
// A loop switch deferred (or preempted) by this stinger must play
|
||||
// once the stinger is done OR failed — the machine already advanced,
|
||||
// so nothing re-triggers it later.
|
||||
const flushPending = () => {
|
||||
if (!_pendingLoop || !_venueActive) return;
|
||||
const pending = _pendingLoop;
|
||||
_pendingLoop = null;
|
||||
showLoop(pending, FADE_MS);
|
||||
};
|
||||
const myGen = ++_stingerGen;
|
||||
const back = () => {
|
||||
// Always detach: a handler left behind by a stop()/manifest swap
|
||||
// must not fire into a LATER stinger's lifecycle on this reused
|
||||
// element (the gen check below guards that; the boolean alone
|
||||
// would pass once a new stinger is active).
|
||||
video.removeEventListener('ended', back);
|
||||
if (_stingerGen !== myGen || !_stingerUntilEnded) return;
|
||||
_stingerUntilEnded = false;
|
||||
// Fade back to the loop layer (which kept playing underneath).
|
||||
fadeMixTo(_activeLayer === 1 ? 1 : 0, STINGER_FADE_MS);
|
||||
flushPending();
|
||||
};
|
||||
loadAndPlay(video, _manifest.stingers[name], false, (ok) => {
|
||||
if (!ok || !_venueActive) {
|
||||
_stingerUntilEnded = false;
|
||||
flushPending();
|
||||
return;
|
||||
}
|
||||
video.addEventListener('ended', back);
|
||||
fadeMixTo(layer === 1 ? 1 : 0, STINGER_FADE_MS);
|
||||
// Safety: an `ended` that never fires (decode stall) must not
|
||||
// freeze the crowd on a stinger frame.
|
||||
setTimeout(back, 15000);
|
||||
});
|
||||
}
|
||||
|
||||
function ensureAudio() {
|
||||
if (_audioEl || typeof document === 'undefined') return;
|
||||
_audioEl = document.createElement('audio');
|
||||
_audioEl.preload = 'auto';
|
||||
_audioEl.style.display = 'none';
|
||||
document.body.appendChild(_audioEl);
|
||||
}
|
||||
|
||||
function fadeAudioOut(durationMs) {
|
||||
if (!_audioEl || _audioEl.paused) return;
|
||||
if (_audioFadeTimer) return; // already fading
|
||||
const from = _audioEl.volume;
|
||||
const t0 = now();
|
||||
_audioFadeTimer = setInterval(() => {
|
||||
const k = Math.min(1, (now() - t0) / durationMs);
|
||||
_audioEl.volume = from * (1 - k);
|
||||
if (k >= 1) {
|
||||
clearInterval(_audioFadeTimer);
|
||||
_audioFadeTimer = 0;
|
||||
_audioEl.pause();
|
||||
}
|
||||
}, 50);
|
||||
}
|
||||
|
||||
function stopAudio() {
|
||||
if (_audioFadeTimer) { clearInterval(_audioFadeTimer); _audioFadeTimer = 0; }
|
||||
if (_audioEl && !_audioEl.paused) _audioEl.pause();
|
||||
}
|
||||
|
||||
// One-shot flyover intro on song load: video flies from the back of the
|
||||
// room onto the stage, crowd ambience plays and ducks out as the song
|
||||
// starts (song:play) or as the flyover lands, whichever comes first.
|
||||
function playIntro() {
|
||||
if (!_manifest || !_manifest.intro || !_manifest.intro.video || !_videos[0]) {
|
||||
return false;
|
||||
}
|
||||
const myGen = ++_introGen;
|
||||
_introActive = true;
|
||||
const layer = idleLayer();
|
||||
const video = _videos[layer];
|
||||
const land = () => {
|
||||
if (_introGen !== myGen || !_introActive) return;
|
||||
_introActive = false;
|
||||
video.removeEventListener('ended', land);
|
||||
fadeAudioOut(1200);
|
||||
const pending = _pendingLoop;
|
||||
_pendingLoop = null;
|
||||
showLoop(pending || machine.current, 400);
|
||||
};
|
||||
loadAndPlay(video, _manifest.intro.video, false, (ok) => {
|
||||
if (_introGen !== myGen) return;
|
||||
if (!ok || !_venueActive) {
|
||||
// Failed intro must not leave the song loop-less: fall back
|
||||
// to the normal loop exactly like the no-intro path.
|
||||
_introActive = false;
|
||||
if (_venueActive) showLoop(machine.current, FADE_MS);
|
||||
return;
|
||||
}
|
||||
fadeMixTo(layer === 1 ? 1 : 0, 300);
|
||||
video.addEventListener('ended', land);
|
||||
setTimeout(land, 15000); // decode-stall safety
|
||||
if (_manifest.intro.audio) {
|
||||
ensureAudio();
|
||||
_audioEl.src = _manifest.intro.audio;
|
||||
_audioEl.volume = 1;
|
||||
// The user's play gesture precedes song:loaded, so autoplay
|
||||
// with sound is normally allowed; degrade silently if not.
|
||||
_audioEl.play().catch(() => { /* no gesture yet */ });
|
||||
// start ducking shortly before the flyover lands
|
||||
video.addEventListener('timeupdate', function duck() {
|
||||
if (video.duration && video.duration - video.currentTime < 1.5) {
|
||||
video.removeEventListener('timeupdate', duck);
|
||||
fadeAudioOut(1400);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
let _sfxEl = null;
|
||||
|
||||
function sfxEnabled() {
|
||||
try { return localStorage.getItem(SFX_KEY) === 'on'; } catch (_) { return false; }
|
||||
}
|
||||
|
||||
// One-shot crowd reaction on committed mood transitions (toggleable):
|
||||
// up the ladder → cheer, down → boos. Committed transitions are already
|
||||
// hysteresis-limited, so this can't spam.
|
||||
function playMoodSfx(direction) {
|
||||
if (!sfxEnabled() || !_manifest || !_manifest.sfx || _introActive) return;
|
||||
const url = direction > 0 ? _manifest.sfx.up : _manifest.sfx.down;
|
||||
if (!url || typeof document === 'undefined') return;
|
||||
if (!_sfxEl) {
|
||||
_sfxEl = document.createElement('audio');
|
||||
_sfxEl.preload = 'auto';
|
||||
_sfxEl.style.display = 'none';
|
||||
document.body.appendChild(_sfxEl);
|
||||
}
|
||||
_sfxEl.src = url;
|
||||
_sfxEl.volume = 0.6;
|
||||
_sfxEl.play().catch(() => { /* pre-gesture; skip silently */ });
|
||||
}
|
||||
|
||||
function onSongPlay() {
|
||||
// Song audio starting is the hard cue: the ambience must yield.
|
||||
fadeAudioOut(1000);
|
||||
}
|
||||
|
||||
function onPerformanceState(e) {
|
||||
if (!_venueActive || !_manifest) return;
|
||||
bindVideosToRenderer();
|
||||
const d = (e && e.detail) || {};
|
||||
// Number(null) === 0: HUD reset events (accuracyPct: null) must not
|
||||
// wipe the value the end-of-song stinger reads via stats:recorded.
|
||||
if (d.accuracyPct != null && Number.isFinite(Number(d.accuracyPct))) {
|
||||
_lastAccuracyPct = Number(d.accuracyPct);
|
||||
}
|
||||
const streak = Number(d.streak) || 0;
|
||||
const sting = stingerForStreak(_prevStreak, streak);
|
||||
_prevStreak = streak;
|
||||
if (sting && !_introActive && CROWD_RANK[machine.current] >= CROWD_RANK.neutral) {
|
||||
playStinger(sting);
|
||||
}
|
||||
const prevRank = CROWD_RANK[machine.current];
|
||||
const next = machine.update(d.state, now());
|
||||
if (next) {
|
||||
playMoodSfx(CROWD_RANK[next] - prevRank);
|
||||
// A stinger or the intro owns the idle layer; defer the switch.
|
||||
if (_stingerUntilEnded || _introActive) _pendingLoop = next;
|
||||
else showLoop(next, FADE_MS);
|
||||
}
|
||||
}
|
||||
|
||||
function onSongLoaded() {
|
||||
machine.reset();
|
||||
_prevStreak = 0;
|
||||
_lastAccuracyPct = null;
|
||||
// Abort any stinger/pending state from the previous song: its ended
|
||||
// handler must not fade back into the old song's layers.
|
||||
cancelFade();
|
||||
_stingerGen++;
|
||||
_introGen++;
|
||||
_stingerUntilEnded = false;
|
||||
_introActive = false;
|
||||
stopAudio();
|
||||
_pendingLoop = null;
|
||||
_loadingLoop = null;
|
||||
_fadingLoop = null;
|
||||
if (_venueActive && _manifest) {
|
||||
if (!playIntro()) showLoop(machine.current, FADE_MS);
|
||||
}
|
||||
}
|
||||
|
||||
function onStatsRecorded() {
|
||||
if (!_venueActive || !_manifest) return;
|
||||
// stats:recorded carries only {filename, arrangement} — the accuracy
|
||||
// comes from the last v3:live-performance-state of the finished song.
|
||||
const sting = stingerForAccuracy(_lastAccuracyPct);
|
||||
_lastAccuracyPct = null; // one reaction per song
|
||||
if (sting) {
|
||||
_lastStingerAt = -Infinity; // end-of-song reaction always allowed
|
||||
playStinger(sting);
|
||||
}
|
||||
}
|
||||
|
||||
function start() {
|
||||
ensureVideos();
|
||||
if (!_videos[0]) return;
|
||||
_prevStreak = 0;
|
||||
// Boot straight into the current machine state on the active layer.
|
||||
const video = _videos[_activeLayer];
|
||||
loadAndPlay(video, _manifest.loops[machine.current], true, (ok) => {
|
||||
if (!ok || !_venueActive) return;
|
||||
setMix(_activeLayer === 1 ? 1 : 0);
|
||||
});
|
||||
}
|
||||
|
||||
function stop() {
|
||||
cancelFade();
|
||||
_stopGen++;
|
||||
_stingerGen++;
|
||||
_introGen++;
|
||||
_introActive = false;
|
||||
stopAudio();
|
||||
if (_sfxEl && !_sfxEl.paused) _sfxEl.pause();
|
||||
_stingerUntilEnded = false;
|
||||
_pendingLoop = null;
|
||||
_loadingLoop = null;
|
||||
_fadingLoop = null;
|
||||
for (const v of _videos) {
|
||||
if (v && !v.paused) v.pause();
|
||||
}
|
||||
// Unbind from the renderer: a paused video still holds its last
|
||||
// frame, and the venue style keeps a bound plane visible whenever
|
||||
// videoWidth > 0 — without this a removed pack would leave a frozen
|
||||
// crowd frame over the static plate. start() re-binds.
|
||||
const setVideo = h3d('h3dVenueBackdropSetVideo');
|
||||
if (_boundToRenderer && setVideo) {
|
||||
setVideo(0, null);
|
||||
setVideo(1, null);
|
||||
}
|
||||
_boundToRenderer = false;
|
||||
// Mix and active layer must reset together: mix 0 shows layer 0, so a
|
||||
// restart that left _activeLayer at 1 would flash layer 0's stale
|
||||
// frame until the new loop loads.
|
||||
_activeLayer = 0;
|
||||
setMix(0);
|
||||
}
|
||||
|
||||
function setVenueActive(on) {
|
||||
const next = !!on;
|
||||
if (next === _venueActive) {
|
||||
// Re-activation (e.g. viz:renderer:ready after a late plugin
|
||||
// load): don't restart the loop, but do retry renderer binding.
|
||||
if (next && _manifest) bindVideosToRenderer();
|
||||
return;
|
||||
}
|
||||
_venueActive = next;
|
||||
if (_venueActive && _manifest) start();
|
||||
else stop();
|
||||
}
|
||||
|
||||
function setManifest(m) {
|
||||
const norm = normalizeManifest(m);
|
||||
_manifest = norm;
|
||||
if (_venueActive) {
|
||||
// Full stop first even when replacing pack-for-pack: it bumps
|
||||
// _stopGen so an in-flight load from the OLD manifest can't
|
||||
// settle and fade a stale URL in after the new pack starts.
|
||||
stop();
|
||||
if (norm) start();
|
||||
}
|
||||
}
|
||||
|
||||
function readDevManifest() {
|
||||
try {
|
||||
const raw = localStorage.getItem(DEV_FLAG_KEY);
|
||||
if (!raw) return null;
|
||||
return JSON.parse(raw);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function bindRuntime() {
|
||||
if (_bound) return;
|
||||
_bound = true;
|
||||
const sm = root && root.feedBack;
|
||||
if (sm && typeof sm.on === 'function') {
|
||||
sm.on('v3:live-performance-state', onPerformanceState);
|
||||
sm.on('stats:recorded', onStatsRecorded);
|
||||
// A new song must not inherit the previous song's crowd mood
|
||||
// through the hysteresis/dwell window.
|
||||
sm.on('song:loaded', onSongLoaded);
|
||||
sm.on('song:play', onSongPlay);
|
||||
}
|
||||
const dev = readDevManifest();
|
||||
if (dev && !_manifest) setManifest(dev);
|
||||
}
|
||||
|
||||
function getState() {
|
||||
return {
|
||||
venueActive: _venueActive,
|
||||
hasManifest: !!_manifest,
|
||||
crowdState: machine.current,
|
||||
activeLayer: _activeLayer,
|
||||
mix: _mix,
|
||||
stingerActive: _stingerUntilEnded,
|
||||
introActive: _introActive,
|
||||
};
|
||||
}
|
||||
|
||||
const api = {
|
||||
CROWD_STATES,
|
||||
STABLE_MS,
|
||||
DWELL_MS,
|
||||
crowdStateOfPerf,
|
||||
createCrowdMachine,
|
||||
stingerForStreak,
|
||||
stingerForAccuracy,
|
||||
normalizeManifest,
|
||||
setManifest,
|
||||
setVenueActive,
|
||||
bindRuntime,
|
||||
getState,
|
||||
};
|
||||
|
||||
if (root) root.v3VenueCrowd = api;
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = api;
|
||||
|
||||
if (typeof document !== 'undefined') {
|
||||
// Same defer/DOMContentLoaded dance as venue-scene-3d.js.
|
||||
if (document.readyState !== 'complete') {
|
||||
document.addEventListener('DOMContentLoaded', bindRuntime);
|
||||
} else {
|
||||
bindRuntime();
|
||||
}
|
||||
}
|
||||
}(typeof window !== 'undefined' ? window : (typeof globalThis !== 'undefined' ? globalThis : null)));
|
||||
@@ -96,6 +96,7 @@
|
||||
if (_active) {
|
||||
syncInstrumentPov();
|
||||
syncVenueMotion();
|
||||
syncCrowd(true);
|
||||
return;
|
||||
}
|
||||
_active = true;
|
||||
@@ -105,6 +106,17 @@
|
||||
setH3dMood(_lastMood);
|
||||
syncInstrumentPov();
|
||||
syncVenueMotion();
|
||||
syncCrowd(true);
|
||||
}
|
||||
|
||||
function syncCrowd(on) {
|
||||
// Reactive crowd video layer (career mode) — inert without a pack.
|
||||
try {
|
||||
if (root && root.v3VenueCrowd &&
|
||||
typeof root.v3VenueCrowd.setVenueActive === 'function') {
|
||||
root.v3VenueCrowd.setVenueActive(!!on);
|
||||
}
|
||||
} catch (_) { /* visual-only */ }
|
||||
}
|
||||
|
||||
function syncVenueMotion() {
|
||||
@@ -128,6 +140,7 @@
|
||||
_assetsLoaded = false;
|
||||
_loadFailed = false;
|
||||
setH3dActive(false);
|
||||
syncCrowd(false);
|
||||
syncPlaceholderVisibility();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
"""Shared pytest fixtures for the feedBack test suite."""
|
||||
|
||||
import importlib
|
||||
import logging
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
import structlog
|
||||
@@ -76,3 +78,61 @@ def isolate_logging():
|
||||
lg.setLevel(original_level)
|
||||
lg.propagate = original_propagate
|
||||
structlog.reset_defaults()
|
||||
|
||||
|
||||
# ── Plugin-loader isolation ─────────────────────────────────────────────────────
|
||||
#
|
||||
# Lifted verbatim out of tests/test_plugins.py so more than one test module can drive
|
||||
# the real plugins.load_plugins(). It has to be ONE fixture, not a copy per file:
|
||||
# load_plugins() mutates sys.path, sys.modules, PENDING_PLUGINS and LOADED_PLUGINS, and a
|
||||
# partial restore makes the suite order- and environment-dependent (Codex [P2] on
|
||||
# test_plugin_context_contract.py — it was right).
|
||||
|
||||
# Bare module names that this test module pre-populates into
|
||||
# sys.modules to simulate the bare-import path. Saved/restored by
|
||||
# the reset_plugin_state fixture so they don't leak to other test
|
||||
# files. Codex / Copilot review on PR for feedBack#33.
|
||||
_BARE_NAMES_USED = ("util", "extractor")
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def reset_plugin_state(monkeypatch):
|
||||
"""Clear loader module-level state and restore on teardown.
|
||||
|
||||
Saves and restores:
|
||||
* `plugins.LOADED_PLUGINS`
|
||||
* any `plugin_*` keys we add to `sys.modules`
|
||||
* the bare names this module simulates (`util`, `extractor`)
|
||||
* `sys.path` — `plugins.load_plugins()` mutates it
|
||||
Also unsets `FEEDBACK_PLUGINS_DIR` for the test's duration
|
||||
(via monkeypatch) so a CI env that pre-sets it can't leak
|
||||
real user plugins into a tmp_path-driven test. Per-module
|
||||
locks are owned by the standard import system
|
||||
(`importlib._bootstrap._module_locks`) and are not our
|
||||
responsibility to reset.
|
||||
"""
|
||||
monkeypatch.delenv("FEEDBACK_PLUGINS_DIR", raising=False)
|
||||
plugins = importlib.import_module("plugins")
|
||||
saved_loaded = list(plugins.LOADED_PLUGINS)
|
||||
saved_pending = dict(plugins.PENDING_PLUGINS)
|
||||
saved_modules = {k: v for k, v in sys.modules.items() if k.startswith("plugin_")}
|
||||
saved_bare = {k: sys.modules[k] for k in _BARE_NAMES_USED if k in sys.modules}
|
||||
saved_path = list(sys.path)
|
||||
plugins.LOADED_PLUGINS.clear()
|
||||
plugins.PENDING_PLUGINS.clear()
|
||||
for k in list(sys.modules):
|
||||
if k.startswith("plugin_") or k in _BARE_NAMES_USED:
|
||||
del sys.modules[k]
|
||||
try:
|
||||
yield plugins
|
||||
finally:
|
||||
plugins.LOADED_PLUGINS.clear()
|
||||
plugins.LOADED_PLUGINS.extend(saved_loaded)
|
||||
plugins.PENDING_PLUGINS.clear()
|
||||
plugins.PENDING_PLUGINS.update(saved_pending)
|
||||
for k in list(sys.modules):
|
||||
if k.startswith("plugin_") or k in _BARE_NAMES_USED:
|
||||
del sys.modules[k]
|
||||
sys.modules.update(saved_modules)
|
||||
sys.modules.update(saved_bare)
|
||||
sys.path[:] = saved_path
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Behavioral tests for the JUCE engine-reroute watcher in static/app.js.
|
||||
// Behavioral tests for the JUCE engine-reroute watcher in static/js/juce-audio.js.
|
||||
//
|
||||
// The watcher (an IIFE, `_installJuceEngineRoutingWatcher`) migrates a loaded
|
||||
// song between the HTML5 <audio> element and the native JUCE backing transport
|
||||
@@ -14,14 +14,15 @@ const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const vm = require('node:vm');
|
||||
|
||||
const APP_JS = path.join(__dirname, '..', '..', 'static', 'app.js');
|
||||
// The JUCE audio shims were carved out of app.js into their own module (R3a).
|
||||
const APP_JS = path.join(__dirname, '..', '..', 'static', 'js', 'juce-audio.js');
|
||||
|
||||
// Brace-balanced extraction of the watcher IIFE, starting at its `(function`
|
||||
// and ending after the matching `})();`.
|
||||
function extractWatcherIIFE(src) {
|
||||
const marker = '(function _installJuceEngineRoutingWatcher() {';
|
||||
const start = src.indexOf(marker);
|
||||
assert.ok(start !== -1, 'watcher IIFE not found in app.js');
|
||||
assert.ok(start !== -1, 'watcher IIFE not found in static/js/juce-audio.js');
|
||||
const openBrace = src.indexOf('{', start);
|
||||
let depth = 1;
|
||||
let i = openBrace + 1;
|
||||
@@ -100,6 +101,17 @@ function makeSandbox({ isAudioRunning, loadBackingTrack, outputType = 'Windows A
|
||||
|
||||
const src = fs.readFileSync(APP_JS, 'utf8');
|
||||
const iife = extractWatcherIIFE(src);
|
||||
// The shims reach back into app.js through the host seam (static/js/host.js).
|
||||
// Route it at the SAME stubs this sandbox already had — a fresh `() => {}` would
|
||||
// swallow the calls and the assertions below would pass vacuously.
|
||||
sandbox.host = {
|
||||
jucePlayer: () => sandbox.jucePlayer,
|
||||
playSong: (...a) => (sandbox.playSong ? sandbox.playSong(...a) : undefined),
|
||||
_audioSeek: (...a) => (sandbox._audioSeek ? sandbox._audioSeek(...a) : Promise.resolve({ completed: true })),
|
||||
setPlayButtonState: (...a) => (sandbox.setPlayButtonState ? sandbox.setPlayButtonState(...a) : undefined),
|
||||
_songEventPayload: (...a) => (sandbox._songEventPayload ? sandbox._songEventPayload(...a) : ({})),
|
||||
showScreen: (...a) => (sandbox.showScreen ? sandbox.showScreen(...a) : undefined),
|
||||
};
|
||||
vm.createContext(sandbox);
|
||||
vm.runInContext(iife, sandbox);
|
||||
return sandbox;
|
||||
|
||||
@@ -77,6 +77,11 @@ const PLUGIN_LOADER_JS = path.join(ROOT, 'static', 'js', 'plugin-loader.js');
|
||||
// The viz layer was carved out of app.js too (R3a).
|
||||
const VIZ_JS = path.join(ROOT, 'static', 'js', 'viz.js');
|
||||
const LIBRARY_JS = path.join(ROOT, 'static', 'capabilities', 'library.js');
|
||||
// The library itself was carved out of app.js into ./static/js/library.js (R3a). Note the
|
||||
// two are DIFFERENT files: LIBRARY_JS above is the capability; this is the UI module.
|
||||
// syncLibrarySong deliberately stayed behind in app.js — it reaches showScreen/playSong,
|
||||
// and moving it would have dragged the whole playback core into the library module.
|
||||
const LIBRARY_MODULE_JS = path.join(ROOT, 'static', 'js', 'library.js');
|
||||
|
||||
function source(file) {
|
||||
// Normalize CRLF: region() slices fixed CHARACTER windows, so on a
|
||||
@@ -93,16 +98,20 @@ function region(src, needle, length = 1200) {
|
||||
|
||||
test('plugin script hydration exposes the current plugin id for legacy registrations', () => {
|
||||
const src = source(PLUGIN_LOADER_JS);
|
||||
const block = region(src, 'script.src = `/api/plugins/${plugin.id}/screen.js');
|
||||
// Anchored on the ASSIGNMENT, not the URL literal: the URL is built in
|
||||
// _pluginScriptUrl() now (#879 — a rollback needs a fresh module URL for the whole
|
||||
// import graph), so the old literal no longer appears at the injection site.
|
||||
const block = region(src, 'script.src = _pluginScriptUrl(');
|
||||
assert.match(block, /window\.feedBack\._loadingPluginId\s*=\s*plugin\.id/);
|
||||
assert.match(block, /delete\s+window\.feedBack\._loadingPluginId/);
|
||||
});
|
||||
|
||||
test('library providers route through native library capability', () => {
|
||||
const src = source(APP_JS);
|
||||
const libModule = source(LIBRARY_MODULE_JS);
|
||||
const librarySrc = source(LIBRARY_JS);
|
||||
const loader = region(src, 'async function loadLibraryProviders', 1800);
|
||||
const selector = region(src, 'async function setLibraryProvider(providerId, options = {})', 1600);
|
||||
const loader = region(libModule, 'async function loadLibraryProviders', 1800);
|
||||
const selector = region(libModule, 'async function setLibraryProvider(providerId, options = {})', 1600);
|
||||
const sync = region(src, 'async function syncLibrarySong(providerId, songId', 1600);
|
||||
|
||||
assert.match(librarySrc, /capabilities\.registerOwner\(['"]library['"]/);
|
||||
|
||||
@@ -18,7 +18,7 @@ const vm = require('node:vm');
|
||||
|
||||
const { extractFunction } = require('./test_utils');
|
||||
|
||||
const APP_JS = path.join(__dirname, '..', '..', 'static', 'app.js');
|
||||
const APP_JS = path.join(__dirname, '..', '..', 'static', 'js', 'transport.js');
|
||||
const SRC = fs.readFileSync(APP_JS, 'utf8');
|
||||
const TOGGLE_PLAY_SRC = extractFunction(SRC, 'async function togglePlay(');
|
||||
|
||||
|
||||
@@ -5,6 +5,10 @@ const path = require('node:path');
|
||||
const vm = require('node:vm');
|
||||
|
||||
const APP_JS = path.join(__dirname, '..', '..', 'static', 'app.js');
|
||||
// SPLIT. _installPlaybackTransportAdapter stayed in app.js — it reads loopA/loopB from
|
||||
// ./js/loops.js, and loops.js imports transport, so moving it would close a cycle.
|
||||
// _waitForSongReady went with the rest of the seek machinery.
|
||||
const TRANSPORT_JS = path.join(__dirname, '..', '..', 'static', 'js', 'transport.js');
|
||||
|
||||
function extractFunction(src, signature) {
|
||||
const start = src.indexOf(signature);
|
||||
@@ -54,7 +58,7 @@ function loadReadyHelper(sandbox, src) {
|
||||
}
|
||||
|
||||
test('_waitForSongReady rejects a ready event from a different audio generation', async () => {
|
||||
const src = fs.readFileSync(APP_JS, 'utf8');
|
||||
const src = fs.readFileSync(TRANSPORT_JS, 'utf8');
|
||||
const sandbox = buildReadySandbox();
|
||||
loadReadyHelper(sandbox, src);
|
||||
|
||||
@@ -72,7 +76,7 @@ test('playback adapter scopes startTime readiness and validates seek targets', (
|
||||
const src = fs.readFileSync(APP_JS, 'utf8');
|
||||
const fn = extractFunction(src, 'function _installPlaybackTransportAdapter()');
|
||||
|
||||
assert.match(fn, /const expectedSeekGen\s*=\s*_audioSeekGen\s*\+\s*1;/);
|
||||
assert.match(fn, /const expectedSeekGen\s*=\s*audioSeekGen\(\)\s*\+\s*1;/);
|
||||
assert.match(fn, /_waitForSongReady\(expectedSeekGen\)/);
|
||||
assert.match(fn, /const seconds\s*=\s*Number\(time\);/);
|
||||
assert.match(fn, /!Number\.isFinite\(seconds\)\s*\|\|\s*seconds\s*<\s*0/);
|
||||
|
||||
@@ -19,13 +19,19 @@ const path = require('node:path');
|
||||
const PLUGIN_LOADER_JS = path.join(__dirname, '..', '..', 'static', 'js', 'plugin-loader.js');
|
||||
const src = fs.readFileSync(PLUGIN_LOADER_JS, 'utf8');
|
||||
|
||||
// Isolate the screen.js <script> injection block: from where its src is built
|
||||
// to where the element is appended.
|
||||
// Isolate the screen.js <script> injection block: from where its src is assigned to
|
||||
// where the element is appended.
|
||||
//
|
||||
// Anchored on the ASSIGNMENT, not on the URL literal. The URL is built in
|
||||
// _pluginScriptUrl() now (#879 — a rollback needs a fresh module URL), so the literal
|
||||
// '/api/plugins/${plugin.id}/screen.js' appears FURTHER DOWN the file than the block
|
||||
// that uses it, and slicing from it ran off the end of the injection block entirely.
|
||||
const SRC_ASSIGN = 'script.src = _pluginScriptUrl(';
|
||||
function injectionBlock() {
|
||||
const start = src.indexOf('/api/plugins/${plugin.id}/screen.js');
|
||||
assert.ok(start !== -1, 'screen.js injection src not found — loader moved?');
|
||||
const start = src.indexOf(SRC_ASSIGN);
|
||||
assert.ok(start !== -1, 'screen.js src assignment not found — loader moved?');
|
||||
const end = src.indexOf('document.body.appendChild(script)', start);
|
||||
assert.ok(end !== -1, 'appendChild(script) not found after screen.js src');
|
||||
assert.ok(end !== -1, 'appendChild(script) not found after the src assignment');
|
||||
return src.slice(start, end);
|
||||
}
|
||||
|
||||
@@ -52,7 +58,7 @@ test('the module type is gated, never set unconditionally', () => {
|
||||
|
||||
test('the module guard sits before appendChild, after the src assignment', () => {
|
||||
const guardAt = src.indexOf('script.type = \'module\'');
|
||||
const srcAt = src.indexOf('/api/plugins/${plugin.id}/screen.js');
|
||||
const srcAt = src.indexOf(SRC_ASSIGN);
|
||||
const appendAt = src.indexOf('document.body.appendChild(script)', srcAt);
|
||||
assert.ok(guardAt > srcAt && guardAt < appendAt,
|
||||
'the module guard must live inside the screen.js injection block');
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
// #879 — a plugin ROLLBACK must actually re-evaluate a module plugin.
|
||||
//
|
||||
// ES modules are evaluated once per URL per document. Re-inserting a
|
||||
// <script type="module"> whose src the module map has already seen fires `load` but
|
||||
// does NOT re-run the body — so rolling back to a version already evaluated this
|
||||
// session left the OLD module live while the loader recorded success.
|
||||
//
|
||||
// The fix puts a generation token in the PATH (/api/plugins/x/g/7/screen.js), not the
|
||||
// query, because a relative specifier resolves against the base URL with the query
|
||||
// DROPPED — so './src/main.js' would otherwise keep resolving to the same cached URL
|
||||
// and the plugin's actual code would never re-run.
|
||||
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const vm = require('node:vm');
|
||||
|
||||
const { extractFunction } = require('./test_utils');
|
||||
const LOADER = path.join(__dirname, '..', '..', 'static', 'js', 'plugin-loader.js');
|
||||
|
||||
function makeUrlBuilder() {
|
||||
const src = fs.readFileSync(LOADER, 'utf8');
|
||||
const sandbox = { _evaluatedModules: new Set(), _moduleReloadSeq: 0 };
|
||||
vm.createContext(sandbox);
|
||||
vm.runInContext(`
|
||||
${extractFunction(src, 'function _pluginScriptUrl(')}
|
||||
globalThis.url = _pluginScriptUrl;
|
||||
`, sandbox);
|
||||
return sandbox.url;
|
||||
}
|
||||
|
||||
const MOD = { id: 'editor', script_type: 'module' };
|
||||
const CLASSIC = { id: 'legacy', script_type: 'classic' };
|
||||
|
||||
test('a module plugin first load uses the stable ?v= URL (ETag/304 stays intact)', () => {
|
||||
const url = makeUrlBuilder();
|
||||
assert.equal(url(MOD, '1.0.0', '?v=1.0.0'), '/api/plugins/editor/screen.js?v=1.0.0');
|
||||
});
|
||||
|
||||
// An UPGRADE has to bust the graph too, and this is the part #879 got wrong. It says
|
||||
// "upgrades are fine — a new version yields a new URL". True of screen.js; FALSE of the
|
||||
// plugin. Driving a real browser through install -> upgrade -> rollback and counting
|
||||
// evaluations of src/main.js gives ONE: the upgrade re-runs the one-line screen.js shim
|
||||
// at its new ?v= URL, the shim imports './src/main.js', that resolves to the SAME url,
|
||||
// and the module map hands back the already-evaluated old module. So the key here is the
|
||||
// plugin ID, not id@version — every re-load of a module plugin needs a fresh path.
|
||||
test('an UPGRADE also gets a fresh /g/<n>/ path — a new ?v= does NOT reach the graph', () => {
|
||||
const url = makeUrlBuilder();
|
||||
url(MOD, '1.0.0', '?v=1.0.0');
|
||||
assert.equal(url(MOD, '1.1.0', '?v=1.1.0'), '/api/plugins/editor/g/1/screen.js?v=1.1.0');
|
||||
});
|
||||
|
||||
test('a ROLLBACK to an already-evaluated version gets a fresh /g/<n>/ PATH', () => {
|
||||
const url = makeUrlBuilder();
|
||||
url(MOD, '1.0.0', '?v=1.0.0'); // installed
|
||||
url(MOD, '1.1.0', '?v=1.1.0'); // upgraded -> /g/1/
|
||||
const back = url(MOD, '1.0.0', '?v=1.0.0'); // rolled back -> /g/2/
|
||||
assert.equal(back, '/api/plugins/editor/g/2/screen.js?v=1.0.0');
|
||||
|
||||
// The token must be in the PATH so a relative import INHERITS it — the whole point.
|
||||
// A query token is dropped by URL resolution and never reaches src/main.js.
|
||||
const resolved = new URL('./src/main.js', `http://h${back}`).pathname;
|
||||
assert.equal(resolved, '/api/plugins/editor/g/2/src/main.js',
|
||||
'the token must reach the module GRAPH, not just the entry point');
|
||||
});
|
||||
|
||||
test('every re-load gets a distinct URL (no reuse across a bounce)', () => {
|
||||
const url = makeUrlBuilder();
|
||||
url(MOD, '1.0.0', '?v=1.0.0');
|
||||
const seen = new Set();
|
||||
for (const v of ['1.1.0', '1.0.0', '1.1.0', '1.0.0']) seen.add(url(MOD, v, `?v=${v}`));
|
||||
assert.equal(seen.size, 4, 'each re-load must be a URL the module map has never seen');
|
||||
});
|
||||
|
||||
test('classic-script plugins are untouched — they always re-run on re-insert', () => {
|
||||
const url = makeUrlBuilder();
|
||||
const first = url(CLASSIC, '1.0.0', '?v=1.0.0');
|
||||
url(CLASSIC, '1.1.0', '?v=1.1.0');
|
||||
const back = url(CLASSIC, '1.0.0', '?v=1.0.0');
|
||||
assert.equal(first, '/api/plugins/legacy/screen.js?v=1.0.0');
|
||||
assert.equal(back, first, 'a classic script needs no cache-busting and must not get a /g/ path');
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
// Behavioral tests for the renderer-audio bus feeder in static/app.js.
|
||||
// Behavioral tests for the renderer-audio bus feeder in static/js/juce-audio.js.
|
||||
//
|
||||
// The feeder (an IIFE, `_installRendererBusFeeder`) captures renderer-side
|
||||
// song audio (stems-plugin WebAudio master, or the core <audio> element) and
|
||||
@@ -16,12 +16,13 @@ const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const vm = require('node:vm');
|
||||
|
||||
const APP_JS = path.join(__dirname, '..', '..', 'static', 'app.js');
|
||||
// The JUCE audio shims were carved out of app.js into their own module (R3a).
|
||||
const APP_JS = path.join(__dirname, '..', '..', 'static', 'js', 'juce-audio.js');
|
||||
|
||||
function extractFeederIIFE(src) {
|
||||
const marker = '(function _installRendererBusFeeder() {';
|
||||
const start = src.indexOf(marker);
|
||||
assert.ok(start !== -1, 'feeder IIFE not found in app.js');
|
||||
assert.ok(start !== -1, 'feeder IIFE not found in static/js/juce-audio.js');
|
||||
const openBrace = src.indexOf('{', start);
|
||||
let depth = 1;
|
||||
let i = openBrace + 1;
|
||||
@@ -123,6 +124,17 @@ function makeSandbox({ isAudioRunning = () => true, exclusive = () => true, disp
|
||||
sandbox.globalThis = sandbox;
|
||||
|
||||
const src = fs.readFileSync(APP_JS, 'utf8');
|
||||
// The shims reach back into app.js through the host seam (static/js/host.js).
|
||||
// Route it at the SAME stubs this sandbox already had — a fresh `() => {}` would
|
||||
// swallow the calls and the assertions below would pass vacuously.
|
||||
sandbox.host = {
|
||||
jucePlayer: () => sandbox.jucePlayer,
|
||||
playSong: (...a) => (sandbox.playSong ? sandbox.playSong(...a) : undefined),
|
||||
_audioSeek: (...a) => (sandbox._audioSeek ? sandbox._audioSeek(...a) : Promise.resolve({ completed: true })),
|
||||
setPlayButtonState: (...a) => (sandbox.setPlayButtonState ? sandbox.setPlayButtonState(...a) : undefined),
|
||||
_songEventPayload: (...a) => (sandbox._songEventPayload ? sandbox._songEventPayload(...a) : ({})),
|
||||
showScreen: (...a) => (sandbox.showScreen ? sandbox.showScreen(...a) : undefined),
|
||||
};
|
||||
vm.createContext(sandbox);
|
||||
vm.runInContext(extractFeederIIFE(src), sandbox);
|
||||
assert.equal(typeof sandbox.window._reevaluateRendererBus, 'function',
|
||||
|
||||
@@ -12,7 +12,7 @@ const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const vm = require('node:vm');
|
||||
|
||||
const APP_JS = path.join(__dirname, '..', '..', 'static', 'app.js');
|
||||
const APP_JS = path.join(__dirname, '..', '..', 'static', 'js', 'transport.js');
|
||||
|
||||
function extractFunction(src, signature) {
|
||||
const start = src.indexOf(signature);
|
||||
@@ -129,11 +129,24 @@ test('every song:play/pause/ended emit uses _songEventPayload', () => {
|
||||
);
|
||||
});
|
||||
|
||||
// CENSUS over the WHOLE frontend, not one file. This test counts call/emit sites, and the
|
||||
// carve keeps moving them between app.js and static/js/*.js — point it at a single file
|
||||
// and the count silently shrinks as code leaves, which reads as "someone deleted an emit"
|
||||
// (or, worse, passes while genuinely missing sites). Read every source that can hold one.
|
||||
function allFrontendSources() {
|
||||
const jsDir = path.join(__dirname, '..', '..', 'static', 'js');
|
||||
const parts = [fs.readFileSync(path.join(__dirname, '..', '..', 'static', 'app.js'), 'utf8')];
|
||||
for (const f of fs.readdirSync(jsDir).sort()) {
|
||||
if (f.endsWith('.js')) parts.push(fs.readFileSync(path.join(jsDir, f), 'utf8'));
|
||||
}
|
||||
return parts.join('\n');
|
||||
}
|
||||
|
||||
test('there are at least 8 song:* emit sites threaded through the helper', () => {
|
||||
// Sanity-check that the helper actually got wired everywhere. If the
|
||||
// count drops, someone removed an emit (regression) or refactored an
|
||||
// event away (intentional — this test then needs updating).
|
||||
const src = fs.readFileSync(APP_JS, 'utf8');
|
||||
const src = allFrontendSources();
|
||||
const matches = src.match(/(?:window\.feedBack|\w+)\.emit\(\s*['"]song:(play|pause|ended)['"][^)]*\)/g) || [];
|
||||
assert.ok(
|
||||
matches.length >= 8,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Verify static/app.js emits `song:seek` for every audio repositioning,
|
||||
// Verify static/js/transport.js emits `song:seek` for every audio repositioning,
|
||||
// with `{ from, to, reason }` payload. Plugins (notedetect detection-
|
||||
// suppression during seek transients) consume this contract.
|
||||
//
|
||||
@@ -11,7 +11,7 @@ const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const vm = require('node:vm');
|
||||
|
||||
const APP_JS = path.join(__dirname, '..', '..', 'static', 'app.js');
|
||||
const APP_JS = path.join(__dirname, '..', '..', 'static', 'js', 'transport.js');
|
||||
|
||||
function extractFunction(src, signature) {
|
||||
const start = src.indexOf(signature);
|
||||
@@ -287,13 +287,26 @@ test('seekBy floors at zero (does not seek to negative time)', async () => {
|
||||
assert.equal(seek.detail.to, 0);
|
||||
});
|
||||
|
||||
// CENSUS over the WHOLE frontend, not one file. This test counts call/emit sites, and the
|
||||
// carve keeps moving them between app.js and static/js/*.js — point it at a single file
|
||||
// and the count silently shrinks as code leaves, which reads as "someone deleted an emit"
|
||||
// (or, worse, passes while genuinely missing sites). Read every source that can hold one.
|
||||
function allFrontendSources() {
|
||||
const jsDir = path.join(__dirname, '..', '..', 'static', 'js');
|
||||
const parts = [fs.readFileSync(path.join(__dirname, '..', '..', 'static', 'app.js'), 'utf8')];
|
||||
for (const f of fs.readdirSync(jsDir).sort()) {
|
||||
if (f.endsWith('.js')) parts.push(fs.readFileSync(path.join(jsDir, f), 'utf8'));
|
||||
}
|
||||
return parts.join('\n');
|
||||
}
|
||||
|
||||
test('every documented seek callsite passes a reason', () => {
|
||||
// Source-order assertion: every _audioSeek call outside the
|
||||
// implementation must pass a kebab-case reason string. Catches a
|
||||
// future contributor adding a new seek path without threading the
|
||||
// reason. Line-based — regex argument capture can't balance parens
|
||||
// through Math.max/_audioTime calls.
|
||||
const src = fs.readFileSync(APP_JS, 'utf8');
|
||||
const src = allFrontendSources();
|
||||
const fnSrc = extractFunction(src, 'async function _audioSeek(');
|
||||
const withoutImpl = src.replace(fnSrc, '');
|
||||
const callLines = withoutImpl.split('\n').filter((l) => /_audioSeek\(/.test(l));
|
||||
|
||||
@@ -16,7 +16,11 @@ const path = require('node:path');
|
||||
|
||||
const root = path.join(__dirname, '..', '..');
|
||||
const SONGS = fs.readFileSync(path.join(root, 'static', 'v3', 'songs.js'), 'utf8');
|
||||
const APP = fs.readFileSync(path.join(root, 'static', 'app.js'), 'utf8');
|
||||
// The rescan path moved into ./static/js/library.js with the rest of the library (R3a).
|
||||
// Read BOTH: this asserts the emit exists SOMEWHERE in the app, and pinning it to one file
|
||||
// just means the test starts lying the next time the code moves.
|
||||
const APP = fs.readFileSync(path.join(root, 'static', 'app.js'), 'utf8')
|
||||
+ '\n' + fs.readFileSync(path.join(root, 'static', 'js', 'library.js'), 'utf8');
|
||||
|
||||
test('app.js emits library:changed when a Settings rescan completes', () => {
|
||||
assert.match(APP, /emit\(\s*['"]library:changed['"]/,
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
'use strict';
|
||||
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const crowd = require('../../static/v3/venue-crowd.js');
|
||||
|
||||
const H3D_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'screen.js');
|
||||
const INDEX_HTML = path.join(__dirname, '..', '..', 'static', 'v3', 'index.html');
|
||||
const SCENE_JS = path.join(__dirname, '..', '..', 'static', 'v3', 'venue-scene-3d.js');
|
||||
|
||||
test('perf state → crowd state mapping', () => {
|
||||
assert.equal(crowd.crowdStateOfPerf('smoke'), 'bored');
|
||||
assert.equal(crowd.crowdStateOfPerf('recovery'), 'bored');
|
||||
assert.equal(crowd.crowdStateOfPerf('idle'), 'neutral');
|
||||
assert.equal(crowd.crowdStateOfPerf('steady'), 'neutral');
|
||||
assert.equal(crowd.crowdStateOfPerf('strong'), 'engaged');
|
||||
assert.equal(crowd.crowdStateOfPerf('fire'), 'ecstatic');
|
||||
assert.equal(crowd.crowdStateOfPerf('FIRE'), 'ecstatic');
|
||||
assert.equal(crowd.crowdStateOfPerf('bogus'), 'neutral');
|
||||
assert.equal(crowd.crowdStateOfPerf(undefined), 'neutral');
|
||||
});
|
||||
|
||||
test('machine: target must be stable for STABLE_MS before committing', () => {
|
||||
const m = crowd.createCrowdMachine();
|
||||
assert.equal(m.current, 'neutral');
|
||||
assert.equal(m.update('fire', 0), null);
|
||||
assert.equal(m.update('fire', crowd.STABLE_MS - 1), null);
|
||||
assert.equal(m.update('fire', crowd.STABLE_MS), 'ecstatic');
|
||||
assert.equal(m.current, 'ecstatic');
|
||||
});
|
||||
|
||||
test('machine: flapping target restarts the stability window', () => {
|
||||
const m = crowd.createCrowdMachine();
|
||||
m.update('fire', 0);
|
||||
// Target changes → candidate resets.
|
||||
m.update('strong', 1000);
|
||||
assert.equal(m.update('strong', 1000 + crowd.STABLE_MS - 1), null);
|
||||
assert.equal(m.update('strong', 1000 + crowd.STABLE_MS), 'engaged');
|
||||
});
|
||||
|
||||
test('machine: returning to current state clears the candidate', () => {
|
||||
const m = crowd.createCrowdMachine();
|
||||
m.update('fire', 0);
|
||||
m.update('steady', 1000); // back to neutral (current) — candidate dropped
|
||||
// 'fire' again must wait a full stability window from scratch.
|
||||
assert.equal(m.update('fire', 2000), null);
|
||||
assert.equal(m.update('fire', 2000 + crowd.STABLE_MS - 1), null);
|
||||
assert.equal(m.update('fire', 2000 + crowd.STABLE_MS), 'ecstatic');
|
||||
});
|
||||
|
||||
test('machine: DWELL_MS enforced between switches', () => {
|
||||
const m = crowd.createCrowdMachine();
|
||||
m.update('fire', 0);
|
||||
assert.equal(m.update('fire', crowd.STABLE_MS), 'ecstatic'); // switch at t=3000
|
||||
const t = crowd.STABLE_MS;
|
||||
// Immediately drop to smoke: stable window passes but dwell hasn't.
|
||||
m.update('smoke', t + 1);
|
||||
assert.equal(m.update('smoke', t + 1 + crowd.STABLE_MS), null);
|
||||
// After the dwell expires the pending candidate commits.
|
||||
assert.equal(m.update('smoke', t + crowd.DWELL_MS), 'bored');
|
||||
});
|
||||
|
||||
test('machine: multi-step jumps allowed (bored → ecstatic)', () => {
|
||||
const m = crowd.createCrowdMachine();
|
||||
m.update('smoke', 0);
|
||||
assert.equal(m.update('smoke', crowd.STABLE_MS), 'bored');
|
||||
const t = crowd.DWELL_MS + 1000;
|
||||
m.update('fire', t);
|
||||
assert.equal(m.update('fire', t + crowd.STABLE_MS), 'ecstatic');
|
||||
});
|
||||
|
||||
test('stingerForStreak fires on rising milestone crossings only', () => {
|
||||
assert.equal(crowd.stingerForStreak(24, 25), 'cheer');
|
||||
assert.equal(crowd.stingerForStreak(0, 100), 'cheer');
|
||||
assert.equal(crowd.stingerForStreak(25, 26), null);
|
||||
assert.equal(crowd.stingerForStreak(50, 50), null);
|
||||
assert.equal(crowd.stingerForStreak(30, 0), null); // streak reset
|
||||
});
|
||||
|
||||
test('stingerForAccuracy thresholds', () => {
|
||||
assert.equal(crowd.stingerForAccuracy(95), 'cheer');
|
||||
assert.equal(crowd.stingerForAccuracy(90), 'cheer');
|
||||
assert.equal(crowd.stingerForAccuracy(80), 'clap');
|
||||
assert.equal(crowd.stingerForAccuracy(75), 'clap');
|
||||
assert.equal(crowd.stingerForAccuracy(60), null);
|
||||
assert.equal(crowd.stingerForAccuracy('nope'), null);
|
||||
assert.equal(crowd.stingerForAccuracy(undefined), null);
|
||||
});
|
||||
|
||||
test('normalizeManifest requires all four loops, resolves base', () => {
|
||||
assert.equal(crowd.normalizeManifest(null), null);
|
||||
assert.equal(crowd.normalizeManifest({}), null);
|
||||
assert.equal(crowd.normalizeManifest({ loops: { bored: 'b.mp4' } }), null);
|
||||
const m = crowd.normalizeManifest({
|
||||
base: '/api/plugins/career/venues/bar/',
|
||||
loops: { bored: 'bored.mp4', neutral: 'neutral.mp4', engaged: 'engaged.mp4', ecstatic: 'ecstatic.mp4' },
|
||||
stingers: { cheer: 'cheer.mp4' },
|
||||
});
|
||||
assert.equal(m.loops.ecstatic, '/api/plugins/career/venues/bar/ecstatic.mp4');
|
||||
assert.equal(m.stingers.cheer, '/api/plugins/career/venues/bar/cheer.mp4');
|
||||
assert.equal(m.stingers.clap, '');
|
||||
});
|
||||
|
||||
test('highway_3d exposes the crowd backdrop globals', () => {
|
||||
const src = fs.readFileSync(H3D_JS, 'utf8');
|
||||
assert.match(src, /window\.h3dVenueBackdropSetVideo\s*=/);
|
||||
assert.match(src, /window\.h3dVenueBackdropSetMix\s*=/);
|
||||
// The venue style must own crowd plane teardown (VideoTexture dispose).
|
||||
assert.match(src, /_venueCrowdVideos/);
|
||||
assert.match(src, /_venueCrowdMix/);
|
||||
});
|
||||
|
||||
test('index.html loads venue-crowd.js deferred, after venue-scene-3d.js', () => {
|
||||
const html = fs.readFileSync(INDEX_HTML, 'utf8');
|
||||
const crowdIdx = html.indexOf('/static/v3/venue-crowd.js');
|
||||
const sceneIdx = html.indexOf('/static/v3/venue-scene-3d.js');
|
||||
assert.ok(crowdIdx > 0, 'venue-crowd.js script tag missing');
|
||||
assert.ok(crowdIdx > sceneIdx, 'venue-crowd.js must load after venue-scene-3d.js');
|
||||
assert.match(html, /<script defer src="\/static\/v3\/venue-crowd\.js"><\/script>/);
|
||||
});
|
||||
|
||||
test('venue-scene-3d activates/deactivates the crowd layer', () => {
|
||||
const src = fs.readFileSync(SCENE_JS, 'utf8');
|
||||
assert.match(src, /syncCrowd\(true\)/);
|
||||
assert.match(src, /syncCrowd\(false\)/);
|
||||
assert.match(src, /v3VenueCrowd/);
|
||||
});
|
||||
@@ -7,6 +7,7 @@ import os
|
||||
import sys
|
||||
import time
|
||||
|
||||
import builtin_content
|
||||
import pytest
|
||||
|
||||
|
||||
@@ -23,13 +24,13 @@ def test_seed_creates_builtin_diagnostic_sloppak(tmp_path, server_mod):
|
||||
"""First seed copies the bundled sloppak into diagnostics-builtin/."""
|
||||
dlc = tmp_path / "dlc"
|
||||
dlc.mkdir()
|
||||
source = server_mod._feedBack_server_root() / server_mod._BUILTIN_DIAGNOSTIC_SOURCES[0][1]
|
||||
source = server_mod._feedBack_server_root() / builtin_content.BUILTIN_DIAGNOSTIC_SOURCES[0][1]
|
||||
if not source.is_file():
|
||||
pytest.skip(f"source sloppak not present in checkout: {source}")
|
||||
|
||||
server_mod._seed_builtin_diagnostic_sloppaks(dlc)
|
||||
builtin_content.seed_builtin_diagnostic_sloppaks(server_mod._feedBack_server_root(), dlc)
|
||||
|
||||
dest = dlc / server_mod._BUILTIN_DIAGNOSTIC_SUBDIR / server_mod._BUILTIN_DIAGNOSTIC_SOURCES[0][0]
|
||||
dest = dlc / builtin_content.BUILTIN_DIAGNOSTIC_SUBDIR / builtin_content.BUILTIN_DIAGNOSTIC_SOURCES[0][0]
|
||||
assert dest.is_file()
|
||||
assert dest.stat().st_size == source.stat().st_size
|
||||
|
||||
@@ -38,16 +39,16 @@ def test_seed_is_idempotent_when_destination_exists(tmp_path, server_mod):
|
||||
"""Second seed leaves an up-to-date destination unchanged."""
|
||||
dlc = tmp_path / "dlc"
|
||||
dlc.mkdir()
|
||||
source = server_mod._feedBack_server_root() / server_mod._BUILTIN_DIAGNOSTIC_SOURCES[0][1]
|
||||
source = server_mod._feedBack_server_root() / builtin_content.BUILTIN_DIAGNOSTIC_SOURCES[0][1]
|
||||
if not source.is_file():
|
||||
pytest.skip(f"source sloppak not present in checkout: {source}")
|
||||
|
||||
server_mod._seed_builtin_diagnostic_sloppaks(dlc)
|
||||
dest = dlc / server_mod._BUILTIN_DIAGNOSTIC_SUBDIR / server_mod._BUILTIN_DIAGNOSTIC_SOURCES[0][0]
|
||||
builtin_content.seed_builtin_diagnostic_sloppaks(server_mod._feedBack_server_root(), dlc)
|
||||
dest = dlc / builtin_content.BUILTIN_DIAGNOSTIC_SUBDIR / builtin_content.BUILTIN_DIAGNOSTIC_SOURCES[0][0]
|
||||
first_mtime = dest.stat().st_mtime_ns
|
||||
first_size = dest.stat().st_size
|
||||
|
||||
server_mod._seed_builtin_diagnostic_sloppaks(dlc)
|
||||
builtin_content.seed_builtin_diagnostic_sloppaks(server_mod._feedBack_server_root(), dlc)
|
||||
|
||||
assert dest.stat().st_mtime_ns == first_mtime
|
||||
assert dest.stat().st_size == first_size
|
||||
@@ -57,18 +58,18 @@ def test_seed_skips_when_destination_is_newer(tmp_path, server_mod):
|
||||
"""An existing newer destination is not overwritten."""
|
||||
dlc = tmp_path / "dlc"
|
||||
dlc.mkdir()
|
||||
source = server_mod._feedBack_server_root() / server_mod._BUILTIN_DIAGNOSTIC_SOURCES[0][1]
|
||||
source = server_mod._feedBack_server_root() / builtin_content.BUILTIN_DIAGNOSTIC_SOURCES[0][1]
|
||||
if not source.is_file():
|
||||
pytest.skip(f"source sloppak not present in checkout: {source}")
|
||||
dest_dir = dlc / server_mod._BUILTIN_DIAGNOSTIC_SUBDIR
|
||||
dest_dir = dlc / builtin_content.BUILTIN_DIAGNOSTIC_SUBDIR
|
||||
dest_dir.mkdir(parents=True)
|
||||
dest_name = server_mod._BUILTIN_DIAGNOSTIC_SOURCES[0][0]
|
||||
dest_name = builtin_content.BUILTIN_DIAGNOSTIC_SOURCES[0][0]
|
||||
dest = dest_dir / dest_name
|
||||
dest.write_bytes(b"user-owned diagnostic copy")
|
||||
future = time.time() + 3600
|
||||
os.utime(dest, (future, future))
|
||||
|
||||
server_mod._seed_builtin_diagnostic_sloppaks(dlc)
|
||||
builtin_content.seed_builtin_diagnostic_sloppaks(server_mod._feedBack_server_root(), dlc)
|
||||
|
||||
assert dest.read_bytes() == b"user-owned diagnostic copy"
|
||||
|
||||
@@ -77,18 +78,18 @@ def test_seed_refuses_to_follow_symlink_destination(tmp_path, server_mod):
|
||||
"""A symlink at the destination is skipped, not written through."""
|
||||
dlc = tmp_path / "dlc"
|
||||
dlc.mkdir()
|
||||
source = server_mod._feedBack_server_root() / server_mod._BUILTIN_DIAGNOSTIC_SOURCES[0][1]
|
||||
source = server_mod._feedBack_server_root() / builtin_content.BUILTIN_DIAGNOSTIC_SOURCES[0][1]
|
||||
if not source.is_file():
|
||||
pytest.skip(f"source sloppak not present in checkout: {source}")
|
||||
|
||||
outside = tmp_path / "outside.txt"
|
||||
outside.write_bytes(b"do not overwrite me")
|
||||
dest_dir = dlc / server_mod._BUILTIN_DIAGNOSTIC_SUBDIR
|
||||
dest_dir = dlc / builtin_content.BUILTIN_DIAGNOSTIC_SUBDIR
|
||||
dest_dir.mkdir(parents=True)
|
||||
dest = dest_dir / server_mod._BUILTIN_DIAGNOSTIC_SOURCES[0][0]
|
||||
dest = dest_dir / builtin_content.BUILTIN_DIAGNOSTIC_SOURCES[0][0]
|
||||
dest.symlink_to(outside)
|
||||
|
||||
server_mod._seed_builtin_diagnostic_sloppaks(dlc)
|
||||
builtin_content.seed_builtin_diagnostic_sloppaks(server_mod._feedBack_server_root(), dlc)
|
||||
|
||||
# The symlink target must be untouched and the link left as-is.
|
||||
assert outside.read_bytes() == b"do not overwrite me"
|
||||
@@ -101,11 +102,11 @@ def test_seed_refuses_symlinked_seed_directory(tmp_path, server_mod):
|
||||
dlc.mkdir()
|
||||
outside_dir = tmp_path / "outside_dir"
|
||||
outside_dir.mkdir()
|
||||
(dlc / server_mod._BUILTIN_DIAGNOSTIC_SUBDIR).symlink_to(
|
||||
(dlc / builtin_content.BUILTIN_DIAGNOSTIC_SUBDIR).symlink_to(
|
||||
outside_dir, target_is_directory=True
|
||||
)
|
||||
|
||||
server_mod._seed_builtin_diagnostic_sloppaks(dlc)
|
||||
builtin_content.seed_builtin_diagnostic_sloppaks(server_mod._feedBack_server_root(), dlc)
|
||||
|
||||
# Nothing was written through the directory symlink into the link target.
|
||||
assert list(outside_dir.iterdir()) == []
|
||||
@@ -116,11 +117,11 @@ def test_seed_missing_source_does_not_crash(tmp_path, server_mod, monkeypatch):
|
||||
dlc = tmp_path / "dlc"
|
||||
dlc.mkdir()
|
||||
monkeypatch.setattr(
|
||||
server_mod,
|
||||
"_BUILTIN_DIAGNOSTIC_SOURCES",
|
||||
builtin_content,
|
||||
"BUILTIN_DIAGNOSTIC_SOURCES",
|
||||
[("missing.sloppak", "docs/diagnostics/does-not-exist.sloppak")],
|
||||
)
|
||||
|
||||
server_mod._seed_builtin_diagnostic_sloppaks(dlc)
|
||||
builtin_content.seed_builtin_diagnostic_sloppaks(server_mod._feedBack_server_root(), dlc)
|
||||
|
||||
assert not (dlc / server_mod._BUILTIN_DIAGNOSTIC_SUBDIR / "missing.sloppak").exists()
|
||||
assert not (dlc / builtin_content.BUILTIN_DIAGNOSTIC_SUBDIR / "missing.sloppak").exists()
|
||||
|
||||
@@ -5,6 +5,7 @@ from __future__ import annotations
|
||||
import importlib
|
||||
import sys
|
||||
|
||||
import builtin_content
|
||||
import pytest
|
||||
|
||||
|
||||
@@ -21,15 +22,15 @@ def server_mod(tmp_path, monkeypatch, isolate_logging):
|
||||
def _source(server_mod):
|
||||
return (
|
||||
server_mod._feedBack_server_root()
|
||||
/ server_mod._BUILTIN_STARTER_SOURCES[0][1]
|
||||
/ builtin_content.BUILTIN_STARTER_SOURCES[0][1]
|
||||
)
|
||||
|
||||
|
||||
def _dest(server_mod, dlc):
|
||||
return (
|
||||
dlc
|
||||
/ server_mod._BUILTIN_STARTER_SUBDIR
|
||||
/ server_mod._BUILTIN_STARTER_SOURCES[0][0]
|
||||
/ builtin_content.BUILTIN_STARTER_SUBDIR
|
||||
/ builtin_content.BUILTIN_STARTER_SOURCES[0][0]
|
||||
)
|
||||
|
||||
|
||||
@@ -41,12 +42,12 @@ def test_seed_creates_starter_content_and_marker(tmp_path, server_mod):
|
||||
if not source.is_file():
|
||||
pytest.skip(f"starter source not present in checkout: {source}")
|
||||
|
||||
server_mod._seed_builtin_starter_content(dlc)
|
||||
builtin_content.seed_builtin_starter_content(server_mod._feedBack_server_root(), dlc)
|
||||
|
||||
dest = _dest(server_mod, dlc)
|
||||
assert dest.is_file()
|
||||
assert dest.stat().st_size == source.stat().st_size
|
||||
assert (server_mod.CONFIG_DIR / server_mod._STARTER_SEED_MARKER).is_file()
|
||||
assert (server_mod.CONFIG_DIR / builtin_content.STARTER_SEED_MARKER).is_file()
|
||||
|
||||
|
||||
def test_seed_preserves_source_mtime(tmp_path, server_mod):
|
||||
@@ -58,7 +59,7 @@ def test_seed_preserves_source_mtime(tmp_path, server_mod):
|
||||
if not source.is_file():
|
||||
pytest.skip(f"starter source not present in checkout: {source}")
|
||||
|
||||
server_mod._seed_builtin_starter_content(dlc)
|
||||
builtin_content.seed_builtin_starter_content(server_mod._feedBack_server_root(), dlc)
|
||||
|
||||
assert _dest(server_mod, dlc).stat().st_mtime_ns == source.stat().st_mtime_ns
|
||||
|
||||
@@ -78,7 +79,7 @@ def test_seed_runs_only_once_and_respects_deletion(tmp_path, server_mod):
|
||||
if not source.is_file():
|
||||
pytest.skip(f"starter source not present in checkout: {source}")
|
||||
|
||||
server_mod._seed_builtin_starter_content(dlc)
|
||||
builtin_content.seed_builtin_starter_content(server_mod._feedBack_server_root(), dlc)
|
||||
dest = _dest(server_mod, dlc)
|
||||
assert dest.is_file()
|
||||
|
||||
@@ -86,7 +87,7 @@ def test_seed_runs_only_once_and_respects_deletion(tmp_path, server_mod):
|
||||
dest.unlink()
|
||||
|
||||
# A subsequent launch must not re-seed it.
|
||||
server_mod._seed_builtin_starter_content(dlc)
|
||||
builtin_content.seed_builtin_starter_content(server_mod._feedBack_server_root(), dlc)
|
||||
assert not dest.exists()
|
||||
|
||||
|
||||
@@ -98,15 +99,15 @@ def test_seed_deferred_until_dlc_configured(tmp_path, server_mod):
|
||||
pytest.skip(f"starter source not present in checkout: {source}")
|
||||
|
||||
# dlc is None and DLC_DIR unset -> _get_dlc_dir() returns None.
|
||||
server_mod._seed_builtin_starter_content(None)
|
||||
assert not (server_mod.CONFIG_DIR / server_mod._STARTER_SEED_MARKER).exists()
|
||||
builtin_content.seed_builtin_starter_content(server_mod._feedBack_server_root(), None)
|
||||
assert not (server_mod.CONFIG_DIR / builtin_content.STARTER_SEED_MARKER).exists()
|
||||
|
||||
# Now a DLC is configured: the deferred seed runs.
|
||||
dlc = tmp_path / "dlc"
|
||||
dlc.mkdir()
|
||||
server_mod._seed_builtin_starter_content(dlc)
|
||||
builtin_content.seed_builtin_starter_content(server_mod._feedBack_server_root(), dlc)
|
||||
assert _dest(server_mod, dlc).is_file()
|
||||
assert (server_mod.CONFIG_DIR / server_mod._STARTER_SEED_MARKER).is_file()
|
||||
assert (server_mod.CONFIG_DIR / builtin_content.STARTER_SEED_MARKER).is_file()
|
||||
|
||||
|
||||
def test_seed_refuses_symlinked_seed_directory(tmp_path, server_mod):
|
||||
@@ -119,13 +120,13 @@ def test_seed_refuses_symlinked_seed_directory(tmp_path, server_mod):
|
||||
|
||||
outside_dir = tmp_path / "outside"
|
||||
outside_dir.mkdir()
|
||||
(dlc / server_mod._BUILTIN_STARTER_SUBDIR).symlink_to(outside_dir)
|
||||
(dlc / builtin_content.BUILTIN_STARTER_SUBDIR).symlink_to(outside_dir)
|
||||
|
||||
server_mod._seed_builtin_starter_content(dlc)
|
||||
builtin_content.seed_builtin_starter_content(server_mod._feedBack_server_root(), dlc)
|
||||
|
||||
assert list(outside_dir.iterdir()) == []
|
||||
# An incomplete seed must NOT write the marker, so a later launch retries.
|
||||
assert not (server_mod.CONFIG_DIR / server_mod._STARTER_SEED_MARKER).exists()
|
||||
assert not (server_mod.CONFIG_DIR / builtin_content.STARTER_SEED_MARKER).exists()
|
||||
|
||||
|
||||
def test_seed_never_overwrites_an_existing_user_file(tmp_path, server_mod):
|
||||
@@ -140,11 +141,11 @@ def test_seed_never_overwrites_an_existing_user_file(tmp_path, server_mod):
|
||||
dest.write_bytes(b"user's own edited pack")
|
||||
_os.utime(dest, (1_000_000, 1_000_000)) # far older than the bundled source
|
||||
|
||||
server_mod._seed_builtin_starter_content(dlc)
|
||||
builtin_content.seed_builtin_starter_content(server_mod._feedBack_server_root(), dlc)
|
||||
|
||||
assert dest.read_bytes() == b"user's own edited pack" # untouched
|
||||
# counted as already-present, so the one-time seed considers itself done
|
||||
assert (server_mod.CONFIG_DIR / server_mod._STARTER_SEED_MARKER).is_file()
|
||||
assert (server_mod.CONFIG_DIR / builtin_content.STARTER_SEED_MARKER).is_file()
|
||||
|
||||
|
||||
def test_seed_does_not_mark_when_destination_is_a_directory(tmp_path, server_mod):
|
||||
@@ -160,10 +161,10 @@ def test_seed_does_not_mark_when_destination_is_a_directory(tmp_path, server_mod
|
||||
bogus.parent.mkdir(parents=True, exist_ok=True)
|
||||
bogus.mkdir() # user (or junk) placed a directory where the pack goes
|
||||
|
||||
server_mod._seed_builtin_starter_content(dlc)
|
||||
builtin_content.seed_builtin_starter_content(server_mod._feedBack_server_root(), dlc)
|
||||
|
||||
assert bogus.is_dir() # untouched
|
||||
assert not (server_mod.CONFIG_DIR / server_mod._STARTER_SEED_MARKER).exists()
|
||||
assert not (server_mod.CONFIG_DIR / builtin_content.STARTER_SEED_MARKER).exists()
|
||||
|
||||
|
||||
def test_seed_does_not_mark_when_source_missing(tmp_path, server_mod, monkeypatch):
|
||||
@@ -172,15 +173,15 @@ def test_seed_does_not_mark_when_source_missing(tmp_path, server_mod, monkeypatc
|
||||
dlc = tmp_path / "dlc"
|
||||
dlc.mkdir()
|
||||
monkeypatch.setattr(
|
||||
server_mod,
|
||||
"_BUILTIN_STARTER_SOURCES",
|
||||
builtin_content,
|
||||
"BUILTIN_STARTER_SOURCES",
|
||||
[("missing.feedpak", "content/starter/does-not-exist.feedpak")],
|
||||
)
|
||||
|
||||
server_mod._seed_builtin_starter_content(dlc)
|
||||
builtin_content.seed_builtin_starter_content(server_mod._feedBack_server_root(), dlc)
|
||||
|
||||
assert not (dlc / server_mod._BUILTIN_STARTER_SUBDIR / "missing.feedpak").exists()
|
||||
assert not (server_mod.CONFIG_DIR / server_mod._STARTER_SEED_MARKER).exists()
|
||||
assert not (dlc / builtin_content.BUILTIN_STARTER_SUBDIR / "missing.feedpak").exists()
|
||||
assert not (server_mod.CONFIG_DIR / builtin_content.STARTER_SEED_MARKER).exists()
|
||||
|
||||
|
||||
def test_every_starter_source_file_is_present(server_mod):
|
||||
@@ -190,7 +191,7 @@ def test_every_starter_source_file_is_present(server_mod):
|
||||
the checkout is clean, so "on disk" == committed."""
|
||||
root = server_mod._feedBack_server_root()
|
||||
missing = [
|
||||
rel for _, rel in server_mod._BUILTIN_STARTER_SOURCES
|
||||
rel for _, rel in builtin_content.BUILTIN_STARTER_SOURCES
|
||||
if not (root / rel).is_file()
|
||||
]
|
||||
assert not missing, f"listed starter sources missing on disk: {missing}"
|
||||
@@ -199,18 +200,18 @@ def test_every_starter_source_file_is_present(server_mod):
|
||||
def test_seed_lands_every_listed_starter_pack(tmp_path, server_mod):
|
||||
"""A real seed run copies every listed pack into starter/ and marks done."""
|
||||
root = server_mod._feedBack_server_root()
|
||||
for _, rel in server_mod._BUILTIN_STARTER_SOURCES:
|
||||
for _, rel in builtin_content.BUILTIN_STARTER_SOURCES:
|
||||
if not (root / rel).is_file():
|
||||
pytest.skip(f"starter source not present in checkout: {rel}")
|
||||
|
||||
dlc = tmp_path / "dlc"
|
||||
dlc.mkdir()
|
||||
server_mod._seed_builtin_starter_content(dlc)
|
||||
builtin_content.seed_builtin_starter_content(server_mod._feedBack_server_root(), dlc)
|
||||
|
||||
for dest_name, _ in server_mod._BUILTIN_STARTER_SOURCES:
|
||||
dest = dlc / server_mod._BUILTIN_STARTER_SUBDIR / dest_name
|
||||
for dest_name, _ in builtin_content.BUILTIN_STARTER_SOURCES:
|
||||
dest = dlc / builtin_content.BUILTIN_STARTER_SUBDIR / dest_name
|
||||
assert dest.is_file(), f"pack not seeded: {dest_name}"
|
||||
assert (server_mod.CONFIG_DIR / server_mod._STARTER_SEED_MARKER).is_file()
|
||||
assert (server_mod.CONFIG_DIR / builtin_content.STARTER_SEED_MARKER).is_file()
|
||||
|
||||
|
||||
def test_no_unlisted_starter_pack_on_disk(server_mod):
|
||||
@@ -220,7 +221,7 @@ def test_no_unlisted_starter_pack_on_disk(server_mod):
|
||||
main before being wired up. In CI the checkout is clean, so this flags any
|
||||
stray/committed pack that isn't listed."""
|
||||
root = server_mod._feedBack_server_root()
|
||||
listed = {rel for _, rel in server_mod._BUILTIN_STARTER_SOURCES}
|
||||
listed = {rel for _, rel in builtin_content.BUILTIN_STARTER_SOURCES}
|
||||
if not listed:
|
||||
pytest.skip("no starter sources declared")
|
||||
content_dir = (root / next(iter(listed))).parent # all sources share this dir
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
"""The plugin context is a THIRD-PARTY CONTRACT. Pin it.
|
||||
|
||||
`context` is handed to every plugin's `setup()`. Plugins — including ones we don't ship
|
||||
and can't grep — read keys out of it and hold the callables as live references. Issue #48
|
||||
flagged this while planning the server.py split and asked for exactly this assertion:
|
||||
|
||||
"Plugin context[...] are passed as live references into already-loaded plugins.
|
||||
Refactoring must preserve the exact callables — moving them to a new module is
|
||||
fine, but renaming or wrapping them breaks third-party plugins. We'd want a
|
||||
'plugin context unchanged' assertion in CI."
|
||||
|
||||
It doesn't exist yet, and server.py is about to be carved apart around the code that
|
||||
builds it. This is the guard that makes the carve safe: a key silently dropped or
|
||||
renamed by a move is invisible to every other test in the suite (nothing in-tree reads
|
||||
most of these) and would break plugins at runtime, in the field.
|
||||
|
||||
Same lesson the frontend carve learned the hard way: a contract that only external code
|
||||
reads cannot be found by a call-graph scan, so it has to be pinned by name.
|
||||
|
||||
WHY A LITERAL LIST AND NOT A DERIVED ONE. Deriving the expected set from the source would
|
||||
assert the code equals itself. The whole point is that a human has to look at a diff and
|
||||
consciously agree to change the contract.
|
||||
"""
|
||||
|
||||
import ast
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
SERVER_PY = Path(__file__).resolve().parents[1] / "server.py"
|
||||
PLUGINS_PY = Path(__file__).resolve().parents[1] / "plugins" / "__init__.py"
|
||||
|
||||
# The keys server.py puts in the shared context handed to register_plugin_api().
|
||||
BASE_CONTEXT_KEYS = {
|
||||
"config_dir",
|
||||
"get_dlc_dir",
|
||||
"extract_meta",
|
||||
"meta_db",
|
||||
"get_scan_status",
|
||||
"get_art_cache_dir",
|
||||
"library_providers",
|
||||
"register_library_provider",
|
||||
"unregister_library_provider",
|
||||
"register_tuning_provider",
|
||||
"unregister_tuning_provider",
|
||||
"get_sloppak_cache_dir",
|
||||
"register_demo_janitor_hook",
|
||||
"award_xp",
|
||||
"get_xp_progress",
|
||||
"seed_xp",
|
||||
"reset_xp",
|
||||
"record_progression_event",
|
||||
}
|
||||
|
||||
# Added PER PLUGIN by plugins/__init__.py on top of the base — so the surface a plugin
|
||||
# actually sees is the union. Real shipped plugins read `log` and `load_sibling`, and
|
||||
# neither is in server.py's dict; a test that pinned only the base would miss them.
|
||||
PER_PLUGIN_KEYS = {"load_sibling", "log"}
|
||||
|
||||
FULL_CONTEXT = BASE_CONTEXT_KEYS | PER_PLUGIN_KEYS
|
||||
|
||||
|
||||
def _plugin_context_keys() -> set:
|
||||
"""The literal keys of server.py's `plugin_context = {...}`, read from the AST.
|
||||
|
||||
AST, not a regex: the dict spans ~40 lines and is dense with comments, lambdas and
|
||||
nested calls, and the values contain braces of their own.
|
||||
"""
|
||||
tree = ast.parse(SERVER_PY.read_text(encoding="utf-8"))
|
||||
for node in ast.walk(tree):
|
||||
if (
|
||||
isinstance(node, ast.Assign)
|
||||
and node.targets
|
||||
and isinstance(node.targets[0], ast.Name)
|
||||
and node.targets[0].id == "plugin_context"
|
||||
and isinstance(node.value, ast.Dict)
|
||||
):
|
||||
keys = set()
|
||||
for k in node.value.keys:
|
||||
assert isinstance(k, ast.Constant), (
|
||||
"plugin_context must be built from literal string keys — a computed "
|
||||
"key makes this contract un-reviewable"
|
||||
)
|
||||
keys.add(k.value)
|
||||
return keys
|
||||
pytest.fail(
|
||||
"server.py no longer builds a literal `plugin_context = {...}` dict. If it moved "
|
||||
"to another module, point this test at that module — do NOT delete it."
|
||||
)
|
||||
|
||||
|
||||
def test_plugin_context_keys_are_exactly_the_pinned_contract():
|
||||
actual = _plugin_context_keys()
|
||||
|
||||
missing = BASE_CONTEXT_KEYS - actual
|
||||
added = actual - BASE_CONTEXT_KEYS
|
||||
|
||||
assert not missing, (
|
||||
f"plugin_context lost {sorted(missing)}. Every one of these is read by plugins we "
|
||||
"do not control and cannot grep. Dropping one breaks them at runtime, in the "
|
||||
"field, with nothing else in this suite failing."
|
||||
)
|
||||
assert not added, (
|
||||
f"plugin_context gained {sorted(added)}. That's fine — but it is a PUBLIC API "
|
||||
"addition, so add the key to BASE_CONTEXT_KEYS here deliberately, and document it "
|
||||
"in docs/. This test exists to make that a conscious act rather than a side effect."
|
||||
)
|
||||
|
||||
|
||||
def test_per_plugin_keys_are_still_layered_on_top():
|
||||
"""`log` and `load_sibling` are added per-plugin in plugins/__init__.py, not by
|
||||
server.py — so they're invisible to the check above. Real plugins read both."""
|
||||
src = PLUGINS_PY.read_text(encoding="utf-8")
|
||||
for key in sorted(PER_PLUGIN_KEYS):
|
||||
assert f'plugin_context["{key}"]' in src, (
|
||||
f"plugins/__init__.py no longer sets plugin_context[{key!r}] — shipped plugins "
|
||||
"read it"
|
||||
)
|
||||
|
||||
|
||||
def test_context_values_reach_a_REAL_plugin_by_identity(tmp_path, reset_plugin_state):
|
||||
"""The contract is CALLABLE IDENTITY, not just key names.
|
||||
|
||||
A carve that moves these into a module and re-exports them through a wrapper (a
|
||||
property, a functools.partial, a lazily-bound getter) keeps every key name intact and
|
||||
STILL breaks plugins that stored the reference at setup() time.
|
||||
|
||||
Codex [P2] on the first cut of this test, and it was right: I originally built a dict
|
||||
locally and called setup() on it, which asserts `dict(x)['k'] is x['k']` — trivially
|
||||
true, and blind to everything plugins/__init__.py does. It has to go through the REAL
|
||||
loader, because the real loader is exactly what copies and re-binds the context.
|
||||
|
||||
(That is not hypothetical: `register_library_provider` IS deliberately wrapped by the
|
||||
loader, per-plugin, to force owner attribution. Pinned below so the one intentional
|
||||
exception can't quietly become two.)
|
||||
"""
|
||||
from fastapi import FastAPI
|
||||
|
||||
# reset_plugin_state (tests/conftest.py) is the ONLY safe way to drive the real
|
||||
# load_plugins(): it also mutates sys.path, sys.modules and PENDING_PLUGINS, and a
|
||||
# hand-rolled partial restore makes the suite order- and environment-dependent.
|
||||
# Codex [P2] on the first cut of this, and it was right.
|
||||
plugins_mod = reset_plugin_state
|
||||
|
||||
plugin_dir = tmp_path / "ctxprobe"
|
||||
plugin_dir.mkdir()
|
||||
(plugin_dir / "plugin.json").write_text(
|
||||
'{"id": "ctxprobe", "name": "ctx probe", "routes": "routes.py"}'
|
||||
)
|
||||
# A backend plugin's entry point is routes.py's `setup(app, ctx)` — the same shape
|
||||
# tests/test_plugins.py::_make_plugin uses. The probe hands the context BACK through a
|
||||
# sink in the context itself: importing the probe module by name does not work (the
|
||||
# loader namespaces plugin modules), and a file/JSON channel would lose the object
|
||||
# IDENTITY that is the entire point of this test.
|
||||
(plugin_dir / "routes.py").write_text(
|
||||
"def setup(app, ctx):\n"
|
||||
" ctx['_probe_sink'].append(ctx)\n"
|
||||
)
|
||||
|
||||
sentinel_db = object()
|
||||
|
||||
def sentinel_extract(_p):
|
||||
return {}
|
||||
|
||||
def sentinel_register_library_provider(provider, *a, **kw):
|
||||
return None
|
||||
|
||||
sink = []
|
||||
context = {
|
||||
"_probe_sink": sink,
|
||||
"meta_db": sentinel_db,
|
||||
"extract_meta": sentinel_extract,
|
||||
"config_dir": tmp_path,
|
||||
"register_library_provider": sentinel_register_library_provider,
|
||||
}
|
||||
|
||||
app = FastAPI()
|
||||
saved_dir = plugins_mod.PLUGINS_DIR
|
||||
plugins_mod.PLUGINS_DIR = tmp_path
|
||||
try:
|
||||
plugins_mod.load_plugins(app, context)
|
||||
finally:
|
||||
plugins_mod.PLUGINS_DIR = saved_dir
|
||||
|
||||
assert sink, "the probe plugin's setup() never ran — the harness is not exercising the loader"
|
||||
seen = sink[0]
|
||||
|
||||
assert seen["meta_db"] is sentinel_db, "meta_db must reach a real plugin BY IDENTITY"
|
||||
assert seen["extract_meta"] is sentinel_extract, (
|
||||
"extract_meta must reach a real plugin BY IDENTITY — wrapping it (partial, "
|
||||
"property, re-binding getter) breaks plugins that stored the reference at setup()"
|
||||
)
|
||||
assert seen["config_dir"] is context["config_dir"]
|
||||
|
||||
# The loader adds these per-plugin; shipped plugins read both.
|
||||
assert callable(seen["load_sibling"])
|
||||
assert seen["log"].name == "feedBack.plugin.ctxprobe"
|
||||
|
||||
# THE ONE DELIBERATE WRAPPER. register_library_provider is scoped per-plugin so a
|
||||
# plugin cannot forge owner attribution and impersonate another. Pinned so that the
|
||||
# single intentional exception to identity cannot quietly become two.
|
||||
assert seen["register_library_provider"] is not sentinel_register_library_provider, (
|
||||
"register_library_provider is supposed to be wrapped per-plugin for owner "
|
||||
"attribution — if that wrapper is gone, a plugin can impersonate another"
|
||||
)
|
||||
@@ -189,9 +189,13 @@ def test_app_event_bus_dispatches_locally_and_preserves_juce_stop_state():
|
||||
# `isPlaying` moved onto the shared player-state container (static/js/player-state.js)
|
||||
# so a carved module can WRITE it — an imported binding is read-only.
|
||||
assert "const hadPlayableSong = !!audio.src || !!window._juceAudioUrl || S.isPlaying" in source
|
||||
assert "sm.emit('song:resume', payload)" in source
|
||||
assert "window.feedBack.emit('song:resume', payload)" in source
|
||||
|
||||
# The JUCE audio-element shim — which re-emits song:resume through the session
|
||||
# manager when JUCE owns the transport — was carved out into its own module (R3a).
|
||||
juce = (ROOT / "static" / "js" / "juce-audio.js").read_text(encoding="utf-8")
|
||||
assert "sm.emit('song:resume', payload)" in juce
|
||||
|
||||
|
||||
def test_nam_and_stems_use_owner_claim_dispatch_semantics():
|
||||
nam_source = _sibling_text("feedBack-plugin-nam-tone", "screen.js", "NAM_STEM_CLAIM_ID = 'nam.amp-active'")
|
||||
|
||||
@@ -138,3 +138,119 @@ def test_unready_plugin_src_is_404(client):
|
||||
c, _ = client
|
||||
plugins.LOADED_PLUGINS[0]["status"] = "installing"
|
||||
assert c.get(f"/api/plugins/{PLUGIN_ID}/src/main.js").status_code == 404
|
||||
|
||||
|
||||
# ── #879: the /g/<token>/ generation prefix ────────────────────────────────────
|
||||
#
|
||||
# A plugin ROLLBACK must actually re-evaluate a module plugin. ES modules are
|
||||
# evaluated once per URL per document, so re-inserting a <script type="module">
|
||||
# whose src the module map has already seen fires `load` without re-running the
|
||||
# body. Busting the ENTRY url alone does not help — screen.js is a one-line
|
||||
# `import './src/main.js'`, and a relative specifier resolves against the base URL
|
||||
# with the QUERY DROPPED, so a ?v= token never reaches the graph.
|
||||
#
|
||||
# Hence a token in the PATH: every relative import inherits it, at every depth,
|
||||
# with no import-specifier rewriting. These routes must serve the SAME bytes and
|
||||
# keep the SAME containment.
|
||||
|
||||
def test_generation_prefix_serves_identical_screen_js(client):
|
||||
c, _ = client
|
||||
plain = c.get(f"/api/plugins/{PLUGIN_ID}/screen.js")
|
||||
gen = c.get(f"/api/plugins/{PLUGIN_ID}/g/7/screen.js")
|
||||
assert gen.status_code == 200
|
||||
assert gen.content == plain.content
|
||||
assert "import './src/main.js'" in gen.text
|
||||
|
||||
|
||||
def test_generation_prefix_serves_the_whole_module_graph(client):
|
||||
"""The point of the path token: a relative import from a /g/7/ entry resolves
|
||||
to a /g/7/ URL, so the graph is fetched fresh — not just the entry."""
|
||||
c, _ = client
|
||||
main = c.get(f"/api/plugins/{PLUGIN_ID}/g/7/src/main.js")
|
||||
assert main.status_code == 200
|
||||
assert main.text == c.get(f"/api/plugins/{PLUGIN_ID}/src/main.js").text
|
||||
# and one level deeper, which is where a query-string token would already have
|
||||
# been lost twice over
|
||||
nested = c.get(f"/api/plugins/{PLUGIN_ID}/g/7/src/util/x.js")
|
||||
assert nested.status_code == 200
|
||||
assert "export const x = 42" in nested.text
|
||||
|
||||
|
||||
def test_generation_token_is_opaque(client):
|
||||
"""Any token serves the same bytes — it exists only to vary the URL."""
|
||||
c, _ = client
|
||||
a = c.get(f"/api/plugins/{PLUGIN_ID}/g/1/src/main.js")
|
||||
b = c.get(f"/api/plugins/{PLUGIN_ID}/g/999999/src/main.js")
|
||||
assert a.status_code == b.status_code == 200
|
||||
assert a.text == b.text
|
||||
|
||||
|
||||
def test_generation_prefix_does_not_widen_containment(client):
|
||||
"""The token is never joined into a path, so containment must be EXACTLY what the
|
||||
un-prefixed route already gives. Asserted as parity rather than as a flat 404:
|
||||
`../screen.js` legitimately 200s on BOTH, because the URL normalises to
|
||||
/api/plugins/<id>/screen.js before routing ever happens — it never leaves the
|
||||
plugin dir. Pinning an absolute expectation here would have encoded my guess
|
||||
about the existing route instead of testing the thing that matters, which is
|
||||
that /g/ changes nothing."""
|
||||
c, _ = client
|
||||
for bad in ("../screen.js", "../../etc/passwd", "..%2f..%2fetc%2fpasswd",
|
||||
"..%5c..%5cwindows%5cwin.ini", "/etc/passwd"):
|
||||
plain = c.get(f"/api/plugins/{PLUGIN_ID}/src/{bad}")
|
||||
gen = c.get(f"/api/plugins/{PLUGIN_ID}/g/1/src/{bad}")
|
||||
assert gen.status_code == plain.status_code, f"/g/ diverged on {bad!r}"
|
||||
assert gen.content == plain.content, f"/g/ served different bytes for {bad!r}"
|
||||
assert "root:" not in gen.text and "[extensions]" not in gen.text
|
||||
|
||||
# and the real traversals are genuinely rejected, on both
|
||||
for bad in ("../../etc/passwd", "..%2f..%2fetc%2fpasswd"):
|
||||
assert c.get(f"/api/plugins/{PLUGIN_ID}/g/1/src/{bad}").status_code == 404
|
||||
|
||||
|
||||
def test_generation_prefix_404s_for_unknown_plugin(client):
|
||||
c, _ = client
|
||||
assert c.get("/api/plugins/nope/g/1/screen.js").status_code == 404
|
||||
assert c.get("/api/plugins/nope/g/1/src/main.js").status_code == 404
|
||||
|
||||
|
||||
def test_generation_prefix_serves_ASSETS_too(client):
|
||||
"""Codex [P2] on the first cut of this fix, and it was right.
|
||||
|
||||
The path token shifts the BASE URL, so everything a module resolves relatively moves
|
||||
with it — not just imports. `new URL('../assets/worklet.js', import.meta.url)` from
|
||||
/api/plugins/<id>/g/1/src/main.js resolves to /api/plugins/<id>/g/1/assets/worklet.js.
|
||||
Mirroring only screen.js and src/ would have fixed imports and 404'd every asset,
|
||||
worklet and wasm file the graph reaches. Hence a path REWRITE, so every plugin route
|
||||
— present and future — works under the prefix."""
|
||||
c, _ = client
|
||||
plain = c.get(f"/api/plugins/{PLUGIN_ID}/assets/worklet.js")
|
||||
gen = c.get(f"/api/plugins/{PLUGIN_ID}/g/1/assets/worklet.js")
|
||||
assert plain.status_code == 200
|
||||
assert gen.status_code == 200, "an asset reached relatively from a reloaded module graph 404'd"
|
||||
assert gen.content == plain.content
|
||||
|
||||
|
||||
def test_generation_prefix_covers_every_plugin_route(client):
|
||||
"""The rewrite is generic, so this holds for routes nobody thought about — which is
|
||||
the point. Any plugin route added later works under /g/ with no extra wiring."""
|
||||
c, _ = client
|
||||
for route in ("screen.js", "src/main.js", "src/util/x.js", "src/theme.css",
|
||||
"assets/worklet.js", "settings.html"):
|
||||
plain = c.get(f"/api/plugins/{PLUGIN_ID}/{route}")
|
||||
gen = c.get(f"/api/plugins/{PLUGIN_ID}/g/42/{route}")
|
||||
assert gen.status_code == plain.status_code, f"/g/ diverged on {route}"
|
||||
assert gen.content == plain.content, f"/g/ served different bytes for {route}"
|
||||
|
||||
|
||||
def test_generation_prefix_handles_non_ascii_filenames(client):
|
||||
"""Codex [P3] on the second cut. A plugin file named e.g. src/工具.js is perfectly
|
||||
valid, and the middleware must not 500 on it — which an eager
|
||||
raw_path.encode("latin-1") did, making the prefixed route LESS capable than the
|
||||
plain one. raw_path is informational; Starlette routes on scope["path"]."""
|
||||
c, tmp = client
|
||||
(tmp / "src" / "工具.js").write_text("export const t = 1;\n")
|
||||
plain = c.get(f"/api/plugins/{PLUGIN_ID}/src/工具.js")
|
||||
gen = c.get(f"/api/plugins/{PLUGIN_ID}/g/3/src/工具.js")
|
||||
assert plain.status_code == 200
|
||||
assert gen.status_code == 200, "non-ASCII module path 500'd or 404'd under /g/"
|
||||
assert gen.content == plain.content
|
||||
|
||||
@@ -46,56 +46,6 @@ def capture_logger(caplog, logger_name, level=logging.WARNING):
|
||||
logger.propagate = orig_propagate
|
||||
|
||||
|
||||
# Bare module names that this test module pre-populates into
|
||||
# sys.modules to simulate the bare-import path. Saved/restored by
|
||||
# the reset_plugin_state fixture so they don't leak to other test
|
||||
# files. Codex / Copilot review on PR for feedBack#33.
|
||||
_BARE_NAMES_USED = ("util", "extractor")
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def reset_plugin_state(monkeypatch):
|
||||
"""Clear loader module-level state and restore on teardown.
|
||||
|
||||
Saves and restores:
|
||||
* `plugins.LOADED_PLUGINS`
|
||||
* any `plugin_*` keys we add to `sys.modules`
|
||||
* the bare names this module simulates (`util`, `extractor`)
|
||||
* `sys.path` — `plugins.load_plugins()` mutates it
|
||||
Also unsets `FEEDBACK_PLUGINS_DIR` for the test's duration
|
||||
(via monkeypatch) so a CI env that pre-sets it can't leak
|
||||
real user plugins into a tmp_path-driven test. Per-module
|
||||
locks are owned by the standard import system
|
||||
(`importlib._bootstrap._module_locks`) and are not our
|
||||
responsibility to reset.
|
||||
"""
|
||||
monkeypatch.delenv("FEEDBACK_PLUGINS_DIR", raising=False)
|
||||
plugins = importlib.import_module("plugins")
|
||||
saved_loaded = list(plugins.LOADED_PLUGINS)
|
||||
saved_pending = dict(plugins.PENDING_PLUGINS)
|
||||
saved_modules = {k: v for k, v in sys.modules.items() if k.startswith("plugin_")}
|
||||
saved_bare = {k: sys.modules[k] for k in _BARE_NAMES_USED if k in sys.modules}
|
||||
saved_path = list(sys.path)
|
||||
plugins.LOADED_PLUGINS.clear()
|
||||
plugins.PENDING_PLUGINS.clear()
|
||||
for k in list(sys.modules):
|
||||
if k.startswith("plugin_") or k in _BARE_NAMES_USED:
|
||||
del sys.modules[k]
|
||||
try:
|
||||
yield plugins
|
||||
finally:
|
||||
plugins.LOADED_PLUGINS.clear()
|
||||
plugins.LOADED_PLUGINS.extend(saved_loaded)
|
||||
plugins.PENDING_PLUGINS.clear()
|
||||
plugins.PENDING_PLUGINS.update(saved_pending)
|
||||
for k in list(sys.modules):
|
||||
if k.startswith("plugin_") or k in _BARE_NAMES_USED:
|
||||
del sys.modules[k]
|
||||
sys.modules.update(saved_modules)
|
||||
sys.modules.update(saved_bare)
|
||||
sys.path[:] = saved_path
|
||||
|
||||
|
||||
def _make_plugin(plugin_root, plugin_id, *, sibling_files=None, routes_body=None):
|
||||
"""Create a minimal plugin directory under `plugin_root`.
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import importlib
|
||||
import json
|
||||
import sys
|
||||
|
||||
import builtin_content
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
@@ -192,7 +193,7 @@ def test_low_accuracy_play_does_not_complete_gated_challenge(client):
|
||||
|
||||
|
||||
def test_diagnostic_at_100_completes_calibration(client, server):
|
||||
diag = server._builtin_diagnostic_filename()
|
||||
diag = builtin_content.builtin_diagnostic_filename()
|
||||
# A near-miss leaves calibration pending.
|
||||
_scored_play(client, filename=diag, accuracy=0.97, score=500)
|
||||
assert client.get("/api/progression").json()["onboarding"]["calibration_status"] == "pending"
|
||||
@@ -207,7 +208,7 @@ def test_diagnostic_play_does_not_feed_challenges_or_quests(client, server):
|
||||
# The calibration run is a perfect guitar play — it must yield rank 1
|
||||
# EXACTLY, advancing neither the guitar path nor the daily song quest.
|
||||
client.post("/api/progression/paths", json={"add": ["guitar"]})
|
||||
r = _scored_play(client, filename=server._builtin_diagnostic_filename(),
|
||||
r = _scored_play(client, filename=builtin_content.builtin_diagnostic_filename(),
|
||||
accuracy=1.0, score=500)
|
||||
summary = r.json()["progression"]
|
||||
assert summary["calibration_completed"] is True
|
||||
@@ -228,7 +229,7 @@ def test_pathless_diagnostic_run_still_completes_calibration(client, server):
|
||||
run is an earned achievement and must count even before any path is
|
||||
selected (e.g. a pre-progression profile playing the diagnostic as a
|
||||
hardware test) — yielding a valid pathless rank-1 state."""
|
||||
_scored_play(client, filename=server._builtin_diagnostic_filename(),
|
||||
_scored_play(client, filename=builtin_content.builtin_diagnostic_filename(),
|
||||
accuracy=1.0, score=500)
|
||||
data = client.get("/api/progression").json()
|
||||
assert data["onboarding"]["calibration_status"] == "completed"
|
||||
@@ -240,7 +241,7 @@ def test_diagnostic_upgrades_skipped_without_rank_change(client, server):
|
||||
client.post("/api/progression/paths", json={"add": ["guitar"]})
|
||||
r = client.post("/api/progression/onboarding", json={"action": "skip"})
|
||||
assert r.json()["onboarding"]["calibration_status"] == "skipped"
|
||||
_scored_play(client, filename=server._builtin_diagnostic_filename(), accuracy=1.0, score=500)
|
||||
_scored_play(client, filename=builtin_content.builtin_diagnostic_filename(), accuracy=1.0, score=500)
|
||||
data = client.get("/api/progression").json()
|
||||
assert data["onboarding"]["calibration_status"] == "completed"
|
||||
assert data["mastery_rank"] == 1
|
||||
|
||||
Reference in New Issue
Block a user