mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-09-13 08:29:28 +00:00
docs: extract plugin contracts into modular docs and slim CLAUDE.md
CLAUDE.md had grown to 545 lines / 50 KB — most of it plugin-author
content that other AI tools (Cursor, Copilot, Codex, Aider) and humans
without AI never reach. Extract the plugin surface into 10 focused
docs and a JSON Schema for plugin.json, then slim CLAUDE.md to a
156-line navigable index.
New docs (~999 lines total, all self-contained):
docs/PLUGIN_AUTHORING.md — entry point and quickstart
docs/plugin-manifest.md — plugin.json field reference
docs/plugin-visualization-contracts.md — setRenderer / overlay / note-state
docs/plugin-audio-mixer.md — fader registration
docs/plugin-logging.md — context["log"] + env vars
docs/plugin-diagnostics.md — server_files / callable
docs/plugin-keyboard-shortcuts.md — registerShortcut + scopes
docs/plugin-sibling-imports.md — load_sibling pattern
docs/websocket-protocol.md — /ws/highway message reference
docs/testing-plugins.md — pytest fixtures + Playwright
schema/plugin.schema.json — Draft 2020-12 schema for
plugin.json; license enum
mirrors CONTRIBUTING's curated
allowlist. Backs CI validation
and the plugin-validate skill.
CLAUDE.md slim (581 lines changed, -485):
- Removed ~300 lines of plugin-author prose (now in docs/).
- Kept architecture quick reference, running the app, testing,
git workflow, versioning, song formats, frontend/backend
conventions, plugin authoring INDEX (table → docs/), first-hour
pitfalls, "For AI agents" footer.
- Anchor stubs preserved next to the new index entries so deep
links from specs/001-slopsmith-platform/analyze.md still resolve.
Verification:
python -c "import json,glob,jsonschema; s=json.load(open('schema/plugin.schema.json')); [jsonschema.validate(json.load(open(p)), s) for p in sorted(glob.glob('plugins/*/plugin.json'))]"
# ok — validates highway_3d, app_tour_library, app_tour_settings
Signed-off-by: Miguel_LZPF <mgcdreamer@gmail.com>
This commit is contained in:
@@ -0,0 +1,73 @@
|
||||
# Plugin Authoring Guide
|
||||
|
||||
Slopsmith's plugin system is the primary extension point. Each plugin lives in `plugins/<name>/` with a `plugin.json` manifest and can provide any combination of frontend (HTML/JS), backend (Python routes), settings UI, diagnostics, and visualization renderers.
|
||||
|
||||
This guide is the entry point. Each topic below has a dedicated doc — read what's relevant to what you're building.
|
||||
|
||||
## Quickstart
|
||||
|
||||
```
|
||||
plugins/my_plugin/
|
||||
├── plugin.json Manifest (required) — see docs/plugin-manifest.md
|
||||
├── screen.html Optional — markup mounted at #plugin-my_plugin
|
||||
├── screen.js Optional — runs in global scope on page load
|
||||
├── routes.py Optional — exports setup(app, context)
|
||||
├── settings.html Optional — settings-panel HTML
|
||||
└── requirements.txt Optional — pip deps auto-installed on load
|
||||
```
|
||||
|
||||
The minimum viable plugin is a `plugin.json` with just `id` and `name`. Everything else is opt-in.
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "my_plugin",
|
||||
"name": "My Plugin",
|
||||
"version": "0.1.0"
|
||||
}
|
||||
```
|
||||
|
||||
## Topics
|
||||
|
||||
| Topic | Doc | When to read |
|
||||
|---|---|---|
|
||||
| **Manifest reference** | [plugin-manifest.md](plugin-manifest.md) | Field-by-field reference for `plugin.json`. Read first. |
|
||||
| **Visualization contracts** | [plugin-visualization-contracts.md](plugin-visualization-contracts.md) | Building a highway renderer (setRenderer), an overlay layer, or a note-state provider. |
|
||||
| **Audio mixer faders** | [plugin-audio-mixer.md](plugin-audio-mixer.md) | Plugin produces audio outside the song `<audio>` element. |
|
||||
| **Backend logging** | [plugin-logging.md](plugin-logging.md) | Plugin has a `routes.py`. Use `context["log"]`, never `print()`. |
|
||||
| **Diagnostics contribution** | [plugin-diagnostics.md](plugin-diagnostics.md) | Adding plugin state to the Export Diagnostics bundle. |
|
||||
| **Keyboard shortcuts** | [plugin-keyboard-shortcuts.md](plugin-keyboard-shortcuts.md) | Registering keys via `window.registerShortcut()`. |
|
||||
| **Sibling Python imports** | [plugin-sibling-imports.md](plugin-sibling-imports.md) | Multi-file backend plugins. Use `context["load_sibling"]`. |
|
||||
| **WebSocket protocol** | [websocket-protocol.md](websocket-protocol.md) | Plugins that read the highway stream directly. |
|
||||
| **Testing plugins** | [testing-plugins.md](testing-plugins.md) | Conftest fixtures and Playwright patterns for plugin tests. |
|
||||
| **Diagnostics bundle spec** | [diagnostics-bundle-spec.md](diagnostics-bundle-spec.md) | Existing in-depth spec — what's inside a diagnostics export. |
|
||||
| **Sloppak format spec** | [sloppak-spec.md](sloppak-spec.md) | Existing in-depth spec — for plugins that read/write sloppaks. |
|
||||
|
||||
## General guidelines
|
||||
|
||||
- Wrap your plugin code in an IIFE: `(function () { 'use strict'; ... })();`
|
||||
- Use `localStorage` for user-facing settings, prefixed with your plugin id.
|
||||
- If hooking `window.playSong`, always call the original and `await` it.
|
||||
- If hooking `window.showScreen`, clean up your state when leaving the player screen.
|
||||
- Use `window.slopsmith.emit()` / `window.slopsmith.on()` for inter-plugin communication.
|
||||
- Use `window.registerShortcut()` to add keyboard shortcuts. Clean up with `window.unregisterShortcut(key, scope)` — pass the same scope you registered with, since the default is `'global'` and won't match `player`/`library`/`settings`/`plugin-*` bindings. For panel-scoped shortcuts, prefer `panel.clearShortcuts()`.
|
||||
|
||||
## Plugin frontend globals available at runtime
|
||||
|
||||
- `window.playSong(filename, arrangementIdx)` — load and play a song
|
||||
- `window.showScreen(name)` — navigate between screens
|
||||
- `window.createHighway()` — factory for the highway renderer (used by main player and splitscreen panels)
|
||||
- `window.slopsmith` — event emitter (`emit`, `on`, `off`)
|
||||
- `window.slopsmith.audio` — audio mixer fader registry
|
||||
- `window.slopsmith.diagnostics` — diagnostics namespace (`contribute`, `snapshotConsole`, etc.)
|
||||
- `window.registerShortcut` / `window.unregisterShortcut` / `window.createShortcutPanel` — keyboard shortcuts API
|
||||
- `highway` global — set when the player is active. Getters: `getTime`, `getNotes`, `getChords`, `getChordTemplates`, `getSongInfo`, `getStringCount`, `getLefty`, `getInverted`, `getBeats`, `isDefaultRenderer`, …
|
||||
|
||||
## Plugin load order
|
||||
|
||||
Plugins load alphabetically by directory name. This determines the `playSong` wrapper chain order (last-loaded wrapper runs first; alphabetically earliest plugin runs closest to the original) and which plugin's UI elements appear first.
|
||||
|
||||
If your plugin depends on another's globals, **check at runtime** with `typeof window.X === 'function'`, not at load time. Plugins are independent — assume any other plugin may be missing or disabled.
|
||||
|
||||
## Licensing for curated plugins
|
||||
|
||||
Plugins submitted for inclusion in the curated list must be AGPL-3.0 or AGPL-compatible (MIT, BSD, Apache-2.0). See [CONTRIBUTING.md](../CONTRIBUTING.md) for the full policy. The `plugin.json` schema enforces this via the `license` field enum — see [plugin-manifest.md](plugin-manifest.md).
|
||||
@@ -0,0 +1,45 @@
|
||||
# Audio mixer fader registration
|
||||
|
||||
Plugins that produce audio outside the song's `<audio>` element (NAM amp output, synth voices, etc.) can register a labeled fader so users can balance them against the song from one mixer popover in the player controls.
|
||||
|
||||
This is the slopsmith#87 contract.
|
||||
|
||||
## Registration
|
||||
|
||||
```js
|
||||
function _registerFader() {
|
||||
const api = window.slopsmith && window.slopsmith.audio;
|
||||
if (!api) return;
|
||||
api.registerFader({
|
||||
id: 'my_plugin', // unique key
|
||||
label: 'My Plugin', // shown above the fader
|
||||
unit: 'dB', // optional suffix shown next to the value (e.g. '%', 'dB')
|
||||
min: 0, max: 2, step: 0.05,
|
||||
defaultValue: 1.0,
|
||||
getValue: () => _myCurrentVolume, // read current value
|
||||
setValue: (v) => _setMyVolume(v), // write + persist + apply
|
||||
});
|
||||
}
|
||||
|
||||
if (window.slopsmith && window.slopsmith.audio) {
|
||||
_registerFader();
|
||||
} else {
|
||||
window.addEventListener('slopsmith:audio:ready', _registerFader, { once: true });
|
||||
}
|
||||
```
|
||||
|
||||
## Contract
|
||||
|
||||
- **Persistence is the plugin's responsibility.** The registry calls `getValue()` when the popover opens and after each `setValue()` during slider drags to re-sync the displayed value.
|
||||
- **`getValue()` must be cheap and side-effect-free.**
|
||||
- **`setValue()` must update whatever backing state `getValue()` reads synchronously** — pair it with whatever your plugin already does internally (write the GainNode, persist to `localStorage`, update any in-plugin label).
|
||||
- **Use `unregisterFader(id)`** when your plugin is teardown-able and you want the strip to disappear; otherwise keep it registered so the user's setting persists across toggle states.
|
||||
|
||||
## Lifecycle event
|
||||
|
||||
`slopsmith:audio:ready` fires once on `window` when the audio mixer registry is ready. Guard registration against this event for plugins that load before the mixer.
|
||||
|
||||
## Related
|
||||
|
||||
- [PLUGIN_AUTHORING.md](PLUGIN_AUTHORING.md) — guide index
|
||||
- [plugin-visualization-contracts.md](plugin-visualization-contracts.md) — visualization counterpart
|
||||
@@ -0,0 +1,86 @@
|
||||
# Plugin diagnostics contribution
|
||||
|
||||
Slopsmith ships an **Export Diagnostics** feature (Settings → Export Diagnostics) that bundles a redacted set of host + plugin state into a zip the user can share with maintainers. Plugins have three independent opt-ins for contributing to this bundle.
|
||||
|
||||
The bundle layout and per-file schemas are documented in [diagnostics-bundle-spec.md](diagnostics-bundle-spec.md). This doc covers how plugins integrate.
|
||||
|
||||
## 1. `manifest.diagnostics.server_files` — opt-in file capture
|
||||
|
||||
Declare files under `context["config_dir"]` to copy verbatim into the bundle:
|
||||
|
||||
```json
|
||||
{
|
||||
"diagnostics": {
|
||||
"server_files": ["my_plugin.diag.json", "my_plugin_models/active.json"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Rules (same as `settings.server_files`):
|
||||
- Relpaths only. No `..`, no abs paths, no backslashes, no leading dots.
|
||||
- Files land at `plugins/<plugin_id>/<relpath>` inside the bundle.
|
||||
- Encoded as `{"encoding": "json", "data": <parsed>}` if `.json` parses cleanly, base64 otherwise.
|
||||
|
||||
Use this for **snapshot-style state** — small DB excerpts, model lists, last-error files. Don't use it for backups (that's `settings.server_files`).
|
||||
|
||||
## 2. `manifest.diagnostics.callable` — opt-in dynamic capture
|
||||
|
||||
Declare a Python entry point that produces diagnostics at export time:
|
||||
|
||||
```json
|
||||
{
|
||||
"diagnostics": {
|
||||
"callable": "diagnostics:collect"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The form is `"<module>:<function>"`. The module is resolved lazily via `load_sibling` when the user clicks Export, then called as:
|
||||
|
||||
```python
|
||||
# plugins/my_plugin/diagnostics.py
|
||||
def collect(ctx):
|
||||
"""ctx is {"plugin_id": str, "config_dir": Path}."""
|
||||
return {
|
||||
"schema": "my_plugin.diag.v1",
|
||||
"active_preset": _read_active_preset(ctx["config_dir"]),
|
||||
"model_count": len(list((ctx["config_dir"] / "models").glob("*.pt"))),
|
||||
}
|
||||
```
|
||||
|
||||
Return-type handling:
|
||||
- `dict` / `list` → written to `plugins/<id>/callable.json`
|
||||
- `bytes` → `callable.bin`
|
||||
- `str` → `callable.txt`
|
||||
|
||||
Exceptions are caught and appended to the bundle's `manifest.notes` — a buggy plugin never crashes the export.
|
||||
|
||||
## 3. Frontend contribution (slopsmith#166)
|
||||
|
||||
Plugins that hold useful debug state in the browser (active model name, last user input, internal counters) can push it into the bundle from `screen.js`:
|
||||
|
||||
```js
|
||||
window.slopsmith.diagnostics.contribute('my_plugin', {
|
||||
schema: 'my_plugin.client_diag.v1',
|
||||
active_preset: getActivePreset(),
|
||||
last_error: _lastError,
|
||||
});
|
||||
```
|
||||
|
||||
- **Idempotent.** Repeated calls overwrite the previous value.
|
||||
- Whatever was last contributed before the user hits Export Diagnostics is what lands in `plugins/<plugin_id>/client.json`.
|
||||
- Available namespace: `window.slopsmith.diagnostics.{contribute, snapshotConsole, snapshotHardware, snapshotUa, snapshotLocalStorage, snapshotContributions}`.
|
||||
- Loaded from `static/diagnostics.js` ASAP in `<head>` so the console-wrap is in place before any other script runs.
|
||||
|
||||
## Best practices
|
||||
|
||||
- **Embed a `schema` field** (e.g. `"my_plugin.diag.v1"`) in JSON returned by `callable` so future tooling can dispatch by version.
|
||||
- **Keep payloads small (< 100 KB).** Diagnostics are not a backup channel — that's `settings.server_files`.
|
||||
- **Don't include secrets, API keys, or session tokens.** Bundles are shared with maintainers / posted to GitHub issues.
|
||||
|
||||
## Related
|
||||
|
||||
- [PLUGIN_AUTHORING.md](PLUGIN_AUTHORING.md) — guide index
|
||||
- [diagnostics-bundle-spec.md](diagnostics-bundle-spec.md) — full bundle layout + per-file schemas
|
||||
- [plugin-manifest.md](plugin-manifest.md) — declaring `diagnostics` fields
|
||||
- [plugin-logging.md](plugin-logging.md) — `log.exception()` writes traceback into the diagnostics log capture
|
||||
@@ -0,0 +1,64 @@
|
||||
# Plugin keyboard shortcuts
|
||||
|
||||
Plugins can register keyboard shortcuts via the global `window.registerShortcut()` function. Shortcuts appear in the `?` help panel.
|
||||
|
||||
## Registration
|
||||
|
||||
```js
|
||||
window.registerShortcut({
|
||||
key: 'k', // key value (e.key) or key code (e.code)
|
||||
description: 'Toggle my view', // shown in the help panel
|
||||
scope: 'player', // 'global' | 'player' | 'library' | 'settings' | 'plugin-{id}'
|
||||
condition: () => _isMyViewActive, // optional guard
|
||||
handler: (e) => _myAction() // called when shortcut triggers
|
||||
});
|
||||
```
|
||||
|
||||
## Scope
|
||||
|
||||
Scope controls when the shortcut is active:
|
||||
|
||||
- **`global`** — works on any screen
|
||||
- **`player`** — only on the player screen
|
||||
- **`library`** — only on the home/favorites screens
|
||||
- **`settings`** — only on the settings screen
|
||||
- **`plugin-{id}`** — only when your plugin's screen is active
|
||||
|
||||
## Panel-scoped shortcuts
|
||||
|
||||
For plugins that create multiple panels (e.g., splitscreen), shortcuts are automatically scoped to the active panel. Use `const panel = window.createShortcutPanel(id)` to create a panel (keep the returned reference so you can call `panel.clearShortcuts()` during cleanup) and `window.setActiveShortcutPanel(id)` to switch between them. Each panel has its own shortcut registry, so multiple panels can have the same key without collisions.
|
||||
|
||||
## Condition
|
||||
|
||||
`condition` is an optional guard function. If it returns false, the shortcut is skipped even if in scope.
|
||||
|
||||
## Key matching
|
||||
|
||||
The handler matches against both `e.key` (character produced) and `e.code` (physical key). Use `e.key` for letters/symbols that depend on keyboard layout, and `e.code` for special keys (e.g. `Space`, `ArrowLeft`).
|
||||
|
||||
## Cleanup
|
||||
|
||||
Clean up with `window.unregisterShortcut(key, scope)`. **You must pass the same scope you registered with** — the default is `'global'` and won't match `player`/`library`/`settings`/`plugin-*` bindings.
|
||||
|
||||
For panel-scoped shortcuts, prefer `panel.clearShortcuts()` over per-key unregister calls.
|
||||
|
||||
## Built-in shortcuts
|
||||
|
||||
| Key | Description |
|
||||
|-----|-------------|
|
||||
| `?` | Show keyboard shortcuts panel (global) |
|
||||
| `Space` | Play/Pause (player only) |
|
||||
| `←` / `→` | Seek ±5 seconds (player only) |
|
||||
| `Escape` | Back to library (player only) |
|
||||
| `[` / `]` | Audio offset ±10ms (Shift: ±50ms) (player only) |
|
||||
|
||||
Don't override these. The `?` panel is the canonical reference for users.
|
||||
|
||||
## Debugging
|
||||
|
||||
Open the browser console and type `_listShortcuts()` to inspect every registered shortcut, its scope, and its source plugin.
|
||||
|
||||
## Related
|
||||
|
||||
- [PLUGIN_AUTHORING.md](PLUGIN_AUTHORING.md) — guide index
|
||||
- [testing-plugins.md](testing-plugins.md) — Playwright tests for shortcut behaviour
|
||||
@@ -0,0 +1,67 @@
|
||||
# Plugin logging
|
||||
|
||||
Use `context["log"]` for all backend plugin output. It is a stdlib `logging.Logger` namespaced to `slopsmith.plugin.<id>`, pre-configured with the app-wide level, format (including JSON mode), and correlation IDs. **Never use `print()`** — it bypasses correlation context and log rotation.
|
||||
|
||||
## Backend usage
|
||||
|
||||
```python
|
||||
def setup(app, context):
|
||||
log = context["log"]
|
||||
log.info("plugin ready")
|
||||
log.warning("optional dependency %r not found, feature disabled", dep)
|
||||
try:
|
||||
risky_init()
|
||||
except Exception:
|
||||
log.exception("unhandled error during setup") # auto-captures traceback
|
||||
```
|
||||
|
||||
## CLI entry-point fallback
|
||||
|
||||
For helper scripts that also run as `__main__`, add a stdlib fallback so the logger works without the server pipeline:
|
||||
|
||||
```python
|
||||
if __name__ == "__main__":
|
||||
import logging
|
||||
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
|
||||
```
|
||||
|
||||
## How it's wired
|
||||
|
||||
The plugin loader assigns each plugin a logger before calling `setup()`:
|
||||
|
||||
```python
|
||||
context["log"] = logging.getLogger(f"slopsmith.plugin.{plugin_id}")
|
||||
```
|
||||
|
||||
That logger inherits from the root `slopsmith` logger, which is configured by `logging_setup.configure_logging()` (called from `main.py` before uvicorn boots). It respects:
|
||||
|
||||
- **`LOG_LEVEL`** — `DEBUG | INFO | WARNING | ERROR` (default `INFO`)
|
||||
- **`LOG_FORMAT`** — `json | text` (default `text` — coloured console)
|
||||
- **`LOG_FILE`** — optional path for a persistent log file (e.g. `/config/slopsmith.log`)
|
||||
- **Correlation IDs** — each HTTP request carries `X-Request-ID`; structlog injects it into every log line during that request's lifetime. WebSocket sessions get their own `ws_conn_id` contextvar bound at accept time.
|
||||
|
||||
## Verifying
|
||||
|
||||
Look for your plugin's logger name in the console output:
|
||||
|
||||
```
|
||||
INFO slopsmith.plugin.my_plugin: plugin ready
|
||||
```
|
||||
|
||||
Switch `LOG_FORMAT=json` to see structured output:
|
||||
|
||||
```json
|
||||
{"timestamp": "...", "level": "info", "logger": "slopsmith.plugin.my_plugin", "event": "plugin ready", "request_id": "..."}
|
||||
```
|
||||
|
||||
## Common mistakes
|
||||
|
||||
- **Using `print()` inside `setup()` or request handlers.** `print()` goes to stdout, bypasses level filtering, log rotation, JSON formatting, and correlation IDs. Audit your plugin with `grep -nR 'print(' plugins/<id>/`.
|
||||
- **Creating a new logger with `logging.getLogger("my_plugin")`.** This creates an unparented logger that doesn't inherit slopsmith's configuration. Always use the one passed via `context["log"]`.
|
||||
- **Logging at `INFO` in hot paths.** Default `LOG_LEVEL` is `INFO`, so `log.info(...)` inside a per-frame or per-message loop floods output. Use `log.debug(...)` for hot-path tracing.
|
||||
|
||||
## Related
|
||||
|
||||
- [PLUGIN_AUTHORING.md](PLUGIN_AUTHORING.md) — guide index
|
||||
- [plugin-manifest.md](plugin-manifest.md) — the `routes.py` / `setup(app, context)` contract
|
||||
- [plugin-diagnostics.md](plugin-diagnostics.md) — capturing logs into the diagnostics bundle
|
||||
@@ -0,0 +1,138 @@
|
||||
# `plugin.json` manifest reference
|
||||
|
||||
Every plugin lives in `plugins/<name>/` and must declare a `plugin.json` manifest. JSON Schema for this format ships at [`schema/plugin.schema.json`](../schema/plugin.schema.json) and is enforced in CI for in-tree plugins.
|
||||
|
||||
## Full example
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "my_plugin",
|
||||
"name": "My Plugin",
|
||||
"version": "1.0.0",
|
||||
"private": false,
|
||||
"type": "visualization",
|
||||
"nav": { "label": "My Plugin", "screen": "plugin-my_plugin" },
|
||||
"screen": "screen.html",
|
||||
"script": "screen.js",
|
||||
"routes": "routes.py",
|
||||
"settings": {
|
||||
"html": "settings.html",
|
||||
"server_files": ["my_plugin.db", "my_plugin_models/"]
|
||||
},
|
||||
"diagnostics": {
|
||||
"server_files": ["my_plugin.diag.json"],
|
||||
"callable": "diagnostics:collect"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
All fields except `id` and `name` are optional. Plugins can have any combination of frontend (screen/script), backend (routes), and settings.
|
||||
|
||||
## Fields
|
||||
|
||||
### `id` (required, string)
|
||||
|
||||
Snake-case identifier. Used to namespace `localStorage` keys, build the plugin's screen id (`plugin-<id>`), namespace the backend logger (`slopsmith.plugin.<id>`), and as the directory name in diagnostics bundles. Cannot contain slashes, dots are encoded by the sibling-import loader (see [plugin-sibling-imports.md](plugin-sibling-imports.md)).
|
||||
|
||||
### `name` (required, string)
|
||||
|
||||
Human-readable name shown in UI surfaces.
|
||||
|
||||
### `version` (string, optional)
|
||||
|
||||
Plain semver string. Advisory only — the plugin loader does not consume this. Plugins commonly include it for publishing/tooling purposes.
|
||||
|
||||
### `private` (boolean, optional)
|
||||
|
||||
Advisory metadata for plugin authors. Not consumed by the loader.
|
||||
|
||||
### `type` (string, optional — role hint, slopsmith#36)
|
||||
|
||||
Supported values:
|
||||
- `"visualization"` — plugin provides a highway renderer. Declaring this makes the plugin eligible for the main-player viz picker AND splitscreen's per-panel picker. Must pair with a `window.slopsmithViz_<id>` factory exporting the setRenderer contract (see [plugin-visualization-contracts.md](plugin-visualization-contracts.md)).
|
||||
- Absent → no declared role; plugin is loaded and its script runs, but it doesn't appear in role-specific UIs.
|
||||
|
||||
### `nav` (object, optional)
|
||||
|
||||
`{ "label": string, "screen": string }` — adds a navbar entry that calls `showScreen(<screen>)`. `screen` is typically `plugin-<id>`.
|
||||
|
||||
### `screen` (string, optional)
|
||||
|
||||
Path to HTML file (relative to plugin dir). Mounted at `#plugin-<id>` in the SPA.
|
||||
|
||||
### `script` (string, optional)
|
||||
|
||||
Path to JS file (relative to plugin dir). Loaded via `<script>` tag in global scope. Wrap in an IIFE.
|
||||
|
||||
### `routes` (string, optional)
|
||||
|
||||
Path to Python file exporting `setup(app, context)`. See "Backend routes" below.
|
||||
|
||||
### `settings` (object, optional)
|
||||
|
||||
`{ "html": string, "server_files": string[] }`
|
||||
|
||||
- **`settings.html`** — settings-panel HTML.
|
||||
- **`settings.server_files`** — **opt-in** for the unified Settings export/import flow (slopsmith#113). List of relpaths under `context["config_dir"]` that the plugin wants included in user-triggered backups. A trailing `/` denotes a directory (recurse).
|
||||
|
||||
Rules:
|
||||
- Relpaths only. Absolute paths, drive letters, `..` segments, and backslashes are rejected at load time with a `[Plugin]` warning.
|
||||
- The same allowlist is consulted at both export and import: a bundle that references a file the *importing host*'s manifest no longer declares is skipped with a warning (handles plugin updates between export and import). A bundle that references a file your host's manifest never declared is also skipped — no surprise writes.
|
||||
- Files are encoded as `{"encoding": "json", "data": <parsed>}` for `.json` files that parse cleanly (diff-friendly), `{"encoding": "base64", "data": "..."}` otherwise (sqlite, model blobs, IRs).
|
||||
- Plugins own their internal data migration. Importing a bundle whose data schema predates your current code restores bytes verbatim — your plugin must cope at next load.
|
||||
- Symlinks are skipped on export and never followed on import.
|
||||
|
||||
Plugins that omit this field have no server-side files exported; their state lives entirely in browser `localStorage`, which is bundled wholesale on every export.
|
||||
|
||||
### `diagnostics` (object, optional)
|
||||
|
||||
`{ "server_files": string[], "callable": string }`
|
||||
|
||||
**Opt-in** for the troubleshooting bundle (slopsmith#166 — Settings → Export Diagnostics). Two independent fields:
|
||||
|
||||
- **`diagnostics.server_files`** — same allowlist semantics as `settings.server_files`: relpaths under `context["config_dir"]`, no `..`, no abs paths, no backslashes, no leading dots. Files listed here are copied verbatim into `plugins/<plugin_id>/<relpath>` inside the bundle. Use this for snapshot-style state (small DB excerpts, model lists, last-error files).
|
||||
- **`diagnostics.callable`** — `"<module>:<function>"` (e.g. `"diagnostics:collect"`). Resolved lazily via `load_sibling` when the user clicks Export, then called as `func({"plugin_id": "...", "config_dir": Path(...)})`. Return `dict`/`list` → written to `plugins/<id>/callable.json`; `bytes` → `callable.bin`; `str` → `callable.txt`. Exceptions are caught and appended to the bundle's `manifest.notes` — a buggy plugin never crashes the export.
|
||||
|
||||
See [plugin-diagnostics.md](plugin-diagnostics.md) for full diagnostics integration patterns.
|
||||
|
||||
### `license` (string, optional but recommended)
|
||||
|
||||
SPDX identifier. For curated plugins, must be AGPL-3.0-or-later or AGPL-compatible (MIT, BSD-2-Clause, BSD-3-Clause, Apache-2.0). See [CONTRIBUTING.md](../CONTRIBUTING.md).
|
||||
|
||||
## Backend routes — `setup(app, context)`
|
||||
|
||||
`routes.py` must export `setup(app, context)`. The `context` dict provides:
|
||||
|
||||
- `config_dir` — persistent config path (`Path`)
|
||||
- `get_dlc_dir()` — returns the DLC folder `Path`
|
||||
- `extract_meta()` — metadata extraction callable
|
||||
- `meta_db` — shared `MetadataDB` instance
|
||||
- `get_sloppak_cache_dir()` — sloppak cache `Path`
|
||||
- `load_sibling(name)` — loads a sibling module from this plugin's directory under a unique, namespaced module name. See [plugin-sibling-imports.md](plugin-sibling-imports.md).
|
||||
- `log` — stdlib `logging.Logger` namespaced to `slopsmith.plugin.<id>`. Pre-configured with the app-wide level, format (including JSON mode), and correlation IDs. Use this for all backend plugin output instead of `print()`. See [plugin-logging.md](plugin-logging.md).
|
||||
|
||||
Example:
|
||||
|
||||
```python
|
||||
def setup(app, context):
|
||||
log = context["log"]
|
||||
extractor = context["load_sibling"]("extractor")
|
||||
|
||||
@app.get("/api/my_plugin/status")
|
||||
def status():
|
||||
return {"ready": True}
|
||||
|
||||
log.info("my_plugin ready")
|
||||
```
|
||||
|
||||
## Validation
|
||||
|
||||
Run the local validator skill `/plugin-validate` (Claude Code) or the CI workflow `.github/workflows/validate-plugins.yml`. Both consume [`schema/plugin.schema.json`](../schema/plugin.schema.json).
|
||||
|
||||
## Related
|
||||
|
||||
- [PLUGIN_AUTHORING.md](PLUGIN_AUTHORING.md) — guide index
|
||||
- [plugin-logging.md](plugin-logging.md) — `context["log"]` pattern
|
||||
- [plugin-sibling-imports.md](plugin-sibling-imports.md) — `load_sibling`
|
||||
- [plugin-diagnostics.md](plugin-diagnostics.md) — diagnostics opt-in details
|
||||
- [diagnostics-bundle-spec.md](diagnostics-bundle-spec.md) — full diagnostics bundle layout
|
||||
@@ -0,0 +1,48 @@
|
||||
# Plugin sibling imports — `load_sibling`
|
||||
|
||||
When a backend plugin spans multiple Python files, **use `context["load_sibling"]`** rather than bare `import` statements. This is the slopsmith#33 contract.
|
||||
|
||||
## The problem
|
||||
|
||||
The plugin loader inserts each plugin's directory onto `sys.path` so `from extractor import X` works. But Python caches imports by **module name** in `sys.modules`. Two plugins that each ship a top-level `extractor.py` (or any other generic name — `util.py`, `client.py`, `parser.py`, `config.py`, …) collide: whichever loads first wins, and the other plugin's `from extractor import X` either gets the wrong module or fails with `cannot import name 'X' from 'extractor'`.
|
||||
|
||||
## The fix
|
||||
|
||||
`context["load_sibling"](name)` loads the sibling under a namespaced module name `plugin_<id>.<name>`, so each plugin gets its own copy. The `<id>` portion is bijectively encoded so reverse-DNS-style ids like `com.example.foo` work without colliding: `_` → `_5f_`, `.` → `_2e_`.
|
||||
|
||||
```python
|
||||
def setup(app, context):
|
||||
extractor = context["load_sibling"]("extractor")
|
||||
PsarcReader = extractor.PsarcReader
|
||||
# …
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- **`name` is a bare module name** — no `.py` suffix, no slashes, no `.`. The helper raises `ValueError` for path traversal / format issues and `ImportError` for missing files.
|
||||
- **Both single-file siblings (`extractor.py`) and package-form siblings (`extractor/__init__.py`) work.** Package form wins when both exist (matches CPython's import-resolution precedence).
|
||||
- **Relative imports between siblings work** — `from .shared import X` in a top-level helper, `from ..shared import X` from inside a sibling package. The synthetic parent package `plugin_<id>` carries the plugin directory in its `__path__`.
|
||||
- **`from . import sibling` (attribute-style) also resolves**: loaded children are exposed as attributes on the parent package.
|
||||
- **Repeat calls return the cached module.** Concurrent first-time calls are serialized via per-module locks so no caller observes a half-initialized module.
|
||||
- **Don't mix `load_sibling` and bare `import` for the same module** — they'd execute the file twice and split module-level state.
|
||||
|
||||
## Migration
|
||||
|
||||
Bare `import sibling` from `routes.py` still works during the transition period, but the loader prints a startup warning when it detects two plugins shipping a same-named top-level module — covering both `.py` files and package directories. Migrate to `load_sibling` to silence the warning and immunize your plugin from future ecosystem collisions.
|
||||
|
||||
## Verification
|
||||
|
||||
The collision-detection logic is encoded in `tests/test_plugins.py`. Run:
|
||||
|
||||
```bash
|
||||
pytest tests/test_plugins.py -v
|
||||
```
|
||||
|
||||
If you're authoring a plugin with a top-level helper, add a test that imports your plugin under a `reset_plugin_state` fixture to confirm clean import behaviour. See [testing-plugins.md](testing-plugins.md).
|
||||
|
||||
## Related
|
||||
|
||||
- [PLUGIN_AUTHORING.md](PLUGIN_AUTHORING.md) — guide index
|
||||
- [plugin-manifest.md](plugin-manifest.md) — declaring `routes.py`
|
||||
- [plugin-logging.md](plugin-logging.md) — `context["log"]` (also exposed via `context`)
|
||||
- [testing-plugins.md](testing-plugins.md) — `reset_plugin_state` fixture
|
||||
@@ -0,0 +1,198 @@
|
||||
# Plugin visualization contracts
|
||||
|
||||
Slopsmith supports two ways for a plugin to participate in the main player's visuals. They coexist; the **setRenderer** contract is the default for any viz that draws a highway-shaped surface, and **overlays** handle layered decorations on top. A third orthogonal contract — **note-state provider** — lets scorers feed per-note judgments back into whichever renderer is active.
|
||||
|
||||
**Pick the right shape:**
|
||||
- Replacing the whole highway drawing on the existing highway canvas (your renderer owns its rendering context / resources; `createHighway()` still owns the canvas element and the rAF loop)? → **setRenderer** (section 1). Enters the viz picker. Works in both the main player and per-panel under splitscreen.
|
||||
- Adding a layer on top of whichever viz is active? → **Overlay** (section 2). Navbar toggle, not in the picker.
|
||||
- Scoring plugin that wants renderers to "light up" notes on correct hits? → **Note-state provider** (section 3). Orthogonal — can coexist with overlay HUDs.
|
||||
|
||||
## 1. setRenderer contract (slopsmith#36) — preferred
|
||||
|
||||
Plugins that want to replace the main highway's draw function (per panel, per session) export a renderer factory on `window.slopsmithViz_<id>` where `<id>` matches the `id` in `plugin.json` (`type: "visualization"` required). The factory returns an object matching this shape:
|
||||
|
||||
```js
|
||||
window.slopsmithViz_my_viz = function () {
|
||||
return {
|
||||
// Required canvas context type. Default '2d' if omitted.
|
||||
// highway.js reads this BEFORE calling init() so it can
|
||||
// replace the underlying <canvas> element if the current
|
||||
// one is locked to a different context type (see "Canvas
|
||||
// context-type swapping" below).
|
||||
contextType: '2d', // or 'webgl2'
|
||||
init(canvas, bundle) {
|
||||
// One-time setup. Own your getContext() call here —
|
||||
// acquire '2d' or 'webgl2' depending on the renderer.
|
||||
// The canvas you receive is guaranteed to either be
|
||||
// unbound or already bound to your declared contextType.
|
||||
this.ctx = canvas.getContext('2d');
|
||||
},
|
||||
draw(bundle) {
|
||||
// Called each requestAnimationFrame tick by the factory.
|
||||
// `bundle` is a snapshot with: currentTime, songInfo, isReady,
|
||||
// notes, chords, anchors (all difficulty-filter-aware),
|
||||
// beats, sections, chordTemplates, stringCount, lyrics,
|
||||
// toneChanges, toneBase, mastery, hasPhraseData, inverted,
|
||||
// lefty, renderScale, lyricsVisible, the 2D coordinate
|
||||
// helpers project and fretX, and getNoteState (see below).
|
||||
// `stringCount` is the active arrangement's string count (4
|
||||
// for bass, 6 for guitar, 7+ for extended-range GP imports —
|
||||
// size string-indexed geometry against this, not a hardcoded
|
||||
// 6). If your renderer needs lefty-aware text rendering, check
|
||||
// bundle.lefty and apply the mirror transform yourself —
|
||||
// a bundle-level helper isn't provided because it would
|
||||
// need your renderer's own context, not the factory's.
|
||||
//
|
||||
// bundle.getNoteState(note, chartTime) (slopsmith#254) — call
|
||||
// this per visible chart note / chord-note to find out whether
|
||||
// a scorer (note_detect) has flagged it 'hit' / 'active' (a
|
||||
// sustain currently being held correctly) / 'miss', so the gem
|
||||
// itself can light up / a held sustain can glow instead of
|
||||
// relying on an overlay ring. Returns null when no provider is
|
||||
// registered or it reports nothing for this note; otherwise
|
||||
// { state: 'hit'|'active'|'miss', alpha: 0..1, color: string|null }.
|
||||
// For chord notes pass the chord's time (note_detect keys its
|
||||
// judgments by `${time}_${string}_${fret}`). 'hit' and 'active'
|
||||
// are both "lit" — a renderer may treat them identically; the
|
||||
// provider owns all fade timing via `alpha` and by simply
|
||||
// ceasing to return state when the effect should end.
|
||||
},
|
||||
resize(w, h) {
|
||||
// Optional. Canvas dims already updated; re-create WebGL
|
||||
// framebuffers / reset 2D transforms here.
|
||||
},
|
||||
destroy() {
|
||||
// Optional. Release resources, remove DOM nodes, null refs.
|
||||
// Called before setRenderer() swaps to another renderer
|
||||
// and on highway.stop().
|
||||
},
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
Selecting this plugin in the main-player viz picker — or in splitscreen's per-panel picker — calls `highway.setRenderer(factory())` on the existing highway instance. The built-in 2D highway is the default renderer and is restored by passing nullish — `setRenderer(null)` and `setRenderer(undefined)` both work (the implementation gates on `r == null`). Splitscreen panels create one `createHighway()` per panel and each independently consults the picker, so N panels can run different renderers (or N copies of the same renderer with different arrangements) without coordination.
|
||||
|
||||
### Lifecycle contract
|
||||
|
||||
The factory returns a single renderer instance that may go through multiple `init() → ... → destroy()` cycles as the user navigates between songs or screens. Specifically:
|
||||
|
||||
- `init(canvas, bundle)` runs when the highway has a canvas and the renderer takes over drawing. This is when to acquire `getContext()`, build shaders / meshes / DOM nodes, and register listeners.
|
||||
- `draw(bundle)` runs on every rAF frame once the WebSocket `ready` message has fired and until the renderer is replaced or the highway stops. It is **not** called during the loading / reconnect window (between `api.init()` + `stop()` and the next `ready`) — that would hand the renderer half-populated chart arrays. Renderers that want to show a "loading" state can read `bundle.isReady` inside a future-widened contract, but today the factory gates `draw` behind the ready flag and `isReady` is only informational once it does fire.
|
||||
- `destroy()` runs when the renderer is replaced via another `setRenderer(...)` call, OR when `highway.stop()` is called (e.g. the user navigates away from the player). It releases everything `init()` acquired.
|
||||
- **After `destroy()`, the same instance may receive another `init()` call** — this happens on `playSong()` which does `stop()` → `init()` to reuse the same canvas element for the next song. Renderers must tolerate `init()` being called again on an instance that was previously destroyed. Practically: null your refs in destroy, re-acquire them in init.
|
||||
- `destroy()` is skipped when it would run on an un-init'd renderer — if a caller does `setRenderer(x)` before the highway ever init'd (possible when restoring a saved picker selection at page load), `x.destroy()` is not called until `x.init()` has run at least once.
|
||||
- `resize(w, h)` is optional; runs after init and whenever the canvas dimensions change.
|
||||
|
||||
### Key rules
|
||||
|
||||
- The factory **returns a fresh object on each call** — important for splitscreen, where multiple panels will each get an independent instance.
|
||||
- The renderer **owns its own rendering context** (2D or WebGL). Factory will not call getContext for you.
|
||||
- **Canvas context-type swapping.** Browsers lock a `<canvas>` to the first context type successfully acquired for its lifetime: once `getContext('2d')` succeeds, `getContext('webgl2')` on that same canvas returns `null`, and vice versa. To let arbitrary 2D ⇄ WebGL renderer swaps work mid-session, `highway.setRenderer()` reads the next renderer's `contextType` before calling its `init()` and, if it differs from the type currently bound, replaces the underlying `<canvas>` element with a fresh one via `oldCanvas.cloneNode(false)` followed by `oldCanvas.replaceWith(newCanvas)`. The factory then calls the renderer's `init(newCanvas, bundle)` with the fresh element so its `getContext()` succeeds. Practical implications:
|
||||
- **What survives the swap.** `cloneNode(false)` preserves *every HTML attribute* on the element — `id`, `class`, inline `style`, all `data-*` and `aria-*` attributes, `role`, `tabindex`, the attribute form of `width`/`height`, and anything else a plugin attached. DOM position is preserved by `replaceWith()`, so siblings, parents, and surrounding layout are unaffected.
|
||||
- **What does NOT survive.** Event listeners attached via `addEventListener` are NOT cloned, and expando properties set imperatively on the JavaScript object (such as the bound rendering context, or any `canvas._myPlugin = …`-style data a plugin attached) are not carried over either. The bound rendering context being left behind on the detached element is exactly what allows the new canvas to start fresh and accept a different `getContext()` call. Note: `canvas.width`/`canvas.height` *are* reflected HTML attributes, so those values do survive the clone; `api.resize()` re-applies the backing-store dimensions on the new element after the swap regardless.
|
||||
- Renderers must **declare `contextType`** on the returned instance (`'2d'` or `'webgl2'`; absent → `'2d'`). Factories may also expose it as a static (`window.slopsmithViz_<id>.contextType = 'webgl2'`) so core can read it before constructing the renderer — used today by Auto-mode evaluation.
|
||||
- Plugins that hold a stale reference to the highway canvas across renderer swaps — including any code that registered listeners directly on the canvas element rather than on `window`/`document` — should listen for the `highway:canvas-replaced` event on `window.slopsmith` and re-acquire / re-register. `window.slopsmith.emit` dispatches a `CustomEvent`, so the payload `{ oldCanvas, newCanvas, contextType }` lives on `event.detail`, not on the event object itself:
|
||||
```js
|
||||
window.slopsmith.on('highway:canvas-replaced', (event) => {
|
||||
const { oldCanvas, newCanvas, contextType } = event.detail;
|
||||
// re-acquire / re-register against newCanvas
|
||||
});
|
||||
```
|
||||
Plugins that re-query `document.getElementById('highway')` lazily inside their own event handlers don't need this listener — they pick up the new element automatically (it keeps `id="highway"`).
|
||||
- **`highway:visibility`** — fired on `window.slopsmith` whenever the highway canvas transitions between displayed and hidden. Detection is DOM-based via `canvas.offsetParent === null` (catches `display:none` on the canvas or any ancestor — e.g. splitscreen's `#highway` hide) or whatever a host explicitly sets via `highway.setVisible(bool)`. While `visible === false`, core skips the rAF `renderer.draw(bundle)` call AND the default 2D draw, so renderers don't have to no-op themselves. The event is emitted only on transitions (including the first one after `init()`), not every frame. Payload `{ visible, canvas }` lives on `event.detail`:
|
||||
```js
|
||||
window.slopsmith.on('highway:visibility', (event) => {
|
||||
const { visible, canvas } = event.detail;
|
||||
// Toggle any sibling DOM your renderer mounts. The 3D Highway
|
||||
// renderer hides its `.h3d-wrap` overlay here so `display:none`
|
||||
// on `#highway` actually hides the visible output.
|
||||
});
|
||||
```
|
||||
Renderers that only paint to the slopsmith canvas don't need this listener — the rAF skip is enough. Renderers that mount sibling DOM (separate WebGL contexts, overlays, etc.) do.
|
||||
- **`highway.setVisible(bool | null)`** — forces the visibility state regardless of `offsetParent`. Pass `null` to clear the override and resume DOM-based detection. Useful when the host hides the highway via `visibility:hidden`, `opacity:0`, transforms, or clipping rather than `display:none`. The override re-emits any resulting transition immediately rather than waiting for the next rAF tick.
|
||||
- Default-renderer ctx is closure-cached. The replace path nulls the closure ctx so stale draw paths short-circuit; the next default-renderer `init()` re-acquires the 2D context from the new canvas cleanly.
|
||||
- `draw(bundle)` receives difficulty-filtered arrays — never read from `_filteredNotes` or other internals.
|
||||
- `_drawHooks` fire for the default 2D renderer (the factory calls them at the end of each frame). Custom WebGL renderers that maintain a 2D overlay canvas (like the bundled 3D highway) also call `window.highway.fireDrawHooks(ctx, W, H)` on that overlay so overlay plugins continue to work regardless of which renderer is active. Custom renderers without a 2D overlay context should not attempt to fire hooks.
|
||||
|
||||
### Auto mode — `matchesArrangement(songInfo)` (optional)
|
||||
|
||||
The viz picker prepends an "Auto (match arrangement)" entry that is the default selection on fresh installs. When Auto is active, core evaluates registered viz factories on every `song:ready` and swaps the renderer to the first factory whose `matchesArrangement(songInfo)` predicate returns truthy. No match → the built-in 2D highway.
|
||||
|
||||
Declare the predicate as a static on the factory (not the instance) so core can evaluate it without constructing a throwaway renderer:
|
||||
|
||||
```js
|
||||
window.slopsmithViz_piano = function () { /* ... */ };
|
||||
window.slopsmithViz_piano.matchesArrangement = function (songInfo) {
|
||||
return /keys|piano|synth/i.test((songInfo && songInfo.arrangement) || '');
|
||||
};
|
||||
```
|
||||
|
||||
- `songInfo` is the highway's live song_info snapshot — `arrangement`, `tuning`, `capo`, `arrangement_index`, `filename`, `artist`, `title`, etc. May be `{}` before the first song loads.
|
||||
- Factories without `matchesArrangement` are skipped during auto-selection — the correct default for arrangement-agnostic viz (tabview, jumpingtab) that only make sense as manual picks.
|
||||
- Explicit picker selections override Auto and are persisted to `localStorage.vizSelection`, so the pinned choice survives page reloads until the user switches back to "Auto" (which also persists). Picking "Auto" re-evaluates against the current song immediately. In contexts where `localStorage` is unavailable (private mode, sandboxed iframes, some test runners) persistence falls back to the current picker `<option>` value, which still overrides Auto for as long as the page stays loaded.
|
||||
- When an Auto-selected renderer fails and core emits `viz:reverted`, the picker falls back to the built-in default and disables auto-switching until the user re-selects Auto.
|
||||
- First match wins (picker order), so the registration order of plugins is the tiebreaker. Keep predicates narrow to avoid stealing songs from more specialized viz.
|
||||
|
||||
**WebGL viz in Auto mode.** Auto evaluation runs on every `song:ready` regardless of which renderer is active. Auto-installing a WebGL renderer when the canvas is currently 2D — or reverting from a WebGL Auto pick to the default 2D — works without a reload because `setRenderer` swaps the canvas element when `contextType` differs (see "Canvas context-type swapping" above). WebGL viz can therefore safely declare `matchesArrangement` and rely on Auto. For 3D Highway specifically, `_canRun3D()` in app.js still gates Auto from picking it on machines without WebGL2 — that fallback is independent of canvas swapping.
|
||||
|
||||
## 2. Overlay contract — for add-on layers
|
||||
|
||||
Plugins that add a layer on top of whichever visualization is active — HUDs, fretboard diagrams, chord labels, practice feedback — don't replace the renderer. They manage their own canvas, their own rAF loop, and a toggle button somewhere visible (typically a navbar pill), reading public highway state via the getters:
|
||||
|
||||
- `highway.getTime()` / `highway.getBeats()` — current playback position
|
||||
- `highway.getNotes()` / `highway.getChords()` — difficulty-filter-aware arrays
|
||||
- `highway.getChordTemplates()` — chord shape lookup table; index by `chord.id` from `getChords()` to get `{ name, fingers, frets }`. `fingers` and `frets` are per-string arrays (length matches the tuning's string count); within `fingers`, `-1` = unused, `0` = open string, `n > 0` = finger number. RS XML sources populate real fingerings; GP imports currently emit all `-1` since pre-import sources don't carry finger data. Not filter-aware: templates are static metadata, every `chord_id` referenced by `getChords()` is guaranteed valid.
|
||||
- `highway.getSongInfo()` — tuning, arrangement, capo
|
||||
- `highway.getStringCount()` — number of strings on the active arrangement (4 for bass, 6 for guitar, 7+ for extended-range GP imports). Derived server-side as `max(notes-max-string + 1, name-based fallback, len(tuning))` where the tuning length only contributes when it isn't the RS-XML padded 6-string form (sloppak / GP-imported sources carry trimmed tuning lengths). The name-based fallback is 4 for arrangements containing "bass" (case-insensitive) and 6 otherwise. This combination handles partial-string-usage charts (a 6-string lead that never plays string 5), extended-range GP imports (5-string bass, 7-string guitar), and sloppaks that explicitly encode the instrument range — without requiring plugins to do their own arrangement-name matching.
|
||||
- `highway.getLefty()` / `highway.getInverted()` — mirror + invert state
|
||||
|
||||
Overlays do NOT appear in the viz picker and do NOT declare `"type": "visualization"` in `plugin.json`. They coexist with whichever renderer (default 2D, 3D highway, piano, ...) the user has picked.
|
||||
|
||||
### Key rules
|
||||
|
||||
- **Own your rAF + canvas** — don't piggyback on `_drawHooks` or on `createHighway`'s rendering context. Draw hooks fire for the default 2D renderer and for custom renderers that explicitly call `window.highway.fireDrawHooks(ctx, W, H)` (e.g. the bundled 3D highway fires them on its 2D overlay canvas), but not for every custom renderer.
|
||||
- **Re-read state every frame** — overlay output must track whatever the current renderer is drawing. Don't cache note positions across frames.
|
||||
- **Respect lefty + invert toggles** — if the overlay depicts strings or frets, mirror using the same transforms the active renderer would.
|
||||
- **If you position with `highway.project` / `highway.fretX` (the 2D-highway geometry), gate on `highway.isDefaultRenderer()`** — those helpers describe the *built-in 2D* highway's depth curve and fret zoom. When a custom renderer (3D highway, piano, …) is active your draw hook still fires (on that renderer's 2D overlay layer), but those coordinates won't match its scene — markers land in arbitrary places. Skip rendering when `isDefaultRenderer()` is false; the custom renderer owns that feedback. Renderer-agnostic overlays (fretboard diagram, chord-label HUD — they use `getNotes()`/`getChordTemplates()` + their own layout) don't need this guard.
|
||||
- **Clean up on toggle-off** — cancel rAF and remove/hide the overlay canvas so inactive overlays aren't wasting frames.
|
||||
|
||||
Reference: [fretboard plugin](https://github.com/byrongamatos/slopsmith-plugin-fretboard) — canonical overlay implementation (navbar toggle, own canvas, 80ms active-note window).
|
||||
|
||||
### Why two contracts?
|
||||
|
||||
setRenderer plugs into an existing highway — main-player or splitscreen-panel — reusing its WebSocket and data parsing, so the common "I want a different look for the same data" case is zero boilerplate AND multi-instance for free. Overlays compose with whatever renderer is active — they decorate rather than replace, so multiple can stack (fretboard + chord labels + practice feedback) without fighting over the canvas.
|
||||
|
||||
A previous standalone-pane contract (`window.createMyVisualization({ container })` with its own WebSocket per pane) was used by splitscreen pre-Wave-C. It's been retired now that splitscreen calls `setRenderer` on per-panel `createHighway()` instances; if you find references in older plugin docs or external integration guides, those describe the legacy path.
|
||||
|
||||
## 3. Note-state provider — for scorers that want renderers to "light up" notes (slopsmith#254)
|
||||
|
||||
A scoring plugin (note_detect) can publish a per-note judgment so whichever renderer is active draws the **gem itself** lit on a correct hit, and keeps a sustain trail glowing while it's still being played correctly — instead of a separate overlay ring floating near the note.
|
||||
|
||||
```js
|
||||
// In the plugin (after resolving the highway instance):
|
||||
highway.setNoteStateProvider((note, chartTime) => {
|
||||
// `note` is the chart note object ({ t, s, f, sus, ... }); for chord
|
||||
// notes `chartTime` is the chord's time. Return one of:
|
||||
// - falsy → no special state (render normally)
|
||||
// - 'hit' — struck correctly; renderer lights the gem
|
||||
// - 'active' — a sustained note is right now being held correctly
|
||||
// - 'miss' — missed; renderer may red-wash the gem
|
||||
// - { state: <one of the above>, alpha?: 0..1, color?: '#rrggbb' }
|
||||
// You own all fade timing: return a decaying `alpha` for a struck-note
|
||||
// glow, `alpha: 1` (or a bare string) for a held sustain, and stop
|
||||
// returning state when the effect should end. Keep it cheap — it's
|
||||
// called per visible note per renderer per frame.
|
||||
});
|
||||
// On teardown: highway.setNoteStateProvider(null);
|
||||
```
|
||||
|
||||
- Only one provider is active at a time (last `setNoteStateProvider` wins). `highway.getNoteStateProvider()` returns the current one (or null).
|
||||
- The built-in 2D highway consults it in `drawNote` / `drawSustains` / the chord-frame path: 'hit'/'active' → bright string colour + additive halo + a contained "sizzle" (crackling sparks, throbbing core, a shockwave ring on a fresh strike) on the gem and a bright (vs dim) sustain trail; 'miss' → faint red wash. The bundled **3D highway** reads the same data via `bundle.getNoteState` (bright string-tinted outline + bright body + glowing sustain + a contained sparkle hugging the note rect on hit/active; red outline + suppressed body on miss). Custom renderers that want it call `bundle.getNoteState(note, chartTime)` — it null-guards and returns the normalized `{ state, alpha, color }` (or null).
|
||||
- This is orthogonal to the overlay contract: note_detect remains an overlay (HUD, diagnostic miss markers, the "currently detected" indicator) *and* a scorer that feeds this provider. A renderer that ignores `getNoteState` simply doesn't light gems — nothing breaks.
|
||||
|
||||
## Related
|
||||
|
||||
- [PLUGIN_AUTHORING.md](PLUGIN_AUTHORING.md) — guide index
|
||||
- [plugin-manifest.md](plugin-manifest.md) — declaring `"type": "visualization"`
|
||||
- [websocket-protocol.md](websocket-protocol.md) — the WebSocket stream that drives `bundle`
|
||||
- [plugin-audio-mixer.md](plugin-audio-mixer.md) — companion contract for audio-producing plugins
|
||||
@@ -0,0 +1,104 @@
|
||||
# Testing plugins
|
||||
|
||||
Slopsmith has three test surfaces — Python unit/integration tests (pytest), JS plugin-API contract tests (Node), and end-to-end browser tests (Playwright). This doc covers what to use when.
|
||||
|
||||
## Test layout
|
||||
|
||||
```
|
||||
tests/
|
||||
├── conftest.py Shared pytest fixtures (isolate_logging)
|
||||
├── test_plugins.py Plugin loader + load_sibling + collision tests (includes reset_plugin_state)
|
||||
├── test_song.py Wire-format serialization round-trips
|
||||
├── test_*.py Per-feature backend tests
|
||||
├── js/ Node --test JS plugin-API contract tests
|
||||
└── browser/ Playwright end-to-end tests
|
||||
```
|
||||
|
||||
## Running tests
|
||||
|
||||
```bash
|
||||
pytest # All Python tests
|
||||
pytest tests/test_plugins.py -v # Specific file
|
||||
pytest -k "load_sibling" -v # Pattern match
|
||||
|
||||
npm run test:js # Node-native JS contract tests
|
||||
npm run install:playwright # One-time: install Chromium
|
||||
npm test # Playwright browser tests
|
||||
npm run test:headed # Playwright with visible browser
|
||||
npm run test:debug # Playwright inspector
|
||||
```
|
||||
|
||||
CI runs `pytest` on every push/PR to `main` (Python 3.12).
|
||||
|
||||
## Shared fixtures
|
||||
|
||||
### `isolate_logging` (in `conftest.py`)
|
||||
|
||||
Saves and restores handlers, level, and `propagate` flag on the `slopsmith`, `uvicorn`, `uvicorn.error`, and `uvicorn.access` loggers, plus calls `structlog.reset_defaults()`. **Import into any test module that calls `configure_logging()`** so mutations don't bleed across tests.
|
||||
|
||||
```python
|
||||
def test_my_log_thing(isolate_logging):
|
||||
configure_logging()
|
||||
# ... assertions
|
||||
```
|
||||
|
||||
### `reset_plugin_state` (in `test_plugins.py`)
|
||||
|
||||
Local fixture used by plugin-loader tests. Saves and restores:
|
||||
- `plugins.LOADED_PLUGINS`
|
||||
- any `plugin_*` keys in `sys.modules`
|
||||
- the bare names the tests simulate (`util`, `extractor`) in `sys.modules`
|
||||
- `sys.path` (the loader mutates it)
|
||||
|
||||
Also unsets `SLOPSMITH_PLUGINS_DIR` for the test's duration via `monkeypatch` so a CI env that pre-sets it can't leak real user plugins into a tmp-path-driven test.
|
||||
|
||||
If you're authoring a plugin that ships top-level helper modules, model new tests on the patterns in `test_plugins.py` — use `reset_plugin_state` to guarantee a clean import slate.
|
||||
|
||||
## Backend test patterns
|
||||
|
||||
- **Wire-format round-trips.** `test_song.py` is the model: pure serialization tests, no fixtures, narrative docstring. Pattern: build a `Song`/`Arrangement`/`Note`, serialize, deserialize, assert equal.
|
||||
- **Loader behaviour.** `test_plugins.py` uses tmp-path-driven plugin roots + `reset_plugin_state` + `_make_plugin` / `_run_load_plugins` helpers. Adopt these helpers for any new loader-touching tests.
|
||||
- **Async / WebSocket.** Tests for FastAPI WebSocket endpoints use `httpx.AsyncClient` and `app.websocket_connect()`. See `test_audio.py` and `test_highway_3d_routes.py` for examples.
|
||||
|
||||
## JS contract tests (`tests/js/`)
|
||||
|
||||
Plain `node --test` files. No browser, no server. Cover the plugin-API surface as exposed on `window.slopsmith` and related globals. Run with `npm run test:js`.
|
||||
|
||||
Use these when a plugin needs to assert against the API contract without spinning up a real browser. They run in seconds; CI can grow these without slowing.
|
||||
|
||||
## Browser tests (`tests/browser/`)
|
||||
|
||||
Playwright + Chromium. `playwright.config.ts` runs serially (`workers: 1`) and boots a real Slopsmith instance via Docker Compose. Traces / video on failure.
|
||||
|
||||
What's currently covered: page load and keyboard shortcuts. **No plugin E2E patterns yet** — if you're adding the first one for your plugin, set the precedent. Suggested structure:
|
||||
|
||||
```ts
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test('my plugin loads its navbar entry', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await expect(page.getByRole('link', { name: 'My Plugin' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('my plugin shortcut fires', async ({ page }) => {
|
||||
await page.goto('/#plugin-my_plugin');
|
||||
await page.keyboard.press('k');
|
||||
await expect(page.getByTestId('my-plugin-toggle')).toHaveAttribute('aria-pressed', 'true');
|
||||
});
|
||||
```
|
||||
|
||||
Prefer `data-testid` over text-based selectors so refactors don't break tests.
|
||||
|
||||
## Debugging
|
||||
|
||||
- **Browser console shortcut inventory.** `_listShortcuts()` prints every registered shortcut, its scope, and source.
|
||||
- **`LOG_FORMAT=json pytest`** — get machine-readable test logs.
|
||||
- **`pytest -s`** — disable output capture (handy for `print()`-style debug, though prefer `log.debug()` in production code).
|
||||
- **Playwright trace viewer** — `npx playwright show-trace test-results/.../trace.zip` after a failure.
|
||||
|
||||
## Related
|
||||
|
||||
- [PLUGIN_AUTHORING.md](PLUGIN_AUTHORING.md) — guide index
|
||||
- [plugin-logging.md](plugin-logging.md) — `context["log"]` and why `print()` breaks the logging pipeline
|
||||
- [plugin-sibling-imports.md](plugin-sibling-imports.md) — `load_sibling` and the collision tests
|
||||
- [plugin-keyboard-shortcuts.md](plugin-keyboard-shortcuts.md) — `window.registerShortcut` and `_listShortcuts()`
|
||||
@@ -0,0 +1,44 @@
|
||||
# Highway WebSocket protocol reference
|
||||
|
||||
The highway WebSocket at `/ws/highway/{filename}?arrangement={index}` streams a song's chart data to the player. Plugins that drive their own highway, replace the renderer (see [plugin-visualization-contracts.md](plugin-visualization-contracts.md)), or consume the stream directly all read these frames.
|
||||
|
||||
## Message order
|
||||
|
||||
Each connection receives the following JSON frames, roughly in this order:
|
||||
|
||||
| Message | Shape | Description |
|
||||
|---------|-------|-------------|
|
||||
| `loading` | `{ type: 'loading', stage }` | Status/progress message during extraction or conversion. |
|
||||
| `song_info` | `{ type, title, artist, arrangement, arrangement_index, arrangements, duration, tuning, capo, format, audio_url, audio_error, stems }` | Song metadata. `arrangements` is the full list for the switcher. `audio_url` is `null` when audio is unavailable, in which case `audio_error` is non-null; otherwise `audio_error` is `null`. `stems` is always present — an empty array for non-sloppak songs or sloppak songs with no split stems. `tuning` is an array (6 for guitar, 4 for bass). |
|
||||
| `beats` | `{ type, data: [{ time, measure }] }` | Beat timestamps with measure numbers. |
|
||||
| `sections` | `{ type, data: [{ time, name }] }` | Named sections (Intro, Verse, Chorus, etc.). |
|
||||
| `anchors` | `{ type, data: [{ time, fret, width }] }` | Fret zoom anchors. |
|
||||
| `chord_templates` | `{ type, data: [{ name, frets: [6] }] }` | Named chord shapes. |
|
||||
| `lyrics` | `{ type, data: [{ w, t, d }] }` | Syllables: `w`=word, `t`=time, `d`=duration. `-` joins to previous, `+` = line break. |
|
||||
| `tone_changes` | `{ type: 'tone_changes', base, data: [{ time, name }] }` | Optional — tone change events relative to the arrangement base tone; only sent if tones were found. |
|
||||
| `notes` | `{ type, data: [{ t, s, f, sus, ho, po, sl, bn, ... }] }` | Single notes. |
|
||||
| `chords` | `{ type, data: [{ t, notes: [{ s, f, sus, ... }] }] }` | Chord events. |
|
||||
| `phrases` | `{ type, data: [{ start_time, end_time, max_difficulty, levels: [...] }], total }` | Optional — per-phrase difficulty ladder for master-difficulty slider (slopsmith#48). Only sent when the source chart carries multi-level phrase data (PSARC / phrase-aware sloppak). Sent in chunks (`data` is a batch, `total` is the full count across messages) to avoid multi-MB single frames. Absent for GP imports and legacy sloppak; consumers must treat missing message as "single fixed difficulty — slider disabled". |
|
||||
| `ready` | `{ type: 'ready' }` | All data sent — safe to finalize and start rendering. |
|
||||
|
||||
## Delivery guarantees
|
||||
|
||||
Message delivery is **incremental**. You may receive `loading` updates and `lyrics` before note/chord payloads. `tone_changes` comes after `lyrics` when present and may be omitted entirely. **Do not finalize rendering until you receive `ready`.**
|
||||
|
||||
## Consumer notes
|
||||
|
||||
- **Tuning array length follows arrangement.** 6 strings for guitar, 4 for bass, more for extended-range GP imports. Use `highway.getStringCount()` for the authoritative count rather than `tuning.length` (which can be the RS-XML padded 6-string form).
|
||||
- **Chord templates are static.** Every `chord_id` referenced by `chords` is guaranteed to be present in `chord_templates`.
|
||||
- **Notes carry technique fields.** `ho` = hammer-on, `po` = pull-off, `sl` = slide target, `bn` = bend amount. The full set is defined in `lib/song.py`.
|
||||
- **Multiple connections are supported.** Split-screen panels, lyrics panes, and jumping-tab panes each open their own WebSocket. By design — don't try to multiplex.
|
||||
|
||||
## Phrase payload (slopsmith#48)
|
||||
|
||||
`phrases.data` is an array of phrase objects. Each phrase has `start_time`, `end_time`, `max_difficulty`, and `levels`. Each `level` has `difficulty`, `notes`, `chords`, `anchors`, `handshapes` — fully scoped to that phrase. The master-difficulty slider applies a single difficulty across all phrases by selecting the matching level.
|
||||
|
||||
## Related
|
||||
|
||||
- [PLUGIN_AUTHORING.md](PLUGIN_AUTHORING.md) — guide index
|
||||
- [plugin-visualization-contracts.md](plugin-visualization-contracts.md) — bundle shape passed to `draw()`
|
||||
- [sloppak-spec.md](sloppak-spec.md) — sloppak format these messages are derived from
|
||||
- `lib/song.py` — server-side `Note`, `Chord`, `Arrangement`, `Song` data models
|
||||
Reference in New Issue
Block a user