mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-09-11 16:04:30 +00:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9cb3e4eaae | ||
|
|
233f5280e8 |
@@ -7,81 +7,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Fixed
|
||||
- **The packaged desktop app could not start (`ModuleNotFoundError: No module named
|
||||
'appstate'`).** feedback-desktop's `scripts/bundle-slopsmith.sh` copies a *hardcoded
|
||||
list* of core files into the app bundle — `server.py`, `VERSION`, `lib/`, `data/`,
|
||||
`static/`, `plugins/__init__.py`. The root-level `appstate.py` and `routers/` added in
|
||||
R3 shipped correctly in Docker and passed every test, and were then silently dropped
|
||||
from the packaged app, which died at startup. Both now live under **`lib/`** — the one
|
||||
core directory the Dockerfile (`COPY lib/`), `docker-compose.yml`, and the desktop
|
||||
bundler (`cp -r lib`) all copy wholesale, and that all three put on `sys.path` (on
|
||||
Windows via the embeddable-Python `._pth`, where `PYTHONPATH` is ignored). This needs no
|
||||
change in feedback-desktop and no new release to take effect. Placing them there is also
|
||||
correct under Principle V: with the injection seam, `appstate.py` constructs nothing and
|
||||
does no import-time IO, and a route module only builds an `APIRouter`. The
|
||||
`Dockerfile` / `.dockerignore` / `docker-compose.yml` entries added for the root layout
|
||||
are reverted. New `tests/test_packaging.py` walks `server.py`'s module-level imports and
|
||||
fails if any first-party module resolves outside a directory the packagers copy, so the
|
||||
next root-level module can't ship broken.
|
||||
|
||||
### Added
|
||||
- **`routers/` — extracting `server.py`'s route layer, cheapest-first (R3).** Each PR moves a cohesive route group into a `fastapi.APIRouter` under `lib/routers/`, mounted with `app.include_router(...)` at its original site (FastAPI matches in registration order; the full route table stays byte-identical). Bodies are verbatim — only the decorator receiver (`@app` → `@router`) and singleton reads (`meta_db` → `appstate.meta_db`, resolved at call time) change. So far: `audio_effects` (5), `artist_aliases` (5), `loops` (3 — saved A/B practice regions). The next cut is picked by a dependency-closure scan that ranks groups by how many `monkeypatch.setattr(server, …)` targets they'd drag along.
|
||||
- **`routers/` — the first extracted route module (R3).** The five audio-effects mapping
|
||||
endpoints move out of `server.py` into `lib/routers/audio_effects.py` as a
|
||||
`fastapi.APIRouter`, mounted with `app.include_router(...)` **at the point in the file
|
||||
where they used to be defined** — FastAPI matches routes in registration order, so the
|
||||
mount site preserves it. Verified: the full 143-route table (paths, methods, *and*
|
||||
order) is byte-for-byte identical to `main`. Bodies are verbatim; the only edits are
|
||||
the decorator receiver (`@app.get` → `@router.get`) and the singleton read
|
||||
(`audio_effect_mappings` → `appstate.audio_effect_mappings`, a module attribute
|
||||
resolved at call time). This proves the seam from #833 under a real consumer, including
|
||||
the second slot. The `_demo_mode_guard` middleware still blocks all four moved write
|
||||
routes with 403, and `Query(...)` validation still 422s — both checked against a running
|
||||
server. `server.py`: **9,445 → 9,386 lines**.
|
||||
- **`appstate.py` — the router seam (R3).** Route modules moving out of `server.py`
|
||||
need `meta_db` and friends but must not `import server`, or the import graph goes
|
||||
circular the moment `server` imports them back. So `server.py` keeps *constructing*
|
||||
its singletons and now **injects** them once — `appstate.configure(meta_db=…,
|
||||
audio_effect_mappings=…)` — and a router reads them back as module attributes at call
|
||||
time (`import appstate; appstate.meta_db.…`). This is the Python analogue of the
|
||||
frontend refactor's injected `configureX({…})` seams and of the plugin
|
||||
`setup(app, context)` contract: dependencies flow one way, `server → routers →
|
||||
appstate`. Two properties are load-bearing and pinned by `tests/test_appstate.py`:
|
||||
(1) `import appstate` constructs nothing and touches no disk, so the ~49 test fixtures
|
||||
that `sys.modules.pop("server")` + re-import (to rebuild `meta_db` under a patched
|
||||
`CONFIG_DIR`) keep working untouched — a singleton *owned* by `appstate` would survive
|
||||
that pop and go stale; (2) reads must be late-bound (`appstate.meta_db`, never
|
||||
`from appstate import meta_db`), since a `from` import freezes the binding and defeats
|
||||
both a later `configure()` and `monkeypatch.setattr` — the same read-only-binding trap
|
||||
as ES `import`. `configure()` rejects an unknown slot rather than silently creating a
|
||||
global nothing reads, and the suite asserts `server` actually calls it (a seam whose
|
||||
wiring can no-op undetected is worse than no seam). Lives at `lib/appstate.py`.
|
||||
|
||||
### Changed
|
||||
- **`AudioEffectsMappingDB` moved out of `server.py` into `lib/audio_effects_db.py`
|
||||
(R3, move-only).** The core-owned song/tone → provider routing index follows
|
||||
`MetadataDB` out of the host file, byte-identical apart from the same constructor
|
||||
seam (`__init__` takes `config_dir`; `audio_effect_mappings = AudioEffectsMappingDB(CONFIG_DIR)`),
|
||||
so the module does no IO at import. The singleton stays in `server.py`; no route,
|
||||
no test and no `monkeypatch.setattr(server, …)` target moves. `server.py`:
|
||||
**9,705 → 9,433 lines**.
|
||||
- **`MetadataDB` moved out of `server.py` into `lib/metadata_db.py` (R3, move-only).**
|
||||
The library metadata cache — the `MetadataDB` class (4,018 lines) plus the query
|
||||
helpers it owns (keyset paging cursors, the tuning grouping key, smart-arrangement
|
||||
naming, tag normalisation, the startup DB-restore swap) — now lives in its own flat
|
||||
`lib/` module. `server.py` drops from **14,037 → 9,705 lines** and keeps the
|
||||
`meta_db` singleton, so `server.meta_db` and `server.app` resolve exactly as before
|
||||
and every route is untouched. The only non-verbatim change is the seam that lets the
|
||||
class leave `server.py`: `MetadataDB.__init__` now takes `config_dir` explicitly
|
||||
(`meta_db = MetadataDB(CONFIG_DIR)`) instead of reading the module-level `CONFIG_DIR`,
|
||||
which also means `lib/metadata_db.py` performs no IO at import (Principle V). Logging
|
||||
still goes through the `feedBack.server` logger, so existing log filters and `caplog`
|
||||
assertions resolve to the same logger object. `tests/test_settings_export_library_db.py`
|
||||
now imports `_apply_pending_db_restore` from `metadata_db` (the test moved with its
|
||||
subject); no other test changed. Every moved block is byte-identical to its
|
||||
`server.py` original.
|
||||
|
||||
### Added
|
||||
- **Plugins can ship an ES-module `src/` tree (module-migration rails, R0).** The host gains three things so a plugin can move off a single global-scope `screen.js` IIFE onto native ES modules with **no build step**: (1) a new sandboxed `GET /api/plugins/{id}/src/{path}` route that serves a plugin's `src/` source subtree, containment-checked by the same `safe_join` guard as `assets/` (traversal/absolute/NUL → 404); (2) the live-edit cache contract — `Cache-Control: no-cache` + a weak mtime/size `ETag` + `If-None-Match`→`304` — applied to `src/`, `screen.js`, and `assets/` (previously `screen.js` sent no cache headers and `assets/` emitted an ETag but never revalidated), so an edited module reloads on refresh while unchanged ones `304`; and (3) `scriptType`/`minHost` passthrough from `plugin.json` to `/api/plugins`, with the loader injecting a plugin that declares `"scriptType":"module"` as `<script type="module">` (its screen.js becomes `import './src/main.js'`). A `<script type=module>` fires its load event only after its whole static-import graph evaluates, preserving the loader's completion-by-`onload` + `_loadingPluginId` contract. Classic plugins are unaffected; `minHost` is passthrough-only for now (enforcement deferred). Tests: `tests/test_plugin_src_route.py` (serve/media-type/traversal/304/no-stale-304/screen.js+assets conditional), `tests/js/plugin_loader_script_type.test.js` (guarded module injection).
|
||||
- **Module-migration governance & rails (R0).** Constitution amended to **v1.2.0**: Principle II now names native ES modules as a first-class, *build-free* extension mechanism (the `scriptType:"module"` load path, both plugins and — over time — core's `static/js/`), keeping the no-bundler/no-transpiler/source-served rule intact; Operating Constraints gains a "Module load contract" clause (a `<script type=module>` load event awaits the whole static-import graph, so completion-by-`onload` is preserved; per-visit re-init comes from the `screen:changed` event, not screen.js re-execution). Mirrored into `CLAUDE.md`. New `docs/plugin-modules.md` (the migration playbook — layering, import-time purity, `import.meta.url` assets, the ETag live-edit loop) and `docs/size-exemptions.md` (the signed 1,500-line size-norm register; Byron signs core/bundled rows, Christian the authored virtuoso row). Adds a **maintainer/CI-only** ESLint gate (`eslint.config.js` + a `lint` CI job): `max-lines` warns at 1,500 as a non-blocking ratchet (ceilings for exempt files mirror the register), and `import-x/no-unresolved` + `import-x/no-cycle` hard-error on ES-module graphs — dormant until module code lands, never on the serve/Docker path.
|
||||
|
||||
@@ -54,12 +54,8 @@ without a *signed* exemption" is unenforceable.
|
||||
|
||||
## Planned, NOT exempt (owned by split plans — listed so nothing falls between states)
|
||||
|
||||
core `static/app.js` (11,852) · `static/highway.js` (4,168, whole file) · `server.py`
|
||||
(9,302 — was 14,037; ratcheted by the R3 `MetadataDB` + `AudioEffectsMappingDB`
|
||||
extractions and the first three `routers/` modules) ·
|
||||
`lib/metadata_db.py` (4,373 — new in R3; the `MetadataDB` class alone is 4,018 lines
|
||||
and is a monolith in its own right, to be split per-table once the router train
|
||||
lands) · `static/v3/songs.js` (4,134) · `static/capabilities/audio-session.js`
|
||||
core `static/app.js` (11,821) · `static/highway.js` (4,154, whole file) · `server.py`
|
||||
(13,948) · `static/v3/songs.js` (4,134) · `static/capabilities/audio-session.js`
|
||||
(2,974) · `plugins/highway_3d/screen.js` (15,656) · `plugins/keys_highway_3d/screen.js`
|
||||
(3,780) · `plugins/drum_highway_3d/screen.js` (3,597) — and every monolith with a PR
|
||||
train in the refactor plan. Test files (e.g. `tests/test_plugins.py`) are out of scope
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
"""Shared application state — the seam that lets route modules reach core
|
||||
singletons without importing ``server``.
|
||||
|
||||
``server.py`` is the host: it owns the FastAPI ``app``, constructs the DB
|
||||
singletons, and runs the lifecycle. As routes move out into ``routers/`` (R3),
|
||||
those modules need ``meta_db`` and friends — but they must not ``import
|
||||
server``, or the import graph goes circular the moment ``server`` imports them
|
||||
back.
|
||||
|
||||
So ``server`` **injects** its singletons here once, at the point it builds them::
|
||||
|
||||
# server.py
|
||||
meta_db = MetadataDB(CONFIG_DIR)
|
||||
appstate.configure(meta_db=meta_db, ...)
|
||||
|
||||
and a router reads them back as **module attributes, at call time**::
|
||||
|
||||
# routers/artists.py
|
||||
import appstate
|
||||
|
||||
@router.get("/api/artist/{name}/page")
|
||||
def artist_page(name):
|
||||
return appstate.meta_db.artist_page(name)
|
||||
|
||||
This is the Python analogue of the injected `configureX({...})` seams the
|
||||
frontend refactor uses (stems' ``configureStreaming``, studio's
|
||||
``configureAudioGraph``, the editor's ``src/host.js``), and of the plugin
|
||||
``setup(app, context)`` contract in Principle III: dependencies flow one way,
|
||||
``server -> routers -> appstate``, and nothing imports back up.
|
||||
|
||||
Two properties this shape buys, both load-bearing:
|
||||
|
||||
* **``import appstate`` performs no IO and constructs nothing.** ``server``
|
||||
still owns construction, so the ~49 test fixtures that do
|
||||
``sys.modules.pop("server")`` + re-import (to rebuild ``meta_db`` under a
|
||||
patched ``CONFIG_DIR``) keep working untouched — a singleton *owned* here
|
||||
would survive that pop and go stale.
|
||||
* **Reads are late-bound.** Routers must use ``appstate.meta_db``, never
|
||||
``from appstate import meta_db`` — a ``from`` import freezes the binding at
|
||||
its current value, so a later ``configure()`` (or a
|
||||
``monkeypatch.setattr(appstate, "meta_db", fake)``) would not reach the
|
||||
router. This is the same read-only-binding trap as ES ``import``.
|
||||
|
||||
Defaults are ``None`` on purpose: they are inert but *type-honest*, so a router
|
||||
that runs before ``configure()`` fails loudly on ``NoneType`` instead of
|
||||
quietly operating on a stand-in.
|
||||
|
||||
Slots are added here only when a router actually needs one — this is a seam,
|
||||
not a grab-bag for everything in ``server.py``.
|
||||
|
||||
**Why this lives in ``lib/`` and not the repo root.** Because it constructs
|
||||
nothing and does no import-time IO, it satisfies Principle V's rule for ``lib/``
|
||||
modules — and ``lib/`` is the only core directory every packaging path already
|
||||
copies: the Dockerfile (``COPY lib/``), ``docker-compose.yml``, and
|
||||
feedback-desktop's ``bundle-slopsmith.sh`` (``cp -r lib``). All three also put
|
||||
both the bundle root and ``lib/`` on ``sys.path``. A root-level module ships in
|
||||
Docker but is silently dropped from the packaged desktop app, whose bundler
|
||||
copies a hardcoded file list — that regression is what moved this file here.
|
||||
"""
|
||||
|
||||
# The singletons routers may read. Every name here must also be a `_SLOTS` key.
|
||||
meta_db = None
|
||||
audio_effect_mappings = None
|
||||
|
||||
# Declared up front so `configure()` can reject a typo'd or stale keyword
|
||||
# instead of silently creating a new global that nothing ever reads. A seam
|
||||
# whose wiring can no-op undetected is worse than no seam.
|
||||
_SLOTS = frozenset({"meta_db", "audio_effect_mappings"})
|
||||
|
||||
|
||||
def configure(**kwargs) -> None:
|
||||
"""Publish `server`'s singletons into this module. Called once per
|
||||
`server` import (and again on re-import), so it must be idempotent."""
|
||||
unknown = set(kwargs) - _SLOTS
|
||||
if unknown:
|
||||
raise TypeError(
|
||||
f"appstate.configure() got unknown slot(s): {sorted(unknown)}. "
|
||||
f"Known slots: {sorted(_SLOTS)}. Add the name to _SLOTS if a router "
|
||||
f"genuinely needs it."
|
||||
)
|
||||
globals().update(kwargs)
|
||||
@@ -1,287 +0,0 @@
|
||||
"""Core-owned song/tone -> audio-effect-provider mapping index.
|
||||
|
||||
Extracted verbatim from ``server.py`` (R3). ``server.py`` still owns the
|
||||
``audio_effect_mappings`` singleton; this module only supplies the class, so
|
||||
nothing here touches config paths at import time — the caller passes
|
||||
``config_dir`` in.
|
||||
"""
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class AudioEffectsMappingDB:
|
||||
"""Core-owned public song/tone -> provider mapping index.
|
||||
|
||||
Providers own the preset/chain rows addressed by provider_ref. Core owns
|
||||
the cross-provider routing index and the active mapping per song/tone.
|
||||
"""
|
||||
|
||||
def __init__(self, config_dir: Path):
|
||||
config_dir.mkdir(parents=True, exist_ok=True)
|
||||
self.db_path = str(config_dir / "audio_effects.db")
|
||||
self.conn = sqlite3.connect(self.db_path, check_same_thread=False)
|
||||
self.conn.execute("PRAGMA journal_mode=WAL")
|
||||
self.conn.execute("PRAGMA foreign_keys=ON")
|
||||
self.conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS audio_effect_mappings (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
song_key TEXT NOT NULL,
|
||||
filename TEXT NOT NULL DEFAULT '',
|
||||
tone_key TEXT NOT NULL,
|
||||
provider_id TEXT NOT NULL,
|
||||
provider_ref TEXT NOT NULL,
|
||||
label TEXT NOT NULL DEFAULT '',
|
||||
source TEXT NOT NULL DEFAULT 'manual',
|
||||
metadata_json TEXT NOT NULL DEFAULT '{}',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
UNIQUE(song_key, tone_key, provider_id)
|
||||
)
|
||||
""")
|
||||
self.conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS audio_effect_active_mappings (
|
||||
song_key TEXT NOT NULL,
|
||||
tone_key TEXT NOT NULL,
|
||||
mapping_id INTEGER NOT NULL,
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
PRIMARY KEY (song_key, tone_key),
|
||||
FOREIGN KEY (mapping_id) REFERENCES audio_effect_mappings(id) ON DELETE CASCADE
|
||||
)
|
||||
""")
|
||||
self.conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_audio_effect_mappings_provider "
|
||||
"ON audio_effect_mappings(provider_id)"
|
||||
)
|
||||
self.conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_audio_effect_mappings_filename "
|
||||
"ON audio_effect_mappings(filename)"
|
||||
)
|
||||
self.conn.commit()
|
||||
self._lock = threading.Lock()
|
||||
|
||||
@staticmethod
|
||||
def _text(value, *, field: str, limit: int, allow_empty: bool = False) -> str:
|
||||
if value is None:
|
||||
text = ""
|
||||
elif not isinstance(value, str):
|
||||
raise ValueError(f"{field} must be a string")
|
||||
else:
|
||||
text = value.strip()
|
||||
if not text and not allow_empty:
|
||||
raise ValueError(f"{field} is required")
|
||||
if len(text) > limit:
|
||||
raise ValueError(f"{field} is too long")
|
||||
return text
|
||||
|
||||
@staticmethod
|
||||
def _mapping_id(value) -> int | None:
|
||||
# Bind only values SQLite can store as an INTEGER; an out-of-range id is a
|
||||
# clean miss (404), not a 500 at bind time.
|
||||
if isinstance(value, int) and not isinstance(value, bool) and -(2 ** 63) <= value < 2 ** 63:
|
||||
return value
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _field(data: dict, *keys):
|
||||
# Select the first present snake/camel alias by key, not by truthiness, so a
|
||||
# falsey non-string value (false/0) still reaches _text() and is rejected
|
||||
# instead of being silently swallowed by an `or` chain.
|
||||
for key in keys:
|
||||
if key in data:
|
||||
return data[key]
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _metadata(value) -> str:
|
||||
if value is None:
|
||||
return "{}"
|
||||
if not isinstance(value, dict):
|
||||
raise ValueError("metadata must be an object")
|
||||
encoded = json.dumps(value, ensure_ascii=True, sort_keys=True)
|
||||
if len(encoded) > 8192:
|
||||
raise ValueError("metadata is too large")
|
||||
return encoded
|
||||
|
||||
@staticmethod
|
||||
def _row(row) -> dict | None:
|
||||
if row is None:
|
||||
return None
|
||||
metadata = {}
|
||||
try:
|
||||
metadata = json.loads(row[8]) if row[8] else {}
|
||||
except Exception:
|
||||
metadata = {}
|
||||
return {
|
||||
"id": int(row[0]),
|
||||
"song_key": row[1],
|
||||
"filename": row[2] or "",
|
||||
"tone_key": row[3],
|
||||
"provider_id": row[4],
|
||||
"provider_ref": row[5],
|
||||
"label": row[6] or "",
|
||||
"source": row[7] or "manual",
|
||||
"metadata": metadata if isinstance(metadata, dict) else {},
|
||||
"created_at": row[9] or "",
|
||||
"updated_at": row[10] or "",
|
||||
"active": bool(row[11]),
|
||||
}
|
||||
|
||||
def _select_sql(self) -> str:
|
||||
return """
|
||||
SELECT m.id, m.song_key, m.filename, m.tone_key, m.provider_id,
|
||||
m.provider_ref, m.label, m.source, m.metadata_json,
|
||||
m.created_at, m.updated_at,
|
||||
CASE WHEN a.mapping_id IS NULL THEN 0 ELSE 1 END AS active
|
||||
FROM audio_effect_mappings m
|
||||
LEFT JOIN audio_effect_active_mappings a
|
||||
ON a.song_key = m.song_key AND a.tone_key = m.tone_key AND a.mapping_id = m.id
|
||||
"""
|
||||
|
||||
def list(self, *, song_key: str = "", filename: str = "", tone_key: str = "", provider_id: str = "") -> list[dict]:
|
||||
clauses: list[str] = []
|
||||
params: list[str] = []
|
||||
song_key = self._text(song_key, field="song_key", limit=240, allow_empty=True)
|
||||
filename = self._text(filename, field="filename", limit=500, allow_empty=True)
|
||||
tone_key = self._text(tone_key, field="tone_key", limit=160, allow_empty=True)
|
||||
provider_id = self._text(provider_id, field="provider_id", limit=96, allow_empty=True)
|
||||
if song_key and filename:
|
||||
clauses.append("(m.song_key = ? OR m.filename = ?)")
|
||||
params.extend([song_key, filename])
|
||||
elif song_key:
|
||||
clauses.append("m.song_key = ?")
|
||||
params.append(song_key)
|
||||
elif filename:
|
||||
clauses.append("(m.song_key = ? OR m.filename = ?)")
|
||||
params.extend([filename, filename])
|
||||
if tone_key:
|
||||
clauses.append("m.tone_key = ?")
|
||||
params.append(tone_key)
|
||||
if provider_id:
|
||||
clauses.append("m.provider_id = ?")
|
||||
params.append(provider_id)
|
||||
sql = self._select_sql()
|
||||
if clauses:
|
||||
sql += " WHERE " + " AND ".join(clauses)
|
||||
sql += " ORDER BY m.song_key COLLATE NOCASE, m.tone_key COLLATE NOCASE, m.provider_id COLLATE NOCASE"
|
||||
with self._lock:
|
||||
rows = self.conn.execute(sql, params).fetchall()
|
||||
return [self._row(row) for row in rows]
|
||||
|
||||
def get(self, mapping_id: int) -> dict | None:
|
||||
mapping_id = self._mapping_id(mapping_id)
|
||||
if mapping_id is None:
|
||||
return None
|
||||
with self._lock:
|
||||
row = self.conn.execute(self._select_sql() + " WHERE m.id = ?", (mapping_id,)).fetchone()
|
||||
return self._row(row)
|
||||
|
||||
def upsert(self, data: dict) -> dict:
|
||||
if not isinstance(data, dict):
|
||||
raise ValueError("mapping body must be an object")
|
||||
filename = self._text(data.get("filename", ""), field="filename", limit=500, allow_empty=True)
|
||||
song_key_raw = self._field(data, "song_key", "songKey")
|
||||
if song_key_raw is None or song_key_raw == "":
|
||||
song_key_raw = filename
|
||||
song_key = self._text(song_key_raw, field="song_key", limit=240)
|
||||
tone_key = self._text(self._field(data, "tone_key", "toneKey"), field="tone_key", limit=160, allow_empty=True)
|
||||
provider_id = self._text(self._field(data, "provider_id", "providerId"), field="provider_id", limit=96)
|
||||
provider_ref = self._text(self._field(data, "provider_ref", "providerRef"), field="provider_ref", limit=240)
|
||||
label = self._text(data.get("label", ""), field="label", limit=160, allow_empty=True)
|
||||
source = self._text(data.get("source", "manual"), field="source", limit=40, allow_empty=True) or "manual"
|
||||
metadata_json = self._metadata(data.get("metadata", {}))
|
||||
with self._lock:
|
||||
self.conn.execute(
|
||||
"""
|
||||
INSERT INTO audio_effect_mappings
|
||||
(song_key, filename, tone_key, provider_id, provider_ref, label, source, metadata_json, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))
|
||||
ON CONFLICT(song_key, tone_key, provider_id) DO UPDATE SET
|
||||
-- Only overwrite filename when a non-empty one was supplied; an
|
||||
-- omitted/empty filename must preserve the stored value (it's an
|
||||
-- alternate lookup key for list(..., filename=...)).
|
||||
filename=CASE WHEN excluded.filename <> '' THEN excluded.filename ELSE audio_effect_mappings.filename END,
|
||||
provider_ref=excluded.provider_ref,
|
||||
label=excluded.label,
|
||||
source=excluded.source,
|
||||
metadata_json=excluded.metadata_json,
|
||||
updated_at=datetime('now')
|
||||
""",
|
||||
(song_key, filename, tone_key, provider_id, provider_ref, label, source, metadata_json),
|
||||
)
|
||||
row = self.conn.execute(
|
||||
"SELECT id FROM audio_effect_mappings WHERE song_key = ? AND tone_key = ? AND provider_id = ?",
|
||||
(song_key, tone_key, provider_id),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise ValueError("failed to create audio-effects mapping")
|
||||
mapping_id = int(row[0])
|
||||
if data.get("active") is True:
|
||||
self.conn.execute(
|
||||
"""
|
||||
INSERT INTO audio_effect_active_mappings (song_key, tone_key, mapping_id, updated_at)
|
||||
VALUES (?, ?, ?, datetime('now'))
|
||||
ON CONFLICT(song_key, tone_key) DO UPDATE SET
|
||||
mapping_id=excluded.mapping_id,
|
||||
updated_at=datetime('now')
|
||||
""",
|
||||
(song_key, tone_key, mapping_id),
|
||||
)
|
||||
self.conn.commit()
|
||||
return self.get(mapping_id)
|
||||
|
||||
def delete(self, mapping_id: int, *, provider_id: str = "") -> bool:
|
||||
mapping_id = self._mapping_id(mapping_id)
|
||||
if mapping_id is None:
|
||||
return False
|
||||
provider_id = self._text(provider_id, field="provider_id", limit=96, allow_empty=True)
|
||||
with self._lock:
|
||||
if provider_id:
|
||||
cur = self.conn.execute(
|
||||
"DELETE FROM audio_effect_mappings WHERE id = ? AND provider_id = ?",
|
||||
(mapping_id, provider_id),
|
||||
)
|
||||
else:
|
||||
cur = self.conn.execute("DELETE FROM audio_effect_mappings WHERE id = ?", (mapping_id,))
|
||||
self.conn.commit()
|
||||
return cur.rowcount > 0
|
||||
|
||||
def activate(self, mapping_id: int, *, provider_id: str = "") -> dict | None:
|
||||
mapping_id = self._mapping_id(mapping_id)
|
||||
if mapping_id is None:
|
||||
return None
|
||||
provider_id = self._text(provider_id, field="provider_id", limit=96, allow_empty=True)
|
||||
with self._lock:
|
||||
row = self.conn.execute(
|
||||
self._select_sql() + " WHERE m.id = ?",
|
||||
(mapping_id,),
|
||||
).fetchone()
|
||||
mapping = self._row(row)
|
||||
if not mapping or (provider_id and mapping["provider_id"] != provider_id):
|
||||
return None
|
||||
self.conn.execute(
|
||||
"""
|
||||
INSERT INTO audio_effect_active_mappings (song_key, tone_key, mapping_id, updated_at)
|
||||
VALUES (?, ?, ?, datetime('now'))
|
||||
ON CONFLICT(song_key, tone_key) DO UPDATE SET
|
||||
mapping_id=excluded.mapping_id,
|
||||
updated_at=datetime('now')
|
||||
""",
|
||||
(mapping["song_key"], mapping["tone_key"], mapping_id),
|
||||
)
|
||||
self.conn.commit()
|
||||
selected = self.conn.execute(self._select_sql() + " WHERE m.id = ?", (mapping_id,)).fetchone()
|
||||
return self._row(selected)
|
||||
|
||||
def clear_active(self, *, song_key: str, tone_key: str) -> bool:
|
||||
song_key = self._text(song_key, field="song_key", limit=240)
|
||||
tone_key = self._text(tone_key, field="tone_key", limit=160, allow_empty=True)
|
||||
with self._lock:
|
||||
cur = self.conn.execute(
|
||||
"DELETE FROM audio_effect_active_mappings WHERE song_key = ? AND tone_key = ?",
|
||||
(song_key, tone_key),
|
||||
)
|
||||
self.conn.commit()
|
||||
return cur.rowcount > 0
|
||||
-4373
File diff suppressed because it is too large
Load Diff
@@ -1,31 +0,0 @@
|
||||
"""FastAPI route modules extracted from ``server.py`` (R3).
|
||||
|
||||
Each module here exposes a module-level ``router`` (a ``fastapi.APIRouter``)
|
||||
that ``server.py`` mounts with ``app.include_router(...)`` at the point in the
|
||||
file where those routes used to be defined — FastAPI matches routes in
|
||||
registration order, so keeping the mount site preserves it.
|
||||
|
||||
**Routers must never ``import server``.** They reach core singletons through
|
||||
the injected seam instead::
|
||||
|
||||
import appstate
|
||||
|
||||
@router.get("/api/thing")
|
||||
def get_thing():
|
||||
return appstate.meta_db.thing()
|
||||
|
||||
and always as a **module attribute, at call time** — never
|
||||
``from appstate import meta_db``, which freezes the binding and defeats both a
|
||||
later ``appstate.configure()`` and ``monkeypatch.setattr``. See ``appstate.py``.
|
||||
|
||||
Dependencies flow one way: ``server -> routers -> appstate``.
|
||||
|
||||
**Why this lives under ``lib/``.** ``lib/`` is the only core directory every
|
||||
packaging path already copies wholesale — the Dockerfile (``COPY lib/``),
|
||||
``docker-compose.yml``, and feedback-desktop's ``bundle-slopsmith.sh``
|
||||
(``cp -r lib``) — and all three put it on ``sys.path``. A root-level package
|
||||
ships in Docker but is silently dropped from the packaged desktop app, whose
|
||||
bundler copies a hardcoded file list. Route modules import nothing at module
|
||||
scope beyond FastAPI and ``appstate``, so they do no import-time IO and satisfy
|
||||
Principle V's rule for ``lib/``.
|
||||
"""
|
||||
@@ -1,68 +0,0 @@
|
||||
"""Artist aliases / Tidy-up (P4) — canonicalize messy artist tags at DISPLAY
|
||||
("ACDC" -> "AC/DC") without touching feedpak files or the scanner-derived
|
||||
songs.artist. All DB-only.
|
||||
|
||||
Extracted verbatim from ``server.py`` (R3); only the decorator receiver
|
||||
(``@app`` -> ``@router``) and the singleton read (``meta_db`` ->
|
||||
``appstate.meta_db``) changed. The read stays a module attribute so a re-imported
|
||||
``server`` re-publishes a fresh DB into the seam — see ``appstate.py``.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
import appstate
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/api/artist-aliases")
|
||||
def list_artist_aliases():
|
||||
"""Existing raw→canonical overrides (the Tidy-up 'current merges' list)."""
|
||||
return {"aliases": appstate.meta_db.list_artist_aliases()}
|
||||
|
||||
|
||||
@router.get("/api/artists/raw")
|
||||
def list_raw_artists(limit: int = 2000):
|
||||
"""Distinct RAW artist names + song counts + current canonical — the Tidy-up
|
||||
picker (you merge raw variants into one canonical)."""
|
||||
return {"artists": appstate.meta_db.raw_artists(limit)}
|
||||
|
||||
|
||||
@router.post("/api/artist-aliases")
|
||||
def set_artist_alias(data: dict):
|
||||
"""Upsert one override: {raw_name, canonical_name, mb_artist_id?}. A self-alias
|
||||
(raw == canonical) clears the row instead (un-merge)."""
|
||||
raw = (data.get("raw_name") or "").strip()
|
||||
canon = (data.get("canonical_name") or "").strip()
|
||||
if not raw or not canon:
|
||||
return JSONResponse({"error": "raw_name and canonical_name are required"}, 400)
|
||||
result = appstate.meta_db.set_artist_alias(raw, canon, (data.get("mb_artist_id") or None))
|
||||
if not result.get("ok"):
|
||||
# Would form a cycle (raw → … → raw) — refuse rather than corrupt the chain.
|
||||
return JSONResponse(
|
||||
{"error": "alias would create a cycle", "raw_name": raw, "canonical_name": canon},
|
||||
409)
|
||||
return {"ok": True, "raw_name": raw, "canonical_name": result.get("canonical_name", canon)}
|
||||
|
||||
|
||||
@router.post("/api/artist-aliases/merge")
|
||||
def merge_artist_aliases(data: dict):
|
||||
"""Merge several raw artist variants into one canonical:
|
||||
{raw_names: [...], canonical_name}. The canonical's own self-alias is skipped.
|
||||
Returns {merged: N}."""
|
||||
canon = (data.get("canonical_name") or "").strip()
|
||||
raws = data.get("raw_names")
|
||||
if not canon:
|
||||
return JSONResponse({"error": "canonical_name is required"}, 400)
|
||||
if not isinstance(raws, list) or not raws:
|
||||
return JSONResponse({"error": "raw_names must be a non-empty array"}, 400)
|
||||
n = appstate.meta_db.merge_artists(raws, canon)
|
||||
return {"merged": n, "canonical_name": canon}
|
||||
|
||||
|
||||
@router.delete("/api/artist-aliases/{raw_name:path}")
|
||||
def delete_artist_alias(raw_name: str):
|
||||
"""Remove one override so that raw artist stands on its own again."""
|
||||
appstate.meta_db.remove_artist_alias(raw_name)
|
||||
return {"ok": True}
|
||||
@@ -1,80 +0,0 @@
|
||||
"""Audio-effects mapping API — the core-owned song/tone -> provider routing index.
|
||||
|
||||
Extracted verbatim from ``server.py`` (R3); only the decorator receiver
|
||||
(``@app`` -> ``@router``) and the singleton read (``audio_effect_mappings`` ->
|
||||
``appstate.audio_effect_mappings``) changed. The read must stay a module
|
||||
attribute so a re-imported ``server`` re-publishes a fresh DB into the seam and
|
||||
`monkeypatch.setattr` reaches this module — see ``appstate.py``.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Body, Query
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
import appstate
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _audio_effects_error(exc: Exception):
|
||||
return JSONResponse({"error": str(exc)}, status_code=400)
|
||||
|
||||
|
||||
@router.get("/api/audio-effects/mappings")
|
||||
def list_audio_effect_mappings(
|
||||
song_key: str = Query(""),
|
||||
filename: str = Query(""),
|
||||
tone_key: str = Query(""),
|
||||
provider_id: str = Query(""),
|
||||
):
|
||||
try:
|
||||
return {
|
||||
"mappings": appstate.audio_effect_mappings.list(
|
||||
song_key=song_key,
|
||||
filename=filename,
|
||||
tone_key=tone_key,
|
||||
provider_id=provider_id,
|
||||
)
|
||||
}
|
||||
except ValueError as exc:
|
||||
return _audio_effects_error(exc)
|
||||
|
||||
|
||||
@router.post("/api/audio-effects/mappings")
|
||||
def upsert_audio_effect_mapping(data: dict = Body(...)):
|
||||
try:
|
||||
mapping = appstate.audio_effect_mappings.upsert(data)
|
||||
except ValueError as exc:
|
||||
return _audio_effects_error(exc)
|
||||
return {"ok": True, "mapping": mapping}
|
||||
|
||||
|
||||
@router.delete("/api/audio-effects/mappings/{mapping_id}")
|
||||
def delete_audio_effect_mapping(mapping_id: int, provider_id: str = Query("")):
|
||||
try:
|
||||
deleted = appstate.audio_effect_mappings.delete(mapping_id, provider_id=provider_id)
|
||||
except ValueError as exc:
|
||||
return _audio_effects_error(exc)
|
||||
if not deleted:
|
||||
return JSONResponse({"error": "mapping not found"}, status_code=404)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.post("/api/audio-effects/mappings/{mapping_id}/activate")
|
||||
def activate_audio_effect_mapping(mapping_id: int, data: dict = Body(default_factory=dict)):
|
||||
try:
|
||||
provider_id = data.get("provider_id") if "provider_id" in data else data.get("providerId")
|
||||
mapping = appstate.audio_effect_mappings.activate(mapping_id, provider_id="" if provider_id is None else provider_id)
|
||||
except ValueError as exc:
|
||||
return _audio_effects_error(exc)
|
||||
if not mapping:
|
||||
return JSONResponse({"error": "mapping not found"}, status_code=404)
|
||||
return {"ok": True, "mapping": mapping}
|
||||
|
||||
|
||||
@router.delete("/api/audio-effects/active-mapping")
|
||||
def clear_audio_effect_active_mapping(song_key: str = Query(...), tone_key: str = Query("")):
|
||||
try:
|
||||
cleared = appstate.audio_effect_mappings.clear_active(song_key=song_key, tone_key=tone_key)
|
||||
except ValueError as exc:
|
||||
return _audio_effects_error(exc)
|
||||
return {"ok": True, "cleared": cleared}
|
||||
@@ -1,60 +0,0 @@
|
||||
"""Practice loops — saved A/B regions per song.
|
||||
|
||||
Extracted verbatim from ``server.py`` (R3); only the decorator receiver
|
||||
(``@app`` -> ``@router``) and the singleton reads (``meta_db`` ->
|
||||
``appstate.meta_db``) changed. See ``appstate.py`` for why the reads stay
|
||||
module attributes.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
import appstate
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/api/loops")
|
||||
def list_loops(filename: str):
|
||||
# Hold the DB lock for the read: the shared single connection
|
||||
# (check_same_thread=False) is serialized through meta_db._lock by every
|
||||
# writer, so an unlocked SELECT here can overlap a POST/DELETE commit.
|
||||
db = appstate.meta_db
|
||||
with db._lock:
|
||||
rows = db.conn.execute(
|
||||
"SELECT id, name, start_time, end_time FROM loops WHERE filename = ? ORDER BY start_time",
|
||||
(filename,)
|
||||
).fetchall()
|
||||
return [{"id": r[0], "name": r[1], "start": r[2], "end": r[3]} for r in rows]
|
||||
|
||||
|
||||
@router.post("/api/loops")
|
||||
def save_loop(data: dict):
|
||||
filename = data.get("filename", "")
|
||||
name = data.get("name", "").strip()
|
||||
start = data.get("start")
|
||||
end = data.get("end")
|
||||
if not filename or start is None or end is None:
|
||||
return {"error": "Missing fields"}
|
||||
db = appstate.meta_db
|
||||
with db._lock:
|
||||
# COUNT + INSERT under one lock so two unnamed POSTs can't read the same
|
||||
# count and both mint "Loop N" (the count is only used to name the row).
|
||||
if not name:
|
||||
count = db.conn.execute(
|
||||
"SELECT COUNT(*) FROM loops WHERE filename = ?", (filename,)
|
||||
).fetchone()[0]
|
||||
name = f"Loop {count + 1}"
|
||||
db.conn.execute(
|
||||
"INSERT INTO loops (filename, name, start_time, end_time) VALUES (?, ?, ?, ?)",
|
||||
(filename, name, float(start), float(end))
|
||||
)
|
||||
db.conn.commit()
|
||||
return {"ok": True, "name": name}
|
||||
|
||||
|
||||
@router.delete("/api/loops/{loop_id}")
|
||||
def delete_loop(loop_id: int):
|
||||
with appstate.meta_db._lock:
|
||||
appstate.meta_db.conn.execute("DELETE FROM loops WHERE id = ?", (loop_id,))
|
||||
appstate.meta_db.conn.commit()
|
||||
return {"ok": True}
|
||||
+1
-1
@@ -3,7 +3,7 @@
|
||||
This module is deliberately kept apart from ``server.py`` so that
|
||||
``ProcessPoolExecutor`` workers can import and unpickle ``_scan_one``
|
||||
without dragging in ``server.py``'s import-time side effects
|
||||
(``configure_logging()``, ``meta_db = MetadataDB(CONFIG_DIR)`` opening/migrating
|
||||
(``configure_logging()``, ``meta_db = MetadataDB()`` opening/migrating
|
||||
SQLite, and ``register_plugin_api(app)`` registering routes).
|
||||
|
||||
The background scan spawns its pool with the ``spawn`` start method (see
|
||||
|
||||
@@ -98,22 +98,6 @@ def open_midis_to_freqs(midis: list[int], reference_pitch: float = DEFAULT_REFER
|
||||
return [round(midi_to_freq(m, reference_pitch), 2) for m in midis]
|
||||
|
||||
|
||||
def freqs_to_midis(freqs: list[float], reference_pitch: float = DEFAULT_REFERENCE_PITCH) -> list[int] | None:
|
||||
"""Return absolute open-string MIDI notes for frequencies at the supplied
|
||||
A4 reference — the inverse of open_midis_to_freqs. None if any entry is
|
||||
non-numeric or non-positive (a provider could hand us anything)."""
|
||||
out: list[int] = []
|
||||
for f in freqs:
|
||||
try:
|
||||
f = float(f)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if f <= 0:
|
||||
return None
|
||||
out.append(int(round(69 + 12 * math.log2(f / reference_pitch))))
|
||||
return out
|
||||
|
||||
|
||||
def tuning_offsets_from_midis(instrument_key: str, midis: list[int]) -> list[int] | None:
|
||||
"""Return semitone offsets from the instrument's standard open strings."""
|
||||
standard = STANDARD_OPEN_MIDIS.get(instrument_key)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"id": "drum_highway_3d",
|
||||
"name": "3D Drum Highway",
|
||||
"version": "0.3.2",
|
||||
"version": "0.3.1",
|
||||
"type": "visualization",
|
||||
"bundled": true,
|
||||
"script": "screen.js",
|
||||
|
||||
@@ -1361,41 +1361,6 @@
|
||||
} catch (_) { /* dispatch unavailable — persisted value applies next init */ }
|
||||
};
|
||||
|
||||
/* ======================================================================
|
||||
* Camera Director bridge resolver (pure — exported via createFactory.__test)
|
||||
* ====================================================================== */
|
||||
|
||||
/**
|
||||
* The active splitscreen API, defensive on the global-name rename in flight
|
||||
* (feedBackSplitscreen is canonical; slopsmithSplitscreen is the legacy alias).
|
||||
* @returns {object|null} the splitscreen API, or null when not present
|
||||
*/
|
||||
function _ssApi() { return window.feedBackSplitscreen || window.slopsmithSplitscreen || null; }
|
||||
|
||||
/**
|
||||
* Resolve the Camera Director camera for a canvas: this panel's camera under
|
||||
* splitscreen, else the global, else null (Camera Director absent → stock
|
||||
* framing). Throw-safe on panelIndexFor so a misbehaving splitscreen build
|
||||
* can't break framing.
|
||||
* @param {HTMLCanvasElement} canvas this renderer's highway canvas
|
||||
* @param {object|null} ss the splitscreen API (see _ssApi)
|
||||
* @param {object|null} panelsMap window.__h3dCamCtlPanels (per-panel cameras by index)
|
||||
* @param {object|null} globalCam window.__h3dCamCtl (single global camera)
|
||||
* @returns {object|null} the resolved free-camera bridge, or null
|
||||
*/
|
||||
function _resolveFreeCam(canvas, ss, panelsMap, globalCam) {
|
||||
if (panelsMap && ss && typeof ss.panelIndexFor === 'function') {
|
||||
try {
|
||||
const i = ss.panelIndexFor(canvas);
|
||||
// Only a non-negative integer indexes the panel map — a non-int /
|
||||
// negative / string index (or a prototype key) must not resolve an
|
||||
// unintended/inherited property; fall through to the global then.
|
||||
if (Number.isInteger(i) && i >= 0 && panelsMap[i]) return panelsMap[i];
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
return globalCam || null;
|
||||
}
|
||||
|
||||
/* ======================================================================
|
||||
* Renderer factory
|
||||
* ====================================================================== */
|
||||
@@ -1449,7 +1414,7 @@
|
||||
let _sparkPts = null, _sparkPos = null, _sparkCol = null, _sparkVel = null, _sparkLife = null;
|
||||
let _fxLastWall = 0; // wall clock for FX integration (sparks, pulse decay)
|
||||
let _kickPulse = 0; // kick-hit camera-dip + floor-wash envelope
|
||||
let _camBaseH = null, _camBaseD = null; // positionCamera's unpulsed pose (null until it first runs; applyCamera's guard depends on this)
|
||||
let _camBaseH = 0, _camBaseD = 0; // positionCamera's unpulsed pose
|
||||
let _gaussTex = null; // shared soft-falloff texture for flash quads
|
||||
let _laneFlashQuads = []; // pooled additive quad per hand lane (z=0)
|
||||
let _kickFlashQuad = null; // full-width flash quad for the kick bar
|
||||
@@ -2686,48 +2651,6 @@
|
||||
cam.lookAt(0, 0, -AHEAD * TS * 0.45);
|
||||
}
|
||||
|
||||
/**
|
||||
* Camera Director bridge for THIS panel — delegates to the pure, unit-
|
||||
* tested _resolveFreeCam / _ssApi (resolver block above the factory).
|
||||
* Reads the live globals: per-panel map __h3dCamCtlPanels → this panel's
|
||||
* camera, else the global __h3dCamCtl, else null (stock framing).
|
||||
* @param {HTMLCanvasElement} canvas this panel's highway canvas
|
||||
* @returns {object|null} the resolved free-camera bridge, or null
|
||||
*/
|
||||
function _freeCamFor(canvas) {
|
||||
return _resolveFreeCam(canvas, _ssApi(), window.__h3dCamCtlPanels, window.__h3dCamCtl);
|
||||
}
|
||||
|
||||
// Per-frame camera write: static base pose (positionCamera) + kick-pulse Y
|
||||
// dip, then layer Camera Director free-cam offsets (dolly/height/orbit on
|
||||
// the camera-from-target vector; pan/pitch on the look target). Runs every
|
||||
// frame so a live free-cam drag is smooth; allocation-free; NaN-safe; a
|
||||
// null/disabled bridge reproduces the stock static+pulse pose exactly.
|
||||
function applyCamera() {
|
||||
if (_camBaseH == null) return; // before first positionCamera()
|
||||
const _dip = (_kickPulse > 0.001) ? (0.8 * K * _kickPulse * fx.hitFx) : 0;
|
||||
let _cx = 0, _cy = _camBaseH - _dip, _cz = _camBaseD;
|
||||
let _lx = 0, _ly = 0, _lz = -AHEAD * TS * 0.45;
|
||||
const _fc = _freeCamFor(highwayCanvas);
|
||||
if (_fc && _fc.enabled) {
|
||||
const _dm = Number.isFinite(_fc.distMul) ? _fc.distMul : 1;
|
||||
const _hm = Number.isFinite(_fc.heightMul) ? _fc.heightMul : 1;
|
||||
const _yaw = Number.isFinite(_fc.yaw) ? _fc.yaw : 0;
|
||||
let _vx = _cx - _lx, _vy = _cy - _ly, _vz = _cz - _lz;
|
||||
_vx *= _dm; _vy *= _dm; _vz *= _dm; // dolly (zoom)
|
||||
_vy *= _hm; // height
|
||||
const _cyw = Math.cos(_yaw), _syw = Math.sin(_yaw);
|
||||
const _rx = _vx * _cyw - _vz * _syw, _rz = _vx * _syw + _vz * _cyw; // orbit around Y
|
||||
_cx = _lx + _rx; _cy = _ly + _vy; _cz = _lz + _rz;
|
||||
const _px = Number.isFinite(_fc.panX) ? _fc.panX : 0;
|
||||
const _py = Number.isFinite(_fc.panY) ? _fc.panY : 0;
|
||||
const _pt = Number.isFinite(_fc.pitch) ? _fc.pitch : 0;
|
||||
_lx += _px * K; _ly += (_pt + _py) * K;
|
||||
}
|
||||
cam.position.set(_cx, _cy, _cz);
|
||||
cam.lookAt(_lx, _ly, _lz);
|
||||
}
|
||||
|
||||
function buildLanes(_floorW, floorD) {
|
||||
laneGroup = new T.Group();
|
||||
laneStripeMats = [];
|
||||
@@ -3508,18 +3431,15 @@
|
||||
BG_STYLES[_bgState._style].update(_bgState.s, bands, fdt, nowMs / 1000);
|
||||
} catch (_) { /* visual-only */ }
|
||||
}
|
||||
// Kick pulse decays each frame; it drives the floor flash and,
|
||||
// via applyCamera(), the camera Y dip.
|
||||
if (_kickPulse > 0.001) {
|
||||
_kickPulse *= Math.exp(-fdt * 7);
|
||||
cam.position.y = _camBaseH - 0.8 * K * _kickPulse * fx.hitFx;
|
||||
if (_floorFlash) _floorFlash.material.opacity = 0.25 * _kickPulse * fx.hitFx;
|
||||
} else if (_kickPulse !== 0) {
|
||||
_kickPulse = 0;
|
||||
cam.position.y = _camBaseH;
|
||||
if (_floorFlash) _floorFlash.material.opacity = 0;
|
||||
}
|
||||
// Write the camera every frame: static base pose + kick dip +
|
||||
// Camera Director free-cam offsets (per-panel-aware).
|
||||
applyCamera();
|
||||
}
|
||||
// Approach highlight: raise each lane stripe toward its next
|
||||
// note (accumulated by the rebuildNotes walk above).
|
||||
@@ -3645,8 +3565,6 @@
|
||||
// vm-loaded with no DOM/WebGL; everything here must stay side-effect
|
||||
// free to call).
|
||||
window.slopsmithViz_drum_highway_3d.__test = {
|
||||
_resolveFreeCam,
|
||||
_ssApi,
|
||||
_variantForHit,
|
||||
_classifyTiming,
|
||||
readFxSettings,
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
// Camera Director bridge resolver tests: per-panel select, global fallback,
|
||||
// null-when-absent, throw-safety, and the splitscreen global-name alias. Loads
|
||||
// screen.js in a bare vm window and exercises the __test exports (no DOM/WebGL).
|
||||
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');
|
||||
|
||||
function load() {
|
||||
const window = {
|
||||
console,
|
||||
location: { protocol: 'http:', host: 'localhost' },
|
||||
slopsmith: {},
|
||||
};
|
||||
window.window = window;
|
||||
window.globalThis = window;
|
||||
const context = vm.createContext(window);
|
||||
const src = fs.readFileSync(path.join(__dirname, '..', 'screen.js'), 'utf8');
|
||||
vm.runInContext(src, context, { filename: 'screen.js' });
|
||||
return { window, __test: window.slopsmithViz_drum_highway_3d.__test };
|
||||
}
|
||||
|
||||
test('_resolveFreeCam: per-panel camera under splitscreen', () => {
|
||||
const { __test } = load();
|
||||
const c0 = {}, c1 = {};
|
||||
const ss = { panelIndexFor: (c) => (c === c0 ? 0 : 1) };
|
||||
const map = { 0: { id: 'p0' }, 1: { id: 'p1' } };
|
||||
assert.equal(__test._resolveFreeCam(c0, ss, map, { id: 'g' }).id, 'p0');
|
||||
assert.equal(__test._resolveFreeCam(c1, ss, map, { id: 'g' }).id, 'p1');
|
||||
});
|
||||
|
||||
test('_resolveFreeCam: falls back to global when there is no panel map', () => {
|
||||
const { __test } = load();
|
||||
const g = { id: 'global' };
|
||||
assert.equal(__test._resolveFreeCam({}, { panelIndexFor: () => 0 }, null, g), g);
|
||||
});
|
||||
|
||||
test('_resolveFreeCam: falls back to global when the panel has no map entry', () => {
|
||||
const { __test } = load();
|
||||
const g = { id: 'global' };
|
||||
const ss = { panelIndexFor: () => 3 }; // index 3 absent from map
|
||||
assert.equal(__test._resolveFreeCam({}, ss, { 0: {} }, g), g);
|
||||
});
|
||||
|
||||
test('_resolveFreeCam: null when Camera Director is absent (no global)', () => {
|
||||
const { __test } = load();
|
||||
assert.equal(__test._resolveFreeCam({}, null, null, null), null);
|
||||
assert.equal(__test._resolveFreeCam({}, { panelIndexFor: () => 0 }, {}, undefined), null);
|
||||
});
|
||||
|
||||
test('_resolveFreeCam: throw-safe on panelIndexFor → falls back to global', () => {
|
||||
const { __test } = load();
|
||||
const g = { id: 'global' };
|
||||
const ss = { panelIndexFor: () => { throw new Error('boom'); } };
|
||||
assert.equal(__test._resolveFreeCam({}, ss, { 0: {} }, g), g);
|
||||
});
|
||||
|
||||
test('_resolveFreeCam: NaN/negative/float/string index → falls back to global', () => {
|
||||
const { __test } = load();
|
||||
const g = { id: 'global' };
|
||||
assert.equal(__test._resolveFreeCam({}, { panelIndexFor: () => NaN }, { 0: {} }, g), g);
|
||||
assert.equal(__test._resolveFreeCam({}, { panelIndexFor: () => -1 }, { 0: {} }, g), g);
|
||||
assert.equal(__test._resolveFreeCam({}, { panelIndexFor: () => 0.5 }, { 0: {} }, g), g);
|
||||
// A string/prototype key must not resolve an inherited property (e.g. toString).
|
||||
assert.equal(__test._resolveFreeCam({}, { panelIndexFor: () => 'toString' }, {}, g), g);
|
||||
});
|
||||
|
||||
test('_ssApi: null when neither global set; slopsmith alias; feedBack canonical wins', () => {
|
||||
const { window, __test } = load();
|
||||
assert.equal(__test._ssApi(), null);
|
||||
const legacy = { panelIndexFor: () => 0 };
|
||||
window.slopsmithSplitscreen = legacy;
|
||||
assert.equal(__test._ssApi(), legacy); // legacy alias picked up
|
||||
const current = { panelIndexFor: () => 1 };
|
||||
window.feedBackSplitscreen = current;
|
||||
assert.equal(__test._ssApi(), current); // canonical name takes precedence
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"id": "highway_3d",
|
||||
"name": "3D Highway",
|
||||
"version": "3.31.5",
|
||||
"version": "3.31.3",
|
||||
"type": "visualization",
|
||||
"bundled": true,
|
||||
"script": "screen.js",
|
||||
|
||||
@@ -548,20 +548,9 @@
|
||||
ctrl.ownsActx = !(fogAudio && fogAudio.ctx);
|
||||
ctrl.actx = (fogAudio && fogAudio.ctx) || new Ctx();
|
||||
if (ctrl.actx.state === 'suspended' && ctrl.actx.resume) ctrl.actx.resume().catch(() => {});
|
||||
// Seed the DRAWING BUFFER (canvas.width/height) to the device-pixel
|
||||
// render size and report that SAME size to Butterchurn. Its on-screen
|
||||
// pass viewports to the reported size but never sizes the output canvas
|
||||
// itself — leaving the buffer at the 300x150 default blits the whole
|
||||
// visualizer into a corner that CSS then stretches across the highway.
|
||||
// pixelRatio:1 because DPR is now folded into the reported size, so
|
||||
// buffer == viewport == internal texsize (no double-counting).
|
||||
const _bcRatio0 = Math.min(window.devicePixelRatio || 1, 1.5);
|
||||
const _bcW0 = Math.max(1, Math.round((sz.w || 1280) * _bcRatio0));
|
||||
const _bcH0 = Math.max(1, Math.round((sz.h || 720) * _bcRatio0));
|
||||
canvas.width = _bcW0; canvas.height = _bcH0;
|
||||
ctrl.viz = bc.createVisualizer(ctrl.actx, canvas, {
|
||||
width: _bcW0, height: _bcH0,
|
||||
pixelRatio: 1, textureRatio: 1,
|
||||
width: sz.w || 1280, height: sz.h || 720,
|
||||
pixelRatio: Math.min(window.devicePixelRatio || 1, 1.5), textureRatio: 1,
|
||||
});
|
||||
if (_bcIsDesktop()) {
|
||||
try {
|
||||
@@ -595,27 +584,6 @@
|
||||
ctrl.actx = null; ctrl.viz = null; ctrl.dead = true;
|
||||
_bcControllers.delete(ctrl);
|
||||
});
|
||||
// Size the Butterchurn output: set the canvas DRAWING BUFFER to the
|
||||
// device-pixel render size AND report that same size, so buffer ==
|
||||
// on-screen viewport == full fill. Butterchurn never sizes the output
|
||||
// canvas itself; the previous code set only CSS size, leaving the buffer
|
||||
// at the 300x150 default -> the viz showed a stretched lower-left corner
|
||||
// (worse the larger the panel). Ratio reuses the highway's DPR budget.
|
||||
function _bcApplySize(cssW, cssH) {
|
||||
if (!(cssW > 0 && cssH > 0)) return;
|
||||
ctrl.lastW = cssW; ctrl.lastH = cssH;
|
||||
const ratio = Math.min(window.devicePixelRatio || 1, 1.5);
|
||||
const bw = Math.max(1, Math.round(cssW * ratio)), bh = Math.max(1, Math.round(cssH * ratio));
|
||||
if (canvas.width !== bw) canvas.width = bw;
|
||||
if (canvas.height !== bh) canvas.height = bh;
|
||||
const wpx = cssW + 'px', hpx = cssH + 'px';
|
||||
// Confine ALL layers to exactly the highway-canvas rect so the opaque
|
||||
// backdrop can't bleed over the transport bar above the highway.
|
||||
[ctrl.canvas, ctrl.backdrop, ctrl.scrim, ctrl.tint].forEach((el) => {
|
||||
if (el) { el.style.width = wpx; el.style.height = hpx; el.style.right = 'auto'; el.style.bottom = 'auto'; }
|
||||
});
|
||||
if (ctrl.viz && ctrl.viz.setRendererSize) { try { ctrl.viz.setRendererSize(bw, bh); } catch (e) {} }
|
||||
}
|
||||
return {
|
||||
applySettings() { ctrl.applySettings(); },
|
||||
dead() { return ctrl.dead; },
|
||||
@@ -644,11 +612,18 @@
|
||||
if (!ctrl.viz || !s.enabled) return; // skip GPU work when the bg is off
|
||||
const sz = sizeProvider && sizeProvider();
|
||||
if (sz && sz.w > 0 && sz.h > 0 && (sz.w !== ctrl.lastW || sz.h !== ctrl.lastH)) {
|
||||
_bcApplySize(sz.w, sz.h);
|
||||
ctrl.lastW = sz.w; ctrl.lastH = sz.h;
|
||||
const wpx = sz.w + 'px', hpx = sz.h + 'px';
|
||||
// Confine ALL layers to exactly the highway-canvas rect so the opaque
|
||||
// backdrop can't bleed over the transport bar above the highway.
|
||||
[ctrl.canvas, ctrl.backdrop, ctrl.scrim, ctrl.tint].forEach((el) => {
|
||||
if (el) { el.style.width = wpx; el.style.height = hpx; el.style.right = 'auto'; el.style.bottom = 'auto'; }
|
||||
});
|
||||
try { ctrl.viz.setRendererSize(sz.w, sz.h); } catch (e) {}
|
||||
}
|
||||
try { ctrl.viz.render(); } catch (e) {}
|
||||
},
|
||||
resize(w, h) { _bcApplySize(w, h); },
|
||||
resize(w, h) { if (ctrl.viz && ctrl.viz.setRendererSize) { try { ctrl.viz.setRendererSize(w, h); } catch (e) {} ctrl.lastW = w; ctrl.lastH = h; } },
|
||||
destroy() {
|
||||
ctrl.dead = true;
|
||||
_bcControllers.delete(ctrl);
|
||||
@@ -2620,51 +2595,10 @@
|
||||
}
|
||||
const FRET_NUMBER_GHOST_SCOPE_IDS = ['chords', 'all'];
|
||||
|
||||
/**
|
||||
* localStorage panel key for per-panel background settings ('main' or
|
||||
* 'panel<index>'). Defensive on the splitscreen global-name rename in flight,
|
||||
* and throw-safe on panelIndexFor — same as _freeCamFor — so a misbehaving
|
||||
* splitscreen build can't take down background-settings resolution. Only a
|
||||
* non-negative integer index yields a 'panel<N>' key; anything else (null,
|
||||
* NaN, negative, non-integer) falls back to 'main' so a bad index can never
|
||||
* mint a bogus "panelNaN"-style key.
|
||||
* @param {HTMLCanvasElement} canvas this renderer's highway canvas
|
||||
* @returns {string} 'main' or 'panel<index>'
|
||||
*/
|
||||
function _bgPanelKey(canvas) {
|
||||
const ss = window.feedBackSplitscreen || window.slopsmithSplitscreen;
|
||||
let idx = null;
|
||||
if (ss && typeof ss.panelIndexFor === 'function') {
|
||||
try { idx = ss.panelIndexFor(canvas); } catch (e) { idx = null; }
|
||||
}
|
||||
return (Number.isInteger(idx) && idx >= 0) ? 'panel' + idx : 'main';
|
||||
}
|
||||
|
||||
/**
|
||||
* Camera Director bridge resolver. Prefers THIS panel's per-panel camera under
|
||||
* splitscreen (window.__h3dCamCtlPanels[panelIndex]) and falls back to the
|
||||
* single global (window.__h3dCamCtl); returns null when Camera Director is
|
||||
* absent → 100% stock framing. Defensive on the splitscreen global-name rename
|
||||
* in flight (feedBackSplitscreen vs slopsmithSplitscreen); throw-safe on
|
||||
* panelIndexFor. Mirrors the panel resolution in _bgPanelKey.
|
||||
* @param {HTMLCanvasElement} canvas this renderer's highway canvas
|
||||
* @returns {object|null} the resolved free-camera bridge, or null
|
||||
*/
|
||||
function _freeCamFor(canvas) {
|
||||
const map = window.__h3dCamCtlPanels;
|
||||
if (map) {
|
||||
const ss = window.feedBackSplitscreen || window.slopsmithSplitscreen;
|
||||
if (ss && typeof ss.panelIndexFor === 'function') {
|
||||
try {
|
||||
const i = ss.panelIndexFor(canvas);
|
||||
// Only a non-negative integer indexes the map (same hardening
|
||||
// as _bgPanelKey) — a non-int / negative / string index must not
|
||||
// resolve an unintended/inherited property; fall through then.
|
||||
if (Number.isInteger(i) && i >= 0 && map[i]) return map[i];
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
}
|
||||
return window.__h3dCamCtl || null;
|
||||
const ss = window.feedBackSplitscreen;
|
||||
const idx = (ss && typeof ss.panelIndexFor === 'function') ? ss.panelIndexFor(canvas) : null;
|
||||
return (idx == null) ? 'main' : 'panel' + idx;
|
||||
}
|
||||
// In-memory fallback for when localStorage is blocked (private mode,
|
||||
// sandboxed iframes, some test runners). _bgWriteGlobal stages the
|
||||
@@ -14730,10 +14664,7 @@
|
||||
// suppressed while the Camera Director owns the view (it wins).
|
||||
const _startAspect = (_tune && Number.isFinite(_tune.startAspect) && _tune.startAspect > 0)
|
||||
? _tune.startAspect : HORPLUS_START_ASPECT;
|
||||
// Resolve the Camera Director bridge once (per-panel under splitscreen,
|
||||
// else global). Used both for the wide-pane gate and the transforms below.
|
||||
const _freeCam = _freeCamFor(highwayCanvas);
|
||||
const _dirActive = !!(_freeCam && _freeCam.enabled);
|
||||
const _dirActive = !!(window.__h3dCamCtl && window.__h3dCamCtl.enabled);
|
||||
const _wide = !!(_tune && _paneAspect > _startAspect) && !_dirActive;
|
||||
const _poseHMul = (_wide && Number.isFinite(_tune.heightMul)) ? _tune.heightMul : 1;
|
||||
const _poseDMul = (_wide && Number.isFinite(_tune.distMul)) ? _tune.distMul : 1;
|
||||
@@ -14760,16 +14691,13 @@
|
||||
if (_poseHMul !== 1) _camY *= _poseHMul;
|
||||
if (_poseDMul !== 1) _camZ *= _poseDMul;
|
||||
// ── Free-camera user tweaks (orbit / height / zoom / pan) ──
|
||||
// Driven by the Camera Director plugin via the camera bridge:
|
||||
// window.__h3dCamCtlPanels[panelIndexFor(canvas)] when split (this
|
||||
// panel's own camera), falling back to the global window.__h3dCamCtl.
|
||||
// Driven by the Camera Director plugin via window.__h3dCamCtl.
|
||||
// Layered ON TOP of the auto-framing so note tracking still works.
|
||||
// The bridge is read once into _freeCam and reused for both the
|
||||
// position and the look-at transforms; every field is coerced to a
|
||||
// finite number before use so a malformed object can never feed NaN
|
||||
// into cam.position / cam.lookAt.
|
||||
// _freeCam resolved above via _freeCamFor(highwayCanvas): the
|
||||
// per-panel __h3dCamCtlPanels entry, else global __h3dCamCtl, else null.
|
||||
const _freeCam = window.__h3dCamCtl;
|
||||
const _lookAtZ = -FOCUS_D * 0.35 * _poseLookZMul;
|
||||
if (_freeCam && _freeCam.enabled) {
|
||||
const _distMul = Number.isFinite(_freeCam.distMul) ? _freeCam.distMul : 1;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"id": "keys_highway_3d",
|
||||
"name": "Keys Highway 3D",
|
||||
"version": "0.2.1",
|
||||
"version": "0.2.0",
|
||||
"description": "RS+-style 3D falling-note piano highway fed by the Sloppak Notation Format, with Web MIDI input scoring.",
|
||||
"type": "visualization",
|
||||
"bundled": true,
|
||||
|
||||
@@ -813,36 +813,20 @@
|
||||
_writeStore(STORE_KEYS.midiPick, JSON.stringify({ id: id || '', name: name || '', key: key || '' }));
|
||||
}
|
||||
|
||||
// Pure decision logic (exported via __test): pick which device to
|
||||
// auto-connect to from the current source list, the domain-wide selection
|
||||
// (`globalKey`, from Settings → Input Setup), and this plugin's own legacy
|
||||
// saved pick. Returns null for "connect to nothing" (explicit None opt-out,
|
||||
// or the configured device currently absent during hotplug recovery).
|
||||
//
|
||||
// The domain-wide selection is the SOURCE OF TRUTH (checked first): a device
|
||||
// configured globally must never be overridden by a stale plugin-local pick
|
||||
// or an arbitrary first-device fallback — that override was the bug. The
|
||||
// local pick is retained only as a fallback BELOW the global (and for
|
||||
// name-recovery when the global's logicalSourceKey went stale, e.g. a
|
||||
// browser that regenerates MIDI port ids across reloads). Auto-connect no
|
||||
// longer writes the local pick, so it only ever holds a value an explicit
|
||||
// selection put there (or a stale one from a pre-fix build — the global
|
||||
// still wins over it).
|
||||
function _pickMidiTarget(inputs, saved, globalKey, allowFallback) {
|
||||
if (!inputs.length) return null;
|
||||
const notBlocked = (i) => !!i && !_MIDI_BLOCKLIST_RE.test(i.name || '');
|
||||
// Explicit "None" opt-out (set only via the device-select API).
|
||||
if (saved && saved.id === '' && saved.name === '') return null;
|
||||
|
||||
// 1. Domain-wide selection (Settings → Input Setup) — source of truth.
|
||||
if (globalKey) {
|
||||
const g = inputs.find(i => i.key === globalKey);
|
||||
if (notBlocked(g)) return g;
|
||||
}
|
||||
|
||||
// 2. Legacy plugin-local pick, as a fallback below the global. Prefer the
|
||||
// globally-unique logicalSourceKey, then the legacy bare sourceId, then
|
||||
// case-insensitive name (Chrome on Linux regenerates ids per page load).
|
||||
function _midiAutoConnect(allowFallback) {
|
||||
// Recovery (sources-changed after unplug) passes false: never switch to a
|
||||
// fallback input, because _midiConnect persists the pick and that would
|
||||
// overwrite the user's saved device on a transient multi-device unplug
|
||||
// (the original returns on replug and reconnects then).
|
||||
if (allowFallback === undefined) allowFallback = true;
|
||||
const inputs = _midiSources();
|
||||
if (!inputs.length) return;
|
||||
const saved = _readSavedPick();
|
||||
// Explicit "None" opt-out.
|
||||
if (saved && saved.id === '' && saved.name === '') return;
|
||||
// Prefer the globally-unique logicalSourceKey, then the legacy bare
|
||||
// sourceId, then case-insensitive name (Chrome on Linux regenerates ids
|
||||
// per page load), then first non-loopback.
|
||||
let target = null;
|
||||
if (saved && saved.key) target = inputs.find(i => i.key === saved.key) || null;
|
||||
if (!target && saved && saved.id) target = inputs.find(i => i.id === saved.id) || null;
|
||||
@@ -850,50 +834,23 @@
|
||||
const n = saved.name.toLowerCase();
|
||||
target = inputs.find(i => (i.name || '').toLowerCase() === n) || null;
|
||||
}
|
||||
// Never honour a saved pick that resolves to a loopback / "Midi Through"
|
||||
// port — it carries no device input, so it silently eats every note.
|
||||
if (target && !notBlocked(target)) target = null;
|
||||
if (target) return target;
|
||||
|
||||
// 3. Nothing configured resolved to a present device. In recovery
|
||||
// (allowFallback=false) with a configured preference — a global pick or a
|
||||
// saved pick — that's currently absent, preserve it rather than switching
|
||||
// to an arbitrary device on a transient multi-device unplug. With no
|
||||
// preference at all, a first-device grab is the intended first-hotplug
|
||||
// auto-connect, allowed even in recovery.
|
||||
const hasPreference = !!(globalKey || (saved && (saved.key || saved.id || saved.name)));
|
||||
if (!allowFallback && hasPreference) return null;
|
||||
// Connect to nothing rather than a loopback: if every present device is
|
||||
// blocklisted, a first-device grab would attach to a "Midi Through"/IAC
|
||||
// port that carries no input and silently eats every note.
|
||||
return inputs.find(notBlocked) || null;
|
||||
}
|
||||
|
||||
function _midiAutoConnect(allowFallback) {
|
||||
// Recovery (sources-changed after unplug) passes false: never switch to a
|
||||
// fallback input on a transient multi-device unplug (the configured
|
||||
// device returns on replug and reconnects then). Auto-connect is
|
||||
// non-persisting (persist omitted → false): it opens the resolved device
|
||||
// for this session WITHOUT writing the plugin-local pick or the shared
|
||||
// domain selection, so opening this highway can't clobber the user's
|
||||
// globally-configured device.
|
||||
if (allowFallback === undefined) allowFallback = true;
|
||||
const inputs = _midiSources();
|
||||
const saved = _readSavedPick();
|
||||
const mi = _mi();
|
||||
const globalKey = mi && typeof mi.getSelected === 'function' ? mi.getSelected() : null;
|
||||
const target = _pickMidiTarget(inputs, saved, globalKey, allowFallback);
|
||||
if (!target) return;
|
||||
// Never honour a saved pick that's a loopback / "Midi Through" port — it
|
||||
// carries no device input, so a stale pick silently eats every note. The
|
||||
// saved-pick lookups above bypass the block-list; re-apply it here.
|
||||
if (target && _MIDI_BLOCKLIST_RE.test(target.name || '')) target = null;
|
||||
if (!target) {
|
||||
// Skip the substitute ONLY when a saved pick exists but is currently
|
||||
// absent (recovery: preserve it, don't clobber on a transient unplug).
|
||||
// With no saved pick at all, a fallback is the intended first-hotplug
|
||||
// auto-connect — allow it even in recovery.
|
||||
const hasSavedPick = !!(saved && (saved.key || saved.id || saved.name));
|
||||
if (!allowFallback && hasSavedPick) return;
|
||||
target = inputs.find(i => !_MIDI_BLOCKLIST_RE.test(i.name || '')) || inputs[0];
|
||||
}
|
||||
_midiConnect(target.id, target.name, target.key);
|
||||
}
|
||||
|
||||
// `persist` gates the two preference writes. Only an EXPLICIT device
|
||||
// selection (the device-select API) persists: it writes the plugin-local
|
||||
// pick AND the shared domain selection (`mi.select`, so the user's choice
|
||||
// becomes the global default). Auto-connect and programmatic opens pass
|
||||
// falsy — they open the resolved device for this session only, never
|
||||
// touching either store, so they can't clobber a globally-configured device.
|
||||
async function _midiConnect(id, name, key, persist) {
|
||||
async function _midiConnect(id, name, key) {
|
||||
// Capture our generation AFTER _midiDetach()'s own bump, so a later
|
||||
// detach (device removal / new connect / opt-out) reliably supersedes us.
|
||||
_midiDetach();
|
||||
@@ -904,7 +861,7 @@
|
||||
for (const inst of _instances) {
|
||||
if (inst && typeof inst._releaseAllHeld === 'function') inst._releaseAllHeld();
|
||||
}
|
||||
if (persist) _writeSavedPick(id || '', name || '', key || '');
|
||||
_writeSavedPick(id || '', name || '', key || '');
|
||||
const mi = _mi();
|
||||
if ((id || key) && mi) {
|
||||
// Prefer the globally-unique logicalSourceKey so two providers that
|
||||
@@ -917,19 +874,13 @@
|
||||
const lkey = src.key || ('web-midi::' + src.id);
|
||||
_midiInput = { id: src.id, name: src.name, key: lkey };
|
||||
_midiJustConnected = true;
|
||||
// Only an explicit selection writes the shared global default;
|
||||
// open takes the logicalSourceKey directly, so select() is not
|
||||
// needed to open — it exists purely to set the global. Persist it
|
||||
// BEFORE the no-instance early return so a settings-panel pick with
|
||||
// no live renderer still updates the shared default (best-effort:
|
||||
// a select hiccup must not abort the connect).
|
||||
if (persist) { try { await mi.select(lkey); } catch (_) { /* best-effort */ } }
|
||||
// No live renderer to consume OR release a session — don't hold one
|
||||
// open (settings-only ensure-init, or the last instance was torn
|
||||
// down during async discovery). A later renderer mount re-runs
|
||||
// auto-connect and opens for real, releasing on destroy.
|
||||
// down during async discovery). The pick is saved; a later renderer
|
||||
// mount re-runs auto-connect and opens for real, releasing on destroy.
|
||||
if (_instances.size === 0) { _midiNotifyDeviceListChanged(); return; }
|
||||
try {
|
||||
await mi.select(lkey);
|
||||
const res = await mi.open({ requester: PLUGIN_ID, logicalSourceKey: lkey });
|
||||
// A newer _midiConnect (device switch / None / replug) ran while
|
||||
// we awaited open — discard this stale session so we don't wire a
|
||||
@@ -1088,11 +1039,10 @@
|
||||
window.keysH3dGetMidiInputId = function () { return _midiInput ? _midiInput.id : ''; };
|
||||
window.keysH3dSetMidiInput = function (id) {
|
||||
// `id` may be a logicalSourceKey (new host calls) or a legacy sourceId.
|
||||
// Explicit user selection → persist (local pick + shared global default).
|
||||
const src = id
|
||||
? (_midiSources().find(s => s.key === id) || _midiSources().find(s => s.id === id))
|
||||
: null;
|
||||
_midiConnect(src ? src.id : (id || ''), src ? src.name : '', src ? src.key : '', true);
|
||||
_midiConnect(src ? src.id : (id || ''), src ? src.name : '', src ? src.key : '');
|
||||
return true;
|
||||
};
|
||||
window.keysH3dGetMidiChannel = function () { return _cfg.midiChannel; };
|
||||
@@ -1597,8 +1547,6 @@
|
||||
|
||||
function _aiOpen(req) {
|
||||
// Opening a MIDI source connects the corresponding Web MIDI input.
|
||||
// Programmatic open (audio-input source.open) — non-persisting: it must
|
||||
// not rewrite the user's saved pick or the shared global default.
|
||||
const idx = _aiIndexFor(req && (req.sourceId || req.logicalSourceKey));
|
||||
const inputs = _midiSources(); // carries .key (logicalSourceKey), unlike _midiListInputs()
|
||||
if (idx == null || idx >= inputs.length) {
|
||||
@@ -1652,41 +1600,6 @@
|
||||
_aiRegisteredCount = 0;
|
||||
}
|
||||
|
||||
/* ======================================================================
|
||||
* Camera Director bridge resolver (pure — exported via createFactory.__test)
|
||||
* ====================================================================== */
|
||||
|
||||
/**
|
||||
* The active splitscreen API, defensive on the global-name rename in flight
|
||||
* (feedBackSplitscreen is canonical; slopsmithSplitscreen is the legacy alias).
|
||||
* @returns {object|null} the splitscreen API, or null when not present
|
||||
*/
|
||||
function _ssApi() { return window.feedBackSplitscreen || window.slopsmithSplitscreen || null; }
|
||||
|
||||
/**
|
||||
* Resolve the Camera Director camera for a canvas: this panel's camera under
|
||||
* splitscreen, else the global, else null (Camera Director absent → 100% stock
|
||||
* framing). Throw-safe on panelIndexFor so a misbehaving splitscreen build
|
||||
* can't break framing.
|
||||
* @param {HTMLCanvasElement} canvas this renderer's highway canvas
|
||||
* @param {object|null} ss the splitscreen API (see _ssApi)
|
||||
* @param {object|null} panelsMap window.__h3dCamCtlPanels (per-panel cameras by index)
|
||||
* @param {object|null} globalCam window.__h3dCamCtl (single global camera)
|
||||
* @returns {object|null} the resolved free-camera bridge, or null
|
||||
*/
|
||||
function _resolveFreeCam(canvas, ss, panelsMap, globalCam) {
|
||||
if (panelsMap && ss && typeof ss.panelIndexFor === 'function') {
|
||||
try {
|
||||
const i = ss.panelIndexFor(canvas);
|
||||
// Only a non-negative integer indexes the panel map — a non-int /
|
||||
// negative / string index (or a prototype key) must not resolve an
|
||||
// unintended/inherited property; fall through to the global then.
|
||||
if (Number.isInteger(i) && i >= 0 && panelsMap[i]) return panelsMap[i];
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
return globalCam || null;
|
||||
}
|
||||
|
||||
/* ======================================================================
|
||||
* Renderer factory
|
||||
* ====================================================================== */
|
||||
@@ -1936,18 +1849,6 @@
|
||||
_rigOut.lookZ = _camPreset.lookZ;
|
||||
return _rigOut;
|
||||
}
|
||||
|
||||
/**
|
||||
* Camera Director bridge for THIS panel — delegates to the pure, unit-
|
||||
* tested _resolveFreeCam / _ssApi (resolver block above the factory).
|
||||
* Reads the live globals: per-panel map __h3dCamCtlPanels → this panel's
|
||||
* camera, else the global __h3dCamCtl, else null (stock framing).
|
||||
* @param {HTMLCanvasElement} canvas this panel's highway canvas
|
||||
* @returns {object|null} the resolved free-camera bridge, or null
|
||||
*/
|
||||
function _freeCamFor(canvas) {
|
||||
return _resolveFreeCam(canvas, _ssApi(), window.__h3dCamCtlPanels, window.__h3dCamCtl);
|
||||
}
|
||||
// Per-key approach glow: a key lights in its pitch-class color ONLY while a
|
||||
// note is heading for it, ramping up the closer that note gets to the hit-line.
|
||||
const KEY_GLOW_AHEAD = 2.0; // seconds before the hit-line a key starts to light
|
||||
@@ -3358,33 +3259,7 @@
|
||||
}
|
||||
_camX += (_camTargetX - _camX) * CAM_PAN_LERP;
|
||||
_camZoom += (_camTargetZoom - _camZoom) * CAM_ZOOM_LERP;
|
||||
{
|
||||
const r = _rig();
|
||||
let _cx = _camX, _cy = r.y * K * _camZoom, _cz = r.z * K * _camZoom;
|
||||
let _lx = _camX, _ly = r.lookY * K * _camZoom, _lz = r.lookZ * K * _camZoom;
|
||||
// Camera Director free-cam offsets (per-panel-aware), layered on top
|
||||
// of the auto-framing so pan/zoom-follow still works. Dolly/height/
|
||||
// orbit act on the camera-from-target vector; pan/pitch shift the
|
||||
// look target. NaN-safe; null/disabled bridge → stock.
|
||||
const _fc = _freeCamFor(highwayCanvas);
|
||||
if (_fc && _fc.enabled) {
|
||||
const _dm = Number.isFinite(_fc.distMul) ? _fc.distMul : 1;
|
||||
const _hm = Number.isFinite(_fc.heightMul) ? _fc.heightMul : 1;
|
||||
const _yaw = Number.isFinite(_fc.yaw) ? _fc.yaw : 0;
|
||||
let _vx = _cx - _lx, _vy = _cy - _ly, _vz = _cz - _lz;
|
||||
_vx *= _dm; _vy *= _dm; _vz *= _dm; // dolly (zoom)
|
||||
_vy *= _hm; // height
|
||||
const _cyw = Math.cos(_yaw), _syw = Math.sin(_yaw);
|
||||
const _rx = _vx * _cyw - _vz * _syw, _rz = _vx * _syw + _vz * _cyw; // orbit around Y
|
||||
_cx = _lx + _rx; _cy = _ly + _vy; _cz = _lz + _rz;
|
||||
const _px = Number.isFinite(_fc.panX) ? _fc.panX : 0;
|
||||
const _py = Number.isFinite(_fc.panY) ? _fc.panY : 0;
|
||||
const _pt = Number.isFinite(_fc.pitch) ? _fc.pitch : 0;
|
||||
_lx += _px * K; _ly += (_pt + _py) * K;
|
||||
}
|
||||
cam.position.set(_cx, _cy, _cz);
|
||||
cam.lookAt(_lx, _ly, _lz);
|
||||
}
|
||||
{ const r = _rig(); cam.position.set(_camX, r.y * K * _camZoom, r.z * K * _camZoom); cam.lookAt(_camX, r.lookY * K * _camZoom, r.lookZ * K * _camZoom); }
|
||||
|
||||
for (const km of keyMeshes.values()) km.userData.glow = 0;
|
||||
for (const { mesh, note, len, label } of noteMeshes) {
|
||||
@@ -4085,8 +3960,6 @@
|
||||
};
|
||||
// Pure data-layer + scoring hooks for headless tests.
|
||||
window.slopsmithViz_keys_highway_3d.__test = {
|
||||
_resolveFreeCam,
|
||||
_ssApi,
|
||||
beatDurSec,
|
||||
flattenNotation,
|
||||
keyRange,
|
||||
@@ -4120,7 +3993,6 @@
|
||||
FX_DEFAULTS,
|
||||
FX_RANGES,
|
||||
_classifyTiming,
|
||||
_pickMidiTarget,
|
||||
};
|
||||
|
||||
// Headless verification hook: lets Playwright drive synthetic note-ons
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
// Camera Director bridge resolver tests: per-panel select, global fallback,
|
||||
// null-when-absent, throw-safety, and the splitscreen global-name alias. Loads
|
||||
// screen.js in a bare vm window and exercises the __test exports (no DOM/WebGL).
|
||||
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');
|
||||
|
||||
function load() {
|
||||
const window = {
|
||||
console,
|
||||
location: { protocol: 'http:', host: 'localhost' },
|
||||
slopsmith: {},
|
||||
};
|
||||
window.window = window;
|
||||
window.globalThis = window;
|
||||
const context = vm.createContext(window);
|
||||
const src = fs.readFileSync(path.join(__dirname, '..', 'screen.js'), 'utf8');
|
||||
vm.runInContext(src, context, { filename: 'screen.js' });
|
||||
return { window, __test: window.slopsmithViz_keys_highway_3d.__test };
|
||||
}
|
||||
|
||||
test('_resolveFreeCam: per-panel camera under splitscreen', () => {
|
||||
const { __test } = load();
|
||||
const c0 = {}, c1 = {};
|
||||
const ss = { panelIndexFor: (c) => (c === c0 ? 0 : 1) };
|
||||
const map = { 0: { id: 'p0' }, 1: { id: 'p1' } };
|
||||
assert.equal(__test._resolveFreeCam(c0, ss, map, { id: 'g' }).id, 'p0');
|
||||
assert.equal(__test._resolveFreeCam(c1, ss, map, { id: 'g' }).id, 'p1');
|
||||
});
|
||||
|
||||
test('_resolveFreeCam: falls back to global when there is no panel map', () => {
|
||||
const { __test } = load();
|
||||
const g = { id: 'global' };
|
||||
assert.equal(__test._resolveFreeCam({}, { panelIndexFor: () => 0 }, null, g), g);
|
||||
});
|
||||
|
||||
test('_resolveFreeCam: falls back to global when the panel has no map entry', () => {
|
||||
const { __test } = load();
|
||||
const g = { id: 'global' };
|
||||
const ss = { panelIndexFor: () => 3 }; // index 3 absent from map
|
||||
assert.equal(__test._resolveFreeCam({}, ss, { 0: {} }, g), g);
|
||||
});
|
||||
|
||||
test('_resolveFreeCam: null when Camera Director is absent (no global)', () => {
|
||||
const { __test } = load();
|
||||
assert.equal(__test._resolveFreeCam({}, null, null, null), null);
|
||||
assert.equal(__test._resolveFreeCam({}, { panelIndexFor: () => 0 }, {}, undefined), null);
|
||||
});
|
||||
|
||||
test('_resolveFreeCam: throw-safe on panelIndexFor → falls back to global', () => {
|
||||
const { __test } = load();
|
||||
const g = { id: 'global' };
|
||||
const ss = { panelIndexFor: () => { throw new Error('boom'); } };
|
||||
assert.equal(__test._resolveFreeCam({}, ss, { 0: {} }, g), g);
|
||||
});
|
||||
|
||||
test('_resolveFreeCam: NaN/negative/float/string index → falls back to global', () => {
|
||||
const { __test } = load();
|
||||
const g = { id: 'global' };
|
||||
assert.equal(__test._resolveFreeCam({}, { panelIndexFor: () => NaN }, { 0: {} }, g), g);
|
||||
assert.equal(__test._resolveFreeCam({}, { panelIndexFor: () => -1 }, { 0: {} }, g), g);
|
||||
assert.equal(__test._resolveFreeCam({}, { panelIndexFor: () => 0.5 }, { 0: {} }, g), g);
|
||||
// A string/prototype key must not resolve an inherited property (e.g. toString).
|
||||
assert.equal(__test._resolveFreeCam({}, { panelIndexFor: () => 'toString' }, {}, g), g);
|
||||
});
|
||||
|
||||
test('_ssApi: null when neither global set; slopsmith alias; feedBack canonical wins', () => {
|
||||
const { window, __test } = load();
|
||||
assert.equal(__test._ssApi(), null);
|
||||
const legacy = { panelIndexFor: () => 0 };
|
||||
window.slopsmithSplitscreen = legacy;
|
||||
assert.equal(__test._ssApi(), legacy); // legacy alias picked up
|
||||
const current = { panelIndexFor: () => 1 };
|
||||
window.feedBackSplitscreen = current;
|
||||
assert.equal(__test._ssApi(), current); // canonical name takes precedence
|
||||
});
|
||||
@@ -188,99 +188,3 @@ test('measureMarkers extracts idx/t pairs', () => {
|
||||
[{ idx: 1, t: 0 }, { idx: 2, t: 2.5 }],
|
||||
);
|
||||
});
|
||||
|
||||
test('_pickMidiTarget: no plugin-local pick defers to the domain-wide selection, not "first device"', () => {
|
||||
const { _pickMidiTarget } = load();
|
||||
const inputs = [
|
||||
{ id: 'a', name: 'Device A', key: 'web-midi::a' },
|
||||
{ id: 'b', name: 'Device B', key: 'web-midi::b' },
|
||||
];
|
||||
// Fresh install / never picked here — must use the Input Setup global,
|
||||
// NOT fall through to inputs[0].
|
||||
const target = _pickMidiTarget(inputs, null, 'web-midi::b', true);
|
||||
assert.equal(target.id, 'b');
|
||||
});
|
||||
|
||||
test('_pickMidiTarget: the domain-wide selection is the source of truth — it wins over a stale plugin-local pick', () => {
|
||||
const { _pickMidiTarget } = load();
|
||||
const inputs = [
|
||||
{ id: 'a', name: 'Device A', key: 'web-midi::a' },
|
||||
{ id: 'b', name: 'Device B', key: 'web-midi::b' },
|
||||
];
|
||||
// A stale local pick (e.g. left by a pre-fix build's auto-connect) must
|
||||
// NOT override the device the user configured in Settings → Input Setup.
|
||||
const target = _pickMidiTarget(inputs, { id: 'a', name: 'Device A', key: 'web-midi::a' }, 'web-midi::b', true);
|
||||
assert.equal(target.id, 'b');
|
||||
});
|
||||
|
||||
test('_pickMidiTarget: local pick is used as a fallback when no global is configured', () => {
|
||||
const { _pickMidiTarget } = load();
|
||||
const inputs = [
|
||||
{ id: 'a', name: 'Device A', key: 'web-midi::a' },
|
||||
{ id: 'b', name: 'Device B', key: 'web-midi::b' },
|
||||
];
|
||||
const target = _pickMidiTarget(inputs, { id: 'a', name: 'Device A', key: 'web-midi::a' }, null, true);
|
||||
assert.equal(target.id, 'a');
|
||||
});
|
||||
|
||||
test('_pickMidiTarget: local pick name-recovers when its logicalSourceKey went stale (id regeneration)', () => {
|
||||
const { _pickMidiTarget } = load();
|
||||
// Same physical device, new id/key across a reload; the saved key/id miss
|
||||
// but the name still matches.
|
||||
const inputs = [{ id: 'a2', name: 'Device A', key: 'web-midi::a2' }];
|
||||
const target = _pickMidiTarget(inputs, { id: 'a1', name: 'Device A', key: 'web-midi::a1' }, null, true);
|
||||
assert.equal(target.id, 'a2');
|
||||
});
|
||||
|
||||
test('_pickMidiTarget: domain-wide selection is ignored if it names a blocklisted loopback port', () => {
|
||||
const { _pickMidiTarget } = load();
|
||||
const inputs = [
|
||||
{ id: 'thru', name: 'IAC Driver Bus 1', key: 'web-midi::thru' },
|
||||
{ id: 'b', name: 'Device B', key: 'web-midi::b' },
|
||||
];
|
||||
const target = _pickMidiTarget(inputs, null, 'web-midi::thru', true);
|
||||
assert.equal(target.id, 'b'); // falls through to the first non-loopback device
|
||||
});
|
||||
|
||||
test('_pickMidiTarget: when every present device is a loopback, connect to nothing (never a dead port)', () => {
|
||||
const { _pickMidiTarget } = load();
|
||||
const inputs = [
|
||||
{ id: 'thru', name: 'MIDI Through Port-0', key: 'web-midi::thru' },
|
||||
{ id: 'iac', name: 'IAC Driver Bus 1', key: 'web-midi::iac' },
|
||||
];
|
||||
// No non-loopback device exists — must NOT fall back to inputs[0] (a port
|
||||
// that carries no input and would silently eat every note).
|
||||
const target = _pickMidiTarget(inputs, null, null, true);
|
||||
assert.equal(target, null);
|
||||
});
|
||||
|
||||
test('_pickMidiTarget: explicit "None" opt-out still wins over any global default', () => {
|
||||
const { _pickMidiTarget } = load();
|
||||
const inputs = [{ id: 'a', name: 'Device A', key: 'web-midi::a' }];
|
||||
const target = _pickMidiTarget(inputs, { id: '', name: '' }, 'web-midi::a', true);
|
||||
assert.equal(target, null);
|
||||
});
|
||||
|
||||
test('_pickMidiTarget: a present global wins even during hotplug recovery', () => {
|
||||
const { _pickMidiTarget } = load();
|
||||
const inputs = [{ id: 'b', name: 'Device B', key: 'web-midi::b' }];
|
||||
// The configured global device is present — reconnect to it, don't bail.
|
||||
const target = _pickMidiTarget(inputs, null, 'web-midi::b', false);
|
||||
assert.equal(target.id, 'b');
|
||||
});
|
||||
|
||||
test('_pickMidiTarget: recovery (allowFallback=false) preserves an absent configured device instead of grabbing a random one', () => {
|
||||
const { _pickMidiTarget } = load();
|
||||
const inputs = [{ id: 'b', name: 'Device B', key: 'web-midi::b' }];
|
||||
// The configured device ('x', global) is currently unplugged; a transient
|
||||
// recovery must NOT switch to the unrelated device that is present.
|
||||
const target = _pickMidiTarget(inputs, null, 'web-midi::x', false);
|
||||
assert.equal(target, null);
|
||||
});
|
||||
|
||||
test('_pickMidiTarget: recovery with no preference at all still allows a first-hotplug grab', () => {
|
||||
const { _pickMidiTarget } = load();
|
||||
const inputs = [{ id: 'b', name: 'Device B', key: 'web-midi::b' }];
|
||||
const target = _pickMidiTarget(inputs, null, null, false);
|
||||
assert.equal(target.id, 'b');
|
||||
});
|
||||
|
||||
+10
-280
@@ -4864,47 +4864,6 @@ window.jucePlayer = jucePlayer;
|
||||
// (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;
|
||||
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);
|
||||
}
|
||||
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,
|
||||
@@ -4945,12 +4904,8 @@ window.jucePlayer = jucePlayer;
|
||||
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);
|
||||
}
|
||||
if (!res.ok) 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) {
|
||||
@@ -5168,12 +5123,8 @@ window.jucePlayer = jucePlayer;
|
||||
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;
|
||||
// Only /audio/ songs are JUCE-routable; sloppak stems stay on HTML5.
|
||||
if (!songAudio || !songAudio.juceEligible) 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.
|
||||
@@ -5191,30 +5142,13 @@ window.jucePlayer = jucePlayer;
|
||||
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
|
||||
if (!!running === !!window._juceMode) return; // routing already consistent
|
||||
|
||||
const wantJuce = running && !window._juceMode;
|
||||
// Don't keep retrying a track JUCE explicitly rejected.
|
||||
if (wantJuce && songAudio.url === _rerouteRejectedUrl) return;
|
||||
|
||||
if (wantJuce) {
|
||||
if (running) {
|
||||
const outcome = await _switchHtml5ToJuce(songAudio);
|
||||
// Memoise ONLY an explicit hard JUCE reject. A successful
|
||||
// switch clears the memo; a 'stale' abort (song changed
|
||||
@@ -5229,10 +5163,9 @@ window.jucePlayer = jucePlayer;
|
||||
// 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.
|
||||
// The engine just stopped. Clear any hard-reject memo so a
|
||||
// later engine restart re-evaluates the track at least once —
|
||||
// the rejection may have been a transient device/decoder state.
|
||||
_rerouteRejectedUrl = null;
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -5264,209 +5197,6 @@ window.jucePlayer = jucePlayer;
|
||||
}, 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') return;
|
||||
|
||||
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');
|
||||
_elCtx = new AudioContext();
|
||||
_elSource = _elCtx.createMediaElementSource(el);
|
||||
_elSource.connect(_elCtx.destination);
|
||||
_elTap = _makeTap(_elCtx);
|
||||
await _elTap.attach(_elSource);
|
||||
}
|
||||
|
||||
// ── Engagement state machine ─────────────────────────────────────────────
|
||||
// 'off' | 'element' | 'stems'
|
||||
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(() => {});
|
||||
}
|
||||
|
||||
async function _disengage() {
|
||||
if (_mode === 'off') return;
|
||||
const prev = _mode;
|
||||
_mode = 'off';
|
||||
try { await api.setRendererBus(false, 0); } catch (_) { /* engine gone */ }
|
||||
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) {
|
||||
if (stems) want = 'stems';
|
||||
else if (elementSong) want = 'element';
|
||||
}
|
||||
|
||||
const stemsGraphChanged = _mode === 'stems' && stems !== _stemsGraph;
|
||||
if (want !== _mode || stemsGraphChanged) {
|
||||
await _disengage();
|
||||
if (want === 'stems') await _engageStems(stems);
|
||||
else if (want === 'element') await _engageElement();
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[renderer-bus] reevaluate failed (will retry):', e);
|
||||
_mode = 'off';
|
||||
} 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
|
||||
|
||||
@@ -9,8 +9,6 @@
|
||||
|
||||
const SCHEMA = 'feedBack.audio_effects.diagnostics.v1';
|
||||
const PLAN_SCHEMA = 'feedBack.audio_effects.chain_plan.v1';
|
||||
// Pre-rebrand plugins (rig_builder <= 2.9.x) still send the old schema id — accept it as an alias.
|
||||
const LEGACY_PLAN_SCHEMA = 'slopsmith.audio_effects.chain_plan.v1';
|
||||
const OWNER_ID = 'core.audio.effects';
|
||||
const DEFAULT_ROUTE_KEY = 'desktop-main';
|
||||
const DEFAULT_TIMEOUT_MS = 2000;
|
||||
@@ -736,7 +734,7 @@ const LEGACY_PLAN_SCHEMA = 'slopsmith.audio_effects.chain_plan.v1';
|
||||
const errors = [];
|
||||
const source = _plainObject(rawPlan);
|
||||
const schema = _string(source.schema || source.version, PLAN_SCHEMA);
|
||||
if (schema !== PLAN_SCHEMA && schema !== LEGACY_PLAN_SCHEMA && schema !== '1') errors.push('Unsupported chain plan schema');
|
||||
if (schema !== PLAN_SCHEMA && schema !== '1') errors.push('Unsupported chain plan schema');
|
||||
const planRoute = _safeRoute(source.routeKey || source.route || routeKey);
|
||||
if (planRoute !== routeKey) errors.push('Chain plan route does not match selected route');
|
||||
const providerId = _safeId(source.providerId || provider.providerId, provider.providerId);
|
||||
|
||||
+5
-58
@@ -3409,57 +3409,21 @@ function createHighway() {
|
||||
if (msg.audio_url) {
|
||||
const audio = document.getElementById('audio');
|
||||
const audioFilename = msg.audio_url.split('/').pop();
|
||||
// /audio/ URLs are always JUCE-routable. A feedpak full-mix
|
||||
// (single-mix pack: original audio, no stems) is routable too,
|
||||
// but ONLY under an exclusive-style output — the actual
|
||||
// exclusive check happens at routing time (app.js watcher /
|
||||
// the async block below), not here, because the share mode
|
||||
// can change while the song is loaded. Sloppak stem URLs are
|
||||
// never routable.
|
||||
// Only attempt JUCE routing for /audio/ URLs — sloppak stems
|
||||
// (/api/sloppak/…) are not resolvable via audio-local-path.
|
||||
const isAudioUrl = msg.audio_url.startsWith('/audio/');
|
||||
// "Full mix" covers BOTH single-mix pack shapes:
|
||||
// - stem-less packs (original_audio: in the manifest,
|
||||
// audio_url == original_audio_url), and
|
||||
// - single-stem packs (stems: [full.ogg] only) — the server
|
||||
// puts the full mix in the stems list, has_original_audio
|
||||
// is false, and audio_url points at the one stem. With one
|
||||
// stem there is no per-stem mix to preserve, so routing it
|
||||
// natively loses nothing. Real multi-stem (>1) stays out
|
||||
// until Phase 2.
|
||||
const isFeedpakFullMix = !isAudioUrl
|
||||
&& msg.audio_url.startsWith('/api/sloppak/')
|
||||
&& ((!!msg.has_original_audio && !msg.has_stems)
|
||||
|| (msg.stems || []).length === 1);
|
||||
// Record the loaded song's audio so app.js can re-route it
|
||||
// between the HTML5 and JUCE paths if the audio engine is
|
||||
// started/stopped after the song is already loaded. Set this
|
||||
// unconditionally (not just on reload): when alreadyLoaded is
|
||||
// true the watcher must still see correct, current metadata.
|
||||
window._currentSongAudio = {
|
||||
url: msg.audio_url,
|
||||
juceEligible: isAudioUrl,
|
||||
feedpakFullMix: isFeedpakFullMix,
|
||||
};
|
||||
window._currentSongAudio = { url: msg.audio_url, juceEligible: isAudioUrl };
|
||||
const alreadyLoaded = window._juceMode
|
||||
? window._juceAudioUrl === msg.audio_url
|
||||
: (audio.src && audio.src.includes(audioFilename));
|
||||
// [feedpak-route] diagnostics: every eligibility input in one
|
||||
// line — shows up in the exported diagnostics bundle. If
|
||||
// has_stems is true the pack is multi-stem and Phase 1
|
||||
// deliberately does not route it (Phase 2 work).
|
||||
console.log('[feedpak-route] song-load:',
|
||||
'url=', msg.audio_url,
|
||||
'isAudioUrl=', isAudioUrl,
|
||||
'isFeedpakFullMix=', isFeedpakFullMix,
|
||||
'has_stems=', !!msg.has_stems,
|
||||
'stems=', (msg.stems || []).length,
|
||||
'has_original_audio=', !!msg.has_original_audio,
|
||||
'format=', msg.format,
|
||||
'alreadyLoaded=', alreadyLoaded,
|
||||
'juceApi=', !!window.feedBackDesktop?.audio);
|
||||
if (!alreadyLoaded) {
|
||||
const juceApi = window.feedBackDesktop?.audio;
|
||||
if ((isAudioUrl || isFeedpakFullMix) && juceApi) {
|
||||
if (isAudioUrl && juceApi) {
|
||||
// Run JUCE routing off the critical message-processing chain
|
||||
// so subsequent notes/chords/ready messages aren't blocked
|
||||
// waiting for IPC + HTTP round-trips. The 'ready' handler
|
||||
@@ -3502,24 +3466,7 @@ function createHighway() {
|
||||
clearTimeout(barrierTimer);
|
||||
if (gen !== _wsGen) return; // navigated away during the wait
|
||||
}
|
||||
// Feedpak full-mix rides the engine ONLY under an
|
||||
// exclusive-style output (shared mode falls through to
|
||||
// the HTML5 fallback below, keeping the WebAudio path
|
||||
// fully working). /audio/ songs route whenever the
|
||||
// engine runs, as before. If the share mode changes
|
||||
// later, the app.js watcher re-evaluates and migrates.
|
||||
let routeToJuce = await juceApi.isAudioRunning();
|
||||
console.log('[feedpak-route] initial-load: engineRunning=', routeToJuce);
|
||||
if (routeToJuce) {
|
||||
if (gen !== _wsGen) return; // stale
|
||||
if (isFeedpakFullMix) {
|
||||
const exclFn = window._juceOutputIsExclusive;
|
||||
routeToJuce = !!(await exclFn?.());
|
||||
console.log('[feedpak-route] initial-load: feedpak exclusive check →',
|
||||
routeToJuce, '(predicate installed=', typeof exclFn === 'function', ')');
|
||||
}
|
||||
}
|
||||
if (routeToJuce) {
|
||||
if (await juceApi.isAudioRunning()) {
|
||||
if (gen !== _wsGen) return; // stale
|
||||
const res = await fetch(`/api/audio-local-path?url=${encodeURIComponent(audioUrl)}`);
|
||||
if (!res.ok) throw new Error('HTTP ' + res.status);
|
||||
|
||||
@@ -40,7 +40,7 @@ function extractWatcherIIFE(src) {
|
||||
|
||||
// Build a sandbox with fakes and run the watcher IIFE inside it. Returns the
|
||||
// sandbox so tests can drive window._reevaluateJuceRouting and inspect state.
|
||||
function makeSandbox({ isAudioRunning, loadBackingTrack, outputType = 'Windows Audio' }) {
|
||||
function makeSandbox({ isAudioRunning, loadBackingTrack }) {
|
||||
const calls = { loadBackingTrack: [], jucePlay: 0, jucePause: 0, audioPlay: 0 };
|
||||
|
||||
const audio = {
|
||||
@@ -65,7 +65,6 @@ function makeSandbox({ isAudioRunning, loadBackingTrack, outputType = 'Windows A
|
||||
const juceApi = {
|
||||
isAudioRunning: () => Promise.resolve(isAudioRunning()),
|
||||
loadBackingTrack: (p) => { calls.loadBackingTrack.push(p); return Promise.resolve(loadBackingTrack()); },
|
||||
getCurrentDevice: () => Promise.resolve({ outputType: typeof outputType === 'function' ? outputType() : outputType }),
|
||||
getBackingDuration: () => Promise.resolve(180),
|
||||
seekBacking: () => Promise.resolve(),
|
||||
startBacking: () => Promise.resolve(),
|
||||
@@ -153,87 +152,6 @@ test('non-JUCE-eligible song (sloppak stems) is never rerouted', async () => {
|
||||
assert.equal(sb.__calls.loadBackingTrack.length, 0);
|
||||
});
|
||||
|
||||
test('feedpak full-mix + exclusive output → migrates to JUCE', async () => {
|
||||
const sb = makeSandbox({
|
||||
isAudioRunning: () => true,
|
||||
loadBackingTrack: () => true,
|
||||
outputType: 'Windows Audio (Exclusive Mode)',
|
||||
});
|
||||
sb.window._juceMode = false;
|
||||
sb.window._currentSongAudio = {
|
||||
url: '/api/sloppak/song.sloppak/file/stems/full.ogg',
|
||||
juceEligible: false,
|
||||
feedpakFullMix: true,
|
||||
};
|
||||
|
||||
await sb.window._reevaluateJuceRouting();
|
||||
|
||||
assert.equal(sb.window._juceMode, true, 'feedpak full-mix rides the engine under exclusive output');
|
||||
assert.equal(sb.__calls.loadBackingTrack.length, 1);
|
||||
});
|
||||
|
||||
test('feedpak full-mix + ASIO output → migrates to JUCE', async () => {
|
||||
const sb = makeSandbox({
|
||||
isAudioRunning: () => true,
|
||||
loadBackingTrack: () => true,
|
||||
outputType: 'ASIO',
|
||||
});
|
||||
sb.window._juceMode = false;
|
||||
sb.window._currentSongAudio = {
|
||||
url: '/api/sloppak/song.sloppak/file/stems/full.ogg',
|
||||
juceEligible: false,
|
||||
feedpakFullMix: true,
|
||||
};
|
||||
|
||||
await sb.window._reevaluateJuceRouting();
|
||||
|
||||
assert.equal(sb.window._juceMode, true, 'ASIO is exclusive-style; feedpak rides the engine');
|
||||
});
|
||||
|
||||
test('feedpak full-mix + shared output → stays on HTML5 (stem mixer untouched)', async () => {
|
||||
for (const shared of ['Windows Audio', 'Windows Audio (Low Latency Mode)', 'DirectSound']) {
|
||||
const sb = makeSandbox({
|
||||
isAudioRunning: () => true,
|
||||
loadBackingTrack: () => true,
|
||||
outputType: shared,
|
||||
});
|
||||
sb.window._juceMode = false;
|
||||
sb.window._currentSongAudio = {
|
||||
url: '/api/sloppak/song.sloppak/file/stems/full.ogg',
|
||||
juceEligible: false,
|
||||
feedpakFullMix: true,
|
||||
};
|
||||
|
||||
await sb.window._reevaluateJuceRouting();
|
||||
|
||||
assert.equal(sb.window._juceMode, false, `stays on HTML5 for shared type "${shared}"`);
|
||||
assert.equal(sb.__calls.loadBackingTrack.length, 0);
|
||||
}
|
||||
});
|
||||
|
||||
test('feedpak on JUCE + output leaves exclusive mode → migrates back to HTML5', async () => {
|
||||
let type = 'Windows Audio (Exclusive Mode)';
|
||||
const sb = makeSandbox({
|
||||
isAudioRunning: () => true,
|
||||
loadBackingTrack: () => true,
|
||||
outputType: () => type,
|
||||
});
|
||||
const url = '/api/sloppak/song.sloppak/file/stems/full.ogg';
|
||||
sb.window._juceMode = true;
|
||||
sb.window._juceAudioUrl = url;
|
||||
sb.window._currentSongAudio = { url, juceEligible: false, feedpakFullMix: true };
|
||||
|
||||
// Still exclusive: routing is consistent, no switch.
|
||||
await sb.window._reevaluateJuceRouting();
|
||||
assert.equal(sb.window._juceMode, true, 'consistent while exclusive');
|
||||
|
||||
// Device switched to shared mid-song: must return to HTML5.
|
||||
type = 'Windows Audio';
|
||||
await sb.window._reevaluateJuceRouting();
|
||||
assert.equal(sb.window._juceMode, false, 'returned to HTML5 after leaving exclusive mode');
|
||||
assert.equal(sb.audio.src, url, 'HTML5 element re-pointed at the song');
|
||||
});
|
||||
|
||||
test('JUCE hard-reject is memoised → not retried on the next poll', async () => {
|
||||
const sb = makeSandbox({ isAudioRunning: () => true, loadBackingTrack: () => false });
|
||||
sb.window._juceMode = false;
|
||||
|
||||
@@ -1,195 +0,0 @@
|
||||
// Behavioral tests for the renderer-audio bus feeder in static/app.js.
|
||||
//
|
||||
// The feeder (an IIFE, `_installRendererBusFeeder`) captures renderer-side
|
||||
// song audio (stems-plugin WebAudio master, or the core <audio> element) and
|
||||
// pushes it into the desktop engine's renderer bus while the output device is
|
||||
// exclusive-style — the Phase 2 path for audio the native backing transport
|
||||
// cannot carry. These tests extract that IIFE from source and exercise
|
||||
// `window._reevaluateRendererBus` against fakes, covering: stems engagement
|
||||
// under exclusive output, disengagement on return to shared mode, inertness
|
||||
// in shared mode / while the native transport owns the song, and the
|
||||
// element-capture fallback.
|
||||
|
||||
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 APP_JS = path.join(__dirname, '..', '..', 'static', 'app.js');
|
||||
|
||||
function extractFeederIIFE(src) {
|
||||
const marker = '(function _installRendererBusFeeder() {';
|
||||
const start = src.indexOf(marker);
|
||||
assert.ok(start !== -1, 'feeder IIFE not found in app.js');
|
||||
const openBrace = src.indexOf('{', start);
|
||||
let depth = 1;
|
||||
let i = openBrace + 1;
|
||||
while (i < src.length && depth > 0) {
|
||||
const ch = src[i];
|
||||
if (ch === '{') depth++;
|
||||
else if (ch === '}') depth--;
|
||||
i++;
|
||||
}
|
||||
assert.ok(depth === 0, 'unbalanced braces in feeder IIFE');
|
||||
const tail = src.slice(i, i + 5);
|
||||
assert.match(tail, /^\)\(\)/, 'feeder IIFE not immediately invoked');
|
||||
return src.slice(start, i) + ')();';
|
||||
}
|
||||
|
||||
function makeFakeContext(sampleRate = 48000) {
|
||||
const ctx = {
|
||||
sampleRate,
|
||||
state: 'running',
|
||||
sinkIdCalls: [],
|
||||
destination: { isDestination: true },
|
||||
setSinkId(v) { this.sinkIdCalls.push(v); return Promise.resolve(); },
|
||||
resume() { this.state = 'running'; return Promise.resolve(); },
|
||||
audioWorklet: { addModule: () => Promise.resolve() },
|
||||
createMediaElementSource(el) {
|
||||
this.mediaSourceEl = el;
|
||||
return { connect() {}, disconnect() {} };
|
||||
},
|
||||
};
|
||||
return ctx;
|
||||
}
|
||||
|
||||
function makeSandbox({ isAudioRunning = () => true, exclusive = () => true } = {}) {
|
||||
const calls = { setRendererBus: [], pushRendererAudio: [] };
|
||||
|
||||
const api = {
|
||||
isAudioRunning: () => Promise.resolve(isAudioRunning()),
|
||||
setRendererBus: (en, g) => { calls.setRendererBus.push([en, g]); return Promise.resolve(); },
|
||||
pushRendererAudio: (buf, rate) => { calls.pushRendererAudio.push([buf.length, rate]); },
|
||||
};
|
||||
|
||||
class FakeWorkletNode {
|
||||
constructor() { this.port = { onmessage: null }; }
|
||||
connect() {}
|
||||
disconnect() {}
|
||||
}
|
||||
|
||||
const sandbox = {
|
||||
console: { log() {}, warn() {}, error() {} },
|
||||
URL: { createObjectURL: () => 'blob:tap', revokeObjectURL() {} },
|
||||
Blob: class { constructor() {} },
|
||||
AudioWorkletNode: FakeWorkletNode,
|
||||
AudioContext: function () { const c = makeFakeContext(); sandbox.__createdContexts.push(c); return c; },
|
||||
WeakSet, WeakMap, Promise, Float32Array, Math,
|
||||
setInterval: () => 0,
|
||||
document: {
|
||||
hidden: false,
|
||||
addEventListener() {},
|
||||
getElementById: () => sandbox.__audioEl,
|
||||
},
|
||||
__createdContexts: [],
|
||||
__audioEl: { id: 'audio' },
|
||||
__calls: calls,
|
||||
window: null,
|
||||
};
|
||||
sandbox.window = {
|
||||
feedBackDesktop: { audio: api },
|
||||
_juceOutputIsExclusive: () => Promise.resolve(exclusive()),
|
||||
_juceMode: false,
|
||||
_currentSongAudio: null,
|
||||
feedBack: { stems: {} },
|
||||
};
|
||||
sandbox.globalThis = sandbox;
|
||||
|
||||
const src = fs.readFileSync(APP_JS, 'utf8');
|
||||
vm.createContext(sandbox);
|
||||
vm.runInContext(extractFeederIIFE(src), sandbox);
|
||||
assert.equal(typeof sandbox.window._reevaluateRendererBus, 'function',
|
||||
'feeder must expose window._reevaluateRendererBus');
|
||||
return sandbox;
|
||||
}
|
||||
|
||||
function makeStemsGraph() {
|
||||
return {
|
||||
context: makeFakeContext(),
|
||||
masterNode: { connect() {}, disconnect() {} },
|
||||
};
|
||||
}
|
||||
|
||||
test('stems graph + exclusive output → bus enabled, stems ctx null-sinked', async () => {
|
||||
const sb = makeSandbox({ exclusive: () => true });
|
||||
const graph = makeStemsGraph();
|
||||
sb.window.feedBack.stems.audioGraph = graph;
|
||||
|
||||
await sb.window._reevaluateRendererBus();
|
||||
|
||||
assert.deepEqual(sb.__calls.setRendererBus.at(-1), [true, 1.0], 'bus enabled');
|
||||
assert.equal(graph.context.sinkIdCalls.at(-1)?.type, 'none', 'stems ctx re-pointed at null sink');
|
||||
});
|
||||
|
||||
test('output returns to shared → bus disabled, sink restored', async () => {
|
||||
let excl = true;
|
||||
const sb = makeSandbox({ exclusive: () => excl });
|
||||
const graph = makeStemsGraph();
|
||||
sb.window.feedBack.stems.audioGraph = graph;
|
||||
|
||||
await sb.window._reevaluateRendererBus();
|
||||
excl = false;
|
||||
await sb.window._reevaluateRendererBus();
|
||||
|
||||
assert.deepEqual(sb.__calls.setRendererBus.at(-1), [false, 0], 'bus disabled');
|
||||
assert.equal(graph.context.sinkIdCalls.at(-1), '', 'default sink restored');
|
||||
});
|
||||
|
||||
test('stems graph + shared output → feeder stays off (no double audio)', async () => {
|
||||
const sb = makeSandbox({ exclusive: () => false });
|
||||
sb.window.feedBack.stems.audioGraph = makeStemsGraph();
|
||||
|
||||
await sb.window._reevaluateRendererBus();
|
||||
|
||||
assert.equal(sb.__calls.setRendererBus.length, 0, 'bus never touched in shared mode');
|
||||
});
|
||||
|
||||
test('element song + exclusive → element captured into bus', async () => {
|
||||
const sb = makeSandbox({ exclusive: () => true });
|
||||
sb.window._currentSongAudio = { url: '/api/sloppak/x.sloppak/file/stems/full.ogg' };
|
||||
sb.window._juceMode = false;
|
||||
|
||||
await sb.window._reevaluateRendererBus();
|
||||
|
||||
assert.equal(sb.__createdContexts.length, 1, 'capture context created');
|
||||
assert.equal(sb.__createdContexts[0].mediaSourceEl, sb.__audioEl, 'element source captured');
|
||||
assert.deepEqual(sb.__calls.setRendererBus.at(-1), [true, 1.0], 'bus enabled');
|
||||
});
|
||||
|
||||
test('song riding the native transport (_juceMode) → feeder stays off', async () => {
|
||||
const sb = makeSandbox({ exclusive: () => true });
|
||||
sb.window._currentSongAudio = { url: '/audio/song.ogg' };
|
||||
sb.window._juceMode = true;
|
||||
|
||||
await sb.window._reevaluateRendererBus();
|
||||
|
||||
assert.equal(sb.__calls.setRendererBus.length, 0, 'native transport owns the song');
|
||||
assert.equal(sb.__createdContexts.length, 0, 'no capture context created');
|
||||
});
|
||||
|
||||
test('stems graph replaced mid-engagement → re-engages on the new graph', async () => {
|
||||
const sb = makeSandbox({ exclusive: () => true });
|
||||
const g1 = makeStemsGraph();
|
||||
sb.window.feedBack.stems.audioGraph = g1;
|
||||
await sb.window._reevaluateRendererBus();
|
||||
|
||||
const g2 = makeStemsGraph();
|
||||
sb.window.feedBack.stems.audioGraph = g2;
|
||||
await sb.window._reevaluateRendererBus();
|
||||
|
||||
assert.equal(g2.context.sinkIdCalls.at(-1)?.type, 'none', 'new graph null-sinked');
|
||||
assert.deepEqual(sb.__calls.setRendererBus.at(-1), [true, 1.0], 're-enabled for new graph');
|
||||
});
|
||||
|
||||
test('engine stops → bus disabled', async () => {
|
||||
let running = true;
|
||||
const sb = makeSandbox({ isAudioRunning: () => running, exclusive: () => true });
|
||||
sb.window.feedBack.stems.audioGraph = makeStemsGraph();
|
||||
await sb.window._reevaluateRendererBus();
|
||||
|
||||
running = false;
|
||||
await sb.window._reevaluateRendererBus();
|
||||
|
||||
assert.deepEqual(sb.__calls.setRendererBus.at(-1), [false, 0], 'bus disabled after engine stop');
|
||||
});
|
||||
@@ -1,153 +0,0 @@
|
||||
"""The router seam (`appstate.py`).
|
||||
|
||||
The load-bearing assertion here is `test_server_wires_the_seam`: that `server`
|
||||
actually calls `appstate.configure(...)`. Every other test in this file would
|
||||
pass just fine against a seam nothing ever wires up — the same class of silent
|
||||
no-op that bit the frontend refactor twice when a scripted `setHostHooks` edit
|
||||
stopped matching its anchor. Unit tests cannot see wiring unless you make them
|
||||
look at it.
|
||||
"""
|
||||
|
||||
import importlib
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
import appstate
|
||||
|
||||
|
||||
def _close_server_dbs(mod):
|
||||
conn = getattr(getattr(mod, "meta_db", None), "conn", None)
|
||||
if conn is not None:
|
||||
getattr(mod, "_join_background_db_threads", lambda: None)()
|
||||
conn.close()
|
||||
ae_conn = getattr(getattr(mod, "audio_effect_mappings", None), "conn", None)
|
||||
if ae_conn is not None:
|
||||
ae_conn.close()
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def isolated_server(tmp_path, monkeypatch):
|
||||
"""A freshly imported `server` bound to a throwaway CONFIG_DIR.
|
||||
|
||||
Importing `server` constructs `MetadataDB` + `AudioEffectsMappingDB` at
|
||||
module level, so it MUST be re-imported under a patched CONFIG_DIR — an
|
||||
unguarded `import server` would create/mutate the developer's real
|
||||
`~/.local/share/feedback` databases. Same idiom as the other ~49
|
||||
server-importing suites.
|
||||
|
||||
Teardown restores the appstate slots as well as closing the connections:
|
||||
leaving `appstate.meta_db` published but pointing at a closed sqlite handle
|
||||
would hand a later test (or router) a live-looking, dead singleton.
|
||||
"""
|
||||
previous = (appstate.meta_db, appstate.audio_effect_mappings)
|
||||
monkeypatch.setenv("CONFIG_DIR", str(tmp_path))
|
||||
sys.modules.pop("server", None)
|
||||
mod = importlib.import_module("server")
|
||||
yield mod
|
||||
_close_server_dbs(mod)
|
||||
# Leave no half-torn-down `server` behind: the next fixture re-imports it.
|
||||
sys.modules.pop("server", None)
|
||||
appstate.configure(meta_db=previous[0], audio_effect_mappings=previous[1])
|
||||
|
||||
|
||||
def test_import_is_side_effect_free():
|
||||
"""`import appstate` must construct nothing and touch no disk.
|
||||
|
||||
This is why the ~49 fixtures that `sys.modules.pop("server")` and re-import
|
||||
(to rebuild `meta_db` under a patched CONFIG_DIR) keep working untouched:
|
||||
server owns construction, appstate only mirrors it. A singleton *owned*
|
||||
here would survive that pop and go stale.
|
||||
"""
|
||||
sys.modules.pop("appstate", None)
|
||||
fresh = importlib.import_module("appstate")
|
||||
try:
|
||||
assert fresh.meta_db is None
|
||||
assert fresh.audio_effect_mappings is None
|
||||
finally:
|
||||
sys.modules["appstate"] = appstate
|
||||
|
||||
|
||||
def test_configure_publishes_known_slots():
|
||||
sentinel = object()
|
||||
original = appstate.meta_db
|
||||
try:
|
||||
appstate.configure(meta_db=sentinel)
|
||||
assert appstate.meta_db is sentinel
|
||||
finally:
|
||||
appstate.configure(meta_db=original)
|
||||
|
||||
|
||||
def test_configure_is_idempotent():
|
||||
"""server re-imports call configure() again; the last write must win."""
|
||||
original = appstate.meta_db
|
||||
try:
|
||||
appstate.configure(meta_db="first")
|
||||
appstate.configure(meta_db="second")
|
||||
assert appstate.meta_db == "second"
|
||||
finally:
|
||||
appstate.configure(meta_db=original)
|
||||
|
||||
|
||||
def test_configure_rejects_an_unknown_slot():
|
||||
"""A typo'd or stale keyword must raise, not silently create a global that
|
||||
nothing reads. A seam whose wiring can no-op undetected is worse than none."""
|
||||
with pytest.raises(TypeError, match="unknown slot"):
|
||||
appstate.configure(met_db="typo")
|
||||
assert not hasattr(appstate, "met_db")
|
||||
|
||||
|
||||
def test_late_bound_read_sees_a_later_configure():
|
||||
"""Routers must read `appstate.meta_db`, never `from appstate import meta_db`.
|
||||
This pins the property that makes that rule work."""
|
||||
def router_style_read():
|
||||
return appstate.meta_db # module attribute, resolved at call time
|
||||
|
||||
original = appstate.meta_db
|
||||
try:
|
||||
appstate.configure(meta_db="before")
|
||||
assert router_style_read() == "before"
|
||||
appstate.configure(meta_db="after")
|
||||
assert router_style_read() == "after"
|
||||
finally:
|
||||
appstate.configure(meta_db=original)
|
||||
|
||||
|
||||
def test_server_wires_the_seam(isolated_server):
|
||||
"""The one that catches a dropped `appstate.configure(...)` call.
|
||||
|
||||
Identity, not truthiness, so a stray re-assignment or a half-applied edit
|
||||
fails here rather than in some router months later.
|
||||
"""
|
||||
assert appstate.meta_db is isolated_server.meta_db
|
||||
assert appstate.audio_effect_mappings is isolated_server.audio_effect_mappings
|
||||
assert appstate.meta_db is not None
|
||||
|
||||
|
||||
def test_reimporting_server_republishes_the_fresh_singletons(
|
||||
isolated_server, tmp_path, monkeypatch
|
||||
):
|
||||
"""The 49-fixture contract, exercised end to end.
|
||||
|
||||
Those fixtures `sys.modules.pop("server")` + re-import to rebuild `meta_db`
|
||||
under a new CONFIG_DIR, and know nothing about appstate. So the seam must
|
||||
re-publish on that second import. This is the test that would fail if
|
||||
`appstate` ever *owned* the singletons: a module-level `meta_db` there
|
||||
survives the pop and the assertions below would still see the FIRST DB.
|
||||
"""
|
||||
first_db = isolated_server.meta_db
|
||||
assert appstate.meta_db is first_db
|
||||
assert str(tmp_path) in first_db.db_path
|
||||
|
||||
second_config = tmp_path / "second"
|
||||
monkeypatch.setenv("CONFIG_DIR", str(second_config))
|
||||
sys.modules.pop("server", None)
|
||||
second_server = importlib.import_module("server")
|
||||
try:
|
||||
assert second_server.meta_db is not first_db # genuinely rebuilt
|
||||
assert str(second_config) in second_server.meta_db.db_path
|
||||
assert appstate.meta_db is second_server.meta_db # ...and re-published
|
||||
assert appstate.audio_effect_mappings is second_server.audio_effect_mappings
|
||||
finally:
|
||||
_close_server_dbs(second_server)
|
||||
sys.modules.pop("server", None)
|
||||
@@ -103,99 +103,13 @@ def test_returns_404_for_nonexistent_file(client_and_server):
|
||||
assert "error" in r.json()
|
||||
|
||||
|
||||
# ── sloppak URLs (feedpak full-mix, desktop exclusive-mode routing) ──────────
|
||||
|
||||
@pytest.fixture()
|
||||
def dlc_client(tmp_path, monkeypatch):
|
||||
"""Loopback TestClient with a temp DLC_DIR for sloppak resolution."""
|
||||
dlc = tmp_path / "dlc"
|
||||
dlc.mkdir()
|
||||
monkeypatch.setenv("DLC_DIR", str(dlc))
|
||||
monkeypatch.setenv("CONFIG_DIR", str(tmp_path / "cfg"))
|
||||
monkeypatch.setenv("FEEDBACK_SYNC_STARTUP", "1")
|
||||
sys.modules.pop("server", None)
|
||||
server = importlib.import_module("server")
|
||||
# Module-level sloppak source-dir cache survives re-import; clear it so a
|
||||
# prior test's filename key can't shadow this test's temp DLC_DIR.
|
||||
server.sloppak_mod._source_cache.clear()
|
||||
monkeypatch.setattr(server, "load_plugins", lambda *a, **kw: None)
|
||||
monkeypatch.setattr(server, "startup_scan", lambda: None)
|
||||
static_tmp = tmp_path / "static"
|
||||
static_tmp.mkdir()
|
||||
monkeypatch.setattr(server, "STATIC_DIR", static_tmp)
|
||||
tc = TestClient(server.app, client=("127.0.0.1", 50000))
|
||||
try:
|
||||
yield tc, server, dlc
|
||||
finally:
|
||||
tc.close()
|
||||
meta_db = getattr(server, "meta_db", None)
|
||||
conn = getattr(meta_db, "conn", None)
|
||||
if conn is not None:
|
||||
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
|
||||
conn.close()
|
||||
|
||||
|
||||
def _make_sloppak(dlc, name="song.sloppak"):
|
||||
"""Create a minimal directory-form sloppak with a full-mix file."""
|
||||
pak = dlc / name
|
||||
(pak / "stems").mkdir(parents=True)
|
||||
(pak / "stems" / "full.ogg").write_bytes(b"OggS-fake")
|
||||
return pak
|
||||
|
||||
|
||||
def test_sloppak_url_resolves_to_local_path(dlc_client):
|
||||
tc, _server, dlc = dlc_client
|
||||
pak = _make_sloppak(dlc)
|
||||
r = tc.get(
|
||||
"/api/audio-local-path",
|
||||
params={"url": "/api/sloppak/song.sloppak/file/stems/full.ogg"},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["path"] == str((pak / "stems" / "full.ogg").resolve())
|
||||
|
||||
|
||||
def test_sloppak_url_percent_encoded_segments_decode(dlc_client):
|
||||
tc, _server, dlc = dlc_client
|
||||
_make_sloppak(dlc, name="My Song.sloppak")
|
||||
r = tc.get(
|
||||
"/api/audio-local-path",
|
||||
params={"url": "/api/sloppak/My%20Song.sloppak/file/stems/full.ogg"},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
|
||||
def test_sloppak_url_rel_traversal_is_403(dlc_client):
|
||||
tc, _server, dlc = dlc_client
|
||||
_make_sloppak(dlc)
|
||||
(dlc / "secret.txt").write_text("top secret")
|
||||
r = tc.get(
|
||||
"/api/audio-local-path",
|
||||
params={"url": "/api/sloppak/song.sloppak/file/..%2Fsecret.txt"},
|
||||
)
|
||||
assert r.status_code == 403, r.text
|
||||
|
||||
|
||||
def test_sloppak_url_filename_traversal_is_403(dlc_client):
|
||||
tc, _server, _dlc = dlc_client
|
||||
r = tc.get(
|
||||
"/api/audio-local-path",
|
||||
params={"url": "/api/sloppak/..%2F..%2F..%2Fetc/file/passwd"},
|
||||
)
|
||||
assert r.status_code == 403, r.text
|
||||
|
||||
|
||||
def test_sloppak_url_without_dlc_configured_is_404(client_and_server):
|
||||
"""No DLC_DIR in the base fixture — resolver reports 'not configured'."""
|
||||
client, _ = client_and_server
|
||||
r = client.get(
|
||||
"/api/audio-local-path",
|
||||
params={"url": "/api/sloppak/mysong/file/stems/full.ogg"},
|
||||
)
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
# ── rejected non-/audio/ inputs ───────────────────────────────────────────────
|
||||
|
||||
def test_rejects_sloppak_url(client_and_server):
|
||||
client, _ = client_and_server
|
||||
r = client.get("/api/audio-local-path", params={"url": "/api/sloppak/mysong/file/stems/full.ogg"})
|
||||
assert r.status_code == 400
|
||||
|
||||
|
||||
def test_rejects_empty_url(client_and_server):
|
||||
client, _ = client_and_server
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
"""Concurrent unnamed loop saves must get unique names.
|
||||
|
||||
`save_loop` auto-names an unnamed loop `Loop {count+1}`. If the `COUNT(*)` runs
|
||||
outside the DB lock (as it did before the fix), two simultaneous unnamed POSTs
|
||||
read the same count and both mint the same name. A `threading.Barrier` releases
|
||||
all workers into `save_loop` at once to force that interleave.
|
||||
|
||||
Single-user app, so this race is unlikely in practice — but the fix is one lock
|
||||
scope, and the test pins it.
|
||||
"""
|
||||
|
||||
import threading
|
||||
|
||||
import pytest
|
||||
|
||||
import appstate
|
||||
from metadata_db import MetadataDB
|
||||
from routers import loops
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def meta_db(tmp_path):
|
||||
prev = appstate.meta_db
|
||||
db = MetadataDB(tmp_path)
|
||||
appstate.configure(meta_db=db)
|
||||
yield db
|
||||
db.conn.close()
|
||||
appstate.configure(meta_db=prev)
|
||||
|
||||
|
||||
def test_concurrent_unnamed_saves_get_unique_names(meta_db):
|
||||
workers = 16
|
||||
barrier = threading.Barrier(workers)
|
||||
names, errors = [], []
|
||||
lock = threading.Lock()
|
||||
|
||||
def save():
|
||||
try:
|
||||
barrier.wait() # release all at once
|
||||
r = loops.save_loop({"filename": "song.feedpak", "start": 0.0, "end": 1.0})
|
||||
with lock:
|
||||
names.append(r["name"])
|
||||
except Exception as e: # noqa: BLE001 — surface any thread error
|
||||
with lock:
|
||||
errors.append(e)
|
||||
|
||||
threads = [threading.Thread(target=save) for _ in range(workers)]
|
||||
for t in threads:
|
||||
t.start()
|
||||
for t in threads:
|
||||
t.join()
|
||||
|
||||
assert not errors, errors
|
||||
assert len(names) == workers
|
||||
# The load-bearing assertion: no two auto-named loops collide.
|
||||
assert len(set(names)) == workers, f"duplicate loop names: {sorted(names)}"
|
||||
# And the DB agrees — every insert landed.
|
||||
stored = meta_db.conn.execute(
|
||||
"SELECT COUNT(*) FROM loops WHERE filename = ?", ("song.feedpak",)
|
||||
).fetchone()[0]
|
||||
assert stored == workers
|
||||
|
||||
|
||||
def test_list_and_save_do_not_error_under_interleave(meta_db):
|
||||
"""A read overlapping writes must not raise (shared connection, one lock)."""
|
||||
stop = threading.Event()
|
||||
errors = []
|
||||
|
||||
def reader():
|
||||
while not stop.is_set():
|
||||
try:
|
||||
loops.list_loops("song.feedpak")
|
||||
except Exception as e: # noqa: BLE001
|
||||
errors.append(e)
|
||||
|
||||
r = threading.Thread(target=reader)
|
||||
r.start()
|
||||
try:
|
||||
for i in range(40):
|
||||
loops.save_loop({"filename": "song.feedpak", "name": f"n{i}", "start": 0.0, "end": 1.0})
|
||||
finally:
|
||||
stop.set()
|
||||
r.join()
|
||||
assert not errors, errors
|
||||
@@ -1,112 +0,0 @@
|
||||
"""Guard: every first-party module `server.py` imports must be one the packagers copy.
|
||||
|
||||
feedback-desktop's `scripts/bundle-slopsmith.sh` copies a **hardcoded list** from
|
||||
core into the app bundle — `server.py`, `VERSION`, `lib/`, `data/`, `static/`,
|
||||
`plugins/__init__.py`. A new root-level module (say `appstate.py`) ships fine in
|
||||
Docker, imports fine under pytest, and is then *silently dropped* from the
|
||||
packaged desktop app, which dies at startup with:
|
||||
|
||||
File ".../Resources/slopsmith/server.py", line 71, in <module>
|
||||
import appstate
|
||||
ModuleNotFoundError: No module named 'appstate'
|
||||
|
||||
That shipped once. This test is why it can't ship twice: it walks `server.py`'s
|
||||
module-level imports, keeps the ones that resolve inside this repo, and asserts
|
||||
each lives under a directory every packaging path already copies wholesale.
|
||||
|
||||
If you add a first-party module for `server.py`, put it in `lib/` — the one core
|
||||
directory the Dockerfile (`COPY lib/`), `docker-compose.yml`, and the desktop
|
||||
bundler (`cp -r lib`) all copy, and that all three put on `sys.path`. If you
|
||||
genuinely need it at the repo root, you must also teach `bundle-slopsmith.sh`,
|
||||
the `Dockerfile`, `.dockerignore`, and `docker-compose.yml` about it — and then
|
||||
update `BUNDLED_ROOTS` below.
|
||||
"""
|
||||
|
||||
import ast
|
||||
import importlib.util
|
||||
import pathlib
|
||||
|
||||
import pytest
|
||||
|
||||
REPO_ROOT = pathlib.Path(__file__).resolve().parent.parent
|
||||
|
||||
# Directories every packaging path copies wholesale, plus the files copied by name.
|
||||
BUNDLED_ROOTS = ("lib", "plugins", "data", "static")
|
||||
BUNDLED_FILES = ("server.py", "main.py")
|
||||
|
||||
|
||||
def _server_toplevel_imports():
|
||||
"""Module names imported at `server.py`'s top level (not inside a function)."""
|
||||
tree = ast.parse((REPO_ROOT / "server.py").read_text())
|
||||
names = set()
|
||||
for node in tree.body: # top level only — lazy imports are fine
|
||||
if isinstance(node, ast.Import):
|
||||
names.update(a.name.split(".")[0] for a in node.names)
|
||||
elif isinstance(node, ast.ImportFrom) and node.level == 0 and node.module:
|
||||
names.add(node.module.split(".")[0])
|
||||
return sorted(names)
|
||||
|
||||
|
||||
# `spec.origin` is not always a path: built-in and frozen stdlib modules use
|
||||
# these sentinels. `Path("frozen").resolve()` would land inside the repo and
|
||||
# report `os` as first-party — so filter them before touching the filesystem.
|
||||
_NON_PATH_ORIGINS = {"built-in", "frozen", "namespace"}
|
||||
|
||||
|
||||
def _first_party_origin(name):
|
||||
"""Path of `name` if it resolves inside this repo, else None (stdlib/site-package)."""
|
||||
try:
|
||||
spec = importlib.util.find_spec(name)
|
||||
except (ImportError, ValueError):
|
||||
return None
|
||||
if spec is None:
|
||||
return None
|
||||
|
||||
if spec.origin and spec.origin not in _NON_PATH_ORIGINS:
|
||||
origin = pathlib.Path(spec.origin)
|
||||
else:
|
||||
# Namespace/frozen: fall back to the first search location, if any.
|
||||
locations = list(getattr(spec, "submodule_search_locations", None) or [])
|
||||
if not locations:
|
||||
return None
|
||||
origin = pathlib.Path(locations[0])
|
||||
|
||||
if not origin.is_absolute():
|
||||
return None # a sentinel, not a real path
|
||||
origin = origin.resolve()
|
||||
try:
|
||||
origin.relative_to(REPO_ROOT)
|
||||
except ValueError:
|
||||
return None # outside the repo → a dependency
|
||||
return origin
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", _server_toplevel_imports())
|
||||
def test_server_import_is_bundled(name):
|
||||
origin = _first_party_origin(name)
|
||||
if origin is None:
|
||||
return # stdlib or an installed dependency
|
||||
|
||||
rel = origin.relative_to(REPO_ROOT)
|
||||
if rel.as_posix() in BUNDLED_FILES or rel.parts[0] in BUNDLED_ROOTS:
|
||||
return
|
||||
|
||||
raise AssertionError(
|
||||
f"server.py imports `{name}` from {rel}, which no packager copies.\n"
|
||||
f"The desktop bundler (scripts/bundle-slopsmith.sh) copies only "
|
||||
f"{BUNDLED_FILES} and {BUNDLED_ROOTS}/, so the packaged app would die "
|
||||
f"at startup with ModuleNotFoundError: No module named '{name}'.\n"
|
||||
f"Move it under lib/, or teach bundle-slopsmith.sh + Dockerfile + "
|
||||
f".dockerignore + docker-compose.yml about it and update BUNDLED_ROOTS."
|
||||
)
|
||||
|
||||
|
||||
def test_the_seam_and_routers_live_under_lib():
|
||||
"""Pin the two that already caused a shipped break."""
|
||||
for name in ("appstate", "routers"):
|
||||
origin = _first_party_origin(name)
|
||||
assert origin is not None, f"{name} does not resolve inside the repo"
|
||||
assert origin.relative_to(REPO_ROOT).parts[0] == "lib", (
|
||||
f"{name} resolved to {origin.relative_to(REPO_ROOT)}; it must live "
|
||||
f"under lib/ or the packaged desktop app will not ship it"
|
||||
)
|
||||
@@ -50,7 +50,7 @@ def client(tmp_path, monkeypatch):
|
||||
# Point CONFIG_DIR at a per-test temp path BEFORE server's
|
||||
# import-time side effects run. server.py reads CONFIG_DIR from the
|
||||
# environment at module load (line 35) and immediately constructs
|
||||
# `meta_db = MetadataDB(CONFIG_DIR)` at module level, which calls
|
||||
# `meta_db = MetadataDB()` at module level, which calls
|
||||
# CONFIG_DIR.mkdir(...) and opens a sqlite file — a plain
|
||||
# post-import monkeypatch on server.CONFIG_DIR wouldn't catch those
|
||||
# side effects, and the real user config dir would get written to.
|
||||
|
||||
@@ -22,11 +22,6 @@ from pathlib import Path
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
# The startup DB swap lives with the DB layer it guards, not with server.py.
|
||||
# metadata_db reads no environment at import, so a plain module import is safe
|
||||
# alongside the env-patched `server_mod` fixture below.
|
||||
from metadata_db import _apply_pending_db_restore
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def server_mod(tmp_path, monkeypatch):
|
||||
@@ -224,7 +219,7 @@ def test_apply_pending_db_restore_swaps_and_clears_sidecars(server_mod, tmp_path
|
||||
(tmp_path / "web_library.db-shm").write_bytes(b"OLD-SHM")
|
||||
(tmp_path / "web_library.db.restore").write_bytes(new_db)
|
||||
|
||||
_apply_pending_db_restore(tmp_path)
|
||||
server_mod._apply_pending_db_restore(tmp_path)
|
||||
|
||||
assert main.read_bytes() == new_db # swapped in
|
||||
assert not (tmp_path / "web_library.db.restore").exists()
|
||||
@@ -239,7 +234,7 @@ def test_apply_pending_db_restore_discards_corrupt_keeps_live(server_mod, tmp_pa
|
||||
main.write_bytes(b"LIVE-GOOD-DB")
|
||||
(tmp_path / "web_library.db.restore").write_bytes(b"SQLite format 3\x00" + b"\xff" * 64)
|
||||
|
||||
_apply_pending_db_restore(tmp_path)
|
||||
server_mod._apply_pending_db_restore(tmp_path)
|
||||
|
||||
assert main.read_bytes() == b"LIVE-GOOD-DB" # live DB preserved
|
||||
assert not (tmp_path / "web_library.db.restore").exists() # bad restore dropped
|
||||
@@ -247,7 +242,7 @@ def test_apply_pending_db_restore_discards_corrupt_keeps_live(server_mod, tmp_pa
|
||||
|
||||
def test_apply_pending_db_restore_noop_without_staging(server_mod, tmp_path):
|
||||
(tmp_path / "web_library.db").write_bytes(b"LIVE")
|
||||
_apply_pending_db_restore(tmp_path) # nothing staged
|
||||
server_mod._apply_pending_db_restore(tmp_path) # nothing staged
|
||||
assert (tmp_path / "web_library.db").read_bytes() == b"LIVE"
|
||||
|
||||
|
||||
@@ -271,7 +266,7 @@ def test_full_db_backup_restore_round_trip(client, server_mod, tmp_path):
|
||||
# Simulate a restart: close the live conn, apply the staged restore,
|
||||
# reopen — the song is back.
|
||||
server_mod.meta_db.conn.close()
|
||||
_apply_pending_db_restore(tmp_path)
|
||||
server_mod._apply_pending_db_restore(tmp_path)
|
||||
conn = sqlite3.connect(str(tmp_path / "web_library.db"))
|
||||
try:
|
||||
rows = conn.execute(
|
||||
|
||||
@@ -249,30 +249,3 @@ def test_flat_string_count_patch_resets_incompatible_named_tuning():
|
||||
patched = apply_flat_instrument_patch_to_profiles(settings, {"string_count": 7})
|
||||
assert patched["string_count"] == 7
|
||||
assert patched["tuning"] == "Standard"
|
||||
|
||||
|
||||
# ── freqs_to_midis (the /api/tunings tuningMidis inverse) ────────────────────
|
||||
|
||||
def test_freqs_to_midis_round_trips_every_builtin_at_440():
|
||||
from tunings import freqs_to_midis
|
||||
for key, presets in TUNING_PRESET_MIDIS.items():
|
||||
for name, midis in presets.items():
|
||||
assert freqs_to_midis(open_midis_to_freqs(midis)) == midis, f"{key}/{name}"
|
||||
|
||||
|
||||
def test_freqs_to_midis_round_trips_at_nonstandard_reference():
|
||||
# The consumer footgun this exists to kill: frequencies served at a 432/450
|
||||
# reference must recover the SAME integer midis when inverted at that
|
||||
# reference (client-side log2-at-440 reconstruction drifts here).
|
||||
from tunings import freqs_to_midis
|
||||
for ref in (430.0, 432.0, 444.0, 450.0):
|
||||
for midis in (TUNING_PRESET_MIDIS["guitar-8"]["Standard"], TUNING_PRESET_MIDIS["bass-5"]["Standard"]):
|
||||
freqs = open_midis_to_freqs(midis, ref)
|
||||
assert freqs_to_midis(freqs, ref) == midis, f"ref={ref}"
|
||||
|
||||
|
||||
def test_freqs_to_midis_rejects_garbage():
|
||||
from tunings import freqs_to_midis
|
||||
assert freqs_to_midis([82.41, 0]) is None # non-positive
|
||||
assert freqs_to_midis([82.41, "x"]) is None # non-numeric
|
||||
assert freqs_to_midis([]) == [] # vacuously fine
|
||||
|
||||
Reference in New Issue
Block a user