mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-09-11 14:34:29 +00:00
Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8a0fc424cd | ||
|
|
9a07ebc3d5 | ||
|
|
04db4a233b | ||
|
|
e76927524a | ||
|
|
4d046b666d | ||
|
|
35863b8886 | ||
|
|
7d0021d04a | ||
|
|
26dba55d7e | ||
|
|
b45751164f | ||
|
|
e4187d0054 | ||
|
|
1214a6c0a0 |
@@ -0,0 +1,42 @@
|
||||
# `.claude/` — Claude Code surfaces
|
||||
|
||||
This directory holds [Claude Code](https://claude.ai/code) artifacts: skills, rules, and subagents. **Other AI tools** (Cursor, Copilot, Codex, Aider) should read [`AGENTS.md`](../AGENTS.md) instead — `.claude/` is Claude-Code-specific.
|
||||
|
||||
## Layout
|
||||
|
||||
```text
|
||||
.claude/
|
||||
├── agents/ Subagents (invoked via @<name>)
|
||||
│ └── slopsmith-reviewer.md Plugin-aware code review
|
||||
├── rules/ Globs-scoped behaviour rules
|
||||
│ └── plugin-author.md Rules that fire when editing plugins/**
|
||||
├── skills/ Skills (description triggers auto-invocation)
|
||||
│ ├── plugin-scaffold/SKILL.md Generate a new plugin skeleton
|
||||
│ ├── plugin-validate/SKILL.md Validate a plugin.json against the schema
|
||||
│ └── speckit-*/ Spec-kit skills (auto-generated; don't edit)
|
||||
├── settings.json Repo defaults (no hooks enabled by default)
|
||||
└── README.md You are here
|
||||
```
|
||||
|
||||
## Conventions
|
||||
|
||||
- **Spec-kit owns `skills/speckit-*`.** Don't edit those manually — they're regenerated by the spec-kit installer (`.specify/`).
|
||||
- **Repo skills live alongside spec-kit skills** in `skills/`. New skills should have a kebab-case directory name and a `SKILL.md` with YAML frontmatter.
|
||||
- **Hooks are off by default.** Hooks in `.claude/settings.json` run in every contributor's Claude Code session — enabling one imposes work on everyone. The settings file ships with `hooks: {}` and a commented example so anyone who wants to opt in can copy it locally.
|
||||
- **Subagent invocation.** Use `@slopsmith-reviewer` to invoke a subagent explicitly. Plain auto-routing isn't currently configured.
|
||||
|
||||
## Adding a new skill
|
||||
|
||||
1. Create `.claude/skills/<kebab-name>/SKILL.md`.
|
||||
2. YAML frontmatter must include `name` (must match directory) and `description` (must read like a sentence — Claude Code matches the description against user intent for auto-invocation).
|
||||
3. Body: instructions Claude follows when the skill triggers. Keep it concrete and short — long skills bit-rot.
|
||||
|
||||
## Adding a new rule
|
||||
|
||||
1. Create `.claude/rules/<topic>.md`.
|
||||
2. Frontmatter may include `globs:` (array of glob patterns scoping when the rule applies). Without `globs`, the rule loads for every session.
|
||||
3. Body: terse, imperative guidance — "always do X", "never do Y", "if you see Z, …".
|
||||
|
||||
## Why so little here?
|
||||
|
||||
The maintainer's personal Claude Code rules (context-mode routing, RTK, `@git-worker`, etc.) deliberately do **not** live in this repo. Those are user-global concerns in `~/.claude/` — keeping them out of the repo keeps the shared surface lean and makes the repo equally useful to contributors using Cursor, Copilot, plain editors, etc.
|
||||
@@ -0,0 +1,68 @@
|
||||
---
|
||||
name: slopsmith-reviewer
|
||||
description: Plugin-aware code reviewer for Slopsmith. USE WHEN reviewing plugin changes, auditing a plugin against the manifest contract, checking that a plugin uses capability metadata / load_sibling / context["log"] correctly, or verifying that a `plugin.json` matches the schema and the directory it lives in. Returns a structured pass/fail report with specific file:line citations.
|
||||
tools: Read, Grep, Glob, Bash
|
||||
model: sonnet
|
||||
---
|
||||
|
||||
# slopsmith-reviewer
|
||||
|
||||
Plugin-aware reviewer for Slopsmith. Use when reviewing a plugin's code or manifest. Does **not** duplicate the built-in `peer-review` skill — focus is narrow: the plugin contract surface defined in [`docs/PLUGIN_AUTHORING.md`](../../docs/PLUGIN_AUTHORING.md) and enforced by [`schema/plugin.schema.json`](../../schema/plugin.schema.json), including `capability-pipelines.v1` metadata, and `tests/test_plugin_schema.py`.
|
||||
|
||||
## When to invoke
|
||||
|
||||
Use this agent when the user asks to:
|
||||
- "review this plugin"
|
||||
- "audit `plugins/<id>/`"
|
||||
- "check the manifest"
|
||||
- "verify the plugin uses load_sibling / logging correctly"
|
||||
- "lint plugin"
|
||||
|
||||
Do **not** invoke for general code review — use the built-in `peer-review` skill for that.
|
||||
|
||||
## Inputs
|
||||
|
||||
The user typically points at a directory: `plugins/<id>/`. If no directory is given, ask which plugin to review.
|
||||
|
||||
## Checklist (every review must run these)
|
||||
|
||||
Run each item; structure the output as `PASS` / `FAIL` / `N/A` with file:line citations.
|
||||
|
||||
1. **Manifest exists and validates.** `plugins/<id>/plugin.json` exists. Run:
|
||||
```bash
|
||||
python -c "import json,jsonschema; s=json.load(open('schema/plugin.schema.json')); jsonschema.validate(json.load(open('plugins/<id>/plugin.json')), s); print('OK')"
|
||||
```
|
||||
2. **Manifest `id` matches the directory name.** `tests/test_plugin_schema.py::test_in_tree_manifest_id_matches_directory` enforces this — but call it out in review.
|
||||
3. **Declared plugin files exist.** For plugin-root-relative file fields (`script`, `routes`, `tour`, `settings.html`), `test -f plugins/<id>/<path>` must succeed.
|
||||
4. **License is on the curated allowlist** if present. Cross-check `plugin.json.license` against the SPDX list in [`CONTRIBUTING.md`](../../CONTRIBUTING.md) "Plugin licensing".
|
||||
5. **`type: "visualization"` ↔ `window.slopsmithViz_<id>` factory.** If `type == "visualization"`, grep `script` for the factory declaration.
|
||||
6. **Backend logging.** Grep `plugins/<id>/*.py` for `print(`, `traceback.print_exc(`, `logging.getLogger(`. Suggest `context["log"]` replacements.
|
||||
7. **Sibling imports.** If `routes.py` exists and grep finds bare `from <module> import` for any sibling Python file in the plugin dir, flag and recommend `context["load_sibling"]`.
|
||||
8. **Frontend IIFE.** If `script` exists, check the top of the file isn't running top-level statements that leak to global scope. Wrapping in `(function () { 'use strict'; ... })();` is the convention.
|
||||
9. **Capability-first frontend integration.** If the script wraps host globals or polls another plugin's globals, flag it as a capability gap unless the PR explicitly documents why no active domain can model the integration yet.
|
||||
10. **`localStorage` prefix.** Grep for `localStorage.` usage; keys must start with `<plugin_id>`.
|
||||
11. **Server-file allowlists are safe.** `settings.server_files` and `diagnostics.server_files` entries are relpaths under `context["config_dir"]`, not files under the plugin directory. Check for no leading `/`, no `..`, no backslashes, no `//`, no `./`, and no leading dotfiles. The schema enforces this but call it out.
|
||||
|
||||
## Output format
|
||||
|
||||
```text
|
||||
plugin-review: <plugin_id>
|
||||
=========================
|
||||
1. manifest validates PASS
|
||||
2. id matches directory PASS
|
||||
3. declared plugin files exist FAIL — script lists "missing.js" but plugins/<id>/missing.js is absent
|
||||
...
|
||||
|
||||
Total: 10 PASS / 1 FAIL / 1 N/A
|
||||
Action items:
|
||||
- Remove the dangling script entry, or create the file.
|
||||
- Replace `print(...)` calls at routes.py:42, routes.py:88 with `context["log"].info(...)`.
|
||||
```
|
||||
|
||||
If everything passes, say so explicitly — silent success is unhelpful in a review context.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- General code style / formatting — use the built-in `peer-review`.
|
||||
- Bug-hunting beyond the plugin contract.
|
||||
- Suggesting major refactors. Stay on contract compliance.
|
||||
@@ -0,0 +1,49 @@
|
||||
---
|
||||
name: plugin-author
|
||||
description: Rules that apply when editing files under plugins/**. Enforces the plugin contracts documented in docs/PLUGIN_AUTHORING.md.
|
||||
globs:
|
||||
- "plugins/**"
|
||||
---
|
||||
|
||||
# Plugin authoring rules
|
||||
|
||||
These rules apply only when editing files under `plugins/**`. They encode the contracts described in [`docs/PLUGIN_AUTHORING.md`](../../docs/PLUGIN_AUTHORING.md) so AI suggestions don't drift from them.
|
||||
|
||||
## Manifest
|
||||
|
||||
- **`plugin.json` is required** and must validate against [`schema/plugin.schema.json`](../../schema/plugin.schema.json). Required fields: `id`, `name`. The `id` must match the parent directory name (the loader keys discovery by directory; drift breaks plugin lookup).
|
||||
- **Plugins declare capability intent** with `standards: ["capability-pipelines.v1"]` and redaction-safe `capabilities` / `ui` metadata. Treat these declarations as the plugin's primary contract with Slopsmith.
|
||||
- **License must come from the curated allowlist** if the plugin is intended for the curated list. See [`CONTRIBUTING.md`](../../CONTRIBUTING.md) "Plugin licensing".
|
||||
- **`type: "visualization"`** requires a `script` field exporting `window.slopsmithViz_<id>`. See [`docs/plugin-visualization-contracts.md`](../../docs/plugin-visualization-contracts.md).
|
||||
|
||||
## Backend (`routes.py`)
|
||||
|
||||
- **Use `context["log"]`, never `print()` or `traceback.print_exc()`.** The CI workflow blocks `print(` and `traceback.print_exc(` in `server.py` / `lib/`; plugin code should follow the same rule. The provided logger is a stdlib `logging.Logger` namespaced to `slopsmith.plugin.<id>` with correlation IDs, JSON mode, and rotation already wired. See [`docs/plugin-logging.md`](../../docs/plugin-logging.md).
|
||||
- **Multi-file plugins must use `context["load_sibling"]("<module>")`**, not bare `from <module> import X`. Two plugins shipping a same-named helper collide via `sys.modules`. See [`docs/plugin-sibling-imports.md`](../../docs/plugin-sibling-imports.md).
|
||||
- **`setup(app, context)` is the required entry.** Don't run side effects at import time.
|
||||
|
||||
## Frontend (`screen.js`)
|
||||
|
||||
- **Wrap in an IIFE** — `(function () { 'use strict'; ... })();`. Frontend scripts share global scope; leaking variables collides with other plugins.
|
||||
- **Prefer capability commands, events, and provider APIs** for app coordination. Don't add new private global wrappers or cross-plugin polling; if the needed domain is missing, call that out as a capability gap.
|
||||
|
||||
## State and config
|
||||
|
||||
- **`localStorage` keys must be prefixed with the plugin id** to avoid collisions.
|
||||
- **`settings.server_files` declares config-dir paths the plugin wants to include in the Settings export/import flow.** Relpaths only — no `..`, no abs paths, no backslashes. See [`docs/plugin-manifest.md`](../../docs/plugin-manifest.md).
|
||||
- **`diagnostics.server_files` / `diagnostics.callable`** declares what enters the Export Diagnostics bundle. Keep payloads under 100 KB and don't include secrets. See [`docs/plugin-diagnostics.md`](../../docs/plugin-diagnostics.md).
|
||||
|
||||
## Visualization specifics
|
||||
|
||||
When `plugin.json` declares `"type": "visualization"`:
|
||||
|
||||
- **Factory must be `window.slopsmithViz_<id>`** where `<id>` matches `plugin.json`.
|
||||
- **Factory must return a fresh object on each call** — splitscreen creates N instances.
|
||||
- **The renderer owns its `getContext()` call.** Declare `contextType: '2d'` or `'webgl2'` on the returned object so the highway can swap the canvas element when needed (`getContext` is one-shot per canvas).
|
||||
- **`draw(bundle)` receives difficulty-filtered arrays** — never read from `_filteredNotes` or other internals.
|
||||
|
||||
See [`docs/plugin-visualization-contracts.md`](../../docs/plugin-visualization-contracts.md) for the full lifecycle and the Overlay + Note-state-provider contracts.
|
||||
|
||||
## Testing
|
||||
|
||||
When changing plugin internals, add or update a test under `tests/` (Python) or `tests/js/` (Node) or `tests/browser/` (Playwright). See [`docs/testing-plugins.md`](../../docs/testing-plugins.md) for fixtures (`isolate_logging`, `reset_plugin_state`).
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/claude-code-settings.json",
|
||||
"_comment": "Repo-default Claude Code settings for Slopsmith contributors. Hooks are intentionally empty by default — see commented example below.",
|
||||
|
||||
"hooks": {},
|
||||
|
||||
"_commented_example_hooks": {
|
||||
"_comment": "Copy the block below into the empty `hooks` object above if you want plugin.json edits to be auto-validated against schema/plugin.schema.json, including capability metadata, on save. Per-user opt-in; not enabled by default because hooks run in every Claude Code session that loads this repo.",
|
||||
"PostToolUse": [
|
||||
{
|
||||
"matcher": "Write|Edit",
|
||||
"command": "if echo \"$CLAUDE_TOOL_INPUT_FILE_PATH\" | grep -qE '^plugins/[^/]+/plugin\\.json$'; then python -c \"import json,sys,jsonschema; s=json.load(open('schema/plugin.schema.json')); m=json.load(open('$CLAUDE_TOOL_INPUT_FILE_PATH')); jsonschema.validate(m,s); print('OK','$CLAUDE_TOOL_INPUT_FILE_PATH')\"; fi"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
---
|
||||
name: plugin-scaffold
|
||||
description: Scaffold a Slopsmith plugin skeleton with capability-pipelines metadata. USE WHEN the user asks to create a new plugin, scaffold a plugin, bootstrap a plugin, new visualization plugin, new overlay plugin, new settings-only plugin, plugin starter, plugin skeleton. Args needed - plugin slug (snake_case) and type (visualization / overlay / settings-only / routes-only). Generates plugins/<id>/ with plugin.json, capability-pipelines metadata, screen.js, and optional routes.py / settings.html / Playwright test stub matching the requested type.
|
||||
---
|
||||
|
||||
# plugin-scaffold
|
||||
|
||||
Generates a minimum-viable Slopsmith plugin skeleton matching a requested shape. The output validates against [`schema/plugin.schema.json`](../../../schema/plugin.schema.json).
|
||||
|
||||
## When to invoke
|
||||
|
||||
The user says one of:
|
||||
- "scaffold a new plugin called X"
|
||||
- "create a visualization plugin"
|
||||
- "new overlay plugin"
|
||||
- "plugin starter for settings"
|
||||
- "bootstrap a routes-only plugin"
|
||||
|
||||
If the plugin slug or type is missing, ask once.
|
||||
|
||||
## Inputs
|
||||
|
||||
| Arg | Required | Values | Notes |
|
||||
|---|---|---|---|
|
||||
| `id` | yes | snake_case | Becomes plugin's `id` field and directory name |
|
||||
| `name` | optional | string | Defaults to title-case of `id` |
|
||||
| `type` | yes | `visualization` / `overlay` / `settings-only` / `routes-only` | Determines which files get scaffolded |
|
||||
|
||||
## What to generate
|
||||
|
||||
**Common to all types:**
|
||||
|
||||
- `plugins/<id>/plugin.json` — schema-valid manifest. Set `id`, `name`, `version: "0.1.0"`, `license: "AGPL-3.0-only"`, and `standards: ["capability-pipelines.v1", "plugin-runtime-idempotent.v1"]` by default.
|
||||
|
||||
**`type=visualization`** — adds:
|
||||
- `"type": "visualization"` and `"script": "screen.js"` to manifest
|
||||
- `"capabilities": { "visualization": { "roles": ["provider"], "operations": ["renderer.create", "renderer.destroy", "renderer.inspect"], "mode": "active", "compatibility": "none", "ownership": "multi-provider", "safety": "safe", "version": 1 } }`
|
||||
- `screen.js` exporting `window.slopsmithViz_<id> = function () { return { contextType: '2d', init(canvas, bundle) { this.ctx = canvas.getContext('2d'); }, draw(bundle) { /* TODO */ }, destroy() {} }; };` plus a static `matchesArrangement` example commented out
|
||||
- `tests/browser/<id>.spec.ts` — Playwright stub that loads the app and asserts the plugin's factory is registered
|
||||
|
||||
**`type=overlay`** — adds:
|
||||
- `"script": "screen.js"` to manifest (no `type` declared — overlays don't use the picker)
|
||||
- `"capabilities": { "ui.player-overlays": { "roles": ["provider"], "mode": "active", "compatibility": "none", "ownership": "multi-provider", "safety": "safe", "version": 1 } }`
|
||||
- a matching `"ui"` contribution with a stable overlay id and redaction-safe label
|
||||
- `screen.js` scaffolding an own-canvas + own-rAF loop for the declared overlay contribution, and respecting renderer ownership if it uses highway geometry helpers
|
||||
- `tests/browser/<id>.spec.ts` — toggle on / off test
|
||||
|
||||
**`type=settings-only`** — adds:
|
||||
- `"settings": { "html": "settings.html" }` to manifest
|
||||
- `"settings_schema"` with a schema version and an empty `packable_keys` list
|
||||
- a `"ui"` settings contribution with a stable id
|
||||
- `settings.html` — empty form skeleton with explanatory comments
|
||||
- `screen.js` reading/writing `localStorage` keys prefixed with `<id>_`
|
||||
|
||||
**`type=routes-only`** — adds:
|
||||
- `"routes": "routes.py"` to manifest
|
||||
- a conservative `"capabilities"` declaration for the route's Slopsmith-facing workflow; if the domain is unclear, ask what capability domain the route owns, provides, requests, or observes before finalizing the scaffold
|
||||
- `routes.py` with `def setup(app, context):` that registers one example route and uses `context["log"].info("plugin ready")` (never `print()`)
|
||||
- `tests/test_<id>_routes.py` — FastAPI TestClient stub
|
||||
|
||||
## After scaffolding
|
||||
|
||||
Run validation locally:
|
||||
|
||||
```bash
|
||||
python -c "import json,jsonschema; s=json.load(open('schema/plugin.schema.json')); jsonschema.validate(json.load(open('plugins/<id>/plugin.json')), s); print('OK')"
|
||||
```
|
||||
|
||||
Then point the user at [`docs/PLUGIN_AUTHORING.md`](../../../docs/PLUGIN_AUTHORING.md) and the relevant contract doc for the type they chose.
|
||||
|
||||
## Don'ts
|
||||
|
||||
- Don't invent plugin-local manifest conventions. If the user wants something custom, either express it through existing `capability-pipelines.v1` metadata or ask whether it should become a real schema field.
|
||||
- Don't scaffold a `requirements.txt` without confirming the deps. The plugin loader installs them on first load; an accidental dep slows everyone's startup.
|
||||
- Don't pre-fill `localStorage` keys without a prefix. Collisions across plugins are real.
|
||||
- Don't generate hidden side effects at import time (`screen.js` top level or `routes.py` top level). Keep all wiring inside the IIFE / `setup()`.
|
||||
|
||||
## Verification
|
||||
|
||||
The scaffolded plugin should pass:
|
||||
|
||||
```bash
|
||||
pytest tests/test_plugin_schema.py::test_in_tree_manifest_validates -v
|
||||
pytest tests/test_plugin_schema.py::test_in_tree_manifest_id_matches_directory -v
|
||||
```
|
||||
@@ -0,0 +1,108 @@
|
||||
---
|
||||
name: plugin-validate
|
||||
description: Validate a Slopsmith plugin's plugin.json against the schema and run structural checks. USE WHEN the user asks to validate a plugin, check the manifest, plugin.json errors, lint plugin, verify plugin structure, plugin license check, or audit plugin contract. Runs JSON Schema validation, capability metadata checks, file-existence checks for declared script/routes/settings.html/tour paths, and license-allowlist check.
|
||||
---
|
||||
|
||||
# plugin-validate
|
||||
|
||||
Runs the same checks `.github/workflows/validate-plugins.yml` + `tests/test_plugin_schema.py` run in CI, but **locally and instantly** — useful for catching errors before push.
|
||||
|
||||
## When to invoke
|
||||
|
||||
The user says one of:
|
||||
- "validate plugins/<id>"
|
||||
- "check the manifest for X"
|
||||
- "lint this plugin"
|
||||
- "verify my plugin.json"
|
||||
- "plugin license check"
|
||||
|
||||
Works on either a specific plugin (e.g. `plugins/highway_3d/`) or all in-tree plugins if none is specified.
|
||||
|
||||
## What to run
|
||||
|
||||
```bash
|
||||
# All in-tree plugins (default)
|
||||
python <<'PY'
|
||||
import json, glob, sys, jsonschema, pathlib
|
||||
schema = json.load(open('schema/plugin.schema.json'))
|
||||
jsonschema.Draft202012Validator.check_schema(schema)
|
||||
ok = True
|
||||
for path in sorted(glob.glob('plugins/*/plugin.json')):
|
||||
plugin_dir = pathlib.Path(path).parent
|
||||
plugin_id = plugin_dir.name
|
||||
m = json.load(open(path))
|
||||
plugin_ok = True # per-iteration flag so we don't print OK after a later FAIL
|
||||
# 1. Schema, including capability-pipelines.v1 metadata
|
||||
try:
|
||||
jsonschema.validate(m, schema)
|
||||
except jsonschema.ValidationError as e:
|
||||
print(f"FAIL {path}: {e.message} (at {list(e.absolute_path)})")
|
||||
ok = False
|
||||
continue
|
||||
# 2. id == directory name
|
||||
if m['id'] != plugin_id:
|
||||
print(f"FAIL {path}: id={m['id']!r} but directory is {plugin_id!r}")
|
||||
ok = False
|
||||
plugin_ok = False
|
||||
# 3. Declared files exist
|
||||
for field in ('script', 'routes', 'tour'):
|
||||
if field in m and not (plugin_dir / m[field]).exists():
|
||||
print(f"FAIL {path}: {field}={m[field]!r} but file missing")
|
||||
ok = False
|
||||
plugin_ok = False
|
||||
if 'settings' in m and 'html' in m['settings']:
|
||||
h = m['settings']['html']
|
||||
if not (plugin_dir / h).exists():
|
||||
print(f"FAIL {path}: settings.html={h!r} but file missing")
|
||||
ok = False
|
||||
plugin_ok = False
|
||||
for field in ('settings', 'diagnostics'):
|
||||
if field in m and 'server_files' in m[field]:
|
||||
for relpath in m[field]['server_files']:
|
||||
# server_files paths live under context["config_dir"], NOT the
|
||||
# plugin dir, so we don't existence-check them here. We only
|
||||
# confirm the path *looks* safe (already enforced by schema).
|
||||
if '..' in relpath or relpath.startswith('/') or '\\' in relpath:
|
||||
print(f"FAIL {path}: {field}.server_files contains unsafe path {relpath!r}")
|
||||
ok = False
|
||||
plugin_ok = False
|
||||
if plugin_ok:
|
||||
print(f"OK {path}")
|
||||
sys.exit(0 if ok else 1)
|
||||
PY
|
||||
```
|
||||
|
||||
## Targeted invocation
|
||||
|
||||
If the user names a specific plugin, swap the glob for `plugins/<id>/plugin.json` and report on just that one.
|
||||
|
||||
## License-allowlist check
|
||||
|
||||
If the user specifically asks for a license check (or the plugin declares `license` in `plugin.json`), additionally run:
|
||||
|
||||
```bash
|
||||
pytest tests/test_plugin_schema.py::test_schema_license_enum_subset_of_contributing_allowlist -v --noconftest
|
||||
```
|
||||
|
||||
(or skip `--noconftest` if `structlog` is installed locally).
|
||||
|
||||
## Output
|
||||
|
||||
Use the format from the script: `OK <path>` per validated manifest, `FAIL <path>: <reason>` per failure. Add a one-line summary:
|
||||
|
||||
```text
|
||||
Result: 3/3 plugins valid (OK app_tour_library, app_tour_settings, highway_3d)
|
||||
```
|
||||
|
||||
or
|
||||
|
||||
```text
|
||||
Result: 2/3 plugins valid; 1 FAIL (see above)
|
||||
```
|
||||
|
||||
## Related
|
||||
|
||||
- [`schema/plugin.schema.json`](../../../schema/plugin.schema.json)
|
||||
- [`tests/test_plugin_schema.py`](../../../tests/test_plugin_schema.py)
|
||||
- [`.github/workflows/validate-plugins.yml`](../../../.github/workflows/validate-plugins.yml)
|
||||
- [`docs/plugin-manifest.md`](../../../docs/plugin-manifest.md)
|
||||
@@ -0,0 +1,85 @@
|
||||
name: Bug report
|
||||
description: Report something broken in Slopsmith core (server, frontend, library, player).
|
||||
title: "[bug] "
|
||||
labels: ["bug"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for reporting! Before submitting:
|
||||
- For **plugin** issues, please open the issue in the plugin's own repository instead — plugins under `plugins/` are separate projects.
|
||||
- For sensitive logs, redact paths/credentials. The `docs/diagnostics-bundle-spec.md` describes the redaction surface used by Export Diagnostics.
|
||||
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
label: Slopsmith version
|
||||
description: "Contents of the `VERSION` file at the root, or what the navbar badge shows."
|
||||
placeholder: "e.g. 0.2.8"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: deployment
|
||||
attributes:
|
||||
label: How are you running Slopsmith?
|
||||
options:
|
||||
- Docker Compose (docker-compose.yml)
|
||||
- Bare Python (python main.py)
|
||||
- Proxmox CT (build-proxmox-ct.sh)
|
||||
- slopsmith-desktop bundle
|
||||
- Other (describe below)
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: os
|
||||
attributes:
|
||||
label: Host OS / browser
|
||||
placeholder: "e.g. macOS 15.4 + Chrome 134; Linux + Firefox 142"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: plugins
|
||||
attributes:
|
||||
label: Plugins enabled
|
||||
description: "List the plugins active when the bug happened. Most bugs reproduce only with certain plugins."
|
||||
placeholder: "highway_3d, fretboard, note_detect"
|
||||
|
||||
- type: textarea
|
||||
id: repro
|
||||
attributes:
|
||||
label: Reproduction steps
|
||||
description: "Numbered steps. Include song format (legacy archive / sloppak / loose) and arrangement type if relevant."
|
||||
placeholder: |
|
||||
1. ...
|
||||
2. ...
|
||||
3. ...
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: expected
|
||||
attributes:
|
||||
label: Expected vs. actual behaviour
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: Logs / diagnostics
|
||||
description: |
|
||||
Either paste relevant log lines (set `LOG_LEVEL=DEBUG` or `LOG_FORMAT=json` if useful) **or** attach an Export Diagnostics bundle. See `docs/diagnostics-bundle-spec.md` for what's inside the bundle and what's redacted.
|
||||
render: text
|
||||
|
||||
- type: checkboxes
|
||||
id: terms
|
||||
attributes:
|
||||
label: Confirmations
|
||||
options:
|
||||
- label: I searched existing issues and didn't find a duplicate
|
||||
required: true
|
||||
- label: This is a core bug, not a plugin bug
|
||||
required: true
|
||||
@@ -1 +1,11 @@
|
||||
blank_issues_enabled: true
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Plugin issues
|
||||
url: https://github.com/slopsmith/slopsmith#plugins
|
||||
about: Plugins live in their own repositories. Open plugin bugs/features in the plugin's own repo.
|
||||
- name: Documentation questions
|
||||
url: https://github.com/slopsmith/slopsmith/tree/main/docs
|
||||
about: Plugin contracts, sloppak spec, diagnostics, and note-detect tuning are documented under docs/. Try there first.
|
||||
- name: Security disclosures
|
||||
url: https://github.com/slopsmith/slopsmith/security
|
||||
about: For security issues, please use GitHub's private security advisory flow rather than a public issue.
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
name: Feature request
|
||||
description: Propose a feature, behaviour change, or new plugin contract.
|
||||
title: "[feature] "
|
||||
labels: ["enhancement"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for the suggestion! For **plugin features**, open the issue in the plugin's own repository unless the change requires new core API.
|
||||
|
||||
- type: textarea
|
||||
id: problem
|
||||
attributes:
|
||||
label: What problem does this solve?
|
||||
description: "Lead with the user/contributor pain — the solution shape often falls out of a sharp problem statement."
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: proposal
|
||||
attributes:
|
||||
label: Proposed solution
|
||||
description: "What you'd like to see. UI mockups, code sketches, config examples all welcome."
|
||||
|
||||
- type: textarea
|
||||
id: alternatives
|
||||
attributes:
|
||||
label: Alternatives considered
|
||||
|
||||
- type: dropdown
|
||||
id: surface
|
||||
attributes:
|
||||
label: Which surface does this touch?
|
||||
multiple: true
|
||||
options:
|
||||
- Server (FastAPI / lib/)
|
||||
- Frontend (static/)
|
||||
- Plugin contract (new field / event / API)
|
||||
- Sloppak format
|
||||
- WebSocket protocol
|
||||
- Diagnostics
|
||||
- Docs only
|
||||
- Other (describe in proposal)
|
||||
|
||||
- type: textarea
|
||||
id: plugin_impact
|
||||
attributes:
|
||||
label: Plugin author impact
|
||||
description: "Does this require existing plugins to change? Add a new optional field? Break a contract? Be specific so the maintainer can size compatibility work."
|
||||
|
||||
- type: checkboxes
|
||||
id: license
|
||||
attributes:
|
||||
label: License compatibility
|
||||
options:
|
||||
- label: If suggesting third-party code, it's AGPL-3.0-compatible (see CONTRIBUTING.md curated list)
|
||||
required: true
|
||||
@@ -0,0 +1,38 @@
|
||||
<!--
|
||||
Thanks for the PR! A few quick reminders before you hit submit:
|
||||
- DCO sign-off on every commit (git commit -s; --amend -s to fix)
|
||||
- Plugin work? Check docs/PLUGIN_AUTHORING.md and validate plugin.json
|
||||
against schema/plugin.schema.json, including capability metadata
|
||||
- Touching the highway / player UI? Add or update a Playwright test
|
||||
under tests/browser/
|
||||
-->
|
||||
|
||||
## Summary
|
||||
|
||||
<!-- 1–3 bullets describing what changes and why. Link related discussion. -->
|
||||
|
||||
## Linked issue
|
||||
|
||||
<!-- Closes #1234, or "n/a" if this is a chore/docs change -->
|
||||
|
||||
## Test plan
|
||||
|
||||
<!-- How did you verify? Tick what applies. -->
|
||||
|
||||
- [ ] `pytest -q` passes locally
|
||||
- [ ] `npm run test:js` passes locally
|
||||
- [ ] `npm test` (Playwright) passes locally — *or* CI will run it
|
||||
- [ ] Verified in `docker compose up` (live-reload working directory)
|
||||
- [ ] Plugin manifest validates against `schema/plugin.schema.json` (including capability metadata)
|
||||
- [ ] Not applicable — explain below
|
||||
|
||||
## Screenshots / recordings
|
||||
|
||||
<!-- For UI changes, paste before/after. Drag-drop into the editor or use a GIF. -->
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] DCO sign-off on every commit (`Signed-off-by:` trailer)
|
||||
- [ ] Conventional-commit subject (`feat(scope):`, `fix(scope):`, `docs:`, `chore:`)
|
||||
- [ ] `CHANGELOG.md` `[Unreleased]` section updated
|
||||
- [ ] Documentation updated if behaviour or contracts changed
|
||||
@@ -0,0 +1,22 @@
|
||||
# GitHub Copilot instructions
|
||||
|
||||
This file customizes GitHub Copilot Chat and Copilot inline suggestions for the Slopsmith repository.
|
||||
|
||||
## Read first
|
||||
|
||||
- [`AGENTS.md`](../AGENTS.md) — canonical project orientation (architecture, conventions, first-hour pitfalls, verification). This file is a thin pointer; the real content lives there.
|
||||
- [`docs/PLUGIN_AUTHORING.md`](../docs/PLUGIN_AUTHORING.md) — plugin work entry point
|
||||
|
||||
## House rules
|
||||
|
||||
- **License: AGPL-3.0-only.** Inbound contributions are AGPL-compatible. Do not suggest code copied verbatim from incompatible sources.
|
||||
- **DCO sign-off required** on every commit (`git commit -s`).
|
||||
- **No frontend frameworks.** Vanilla JS, Canvas, Tailwind classes. Do not suggest React/Vue/Svelte additions.
|
||||
- **Plugin backend logging.** Suggest `context["log"]`, never `print()`.
|
||||
- **Plugin Python imports.** For cross-file backend plugins, suggest `context["load_sibling"]("module_name")` instead of bare `from module_name import X`.
|
||||
- **Capability metadata.** For plugin integrations, suggest `standards: ["capability-pipelines.v1"]` and redaction-safe `capabilities` / `ui` metadata as the primary contract.
|
||||
- **DCO/license headers.** When creating a new file in the main repo, no license header is needed (the LICENSE file at root governs). Plugin authors should add an SPDX-License-Identifier comment to their plugin's source files; the `license` field in `plugin.json` must match the allowlist in [`CONTRIBUTING.md`](../CONTRIBUTING.md).
|
||||
|
||||
## Validation
|
||||
|
||||
When suggesting changes to a `plugin.json`, validate against [`schema/plugin.schema.json`](../schema/plugin.schema.json), including native `capability-pipelines.v1` metadata.
|
||||
@@ -39,7 +39,7 @@ jobs:
|
||||
first=$(printf '%s\n' "$hits" | head -n1)
|
||||
file=$(printf '%s' "$first" | cut -d: -f1)
|
||||
line=$(printf '%s' "$first" | cut -d: -f2)
|
||||
echo "::error file=${file},line=${line}::print() or traceback.print_exc() found in server.py, lib/, or a bundled plugin routes.py. Use the feedBack logger (lib/logging_setup.py) — see issues #155 / #242."
|
||||
echo "::error file=${file},line=${line}::print() or traceback.print_exc() found in server.py, lib/, or a bundled plugin routes.py. Use the slopsmith logger (lib/logging_setup.py) — see issues #155 / #242."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -52,11 +52,11 @@ jobs:
|
||||
run: pytest
|
||||
|
||||
- name: Run JS plugin-API tests
|
||||
run: node --test tests/js/*.test.js 'tests/plugins/*/js/*.test.js' 'plugins/*/tests/*.test.js'
|
||||
run: node --test tests/js/*.test.js 'tests/plugins/*/js/*.test.js'
|
||||
|
||||
tailwind-fresh:
|
||||
# Guard that the committed static/tailwind.min.css is in sync with source.
|
||||
# The Play CDN's runtime JIT was removed (feedBack-desktop#110); a prebuilt
|
||||
# The Play CDN's runtime JIT was removed (slopsmith-desktop#110); a prebuilt
|
||||
# stylesheet only contains classes the scanner saw at build time, so stale
|
||||
# CSS silently ships unstyled elements. Rebuild and fail on any diff.
|
||||
name: tailwind-fresh
|
||||
@@ -123,28 +123,3 @@ jobs:
|
||||
sys.exit(1)
|
||||
print(f"Validated {len(manifests)} manifest(s) — OK")
|
||||
EOF
|
||||
|
||||
lint:
|
||||
# Maintainer/CI-only size + module-hygiene gate (constitution Principle I:
|
||||
# dev tooling, never on the serve/Docker path — same category as
|
||||
# scripts/build-tailwind.sh). max-lines WARNS (the 1,500-line size ratchet;
|
||||
# non-blocking), while import-x/no-unresolved + no-cycle HARD-ERROR on the
|
||||
# ES-module graphs the refactor produces. Exemptions: docs/size-exemptions.md.
|
||||
name: lint
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
# npm ci runs third-party postinstall scripts; don't leave the token in
|
||||
# git config for them (this job never pushes).
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: ESLint (size norm + module hygiene)
|
||||
run: npm run lint
|
||||
|
||||
@@ -1,19 +1,41 @@
|
||||
name: Nightly
|
||||
|
||||
# Trunk-based: nightly always builds main — the release-branch discovery
|
||||
# from the old release-centric flow is gone (it pinned nightlies to the
|
||||
# highest release/v* branch forever, even after it shipped). Stabilization
|
||||
# builds from release/** come from rc.yml instead.
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 23 * * *'
|
||||
- cron: '0 2 * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
setup:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
branch: ${{ steps.branch.outputs.branch }}
|
||||
date: ${{ steps.date.outputs.date }}
|
||||
|
||||
steps:
|
||||
- name: Find active release branch
|
||||
id: branch
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
branch=$(gh api "repos/${{ github.repository }}/git/matching-refs/heads/release/v" \
|
||||
--jq '[.[].ref | ltrimstr("refs/heads/")] | map(ltrimstr("refs/heads/")) | .[]' \
|
||||
| sort -V | tail -1 || true)
|
||||
if [[ -z "$branch" ]]; then
|
||||
branch="main"
|
||||
fi
|
||||
echo "branch=$branch" >> "$GITHUB_OUTPUT"
|
||||
echo "Active branch: $branch"
|
||||
|
||||
- name: Get date
|
||||
id: date
|
||||
run: echo "date=$(date -u +%Y%m%d)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
build-docker:
|
||||
needs: setup
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -22,12 +44,9 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ needs.setup.outputs.branch }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: Get date
|
||||
id: date
|
||||
run: echo "date=$(date -u +%Y%m%d)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
@@ -46,6 +65,6 @@ jobs:
|
||||
push: true
|
||||
tags: |
|
||||
ghcr.io/got-feedback/feedback:nightly
|
||||
ghcr.io/got-feedback/feedback:nightly-${{ steps.date.outputs.date }}
|
||||
ghcr.io/got-feedback/feedback:nightly-${{ needs.setup.outputs.date }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
name: rc
|
||||
|
||||
# Release-candidate images for stabilization: every push to a release/**
|
||||
# branch builds and pushes ghcr.io tags :rc (moving) and
|
||||
# :rc-<version>-<date> (pinned). Final versioned images still come from
|
||||
# release.yml on tag push.
|
||||
on:
|
||||
push:
|
||||
branches: ['release/**']
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
# One build per branch at a time; a newer push supersedes an in-flight one.
|
||||
concurrency:
|
||||
group: rc-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build-docker:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Derive RC tags
|
||||
id: meta
|
||||
run: |
|
||||
# release/v0.3.0 -> 0.3.0 (tolerate a missing v prefix too)
|
||||
version="${GITHUB_REF_NAME#release/}"
|
||||
version="${version#v}"
|
||||
date="$(date -u +%Y%m%d)"
|
||||
{
|
||||
echo "tags<<TAGS_EOF"
|
||||
echo "ghcr.io/got-feedback/feedback:rc"
|
||||
echo "ghcr.io/got-feedback/feedback:rc-${version}-${date}"
|
||||
echo "TAGS_EOF"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to GHCR
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
@@ -35,9 +35,9 @@ jobs:
|
||||
# stable releases (no pre-release suffix).
|
||||
{
|
||||
echo "tags<<TAGS_EOF"
|
||||
echo "ghcr.io/${GITHUB_REPOSITORY,,}:${version}"
|
||||
echo "ghcr.io/${GITHUB_REPOSITORY}:${version}"
|
||||
if [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
echo "ghcr.io/${GITHUB_REPOSITORY,,}:latest"
|
||||
echo "ghcr.io/${GITHUB_REPOSITORY}:latest"
|
||||
fi
|
||||
echo "TAGS_EOF"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
@@ -8,11 +8,6 @@ name: ship-ci
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main, 'release/**']
|
||||
# Trunk-based: post-merge CI on main catches semantic conflicts between
|
||||
# independently-green PRs; push on release/** covers stabilization
|
||||
# cherry-picks that land without a PR.
|
||||
push:
|
||||
branches: [main, 'release/**']
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
name: Sync VERSION from desktop release
|
||||
|
||||
# Updates the VERSION file in this repo whenever feedBack-desktop
|
||||
# publishes a new tagged release. feedBack-desktop's build.yml
|
||||
# Updates the VERSION file in this repo whenever slopsmith-desktop
|
||||
# publishes a new tagged release. slopsmith-desktop's build.yml
|
||||
# dispatches the `desktop-released` event at the end of a successful
|
||||
# tag build (see docs in CLAUDE.md). A `workflow_dispatch` trigger is
|
||||
# tag build (see Versioning section in AGENTS.md). A `workflow_dispatch` trigger is
|
||||
# kept for manual testing / recovery.
|
||||
#
|
||||
# Related issue: #81.
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
name: Validate plugins
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'plugins/**/plugin.json'
|
||||
- 'plugins/__init__.py'
|
||||
- 'schema/plugin.schema.json'
|
||||
- 'docs/plugin-manifest.schema.json'
|
||||
- 'static/capabilities.js'
|
||||
- 'CONTRIBUTING.md'
|
||||
- 'requirements-test.txt'
|
||||
- 'tests/test_plugin_schema.py'
|
||||
- '.github/workflows/validate-plugins.yml'
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'plugins/**/plugin.json'
|
||||
- 'plugins/__init__.py'
|
||||
- 'schema/plugin.schema.json'
|
||||
- 'docs/plugin-manifest.schema.json'
|
||||
- 'static/capabilities.js'
|
||||
- 'CONTRIBUTING.md'
|
||||
- 'requirements-test.txt'
|
||||
- 'tests/test_plugin_schema.py'
|
||||
- '.github/workflows/validate-plugins.yml'
|
||||
|
||||
jobs:
|
||||
validate:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.12'
|
||||
cache: 'pip'
|
||||
|
||||
- name: Install jsonschema
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -r requirements-test.txt
|
||||
|
||||
- name: Validate every in-tree plugin.json against capability schema
|
||||
run: |
|
||||
python - <<'PY'
|
||||
import glob, json, sys
|
||||
import jsonschema
|
||||
|
||||
schema_path = "schema/plugin.schema.json"
|
||||
with open(schema_path) as f:
|
||||
schema = json.load(f)
|
||||
|
||||
# Validate the schema itself is a well-formed JSON Schema.
|
||||
jsonschema.Draft202012Validator.check_schema(schema)
|
||||
|
||||
manifests = sorted(glob.glob("plugins/*/plugin.json"))
|
||||
if not manifests:
|
||||
print("No plugin manifests found under plugins/*/plugin.json")
|
||||
sys.exit(0)
|
||||
|
||||
failures = []
|
||||
for path in manifests:
|
||||
with open(path) as f:
|
||||
manifest = json.load(f)
|
||||
try:
|
||||
jsonschema.validate(manifest, schema)
|
||||
print(f"OK {path}")
|
||||
except jsonschema.ValidationError as e:
|
||||
failures.append((path, e))
|
||||
print(f"FAIL {path}: {e.message} (at {list(e.absolute_path)})")
|
||||
if failures:
|
||||
sys.exit(1)
|
||||
PY
|
||||
|
||||
- name: Run schema and capability contract tests
|
||||
# --noconftest skips tests/conftest.py, which imports structlog
|
||||
# (not in requirements-test.txt). The schema tests don't use
|
||||
# shared fixtures, so this is safe and avoids dragging the full
|
||||
# runtime requirements into a 2 KB schema-validation job.
|
||||
run: pytest tests/test_plugin_schema.py -v --noconftest
|
||||
-16
@@ -9,7 +9,6 @@ build/
|
||||
.env*
|
||||
.DS_Store
|
||||
.vscode/
|
||||
data/web_library.db
|
||||
static/*.ogg
|
||||
static/*.mp3
|
||||
static/*.wav
|
||||
@@ -21,15 +20,9 @@ plugins/*/
|
||||
# treats them identically to user-installed ones) but are bundled with
|
||||
# the default container image and marked `"bundled": true` in their
|
||||
# manifest. Add new core plugins as `!plugins/<id>/` exceptions.
|
||||
!plugins/achievements/
|
||||
!plugins/achievements/**
|
||||
plugins/achievements/__pycache__/
|
||||
!plugins/highway_3d/
|
||||
!plugins/highway_3d/**
|
||||
plugins/highway_3d/__pycache__/
|
||||
!plugins/folder_library/
|
||||
!plugins/folder_library/**
|
||||
plugins/folder_library/__pycache__/
|
||||
!plugins/app_tour_library/
|
||||
!plugins/app_tour_library/**
|
||||
!plugins/app_tour_settings/
|
||||
@@ -42,14 +35,6 @@ plugins/minigames/__pycache__/
|
||||
!plugins/tuner/
|
||||
!plugins/tuner/**
|
||||
plugins/tuner/__pycache__/
|
||||
!plugins/input_setup/
|
||||
!plugins/input_setup/**
|
||||
!plugins/drum_highway_3d/
|
||||
!plugins/drum_highway_3d/**
|
||||
plugins/drum_highway_3d/__pycache__/
|
||||
!plugins/keys_highway_3d/
|
||||
!plugins/keys_highway_3d/**
|
||||
plugins/keys_highway_3d/__pycache__/
|
||||
node_modules/
|
||||
test-results/
|
||||
playwright-report/
|
||||
@@ -61,4 +46,3 @@ Thumbs.db
|
||||
*.tmp
|
||||
*.swp
|
||||
.idea/
|
||||
plugins/support_creators
|
||||
|
||||
@@ -1,19 +1,34 @@
|
||||
# FeedBack Constitution
|
||||
<!--
|
||||
Sync Impact Report
|
||||
Version change: 1.1.0 -> 1.2.0
|
||||
Modified principles:
|
||||
- III. Plugins Are the Extension Point — Isolated by `load_sibling` -> III. Plugins Are the Extension Point — Capability-Declared and Isolated
|
||||
Added sections: None
|
||||
Removed sections: None
|
||||
Templates requiring updates:
|
||||
- [updated] .specify/templates/plan-template.md — Constitution Check now names Slopsmith's concrete gates, including plugin capability declarations.
|
||||
- [reviewed] .specify/templates/spec-template.md — no structural update required; requirements remain feature-focused.
|
||||
- [reviewed] .specify/templates/tasks-template.md — no structural update required; the plan gate drives capability-related tasks.
|
||||
- [reviewed] .specify/templates/checklist-template.md — no structural update required.
|
||||
Follow-up TODOs: None
|
||||
-->
|
||||
|
||||
> FeedBack is a self-hosted, single-user web app for browsing, playing, and
|
||||
> practicing interactive music notation, built around its own open `.sloppak`
|
||||
# Slopsmith Constitution
|
||||
|
||||
> Slopsmith is a self-hosted, single-user web app for browsing, playing, and
|
||||
practicing interactive music notation, built around its own open `.sloppak`
|
||||
> chart format (charts imported from Guitar Pro / MusicXML or authored in the
|
||||
> built-in editor). This constitution captures the non-negotiable principles
|
||||
> that govern its core (`server.py`, `lib/`, `static/`) and that all in-tree
|
||||
> plugins (`plugins/<name>/`) inherit by default. It is a *retrospective*
|
||||
> document — the codebase came first, the principles below were distilled from
|
||||
> `CLAUDE.md`, `README.md`, and the shape of the existing implementation.
|
||||
> `AGENTS.md`, `README.md`, and the shape of the existing implementation.
|
||||
|
||||
## Core Principles
|
||||
|
||||
### I. Self-Hosted, Single-User, Docker-First
|
||||
|
||||
FeedBack targets one user running one container against a personal
|
||||
Slopsmith targets one user running one container against a personal
|
||||
song library folder. There is no multi-tenant model, no
|
||||
authentication, no rate limiting, and no shared backend. Deployment is
|
||||
expressed as a single `docker compose up -d` against the bundled
|
||||
@@ -41,37 +56,18 @@ is Tailwind CSS, served as a prebuilt static stylesheet
|
||||
(`static/tailwind.min.css`, regenerated by `scripts/build-tailwind.sh`)
|
||||
— never the runtime Play CDN, whose on-the-fly JIT rescans the DOM on
|
||||
the main thread and caused sustained frame drops with the 3D highway
|
||||
(feedBack-desktop#110). No React, Vue, Svelte, bundler, transpiler, or
|
||||
(slopsmith-desktop#110). No React, Vue, Svelte, bundler, transpiler, or
|
||||
TypeScript appears in the core static tree, and no build step runs on
|
||||
the serve path: the Tailwind build is a maintainer-only one-shot whose
|
||||
output is committed, so Docker / desktop / end users never build. New
|
||||
features extend `app.js` and the existing globals (`window.playSong`,
|
||||
`window.showScreen`, `window.createHighway`, `window.feedBack`).
|
||||
|
||||
Native ES modules are a first-class, build-free extension mechanism.
|
||||
Because `<script type="module">` and `import` are browser features — not
|
||||
a bundler — a large source file MAY be split into an `import`-ed module
|
||||
graph of plain source files, with **no build step and no framework**. A
|
||||
plugin opts in with `"scriptType": "module"` in `plugin.json`: its
|
||||
`screen.js` becomes a one-line `import './src/main.js'`, and the host
|
||||
serves the `src/` subtree from the sandboxed `/api/plugins/<id>/src/…`
|
||||
route and injects the entry as `<script type="module">`. The classic
|
||||
global-scope `screen.js` path remains fully supported; both coexist, and
|
||||
module scripts are still source-served — the no-bundler, no-transpiler,
|
||||
build-free-at-serve rule is unchanged. Core's own `static/` tree may
|
||||
migrate to the same module-graph shape (`static/js/…`) over time under
|
||||
this rule.
|
||||
`window.showScreen`, `window.createHighway`, `window.slopsmith`).
|
||||
|
||||
**Non-negotiable rules**
|
||||
|
||||
- Do not introduce a frontend framework, JSX, or a JS build pipeline in
|
||||
core. Plugins MAY ship their own bundled assets but core MUST remain
|
||||
source-served.
|
||||
- ES-module plugins remain source-served: no bundler or transpiler, and
|
||||
their own asset URLs (worklets, WASM, images) resolve via
|
||||
`import.meta.url` — never `document.currentScript`, which is `null`
|
||||
inside a module. `scriptType:"module"` and the optional `minHost`
|
||||
version floor are the only new `plugin.json` keys the module path adds.
|
||||
- Because the core Tailwind stylesheet is prebuilt, it contains only the
|
||||
classes present in core source at build time. Core's committed
|
||||
`static/tailwind.min.css` MUST stay in sync with source — CI enforces
|
||||
@@ -88,40 +84,55 @@ this rule.
|
||||
layout invariants (`#player` flex-column, `#highway` flex:1,
|
||||
`#player-controls` at the bottom) MUST be preserved.
|
||||
|
||||
### III. Plugins Are the Extension Point — Isolated by `load_sibling`
|
||||
### III. Plugins Are the Extension Point — Capability-Declared and Isolated
|
||||
|
||||
Functionality that is not part of the irreducible "browse + play charts"
|
||||
loop ships as a plugin under `plugins/<name>/`, not as core code. Each
|
||||
plugin is its own directory (typically a separate git repo), discovered
|
||||
at startup via `plugin.json`, and free to add nav links, screens,
|
||||
settings panels, and `/api/plugins/<id>/...` routes. Plugins MUST
|
||||
isolate their backend Python imports via `context["load_sibling"]` so
|
||||
two plugins shipping a generic `extractor.py` / `util.py` / `client.py`
|
||||
do not collide in `sys.modules`.
|
||||
settings panels, and `/api/plugins/<id>/...` routes. Slopsmith-facing
|
||||
plugin behavior is capability-declared by default: manifests describe
|
||||
the domains a plugin owns, provides, requests, observes, validates, or
|
||||
contributes to before any runtime script hydrates. Runtime handlers,
|
||||
legacy wrappers, and private globals are implementation details or
|
||||
compatibility bridges, never the primary integration contract. Plugins
|
||||
MUST isolate their backend Python imports via `context["load_sibling"]`
|
||||
so two plugins shipping a generic `extractor.py` / `util.py` /
|
||||
`client.py` do not collide in `sys.modules`.
|
||||
|
||||
**Non-negotiable rules**
|
||||
|
||||
- Generic features (practice journal, setlist, metronome, tone player,
|
||||
tab view, MIDI control, stem mixing, editors, etc.) belong in a plugin
|
||||
repo, not in `lib/` or `server.py`.
|
||||
- Plugins with Slopsmith-facing behavior MUST declare
|
||||
`standards: ["capability-pipelines.v1"]` plus redaction-safe
|
||||
`capabilities`, `ui`, or `ui_contributions` metadata for the behavior
|
||||
they expose. Metadata-only or transitional manifests that omit
|
||||
capability participation MUST document why no current domain applies.
|
||||
- Runtime participants, event listeners, wrapper hooks, timers, DOM
|
||||
roots, diagnostics contributors, and media nodes MUST be idempotent
|
||||
across repeated script hydration. If a wrapper or private global is
|
||||
still required, it MUST be documented as a compatibility bridge or a
|
||||
missing capability-domain gap.
|
||||
- Plugin backend modules MUST use `context["load_sibling"]("name")` for
|
||||
sibling imports. Bare `import sibling` works during transition but
|
||||
triggers a startup warning when a name collides.
|
||||
- Plugins MUST register routes under `/api/plugins/<plugin_id>/...`,
|
||||
use `window.feedBack.emit/on` for cross-plugin communication, and
|
||||
prefix their `localStorage` keys with their plugin id.
|
||||
use capability commands/events when an active domain exists, and
|
||||
prefix their `localStorage` keys with their plugin id. General
|
||||
`window.slopsmith.emit/on` events remain acceptable for host events or
|
||||
domains that have not yet been promoted.
|
||||
- Plugins inherit this constitution and may layer additional rules in
|
||||
their own `CLAUDE.md`, but MUST NOT relax core principles (e.g. a
|
||||
plugin cannot require a frontend framework in core).
|
||||
|
||||
### IV. Backwards-Compatible Chart Library
|
||||
|
||||
The whole point of FeedBack is that a user points it at an existing
|
||||
The whole point of Slopsmith is that a user points it at an existing
|
||||
song library folder and it Just Works. The library is scanned and
|
||||
indexed in `meta.db` (SQLite via `MetadataDB`). The open Sloppak
|
||||
format (`lib/sloppak.py`; specified at
|
||||
[got-feedback/feedback-feedpak-spec](https://github.com/got-feedback/feedback-feedpak-spec),
|
||||
published as feedpak — same format) is the preferred
|
||||
format (`lib/sloppak.py`, `docs/sloppak-spec.md`) is the preferred
|
||||
format and the home for new features; loose-folder XML charts
|
||||
(`lib/loosefolder.py`) are also discovered and played as a first-class
|
||||
format. Both must keep playing across releases.
|
||||
@@ -164,7 +175,7 @@ push and PR to `main` against Python 3.12.
|
||||
All backend output goes through the stdlib `logging` pipeline configured
|
||||
by `lib/logging_setup.py`, controlled by `LOG_LEVEL` / `LOG_FORMAT` /
|
||||
`LOG_FILE`. Plugins receive a pre-configured `context["log"]` namespaced
|
||||
to `feedBack.plugin.<id>` and MUST use it instead of `print`. HTTP
|
||||
to `slopsmith.plugin.<id>` and MUST use it instead of `print`. HTTP
|
||||
responses carry a `X-Request-ID` header from `CorrelationIdMiddleware`
|
||||
and the same id appears as `request_id` in JSON log lines. The
|
||||
"Settings → Export Diagnostics" bundle (`lib/diagnostics_bundle.py`)
|
||||
@@ -190,7 +201,7 @@ User configuration lives in two places: server-side under `CONFIG_DIR`
|
||||
(SQLite `meta.db`, `config.yaml`, plugin opted-in files) and client-
|
||||
side in browser `localStorage`. Both can be exported and re-imported
|
||||
as a single bundle (`POST /api/settings/import`,
|
||||
`GET /api/settings/export`, feedBack#113). Import is two-phase:
|
||||
`GET /api/settings/export`, slopsmith#113). Import is two-phase:
|
||||
phase-1 validates the entire bundle (schema, paths, encoding) and
|
||||
phase-2 commits each file atomically via temp+rename. Plugins opt
|
||||
their server-side files into the bundle via
|
||||
@@ -207,7 +218,7 @@ no `..`, no absolute paths).
|
||||
Importing a bundle whose schema predates the running plugin's code
|
||||
MUST restore bytes verbatim — the plugin copes at next load.
|
||||
- The `VERSION` file is the single source of truth for the running
|
||||
release; it is auto-bumped from `feedBack-desktop` releases via
|
||||
release; it is auto-bumped from `slopsmith-desktop` releases via
|
||||
`.github/workflows/sync-version.yml`. Manual edits are reserved for
|
||||
out-of-band recovery only.
|
||||
|
||||
@@ -228,26 +239,18 @@ no `..`, no absolute paths).
|
||||
`flex:1`; `#player-controls` sits at the bottom. Hiding the highway
|
||||
collapses the layout — use `margin-top: auto` on controls if you
|
||||
need to hide it.
|
||||
- **Plugin load order**: alphabetical by directory name. The
|
||||
`playSong` wrapper chain runs outermost-first (last-loaded wrapper
|
||||
runs first). Plugins MUST tolerate dependent globals being absent
|
||||
at load time and check at runtime
|
||||
(`typeof window.X === 'function'`).
|
||||
- **Module load contract**: a `scriptType:"module"` plugin is injected
|
||||
as `<script type="module">`, whose load event fires only after its
|
||||
whole static-import graph fetches and evaluates — so the loader's
|
||||
completion-by-`onload` guarantee (and the `playSong` wrapper-chain
|
||||
order above) is preserved exactly. The host loads `screen.js` once per
|
||||
version and `showScreen` re-injects nothing, so a plugin's per-visit
|
||||
re-initialization comes from its `screen:changed` handler, not from
|
||||
screen.js re-running; ES-module plugins inherit this unchanged (module
|
||||
top-level code does not re-execute on same-version re-mount).
|
||||
- **Plugin load order**: alphabetical by directory name. Scripts MUST
|
||||
tolerate dependent globals being absent at load time and check at
|
||||
runtime (`typeof window.X === 'function'`). Load order and the
|
||||
`playSong` wrapper chain are implementation details, not integration
|
||||
contracts; plugins declare roles, ownership, compatibility, and UI
|
||||
contributions through capability metadata.
|
||||
|
||||
## Development Workflow
|
||||
|
||||
- **Branching**: never push directly to `main`. Always feature branch
|
||||
+ PR. Exception: the automated `VERSION` bump from
|
||||
`feedBack-desktop`'s release job, which commits to `main` as
|
||||
`slopsmith-desktop`'s release job, which commits to `main` as
|
||||
`github-actions[bot]`.
|
||||
- **Reviews**: PRs run the local Codex review loop
|
||||
(`feedback_codex_preflight.md`) and the GitHub Copilot review pass
|
||||
@@ -255,8 +258,9 @@ no `..`, no absolute paths).
|
||||
After pushing a fix, the CodeRabbit loop
|
||||
(`feedback_coderabbit_review.md`) runs to silence.
|
||||
- **Testing**: `pytest` for backend (`requirements-test.txt`),
|
||||
Playwright for browser interactions (`tests/browser/`), CI runs both
|
||||
on every push/PR to `main`.
|
||||
Playwright for browser interactions (`tests/browser/`), and plugin
|
||||
manifest schema validation for capability metadata. CI runs these on
|
||||
every relevant push/PR to `main`.
|
||||
- **CHANGELOG**: every PR updates `[Unreleased]`. Releases rename
|
||||
`[Unreleased]` to `[X.Y.Z] - YYYY-MM-DD` (the VERSION bump itself is
|
||||
automated).
|
||||
@@ -276,12 +280,13 @@ no `..`, no absolute paths).
|
||||
explicit constitutional amendment in this file.
|
||||
- Amendments require: (a) a PR that updates this file alongside the
|
||||
code change, (b) an entry in `CHANGELOG.md` under "Migration notes"
|
||||
if user-visible, and (c) a corresponding update to `CLAUDE.md` so
|
||||
AI agents and humans see the same source of truth.
|
||||
if user-visible, and (c) a corresponding update to `AGENTS.md` and any
|
||||
agent-specific imports or instructions so AI agents and humans see the
|
||||
same source of truth.
|
||||
- The principles are listed in priority order. When two principles
|
||||
conflict (e.g. "vanilla frontend" vs. a plugin that wants to ship
|
||||
React), the lower-numbered principle wins by default; the
|
||||
higher-numbered principle's escape hatch is to live in a plugin
|
||||
with its own bundled assets.
|
||||
|
||||
**Version**: 1.2.0 | **Ratified**: 2026-05-09 | **Last Amended**: 2026-07-08
|
||||
**Version**: 1.2.0 | **Ratified**: 2026-05-09 | **Last Amended**: 2026-06-03
|
||||
|
||||
@@ -31,7 +31,29 @@
|
||||
|
||||
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
||||
|
||||
[Gates determined based on constitution file]
|
||||
Answer each gate with PASS / FAIL / N/A and a short justification:
|
||||
|
||||
- **Self-hosted Docker path**: Does the feature keep `DLC_DIR` and
|
||||
`CONFIG_DIR` as the only required runtime inputs, with any new
|
||||
dependency installable in the existing Docker image?
|
||||
- **Vanilla/source-served frontend**: Does core remain plain JS and
|
||||
committed CSS, with no framework, runtime CDN/JIT, bundler, or
|
||||
serve-path build step? If plugin CSS is needed, does it ship through
|
||||
`styles`?
|
||||
- **Plugin and capability boundary**: If the feature is outside browse +
|
||||
play custom DLC, is it plugin-shaped? Does every Slopsmith-facing plugin
|
||||
behavior declare `capability-pipelines.v1` metadata (`capabilities`,
|
||||
`ui`, or `ui_contributions`) and use `load_sibling` for backend
|
||||
siblings?
|
||||
- **Song format compatibility**: Are legacy archive scanning, sloppak manifests,
|
||||
arrangement IDs, and highway WebSocket messages kept backward-
|
||||
compatible, or is a migration note planned?
|
||||
- **Pure/testable core**: Are new `lib/` helpers flat-importable,
|
||||
side-effect-light, and covered where practical?
|
||||
- **Observability and diagnostics**: Does backend output use logging,
|
||||
and do diagnostics remain redacted and versioned?
|
||||
- **Settings/versioning**: Are settings imports/export paths safe,
|
||||
additive/idempotent, and compatible with the `VERSION` workflow?
|
||||
|
||||
## Project Structure
|
||||
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
# AGENTS.md
|
||||
|
||||
Project orientation for AI coding assistants (Cursor, GitHub Copilot, OpenAI Codex, Aider, Claude Code, Cline, Continue, Cody, Devin, …) and human contributors.
|
||||
|
||||
Slopsmith is a self-hosted web app for browsing, playing, and practicing interactive music notation, built around its own open `.sloppak` chart format. Charts come from importing Guitar Pro (GP5/GP8) or MusicXML, or from authoring in the built-in editor. It runs as a Docker container with a FastAPI backend (`server.py`), vanilla JavaScript frontend (`static/`), shared Python libraries (`lib/`), and an extensive plugin system (`plugins/`). No frontend frameworks — plain JS, HTML, Tailwind CSS. AGPL-3.0-only.
|
||||
|
||||
This file is the canonical orientation. Tool-specific automation (Claude skills/subagents/rules, Copilot instructions, etc.) lives in [`.claude/`](.claude/) and [`.github/copilot-instructions.md`](.github/copilot-instructions.md); both point back here. For plugin work, start at [`docs/PLUGIN_AUTHORING.md`](docs/PLUGIN_AUTHORING.md).
|
||||
|
||||
## Architecture quick reference
|
||||
|
||||
```text
|
||||
server.py FastAPI app — library API, WebSocket highway, plugin loading
|
||||
main.py Programmatic uvicorn entrypoint — installs structlog before boot
|
||||
logging_setup.py Structured logging + correlation IDs (LOG_LEVEL/LOG_FORMAT/LOG_FILE)
|
||||
static/
|
||||
app.js Main frontend — screens, library views, player, plugin loader
|
||||
highway.js Canvas note highway renderer (createHighway factory)
|
||||
diagnostics.js window.slopsmith.diagnostics namespace (loaded first in <head>)
|
||||
index.html Single-page app shell
|
||||
lib/
|
||||
song.py Core data models (Note, Chord, Arrangement, Song)
|
||||
sloppak.py Sloppak format support
|
||||
sloppak_convert.py Import conversion + Demucs stem split
|
||||
loosefolder.py Loose-folder XML chart support
|
||||
audio.py WEM/OGG/MP3 audio handling
|
||||
retune.py Pitch-shifting logic
|
||||
tunings.py Tuning name/offset utilities
|
||||
gp2rs.py Guitar Pro to arrangement XML conversion
|
||||
gp2midi.py Guitar Pro to MIDI
|
||||
plugins/
|
||||
__init__.py Plugin discovery, loading, requirements install, load_sibling
|
||||
<plugin>/ Each plugin is its own directory (often a git submodule)
|
||||
schema/
|
||||
plugin.schema.json JSON Schema for plugin.json (validated in CI)
|
||||
docs/ Plugin contracts + format specs (see Plugin authoring below)
|
||||
tests/ pytest + tests/js/ (node --test) + tests/browser/ (Playwright)
|
||||
specs/ Active spec-kit features (specs/001-slopsmith-platform/...)
|
||||
```
|
||||
|
||||
## Running the app
|
||||
|
||||
Canonical dev path is Docker Compose — `docker-compose.yml` live-mounts `static/`, `server.py`, `lib/`, `plugins/`, and `VERSION` into the container, so frontend edits are visible on refresh and backend edits trigger uvicorn auto-reload.
|
||||
|
||||
```bash
|
||||
docker compose up # build + run on :8000
|
||||
DLC_PATH=/path/to/dlc docker compose up # override default Steam DLC path
|
||||
```
|
||||
|
||||
For host-side runs (tests, scripts, no Docker), the programmatic entry point is `main.py`:
|
||||
|
||||
```bash
|
||||
python main.py # HOST=0.0.0.0 PORT=8000 default
|
||||
HOST=127.0.0.1 PORT=8001 python main.py
|
||||
```
|
||||
|
||||
`main.py` installs the structlog pipeline via `logging_setup.configure_logging()` **before** uvicorn boots and passes `log_config=None` so uvicorn's `dictConfig` never overwrites it. Do not invoke `uvicorn server:app` directly during development — early lifecycle log lines will bypass the structured pipeline and correlation IDs.
|
||||
|
||||
**Logging env vars** (read by `logging_setup`):
|
||||
- `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`)
|
||||
|
||||
Plugin backend code receives a pre-configured `context["log"]` logger namespaced to `slopsmith.plugin.<id>` — never use `print()`. See [`docs/plugin-logging.md`](docs/plugin-logging.md).
|
||||
|
||||
## Testing
|
||||
|
||||
```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 plugin-API contract tests (tests/js/)
|
||||
npm run install:playwright # One-time: install Chromium for Playwright
|
||||
npm test # Playwright browser tests (tests/browser/)
|
||||
```
|
||||
|
||||
Pytest config in `pyproject.toml` sets `pythonpath = [".", "lib"]` and `testpaths = ["tests"]`. CI runs `pytest` on every push/PR to `main` (Python 3.12). See [`docs/testing-plugins.md`](docs/testing-plugins.md) for fixtures (`isolate_logging`, `reset_plugin_state`) and Playwright patterns.
|
||||
|
||||
## Git workflow
|
||||
|
||||
- **Never push directly to `main`** — always create a feature branch and open a PR.
|
||||
- **DCO sign-off is mandatory.** `git commit -s` appends `Signed-off-by:`. Forgot? `git commit --amend -s`. See [`CONTRIBUTING.md`](CONTRIBUTING.md).
|
||||
- **Upstream remote** — set `upstream` to the canonical Slopsmith repository; `origin` is your fork.
|
||||
- **Plugins are gitlinks** — each plugin in `plugins/` is typically its own git repo (submodule or clone). Branch switches on the main repo can clobber plugin directories. Use `git update-index --assume-unchanged` for plugin dirs if needed.
|
||||
- **Commit style** — short imperative subject line, blank line, then body explaining *why*. Conventional-commit prefixes (`feat(scope):`, `fix(scope):`, `chore:`, `docs:`) are conventional in the log but not enforced.
|
||||
|
||||
## Versioning
|
||||
|
||||
- **`VERSION`** (repo root) — single source of truth; plain semver string. Bind-mounted into the container and copied by the Dockerfile so it's always available at `/app/VERSION`.
|
||||
- **`GET /api/version`** — returns `{"version", "source_url", "license_url"}`. `source_url` is overridable via `APP_SOURCE_URL` (default `https://github.com/byrongamatos/slopsmith`); `license_url` falls back to `source_url + "/blob/main/LICENSE"` and is overridable via `APP_LICENSE_URL`. Both must be `http(s)`; non-http(s) values are rejected.
|
||||
- **Auto-sync** — `.github/workflows/sync-version.yml` rewrites `VERSION` via a `repository_dispatch` (`desktop-released`) from `slopsmith-desktop`'s release job. As an explicit automation-only exception to "Never push directly to main", the sync job commits straight to `main` as `github-actions[bot]`. Humans still go through PRs.
|
||||
- **`CHANGELOG.md`** — [Keep a Changelog](https://keepachangelog.com/) format. Update `[Unreleased]` on each PR; release renames it.
|
||||
|
||||
## Song formats
|
||||
|
||||
Slopsmith supports two:
|
||||
|
||||
- **Legacy archive** (encrypted, read-only) — fast metadata scan via `lib/sloppak.py`; full unpack for playback. Audio via `vgmstream-cli` + `ffmpeg`.
|
||||
- **Sloppak** (open format) — hand-editable, two interchangeable forms: `.sloppak` zip or `.sloppak/` directory. Preferred for new features. Full spec: [`docs/sloppak-spec.md`](docs/sloppak-spec.md). Key code: `lib/sloppak.py`, `lib/sloppak_convert.py`, `lib/song.py`.
|
||||
|
||||
## Frontend conventions
|
||||
|
||||
- **No frameworks** — vanilla JS, fetch API, DOM manipulation
|
||||
- **Plugin integration** — prefer documented capability domains, provider APIs, and redaction-safe UI contributions over private globals
|
||||
- **Storage** — `localStorage` for all user preferences, prefixed with plugin id
|
||||
- **Styling** — Tailwind utility classes; dark theme (`bg-dark-600`, `text-gray-300`, accent `#4080e0`, gold `#e8c040`)
|
||||
- **Naming** — camelCase for JS, kebab-case for CSS, snake_case for plugin IDs
|
||||
- **Player layout** — `#player` is `display:flex; flex-direction:column; position:fixed; inset:0`. `#highway` is `flex:1`. Hiding the highway collapses the layout — use `margin-top: auto` on controls if you must hide it.
|
||||
|
||||
## Backend conventions
|
||||
|
||||
- **Framework** — FastAPI + uvicorn (boot via `main.py`)
|
||||
- **Imports** — flat imports from `lib/` (no `__init__.py`): `from song import Song`
|
||||
- **Database** — SQLite via `MetadataDB` class with `threading.Lock`
|
||||
- **WebSocket** — JSON frames, try/except `WebSocketDisconnect`. Protocol: [`docs/websocket-protocol.md`](docs/websocket-protocol.md)
|
||||
- **Error handling** — graceful fallbacks (audio conversion errors don't crash the song; missing art returns a placeholder)
|
||||
- **Type hints** — used sparingly (`Path | None`, `dict`, `list`)
|
||||
- **Docstrings** — minimal; code is self-documenting
|
||||
|
||||
## Plugin authoring — see [`docs/PLUGIN_AUTHORING.md`](docs/PLUGIN_AUTHORING.md)
|
||||
|
||||
Plugins are the primary extension point. Each lives in `plugins/<name>/` with a `plugin.json` manifest that declares its `capability-pipelines.v1` participation. Curated plugins must be AGPL-3.0 or AGPL-compatible — see [`CONTRIBUTING.md`](CONTRIBUTING.md) for the allowlist. Manifest is validated in CI against [`schema/plugin.schema.json`](schema/plugin.schema.json), including capability metadata.
|
||||
|
||||
Topic | Doc
|
||||
--- | ---
|
||||
Manifest reference (`plugin.json` fields) | [`docs/plugin-manifest.md`](docs/plugin-manifest.md)
|
||||
Capability declarations (`standards`, `capabilities`, `ui`) | [`docs/plugin-manifest.md#capabilities`](docs/plugin-manifest.md#capabilities)
|
||||
Visualization contracts | [`docs/plugin-visualization-contracts.md`](docs/plugin-visualization-contracts.md)
|
||||
Plugin styles (`styles: "assets/plugin.css"`) | [`docs/plugin-styles.md`](docs/plugin-styles.md)
|
||||
Backend `context["log"]` logging | [`docs/plugin-logging.md`](docs/plugin-logging.md)
|
||||
Diagnostics opt-in (export bundle) | [`docs/plugin-diagnostics.md`](docs/plugin-diagnostics.md)
|
||||
Multi-file backends (`load_sibling`) | [`docs/plugin-sibling-imports.md`](docs/plugin-sibling-imports.md)
|
||||
WebSocket highway protocol | [`docs/websocket-protocol.md`](docs/websocket-protocol.md)
|
||||
Testing plugins (pytest + Playwright) | [`docs/testing-plugins.md`](docs/testing-plugins.md)
|
||||
Diagnostics bundle layout | [`docs/diagnostics-bundle-spec.md`](docs/diagnostics-bundle-spec.md)
|
||||
Sloppak format spec | [`docs/sloppak-spec.md`](docs/sloppak-spec.md)
|
||||
Tuning the note_detect plugin | [`docs/note-detect-tuning.md`](docs/note-detect-tuning.md)
|
||||
|
||||
## First-hour pitfalls (read these before your first PR)
|
||||
|
||||
1. **`load_sibling` for cross-file backend plugins.** Bare `from extractor import X` in `routes.py` collides across plugins because Python caches by module name in `sys.modules`. Use `context["load_sibling"]("extractor")` — gets a per-plugin namespaced module. Full explanation: [`docs/plugin-sibling-imports.md`](docs/plugin-sibling-imports.md).
|
||||
|
||||
2. **Capability declarations are the integration map.** Plugin behavior should be visible in `standards`, `capabilities`, and `ui` metadata before runtime code hydrates. If the domain you need is missing, document it as a capability gap instead of adding another private global contract.
|
||||
|
||||
3. **Highway flex layout.** `#highway` has `flex:1` in the player. Hiding it with `display:none` removes the flex child and `#player-controls` floats to the top. If you must hide the highway, add `margin-top: auto` to the controls div.
|
||||
|
||||
4. **Plugin gitlinks bite on branch switches.** Plugins are separate git repos cloned into `plugins/`. `git checkout` / `git clean` on the main repo can delete or clobber them. Use `git update-index --assume-unchanged plugins/<id>` if needed.
|
||||
|
||||
5. **DCO sign-off is mandatory.** Every commit needs `Signed-off-by:`. Add via `git commit -s` (or `git commit --amend -s` if you forgot). PRs without DCO won't merge.
|
||||
|
||||
## Verification (run before claiming done)
|
||||
|
||||
```bash
|
||||
pytest -q # backend tests
|
||||
npm run test:js # JS plugin-API contract tests
|
||||
npm test # Playwright browser tests (slow; defers to CI)
|
||||
```
|
||||
|
||||
If you touched `plugins/*/plugin.json` or `schema/`:
|
||||
|
||||
```bash
|
||||
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'))]"
|
||||
```
|
||||
|
||||
## House rules
|
||||
|
||||
- **AGPL-3.0-only.** Inbound contributions are inbound under the same terms. Don't paste from incompatible sources.
|
||||
- **DCO sign-off mandatory** on every commit (`git commit -s`).
|
||||
- **No frontend frameworks.** Vanilla JS, fetch API, Tailwind classes. Don't add React/Vue/Svelte.
|
||||
- **Backend logging.** Plugin `routes.py` must use `context["log"]`, never `print()`. See [`docs/plugin-logging.md`](docs/plugin-logging.md).
|
||||
- **Plugin Python imports.** Multi-file backends use `context["load_sibling"]("<module>")`, not bare `from <module> import`. See [`docs/plugin-sibling-imports.md`](docs/plugin-sibling-imports.md).
|
||||
- **Capability metadata.** Plugin integrations declare `standards: ["capability-pipelines.v1"]` plus redaction-safe `capabilities`/`ui` metadata as the primary integration contract.
|
||||
- **Spec-kit owns `.specify/` and `specs/`.** Don't modify those without explicit instruction; the `/speckit-*` skills own that surface.
|
||||
|
||||
## Tool-specific surfaces (optional reading)
|
||||
|
||||
- [`CLAUDE.md`](CLAUDE.md) — points back here. Exists for Claude Code's auto-loading convention. Claude-specific *automation* lives in [`.claude/`](.claude/) (skills, subagents, rules, settings).
|
||||
- [`.github/copilot-instructions.md`](.github/copilot-instructions.md) — points back here. Exists for GitHub Copilot's native instructions format.
|
||||
- No `.cursorrules` — Cursor reads `AGENTS.md` natively. Cursor-specific rules, if ever needed, would go under `.cursor/rules/`.
|
||||
+33
-222
@@ -7,259 +7,70 @@ 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.
|
||||
- **Perf-baseline harness (R0).** `scripts/perf-baseline.mjs` (maintainer-only, Playwright-driven) captures server p50/p95/p99 latency, cold boot-to-interactive, JS-heap after an idle soak, and the injected plugin-script shape (how many are `type=module`), so every refactor phase can be checked for "screen-entry and frame-time no worse." Methodology + the R0 baseline live in `docs/perf-baseline.md`; playback frame-time and chart-loaded screen-entry rows need a seeded library and are re-taken per environment.
|
||||
- **Sort and filter the library by your personal difficulty rating — now visible at a glance, not just in the edit drawer.** `song_user_meta.user_difficulty` (the 1–5 planning rating, settable manually or seeded by a plugin like the community `difficulty_tagger`) already existed but was only readable by opening a song's per-song details drawer. The library API gains `sort=difficulty` / `sort=difficulty-desc` — a correlated subquery over `song_user_meta`, following the same unrated-rows-sort-to-the-bottom-in-both-directions pattern as the existing `mastery` sort — and library cards now show the rating as a `◆N` badge (v2 grid/tree views and the v3 grid alike), next to the tuning and lyrics badges. The classic tree view's `query_artists` batch-attaches `user_difficulty` the same way `query_page` already did for the grid, so the badge actually renders there too instead of staying dark. Tests: `tests/test_library_filters.py::test_difficulty_sort_pushes_unrated_to_bottom`, `tests/test_library_filters.py::test_tree_view_songs_carry_user_difficulty`.
|
||||
- **`lib/midi_import.py`: `convert_midi_tempo_map` — MIDI imports can finally carry
|
||||
their bars.** The keys/drums note converters always computed a tempo-aware
|
||||
tick→seconds map internally (to bake note times to absolute seconds) and then threw
|
||||
it away — and never read `time_signature` meta at all — so every MIDI import landed
|
||||
with no measures and an implied 4/4 regardless of what the file said. The new helper
|
||||
extracts the whole grid: `tempos` (`{time, bpm}`), `time_signatures` (`{time,
|
||||
ts:[num,den]}`, the song-timeline sidecar shape), and a full `beats` grid on the
|
||||
editor's row shape (numbered downbeats with a `den` hint, `-1` interior beats,
|
||||
eighth-note rows in 6/8 etc.). Event scope mirrors the existing tick map — SMF
|
||||
type 0/1 merge meta across tracks, type 2 reads only the chosen track (independent
|
||||
timelines must never share a grid); mid-bar signature events apply at the next bar
|
||||
boundary; times are computed from absolute ticks through the cumulative tempo table
|
||||
and rounded once at emit, so rounding error never accumulates with song length.
|
||||
Consumed by the editor's upcoming multitrack MIDI import (tempo-seed dialog). Tests:
|
||||
`tests/test_midi_tempo_map.py`.
|
||||
|
||||
### Fixed
|
||||
- **Tuner: opening the player screen no longer throws `NotFoundError` and aborts the player render (feedBack#800).** `injectPlayerButton()` anchored the injected Tuner button with `controls.querySelector('button:last-child')`, which — unlike a `:scope`-scoped query — can match a **nested** button that is not a direct child of `#player-controls`. `controls.insertBefore(btn, nestedButton)` then throws `NotFoundError` (the reference node must be a direct child), and because the injection runs from the tuner's `screen:changed` → player handler, the throw propagated out of the player-screen transition and stalled its render (surfaced by a headless render of a notation arrangement; the v3 path was already safe via the plugin-control slot, only the classic path had the bad anchor). The anchor is now `:scope > button:last-of-type` (a direct child only) with a `parentNode === controls` guard before `insertBefore`, falling back to `appendChild`. `plugins/tuner` → 1.3.4. Tests: `tests/plugins/tuner/js/inject_player_button.test.js` (nested-last-button repro, direct-child insert, no-button append, idempotency, v3 slot path).
|
||||
- **Auto-sync: DTW step constraint — riff-based songs no longer produce garbage sync points.** `librosa.sequence.dtw`'s default step pattern allows unbounded horizontal/vertical path runs, and on music with long self-similar chroma stretches (riff-driven stoner/doom, drone sections) the flat cost surface let the warping path collapse — minutes of score mapped onto a single audio frame, so the per-bar warp imported charts wildly out of sync while reporting success (observed on a real 138 BPM tab: effective displayed tempo 159 BPM, three sync points sharing one audio timestamp). `_dtw_align` now uses the standard music-sync slope-constrained step pattern (`[[1,1],[1,2],[2,1]]`, local tempo ratio bounded to 0.5x–2x), which makes the degenerate path impossible, with a fallback to unconstrained steps when the global length ratio makes the constrained pattern infeasible (e.g. a tab aligned against a full-concert video). Validated on the failing song: coarse points track the recording 1:1, refined downbeats land on onset peaks at 3.3x background energy.
|
||||
|
||||
### Added
|
||||
- **3D Keys Highway: key layout modes, lane-color opacity & octave lines.** A new **Highway layout** settings section rebuilds how sharps/flats and lanes draw on the 3D piano highway. **Sharps & flats layout** (`keys3d_bg_sharpMode`) picks between **floating** (the original raised-sharp look), **flat** (one plane, zero-overlap piano-shaped tiled lanes with the naturals evened out), and **realistic** (one plane, bars sized like the physical keys) — default **realistic**; the geometry lives in pure, unit-tested `laneSpanFlat()`/`laneSpanReal()` helpers. **Lane color opacity** (`keys3d_bg_laneOpacity`, 0–1, default 0) fades the pitch-class lane tint from full vivid color down to a dark floor with guide lines only at the key-block boundaries (E→F and each octave); the lane strips, per-lane separators and block lines crossfade with the value. **Octave separators** (`keys3d_bg_octaveGaps`, default on) and **Octave line contrast** (`keys3d_bg_octaveContrast`, 0–1) control the B→C octave divider, which auto-shifts from a dark to a bright layer as lane opacity fades. Settings re-read on init and apply on the next chart build. `plugins/keys_highway_3d` → 0.2.0. Tests: `plugins/keys_highway_3d/tests/fx_settings.test.js` (new defaults, sharp-mode setting, lane-geometry tiling/evening for flat, uniform/overlap for realistic, and an active-range boundary case where a white key's edge stays untrimmed when its neighboring sharp falls outside the active range).
|
||||
- **Unmapped-percussion capture now records velocities alongside times.** Both drum converters' opt-in `out_unmapped` reporting (`lib/midi_import.py` `convert_drum_track_from_midi`, `lib/gp2rs.py` `convert_drum_track_to_drumtab`) gain an index-aligned `velocities` list next to `times`, carrying each dropped note's real dynamics (MIDI velocity verbatim; GP velocity with the same 1–127 gate as mapped hits, falling back to the 100 import default). This lets a hand-mapping UI (the editor's unmapped-notes dialog) restore mapped notes at their source dynamics instead of flattening everything to `v:100`. The GP path's chronological sort now reorders times and velocities in lockstep so multi-voice measures can't silently reassign dynamics. Additive — callers that ignore the new key are unaffected. Tests: `tests/test_midi_import_drums.py`, `tests/test_gp2rs_drums.py`.
|
||||
- **Handedness (left-handed) is now a first-class choice in the instrument selector — and surfaced during onboarding.** Left-handed players could already mirror the highway, but only via a buried Settings toggle they had to find *after* setup — so a lefty hit the tour, the tuner and calibration all right-handed first. The v3 instrument badge popover now has a **Handedness: Right / Left** row alongside Instrument / Strings / Tuning (all player-orientation choices), writing the same `lefty` preference (`highway.setLefty` when a live highway exists, else the `lefty` localStorage key it reads on init; the Settings checkbox stays in sync). The first-run tour's "Choose your instrument" step — which runs **before** the tuner/audio-calibration steps — now calls it out so lefties flip it up front. Frontend-only, additive: `static/v3/badges.js`, `static/v3/onboarding-tour.js`. Tests: `tests/js/badges_handedness.test.js`.
|
||||
- **"Colorblind (deuteranope)" highway string-color preset.** Adds a one-click preset to the shared "Highway String Colors" picker, sitting next to the existing Okabe–Ito "Colorblind-friendly" preset — contributed by a deuteranopic player who found the Okabe–Ito set still hard to separate. It retunes the six main strings (red / yellow-green / blue / orange / teal / deep-purple) and keeps that set's 7/8-string colors, and applies to **both** the 2D and 3D highways via the shared picker. Frontend-only, additive: `static/app.js` (`HWC_PRESETS`).
|
||||
- **`lib/gp_autosync.py`: piecewise time-warp helpers + a working `refine_sync()`.** `auto_sync()` has always computed per-bar sync points (DTW), but consumers could only apply the scalar bar-1 `audio_offset`, so any tempo difference between the recording and the tab's authored tempo accumulated audibly over the song. New librosa-free helpers expose the full mapping: `bar_start_times(gp_path)` (per-bar score times on the same axis as the sync points — GPIF bar-resolution map for `.gp`/`.gpx`, per-tick integration for GP3/4/5), `build_warp_anchors(points, bar_starts)` (strictly-monotonic `(score, audio)` anchor pairs), `warp_time(t, anchors)` (piecewise-linear map with edge-slope extrapolation for count-ins/tails), `warp_song_times(song, warp)` (retimes a `lib.song.Song` in place: notes + sustains, chords, beats, sections, anchors, handshapes, per-phrase difficulty levels, tone changes, tempo overrides), and `gp_has_expandable_repeats(gp_path)` (detects GP3/4/5 repeat/volta/direction markup whose playback expansion the as-written sync points cannot map — callers fall back to offset-only sync). Also implements `refine_sync()`, which the editor plugin's refine-sync endpoint has imported since the snapshot but which never existed in core (the Refine button 500'd): it densifies the coarse DTW points to every Nth bar and re-times each with a local onset phase sweep (sweep radius clamped under half a beat so periodic material can't lock a full beat off; short scoring grid + median residual snap). Synthetic click-track validation: ~13ms mean / ~40ms max error from ±180ms coarse input across 117–123 BPM recordings of a 120 BPM tab. Tests: `tests/test_gp_autosync_warp.py`.
|
||||
- **Playlist shuffle.** The v3 playlist detail page gains a crossing-arrows shuffle toggle next to Play all / Play album. When on, `playQueue.start` Fisher-Yates-shuffles the queue once at start (on a copy — the stored playlist order is untouched), swapping any per-slot album arrangements in lockstep so each slot keeps its pinned arrangement. The preference is global and persists in `localStorage` (`v3PlaylistShuffle`). Tests: `tests/js/play_queue_shuffle.test.js`.
|
||||
|
||||
### Changed
|
||||
- **Player frame-time hotspots removed (trace-backed) + weak-hardware hardening.** A Chrome performance trace of a 3D-highway session surfaced two core per-frame layout-thrash sources, now fixed: the highway's visibility check read `canvas.offsetParent` every rAF frame (forces style/layout recalc — now sampled every 10th frame with a cached value, force-refreshed on init/canvas-replace/resize/override-clear), and the v3 player chrome loop called `matches(':hover')` per frame and unconditionally rewrote the Up-Next pill's `textContent`/bar width at 6 Hz (now hover-tracked via mouseenter/mouseleave, DOM writes only on value change, progress bar moved from `width` to compositor-only `scaleX`). The 3D highway pre-warms shader programs (`ren.compile`) and deterministic label textures at init — and chart-dependent chord/section label textures on first draw — so first-appearance shader-compile/texture-upload frame spikes move into the load spinner. For weaker hardware: the per-frame renderer bundle is now a single reused object instead of a fresh ~35-field allocation per frame (object identity is stable and meaningless; array fields still swap reference on chart changes), custom viz get `bundle.lowerBoundT`/`bundle.lowerBoundTime` binary-search helpers for visible-window culling, the default 2D highway's beat lines no longer scan every beat in the song per frame, and the 3D highway stops reading `localStorage` per frame (1 Hz poll) and caches its lyrics text-measurement layout per displayed line instead of re-measuring every syllable every frame. A second, throttled-CPU trace pass additionally removed: shader-program re-resolution churn from label texture swaps (`material.needsUpdate` is now only set on a null↔texture transition — swapping between two cached label textures never changes the compiled program), the 3D highway's per-frame `getBoundingClientRect` layout read in its canvas-size self-check (now every 10th frame, still immediate on backing-store change), and the core 60 Hz HUD clock rewriting `textContent` on every tick (now write-on-change, ~1/s). The dominant residual — steady `getParameters` shader-program re-resolution (~4% of throttled main thread) — turned out to be Three r158+'s transparent-DoubleSide two-pass rendering, which sets `material.needsUpdate` twice per object per frame; all 18 of the 3D highway's transparent DoubleSide materials are flat unlit quads (labels, rails, chord frames, lanes), so they now declare `forceSinglePass: true`, eliminating the recompile churn and halving those objects' draw calls.
|
||||
|
||||
### Fixed
|
||||
- **`playback.loop-api` bridge no longer fires dozens of times per second.** Every `window.feedBack.getLoop()` call recorded a full bridge hit — compat-shim bookkeeping, a `playback:bridge-hit` event, and a diagnostics snapshot rebuild + stringify — so a plugin polling loop state from a HUD tick (note_detect at ~30 Hz) flooded the capability inspector and burned main-thread time even with no song playing. `_recordPlaybackBridge` now throttles per bridge/surface (5 s window): bridge hits are a "surface still in use" signal, not a call counter. The manual A/B loop buttons (`setLoopEnd`) also now emit the same `loop-set` transport event as `setLoop()`, so plugins can react to loop changes via `playback:loop-set` / `playback:loop-cleared` events instead of polling `getLoop()`.
|
||||
- **3D Highway: recover from a WebGL context loss instead of crashing on alt-tab.** Switching the active window / alt-tabbing away from the app (most often on Windows) can trigger a GPU context reset; the 3D highway's WebGL renderer had **no `webglcontextlost` handler**, so a lost context was left to escalate into a render-process crash — matching the intermittent "randomly crashes when I change windows" desktop reports. The renderer now binds `webglcontextlost`/`webglcontextrestored` on its own WebGL canvas (`ren.domElement`): the loss is `preventDefault()`'d so the browser keeps the context restorable, `draw()` bails while the context is down so no GL work runs on a dead context, and on restore the viewport is re-applied and rendering resumes (Three re-uploads scene resources on the next frame). Listeners are torn down with the renderer. `plugins/highway_3d` → 3.31.3. Tests: `tests/js/highway_3d_context_loss.test.js`. (The sibling `keys_highway_3d` / `drum_highway_3d` renderers share the same gap — tracked as a follow-up in their repos.)
|
||||
- **Guitar Pro 6 (`.gpx`) import no longer fails on every real file.** The GPX BCFS container reader (`lib/gp2rs_gpx.py`) rejected any file whose final sector wasn't a full `0x1000` block — but a real `.gpx`'s BCFZ-declared decompressed size isn't sector-aligned, so the last (small) container file always lands in a partial trailing sector. The bounds check *raised* `GPX BCFS sector pointer out of range (malformed file)` instead of clamping the tail read, so `_load_gpif` threw before `score.gpif` could be extracted and **no GP6 file could be imported into the song editor** (both real test files failed identically — this wasn't file-specific). GP7/GP8 `.gp` files were unaffected — they take the ZIP path, not BCFS, which is why prior GP-import work didn't surface it. The reader now **clamps the final sector read to the buffer end** (the per-file size field trims the padding anyway), matching canonical GPX readers (alphaTab / PyGuitarPro); a sector whose *start* is past the end still raises, preserving the malformed-file guard. Verified against two real GP6 files — both now unpack to valid GPIF with all tracks. Tests: `tests/test_gp2rs_gpx.py` (partial-final-sector round-trip, multi-file container, sector-aligned baseline, and the preserved out-of-range guard).
|
||||
- **v3 Songs grid: fixed the scroll stutter that "skips every so many scrolls," up or down.** The virtualized grid rebuilt its **entire** visible window (`grid.innerHTML = …` + a full `wireCards` pass) every time it slid by one row, so each row-boundary crossing was a heavy synchronous frame that stalled the main thread and buffered held-arrow key-repeats into a visible lurch (a tester's "super fast for a second then slowed down") at fixed scroll offsets — in **both directions and regardless of whether the page was already loaded** (the cost was DOM teardown, not fetching, which is why scrolling back up over cached songs hitched too). `renderWindow()` now **reconciles the window in place**: it reuses the card nodes that stay on-screen and builds only the row that enters/leaves (~6 nodes per slide instead of ~60), keyed by absolute index with a real-vs-skeleton + select-mode signature so hole-fills (after a page fetch) and select-mode toggles still rebuild exactly the nodes that changed. `wireCards`'s `data-wired` guard then wires only the freshly-built nodes, so per-slide listener churn drops with it. Follow-up to the stage-2 virtualized grid (got-feedback/feedBack#636 item 3). Frontend-only: `static/v3/songs.js`. Tests: `tests/js/v3_songs_window_recycle.test.js` (window stays `[start,end)` contiguous + in-window node identity reused across a down-then-up scroll; select-mode toggle and rail-seek jump rebuild correctly).
|
||||
- **Starter content seeds again (and now ships The Adicts' "Ode to Joy").** `_BUILTIN_STARTER_SOURCES` still listed `beethoven-ode_to_joy.feedpak` after that pack was deleted, and never wired up its replacement `the_adicts-ode-to-joy_vst_cover.feedpak` that landed on disk. The listed-but-missing file made the all-present gate never fire, so **no** starter songs seeded on first run. Synced the manifest to what's on disk (Für Elise, Star Spangled Banner, The Adicts' Ode to Joy). Tests: `tests/test_builtin_starter_seed.py` (the present/unlisted guards were red on `main`).
|
||||
- **Edit Metadata now writes into `.feedpak` files, not just legacy `.sloppak` ones.** `lib/songmeta.py`'s suffix gate predated the format rename — core reads both suffixes everywhere else (`sloppak.SONG_EXTS`), but the metadata writer only dispatched on `.sloppak`, so editing a zip-form `.feedpak`'s title/artist/album/year silently fell back to a DB-only update. That looked fine until the next **full library rescan** re-derived metadata from the file and reverted the edit (directory-form packages were unaffected — they dispatch on manifest presence, not suffix). The gate now accepts both package suffixes. Tests: `tests/test_songmeta.py` `TestWriteSongMetadata` (both zip suffixes, mixed-case suffix, directory form, unknown-suffix fallback).
|
||||
- **3D Drum & Keys highways now re-frame on fullscreen/layout drift under splitscreen.** The guitar/bass `highway_3d` self-detects when its panel canvas changes size and re-runs `applySize()` every frame, because the splitscreen host overrides `hw.resize` and never calls `renderer.resize()`. The drum and keys highways lacked that fallback — they only re-framed when the host explicitly called `resize(w, h)` — so their panels stayed framed for the pre-fullscreen size while the guitar/bass panels adapted (visible as a too-small, off-center highway after maximizing a split-screen session). Both draw loops now port `highway_3d`'s per-frame drift check: they re-apply on backing-store change (`canvas.width/height`) AND on CSS-box drift (`clientWidth/clientHeight` vs the last applied logical size, throttled to every 10th frame), and reset the tracking in `destroy()` so a reused instance re-frames on the next song. `plugins/drum_highway_3d` → 0.3.1, `plugins/keys_highway_3d` → 0.1.1. Tests: `tests/js/drum_keys_highway_3d_resize_reframe.test.js`.
|
||||
- **Tuner: finished the "remove unused settings" cleanup and fixed the sidebar panel position.** The Floating Button and Tuning Visibility settings sections were removed, but their config was still live: `disabledTunings` still filtered the tuner menu (with no UI left to re-enable a hidden tuning — a one-way trap) and `showFloatingButton` still gated the floating launcher. Both are now fully retired — the enforcement paths in `plugins/tuner/screen.js`/`utils/ui.js` and the persistence in `plugins/tuner/routes.py` are gone (and `routes.py` strips the retired keys on write, so stale values are purged). The tuner panel opened from the v3 sidebar Plugins rail popover now anchors beside it via the host's stable plugin-control slot API (falling back to the popover id), is **clamped to the viewport** so it can't open off the right/bottom edge on narrow/short windows, and re-anchors on window resize. `plugins/tuner` → 1.3.3.
|
||||
|
||||
### Added
|
||||
- **Host theme read surface — `window.feedBack.theme` + always-present `--fbv-*` tokens — so a plugin feature can render correctly under any theme instead of binding to whichever one the developer happened to see.** The cosmetics applier (`static/v3/theme-core.js`) previously only *applied* themes and emitted `--fbv-*` vars **only while a theme was equipped** (nothing for a plugin to read in the default, un-themed state) with no read/capability API — so plugins reinvented their own theming and a new visual *device* (a glow, a gradient) silently bound to one look. It now: (1) emits the default `fb` palette as **always-present `--fbv-*` on `:root`** (additive — the un-themed look is unchanged; the `fb-*` utilities still use their compiled defaults; this only hands plugins a stable host token to read + derive surfaces from), plus two keystone ROLES the palette lacked — **`on-accent`** (a foreground legible *on* the accent fill — the missing piece behind white-on-accent contrast bugs) and **`focus-ring`**; (2) adds **`window.feedBack.theme`** — `get()` → `{id, isThemed, tokens}`, **`capabilities()`** → `{glow, gradients, motion}` (the device-affordance signal a feature reads to choose a glow vs. a solid device; recolor-only themes report defaults, a theme may opt out via a `capabilities` block in its payload, and `motion` is additionally reduced-motion-gated), and **`prefersReducedMotion()`** (one central matchMedia wrapper); and (3) emits a normalized **`theme:changed`** event (`{id, isThemed, tokens, capabilities}`) from the single `apply()` chokepoint. All additive + feature-detected (the apply side stays on `window.v3Theme`; the read surface is attached defensively so it survives the `feedBack` bus being (re)built by `capabilities.js` regardless of load order). First slice of the host theme contract (got-feedback/feedBack#644) — the framework fix so a plugin UI feature can't accidentally carve itself into a single theme; see `docs/host-theme-contract.md`. Verified by a headless render (apply/unequip intact, defaults present + restored, capability opt-out honored, event payload correct). Tests: `tests/js/v3_theme_read_api.test.js`.
|
||||
- **3D Keys Highway: audio-reactive background ambience + score effects (parity slice 4 — completes the keys side of the visual-parity epic).** The gradient sky behind the highway gains the guitar's **background ambience styles** — drifting **Particles**, pitch-class-colored pulsing **Stage lights**, wireframe **Geometric** shapes, or Off — driven by the shared audio-analyser bridge (stems-first on sloppaks, one-shot `#audio` fallback; bass/mid/treble bands, 5 ms cache) with an **Ambience intensity** slider and an **Audio-reactive** toggle. And the score talks back: a **score-FX overlay** canvas draws rising **“+1” pops** off each scored key, an **expanding ring every 10-combo tier**, **milestone bursts** at 25/50/100 streak, and a brief **red flicker** when a 3+ streak breaks (wrong notes and swept misses both count). All settings-gated and on by default (`keys3d_bg_*`), pooled, cleared when idle, torn down with the scene. Butterchurn/image/video remain out of scope. The `#audio` analyser tap is also **shared across visualizers** now (`window.__feedBackAudioTap` — whoever taps first publishes, everyone else adopts, `highway_3d` included), so switching between or splitting the guitar/drum/keys highways can't strand a permanently non-reactive backdrop, and the tap is never created before the page has user activation (a suspended AudioContext would silence live playback). Tests: bg-style id validation + FX defaults (30 total).
|
||||
- **3D Keys Highway: the anti-plastic pass — lacquered note gems, glossy piano-black keys, a studio environment, scene themes and a gradient sky (parity slice 3).** The note gems move to `MeshPhysicalMaterial` with a full **clearcoat** (roughness 0.32, clearcoat 1.0/0.18, envMapIntensity 0.9): a sharp lacquer highlight over the colored body instead of the old dead matte surface — glass, not plastic. What sells it is **image-based lighting**: the same procedural PMREM "studio" environment as the drum highway (dark room + cool overhead / warm+cool side light strips, no addon dependency) feeds `scene.environment`, so the **black keys finally read as glossy piano black** (roughness 0.55 → 0.22, envMapIntensity 1.3) with visible strip reflections, whites keep an ivory sheen (0.42/0.55), and the highway floor gets a stage sheen (roughness 0.9 → 0.55, metalness 0.15). The flat background becomes a **vertical gradient** (lighter above the horizon → theme color → darker toward the keyboard), and the guitar highway's **11 scene themes** arrive (same names/values — your look carries across instruments; `default` preserves the original keys palette; pitch-class note/key colors are never themed — themes own the scene, Synthesia colors own the notes). Plus **Cinematic lighting** (ambient 0.55/key 1.3, on by default) and a **Glow strength** slider multiplying the note glow, key approach-glow and the sustain consume-flash (0.5 = stock). All live-applying from the Graphics settings (`keys3d_bg_theme` + `keys3d_bg_*`); the PMREM target and gradient texture are disposed with the scene. Tests: theme-table id parity + default-look preservation + fallbacks (28 total).
|
||||
- **3D Drum Highway: audio-reactive background ambience + score effects (parity slice 4 — completes the drum side of the visual-parity epic).** The empty fog band behind the kit gains the guitar highway's **background ambience styles**: drifting **Particles**, palette-colored pulsing **Stage lights**, and slowly-tumbling wireframe **Geometric** shapes (plus Off) — driven by the same audio-analyser bridge the guitar uses (prefers the stems plugin's per-song analyser on sloppaks, falls back to a one-shot `#audio` tap; bass/mid/treble bands with a 5 ms cache), with an **Ambience intensity** slider and an **Audio-reactive** toggle (off = the styles animate on time only; a permanently-tapped `#audio` in a mixed split degrades the same way). The guitar's butterchurn/image/video styles are deliberately out of scope (vendored megabytes / upload plumbing; the style enum is extensible). And your combo finally talks back: a **score-FX overlay** (2D canvas over the WebGL scene, guitar `drawScoreFx` adapted to this plugin's internal scoring) draws rising **“+1” pops** off each scored lane, an **expanding ring pulse every 10-combo tier**, **milestone particle bursts** at 25/50/100 streak, and a brief **red flicker** when a 3+ streak breaks. Everything is settings-gated (Background ambience dropdown + intensity + reactive, Score effects toggle — all on by default, live-applying, `drum_h3d_bg_*` keys), pooled (zero per-frame allocation), and torn down with the scene across kit changes. Tests: bg-style id validation + FX defaults (15 total).
|
||||
- **3D Drum Highway: real materials + scene themes (parity slice 3).** The scene gets **image-based lighting**: a procedural PMREM "studio" environment (dark room + three emissive light strips — cool overhead key, warm/cool side fills; no vendored-addon dependency) feeds `scene.environment`, so the cymbals' metalness **finally reads as metal** (retuned to roughness 0.2 / metalness 0.85 / envMapIntensity 1.2 — the old 0.7-metalness look was matte because there was nothing to reflect), drumheads get a satin sheen, and the floor (roughness 0.95 → 0.7) catches the strips without turning into a mirror. **Scene themes arrive** — the same 11 theme names as the guitar highway (Midnight, Charcoal, Deep Purple, Forest, Warm Slate, Deep Focus, Deep Sea, Cathode, Cathode Green, Hearth) retint the background/fog, floor and lane stripes so your look carries across instruments; `default` preserves the original drum palette byte-for-byte, and piece colours stay with the existing Palette picker (themes own the scene, palettes own the kit). Plus **Cinematic lighting** (dimmer ambient / stronger key, on by default), a **Glow strength** slider (0–1, 0.5 = stock) multiplying every emissive base — notes, hit line, snare wires — and a **Lane vibrancy** slider driving stripe/halo/ghost-ring strength (the hit-FX approach highlight stacks on top). Everything applies live from the plugin settings (`drum_h3d_bg_theme` + `drum_h3d_bg_*` keys); the PMREM render target is rebuilt across kit-change renderer recreation and disposed in both teardown paths — and the floor/hit-bar geometry+materials that previously leaked on every kit change are now tracked and disposed too. Tests: theme-table parity with the guitar ids + default-look preservation + fallbacks (13 total).
|
||||
- **3D Drum Highway: hit FX — sparks, timing-colored lane flashes, kick camera pulse, approach glow, and open hi-hat notation (parity slice 2).** Striking a pad now *feels* struck: a pooled additive **spark burst** fires at the lane (ported from the guitar highway's Points-cloud system, pool 160), colored by **timing** — on-time green, early cyan, late amber (same `_timingHex` vocabulary as `highway_3d`, classified against the ±50 ms hit window with the inner 40% reading as on-time); with **Streak feedback** on, bursts grow with your combo. The **lane flash** feedback that was removed when note-recoloring landed is resurrected properly: pooled additive quads with a soft gaussian falloff light up the struck lane at the hit line (timing-colored; red for wrong-pad hits), and a **kick** hit fires triple amber bursts across the bar plus a subtle **camera dip + amber floor wash** that decays exponentially. Lanes also glow ahead of time: each stripe brightens as its next note approaches the hit line, so the eye is led to where the next hit lands. **Open hi-hat finally renders distinctly** — `hh_open` chart hits get a thin warm ring around the cymbal gem (standard notation's "o"), closing the long-standing TODO; the flag is orthogonal to accents/ghosts/flams so combined cues stack. All of it is settings-gated (Graphics → Hit sparks / Timing colours / Streak feedback / a 0–1 **Hit feedback intensity** slider driving flashes, approach glow and the kick pulse; everything on by default, `drum_h3d_bg_*` keys, live-applying) and GPU-frugal: every new visual is pooled or shares geometry/materials — zero per-note allocation on top of the per-frame notes rebuild, all registered in both dispose paths (kit-change renderer recreation included). Tests: timing-classifier boundaries + FX defaults added to `plugins/drum_highway_3d/tests/data_layer.test.js` (10 total).
|
||||
- **3D Keys Highway: hit FX — vibrant note gems, timing-colored sparks, and a hit-line that reacts to your playing (parity slice 2).** The washed-out note look is gone: gem opacity is now driven by a **Note vibrancy** slider (default 0.85 → opacity 0.92, up from a fixed 0.8; lane guides scale with it too, live-applying without a chart rebuild) and the resting emissive glow rises 0.08 → 0.22, so the falling notes finally read saturated against the dark floor. Scored key presses fire a pooled additive **spark burst** at the struck key (guitar-highway port, pool 96) **colored by timing** — on-time green, early cyan, late amber, classified against the ±100 ms window with the inner 40% reading as on-time (the timing delta is recovered from the matched note's key, so `judgeHit`'s tested contract is untouched); the per-pitch-class flame sprite keeps its identity color so pitch and timing stay separate signals. With **Streak feedback** on, bursts grow with the combo. The **hit line kicks brighter** for a beat on every scored press (exponential decay, scaled by a 0–1 **Hit feedback intensity** slider). All new controls live in the plugin's Graphics settings (on by default, `keys3d_bg_*` keys, live-applying), and the spark pool is disposed with the scene like every other GPU resource. Tests: timing-classifier boundaries, the noteKey time round-trip that the delta recovery relies on, and the new FX defaults (26 total).
|
||||
- **3D Drum Highway: bloom glow + adaptive-resolution support — the first slice of visual parity with the guitar highway.** The drum highway now renders through the same post-processing path as `highway_3d`: an `UnrealBloomPass` (strength 0.65, radius 0.5, threshold 0.82 — high, so only emissive/bright surfaces bleed) on a multisampled HalfFloat target with ACES filmic tone mapping, so the white hit-line bar and proximity-lit notes get a real glow instead of a flat emissive tint. **On by default**, with a new **Graphics → "Glow (bloom)"** toggle in the plugin settings (`drum_h3d_bg_bloom`, applies live, no reload); if the vendored postprocessing addons can't load (older self-hosted core), the plugin silently falls back to the direct render path. The plugin also now honors the host's **adaptive render scale** (`bundle.renderScale` — the Quality/"Min res" controls that the guitar highway already respected), multiplying it into the device pixel ratio, and caps DPR at 1.25 when more than one viz instance is live (splitscreen) so two panels don't double the GPU fill cost. Groundwork for the rest of the parity series: an FX-settings scaffold (`FX_DEFAULTS`/`readFxSettings`/`window.drumH3dSetFx`, `drum_h3d_bg_*` localStorage keys) that the sparks/themes/backgrounds PRs extend, plus a first node test suite for the plugin (`plugins/drum_highway_3d/tests/data_layer.test.js` — vm-loaded like the keys plugin's, covering the hit-variant precedence, the Auto-mode steal-guard predicate, and FX defaults; 8 tests, runs in CI via the `plugins/*/tests/*.test.js` glob).
|
||||
- **3D Keys Highway: sharp HiDPI rendering, bloom glow, a live combo HUD, and a graphics settings panel — the first slice of visual parity with the guitar highway.** The biggest single fix is resolution: the plugin never called `setPixelRatio`, so on HiDPI/retina displays (and Windows display scaling) it rendered at CSS resolution and was upscaled — soft and aliased. It now multiplies the device pixel ratio (capped at 2, or 1.25 when two viz panels are live in splitscreen) with the host's **adaptive render scale** (`bundle.renderScale`, the Quality/"Min res" controls), exactly like `highway_3d`. On top of that: the same **bloom** post-processing path as the guitar highway (UnrealBloomPass 0.65/0.5/0.82 on a multisampled HalfFloat target + ACES tone mapping — the cyan hit-line, hit flames and the sustain "consume" glow finally bleed light instead of reading flat), **on by default** with a graceful direct-render fallback when the vendored addons can't load. The plugin gains its first **settings panel** (`settings.html`, Settings → graphics category, `"settings"` block in plugin.json) with a live-applying "Glow (bloom)" toggle (`keys3d_bg_bloom`), plus the FX scaffold (`FX_DEFAULTS`/`readFxSettings`/`window.keys3dSetFx`, `keys3d_bg_*` keys) the later parity PRs extend. And the score state the plugin was already tracking is finally visible: a **combo / accuracy / best-streak HUD** overlay (drum-highway pattern), shown only while a MIDI keyboard session is wired so it never renders a frozen 0× combo. Tests: `plugins/keys_highway_3d/tests/fx_settings.test.js` (defaults, localStorage overrides + type coercion, setter persist/dispatch/unknown-key guard; 3 tests alongside the existing 20).
|
||||
- **The 3D Drum Highway and 3D Keys Highway are now bundled core plugins** (`plugins/drum_highway_3d/`, `plugins/keys_highway_3d/`), imported from their former standalone repos (`feedBack-plugin-drum-highway-3d`, `feedBack-plugin-keys-highway-3d`, now archived) via `git subtree` so their history is preserved. They join the other in-tree plugins-as-plugins: the loader treats them identically to user-installed ones, both are marked `"bundled": true` in their manifests, and `.gitignore` gains the matching `!plugins/<id>/` exceptions. This puts all three 3D highways (guitar, drums, keys) in one repo ahead of a visual-parity pass that ports the guitar highway's polish (bloom, sparks, themes, reactive backgrounds) to the other two — shared helper code and theme tables can now be reviewed and kept in sync in a single place. The keys plugin's existing node test suite is wired into CI (the JS test step gains a `plugins/*/tests/*.test.js` glob, +20 tests), and `static/tailwind.min.css` is regenerated since the core Tailwind build scans `plugins/**`. One deliberate behavior change ships with the bundling: the drum highway's Auto-mode predicate is **narrowed** (it used to claim any pack with `has_drum_tab` — a pack-level flag — which, now that the plugin ships to everyone and sorts before `highway_3d` in first-match-wins Auto order, would have stolen full-band packs from the guitar highway even on Lead/Bass arrangements; it now claims only drum arrangements, or packs nothing more specific can render). Picking the drum highway manually from the viz picker is unchanged.
|
||||
- **The tuner now tracks what tuning your instrument is *actually* in, so it prompts you to retune in BOTH directions — down to a song's tuning, and back up when the next song needs it.** The coverage check used to compare each song against your fixed instrument-profile tuning, so it only ever prompted you *away* from "home" (e.g. E → Drop C#) and stayed silent coming back (Drop C# → E), even though you'd physically retuned. It now reads the host's live **per-instrument working tuning** (`window.feedBack.workingTuning`) — what your selected instrument is currently in — so coverage is measured against your *actual* tuning and fires both ways. When you clear an auto-opened tuner, the tuner publishes that song's tuning as your instrument's live working tuning (`assumed` — an explicit "I tuned / Skip" refines it in a later PR), so the next song is judged against where you now are. **Per-instrument** — your guitar's and bass's tunings are tracked separately (keyed like the selector), so switching instruments uses the right one. Feature-detected: on a host without the working-tuning capability it falls back to the static `/api/settings` tuning (today's behavior). `plugins/tuner/screen.js` (`_playerTuning` reads `workingTuning` keyed by the selected instrument; `_publishWorkingTuning` writes on clear). Builds on the host `workingTuning` foundation (PR 1 of the series) + the instrument→chart routing (PR 2). Tests: `tests/js/tuner_auto_open.test.js` (both-directions coverage via a live Drop-D working tuning; publish-on-clear targets the right instrument slot) — 29 pass.
|
||||
- **`.jsonc` support for feedpak data files** (feedpak-spec §8, FEP #3 / PR #13). Hand-edited packs may now use the `.jsonc` extension (JSON with C-style `//` line and `/* */` block comments) for any data file the manifest points at — arrangements, notation sidecars, `drum_tab`, `song_timeline`, `lyrics`, and `keys`. New shared `lib/jsonc.py` provides `parse_jsonc(text)` + `load_json(path)` (auto-detects `.jsonc` by suffix, string-aware so comment-like text inside JSON string values is preserved) and is now used by every reader in `lib/sloppak.py` (six side-file sites) and `scripts/lift_keys_notation.py` (three arrangement / song_timeline read sites). The strip regex mirrors the reference validator in `feedpak-spec/tools/validate.py`. This is an additive (MINOR) change: `.jsonc` is opt-in, so any pack that keeps its data files as `.json` is unaffected and needs no regeneration. Note that a `.jsonc` file containing real comments only loads on a reader that implements §8 — a pre-this-change reader calls bare `json.loads` and fails on the comments rather than ignoring them, so don't hand out `.jsonc` packs to older hosts. Tests: `tests/test_sloppak_jsonc_load.py` (covers all six side-file types, the lift helper, and the string-boundary preservation rule end-to-end).
|
||||
- **The highway now loads the part that matches your selected instrument — a bass player gets the Bass arrangement, not the default Lead/guitar chart.** When you open a song without an explicit arrangement, the WebSocket handler (`server.py` `highway_ws`) reads your selected `instrument` from `config.json` (the same file it already reads for your default-arrangement preference) and routes to the matching part: **bass → the Bass arrangement**; guitar — and any unknown/future instrument (drums, keys) — falls through to the existing preference/most-notes default, which already lands on a guitar part. Previously the instrument selector only fed the tuner, so a bass player was handed a guitar chart (and a tune/coverage check then compared a 4-string bass against a 6-string part). An **explicit arrangement request always wins** (a manual arrangement switch is untouched), and a bass player's saved default-arrangement preference is still honored **within** the bass parts (so a preferred `Bass 2` / `Alt. Bass` wins over the canonical Bass), so this only changes the *default* part chosen on load. Server-only — every launch path already flows through the WS, so there's no client change. This is the instrument↔chart-routing piece the working-tuning series leans on (otherwise coverage compares across instruments). Tests: `tests/test_highway_ws_instrument_routing.py` (bass→Bass, bass-honors-pref, bass-no-bass-part→guitar, guitar→default, explicit-wins).
|
||||
- **Host "working tuning" — a live, app-wide record of what tuning your instrument is *actually* in right now (foundation; no behavior change yet).** Introduces `window.feedBack.workingTuning`, a host-owned, session-lived state distinct from any one song's tuning and from a soft opt-in default: the offsets + string-count + reference pitch the player's instrument is currently in, plus an `assumed`/`verified` provenance flag. It's **per-instrument** — your guitar's current tuning and your bass's are kept *separately* (keyed like the instrument selector, e.g. `guitar-6` / `bass-4`), so switching instruments surfaces that instrument's own remembered tuning and you only ever deal with the one you've selected. It exists so a retune — or an instrument swap mid-session — is reflected **everywhere** (the highway, the library/song-picker, and plugins like the tuner, Virtuoso, and the minigames) instead of being re-derived per surface or wrongly assumed from a fixed profile. Modeled on the shipped `tuning` capability + the `feedBack.theme` read-API: a **synchronous `get(instrument?)`** (returns the selected instrument's state, defaulting to the seed until known), a `set(state, {provenance, instrument})` mutator (the tuner becomes the sole writer in the next change), `setCurrentInstrument()` for the selector, `resetToDefault()`, and a `working-tuning-changed` event that fires on every change **and once on hydration** (carrying which instrument changed) so a late-mounting consumer is never stuck on stale state. State is **in-memory, seeded from `/api/settings` on boot and reset on restart** — a stale "you're in drop-A" assumption is worse than re-asking. Registered as a separate `working-tuning` **exclusive-owner** capability (tuner = writer, the rest = requesters). This is the foundation (plumbing only — nothing writes to it yet) of the working-tuning series, which fixes the tuner gate only ever prompting *away from* a fixed "home" tuning (never back) and makes the current tuning a first-class signal the whole app shares. Offsets use the same per-string semitone vocabulary as song tunings, so fully custom/extended tunings (e.g. a drop-A 8-string) are first-class. Frontend-only: new `static/capabilities/working-tuning.js`, loaded from `static/index.html` + `static/v3/index.html`.
|
||||
- **The v3 Songs grid is now DOM-virtualized — card-node count stays bounded no matter how big the library is or how far you scroll.** The grid used to append every scrolled page and never let go, so a 2000-song library grew the DOM from 24 → 624 → 2001 card nodes as you scrolled (layout/memory cost scaling with depth). It now renders only the **visible window** of cards (± a small overscan); a sizer element sized to the whole library (`ceil(total/cols) × rowH`) gives the scrollbar its full geometry while the grid is absolutely positioned to the first visible row. `state.songs` is a sparse, absolutely-indexed store fetched a page at a time on demand — using the stage-1 **keyset cursor** for contiguous forward scroll (O(page)) and falling back to `OFFSET page=` for jumps/restore/non-keyset providers (collections, remote). Verified bounded (~60 nodes for a 2001-song library while the count still reads "2001 songs"). The **A–Z rail now seeks directly**: `sort_letters` gives a letter's first-row index (cumulative of prior buckets), converted to a scrollTop in O(1) — no more paging through every intervening row (a bounded forward scan covers the rare legacy provider without `sort_letters`). Select-mode selections, accuracy badges, the ⋮ card menu, plugin card actions, scroll-restore (now scrollTop-based, since geometry is stable), and the tree/folder views all survive cards leaving and re-entering the DOM. Plugins that decorate cards get a stable `window.v3Songs.visibleCards()` accessor + a `v3:library-window-rendered` event instead of assuming every card is present (the highway-stutter lesson). Stage 2 of the virtualized-grid project (got-feedback/feedBack#636 item 3), building on the stage-1 keyset data layer below. Frontend-only: `static/v3/songs.js`, `static/v3/v3.css`. Tests: `tests/browser/v3-grid-virtualization.spec.ts` (bounded-DOM invariant across a 2001-song scroll + direct rail jump), updated `tests/js/v3_az_rail.test.js` + `tests/js/v3_songs_scroll.test.js`.
|
||||
- **Keyset (cursor) pagination for the library grid — the data layer for an upcoming virtualized grid, and a latent paging bug fixed along the way.** Every library sort now carries a unique `filename` tiebreak, making the order **total** — which fixes a latent bug where rows sharing a sort key (e.g. two songs by the same artist) could be skipped or duplicated across `OFFSET` pages. `GET /api/library` gains an opaque `after` cursor + a `next_cursor` in the response: passing the cursor back fetches the next page with a **WHERE-seek** instead of `OFFSET`, so deep paging is O(page) regardless of depth. The seek is NULL-aware and exactly `OFFSET`-equivalent (verified across artist/title/recent, ascending + descending, including the legacy `dir=desc` shape and NULL sort keys); unknown/compound sorts and bad cursors fall back to `OFFSET`, and only the local provider is handed a cursor (collections/remote page by `OFFSET`). New composite `(artist NOCASE, filename)` / `(title NOCASE, filename)` / `(mtime, filename)` indexes cover the order. This is stage 1 of the virtualized-grid project (got-feedback/feedBack#636 item 3); the DOM-recycling render window builds on it next. Tests: `tests/test_library_keyset.py` (keyset==OFFSET parity, stable tiebreak, dir=desc, NULL keys, cursor fallback).
|
||||
- **Smart collections — save a set of library filters as a live, auto-updating source.** A collection is a saved `/api/library` query (e.g. "Drop-D tunings", "sloppak only", "recently added") that stays live: it's registered as a **library provider**, so it shows up in the v3 Songs source picker and inherits the whole grid UI — paging, stats, the A–Z rail, art — for free, with **no new screen**. Storage reuses the playlist subsystem (a `playlists.rules` JSON blob = a smart collection; membership is the live filter result, not stored songs, and collections are excluded from the manual-playlist list + read-only to playlist mutations). New `GET`/`POST`/`PUT`/`DELETE /api/collections`; a per-collection `SmartCollectionProvider` delegates `query_page`/`query_stats`/`query_artists` to the local DB with the stored rules applied; providers are re-registered from a boot scan so collections survive a restart. Rules mirror the raw `/api/library` query params (unknown keys dropped, never 500). Frontend: a "+ Save as collection" action in the v3 filter drawer (shown when filters are active) names the current filter set and switches to it. The charrette's "the homelab primitive FeedBack was missing" pick (got-feedback/feedBack#636 item 2); richer rule fields (accuracy, genre, difficulty) follow as the metadata work lands. Tests: `tests/test_collections_api.py`, `tests/js/v3_collections.test.js`.
|
||||
- **The settings backup now includes your library database + custom art — your scores, favorites, playlists, and play history are no longer the one thing a backup can't save.** `GET /api/settings/export` gains an additive `core_server_files` section carrying a **consistent snapshot of `web_library.db`** (taken via the SQLite online-backup API, so it's a complete single file even while the server is running) plus any custom **playlist covers** and **avatar** (`CONFIG_DIR/playlist_covers/`, `CONFIG_DIR/avatars/`). On `POST /api/settings/import` the database is **staged** to `web_library.db.restore` rather than written over the live, open DB; it's swapped in at the next startup (`_apply_pending_db_restore`, before the connection opens), which also clears the old WAL sidecars so a stale `-wal` can't be replayed onto the restored file — the import response sets `restart_required: true` and warns accordingly. Custom art is written immediately. The bundle stays backward-compatible (older servers ignore the new section). Came out of the library design charrette (dev-ops lens's top "protect irreplaceable data" pick, got-feedback/feedBack#636). _Known gap:_ custom uploaded **song** art is still commingled with the rebuildable thumbnail cache in `art_cache/`, so it isn't bundled yet (a tracked follow-up). Tests: `tests/test_settings_export_library_db.py` (snapshot consistency, staged-not-live restore, sidecar clearing, traversal rejection, full round-trip).
|
||||
- **A persisted wishlist — keep a list of songs you want but don't own yet.** New `wanted` table + `GET`/`POST`/`DELETE /api/wanted` give FeedBack the *arr-style "Wanted/Monitored" primitive it was missing: an entry is a *not-owned* song (artist/title/source/source_ref/note), so it lives in its own table rather than the playlist subsystem (which references owned local files). The API is idempotent on identity (case-insensitive artist+title, plus source+source_ref), so a producer — the `find_more` ownership-diff, or a manual add — can re-post without duplicating. Newest-first. Backend primitive for the charrette's wishlist finding (got-feedback/feedBack#636 item 4); the consuming UI lives in the producing plugin. Tests: `tests/test_wanted_api.py`.
|
||||
- **Practice-aware library home — a "Repertoire" meter + a "Keep practicing" shelf on the v3 Songs page.** The library opened cold into a flat sorted grid; now the unfiltered grid front door leads with two practice-aware surfaces built entirely from data already on hand (no new endpoints or stored state). A **Repertoire meter** shows how much of your library you can actually play — *"Repertoire: 12 of 80 songs · 7 in progress"* with a progress bar — counting songs at or above the same mastery threshold the green accuracy badge uses (≥ 90% best accuracy) over the unfiltered library total. A **"Keep practicing" shelf** is a horizontal row of your recently-played-but-not-yet-mastered songs (newest first, click to play) — the practice-accuracy-driven "continue" rail a media server can't do. Both reuse `/api/stats/best` (already loaded for the card badges) + `/api/stats/recent`; they show **only** on the grid view when you aren't searching/filtering/selecting, refresh after a song is scored, and collapse to nothing on an empty library. Soft-gamification only — descriptive encouragement (goal-gradient / endowed-progress), never content-gating, decay, or nagging. Frontend-only: `static/v3/songs.js` (`renderLibraryHome`/`_repertoireCounts`), `static/v3/v3.css`. Came out of the library design charrette (the UX + gamification lenses' top pick). Tests: `tests/js/v3_keep_practicing.test.js`.
|
||||
- **A–Z fast-scroll rail on the v3 Songs grid.** A vertical letter rail (Plex/Radarr/iOS-contacts pattern) pinned to the right edge next to the scrollbar lets you jump the library to a starting letter — tap a letter, drag to scrub with a live letter bubble, or arrow-key between letters. It shows **only** for the grid view + alphabetical (artist/title) sorts, and only offers letters actually present in the current sort **and filter set**, so a tap always lands on a real card (absent letters are dimmed + non-interactive). Because the grid is forward-only, server-paged infinite scroll, a jump pages through to the target card and scrolls to it (a newer jump supersedes an in-flight one); a keyset-seek + virtualized window is the noted scaling follow-up for very large libraries. Backend: `/api/library/stats` now accepts `sort` and returns an additive `sort_letters` map (songs-per-first-letter of the active sort column — artist or title), filter-synced; the legacy `letters` (distinct-artist) field is unchanged for the dashboard + classic tree. Frontend: `static/v3/songs.js` (`refreshRail`/`jumpToLetter`, cards tagged with `data-letter`), `static/v3/v3.css` (`.v3-azrail`). The classic (v2) tree already had letter selection; this brings the new grid to parity. Tests: `tests/test_library_filters.py` (sort_letters artist/title + song-vs-artist counting), `tests/test_library_providers.py` (sort forwarded to providers), `tests/js/v3_az_rail.test.js`.
|
||||
- **Playlists get content-dependent covers + custom art.** Playlist cards were a tiny `🎵` emoji on an empty square. Now a playlist's cover reflects its contents: **empty → the icon**, **a few songs → the first song's album art**, **4+ songs → a 2×2 art mosaic**. You can also **upload a custom cover** (a "Cover" button in the playlist detail view → image picker; "Remove cover" reverts to the content view). `MetadataDB.list_playlists()` now returns each playlist's first few song `art_urls`; `GET /api/playlists` and `GET /api/playlists/{id}` add `cover_url` when a custom cover exists. New routes `POST` / `GET` / `DELETE /api/playlists/{id}/cover` store a small PNG thumbnail under `CONFIG_DIR/playlist_covers/` (PIL-converted, like song-art upload); the cover is removed with the playlist. Frontend: `playlistCoverHtml(p)` in `static/v3/playlists.js`. Tests: `tests/test_playlists_api.py` (art_urls + cover roundtrip / reject-non-image / delete-cleanup), `tests/js/v3_playlist_cover.test.js`.
|
||||
- **v3 Songs: "Add to playlist" is now on each song's ⋮ "More" menu.** Previously a song could only be added to a playlist through select-mode (the checkbox → batch bar). The per-card overflow menu now has an **Add to playlist** row that targets that one song, reusing the same picker (choose a listed number or type a new name to create it). The select-mode batch flow and the single-song menu now share one extracted `addFilenamesToPlaylist(filenames)` helper in `static/v3/songs.js` (both grid and tree rows, since they share `openCardMenu`). Tests: `tests/js/v3_add_to_playlist_menu.test.js`.
|
||||
- **Resume where you left off — leaving a song now snapshots your place so an exit is recoverable, not a restart-from-zero.** Exiting the player (`showScreen()` teardown, before audio unload) writes `{song, arrangement, position, speed}` to `localStorage` (`feedBack.resumeSession`), and a non-blocking **"Resume practice"** pill offers it back on the next non-player screen (and on the next app launch). Clicking Resume re-enters the song, restores the arrangement + playback speed, and seeks to the saved position via the existing `_audioSeek` funnel; `playSong()` gains a `{ resume: {position, speed} }` option that arms a `song:ready`-consumed restore instead of the normal autostart, so the two never fight over playback. The snapshot is deliberately conservative — ignored for a song you barely started (< 3s) or had basically finished (within 5s of the end), cleared on natural song-end and once consumed, and expired after 24h. The pill is self-contained (inline-styled, body-appended, works identically in the classic and v3 shells with no Tailwind rebuild), never blocks, and a dismiss forgets the current snapshot for the session. This pairs with the Escape focus fix: now that Escape reliably leaves regardless of focus, an *accidental* exit is one tap to undo. Public surface: `window.resumeLastSession()` / `window.feedBack.resumeLastSession`. (The broader nav-state work — returning to a song after wandering into Settings → Tone Builder — is a separate, larger track; this lands the player-session slice.) Tests: `tests/browser/resume-session.spec.ts` (snapshot guards, staleness, pill show/hide/dismiss, resume consumption).
|
||||
- **Optional "Ask before leaving a song" confirm (Gameplay tab, default OFF).** A new client-only toggle (`confirmExitSong` in `localStorage`, in the v3 Gameplay settings + the Gameplay "Reset" set) for players who want a guard against an accidental exit. **Off by default — Escape leaves instantly, zero change for everyone else.** When on, a *user-initiated* exit (the player-scope Escape shortcut, or the player's ✕) opens a small true-modal confirm instead of leaving; auto-exit on song-end and a results screen's own Close are unaffected (they call `closeCurrentSong()` directly, which stays the unguarded actual-exit). The confirm honors the team's refined asks: **opening it pauses the song** (so it isn't running or being scored behind the prompt) and **Stay resumes exactly what was paused**; **Escape = Stay** — the dialog's capture-phase handler *dismisses* it (now consistent with every other modal and the generic `_confirmDialog`'s Esc=cancel), so a second Escape returns you to the (resumed) song rather than leaving; **Space/Enter (or click) Leave** by natively activating the default-focused "Leave" button ("just get me out"). Pause/resume run through the canonical `togglePlay()` path (HTML5 + `_juceMode`), guarded so a count-in, an already-paused song, or a teardown/seek/end behind the modal can't mis-resume. It's a real modal (`role="dialog" aria-modal="true"` / `.feedBack-modal`) with **Tab trapped inside it** and a **backdrop click that also Stays**, so the Escape/Space focus carve-outs treat it as a trap and don't fire player-back / play-pause behind it. The player Escape shortcut and the v3 ✕ route through a shared `window.requestExitSong()` gate (the ✕ also becomes origin-aware, matching Escape). Tests: `tests/browser/exit-confirm.spec.ts` (default-off instant exit, confirm-on opens + stays, second-Escape stays, backdrop stays, Stay/Leave, Enter-leaves); the audio pause/resume is verified manually on web + desktop (the mock song has no backing track).
|
||||
- **Folder Library — a bundled core plugin (`plugins/folder_library/`) that browses the DLC library by its on-disk folder tree.** Surfaces top-level folders → subfolders → songs (root-level songs land in `(Unsorted)`), with in-app folder management (create / rename / delete nested folders), song moves via dialog or drag-and-drop, and sort/filter that mirrors the host library's filter state. Wired into both the classic (v2) library toolbar and the v3 Songs page as a third **Folders** view alongside grid/tree; the plugin's `screen.js` is loaded once by the host and reused (idempotent IIFEs). Supersedes the former standalone "Folder Organizer" community plugin (removed from the README list). Backend (`routes.py`) registers `/api/plugins/folder_library/{tree,folder/create,folder/rename,folder/delete,song/move}`; **all filesystem mutations are confined to `DLC_DIR` and validated against path traversal** (per-segment name validation plus a resolved-containment check on `song/move`), and folder deletion relocates every song — de-duplicating colliding names — so a name clash never destroys a song. A two-level cache keeps re-opening folders fast. Tests: `tests/plugins/folder_library/test_routes.py` (path-safety helpers + move-traversal and delete-no-data-loss end-to-end).
|
||||
- **Full-screen (immersive) plugin screens — opt-in via `"fullscreen": true` in `plugin.json`.** DAW-style plugin UIs (e.g. a practice studio) need the whole viewport, not a scrolling content page below the topbar — embedded in the v3 shell they get cut off at the bottom with dead space up top. A plugin can now declare a top-level `"fullscreen": true`; `plugins/__init__.py` surfaces it as the `fullscreen` boolean on `/api/plugins` (mirroring the `settings_category` plumbing). When such a plugin's screen is active, `static/v3/shell.js` toggles `html.fb-immersive` from `syncActive()` (so it tracks every navigation incl. deep-link), and `static/v3/v3.css` hides the topbar, collapses the sidebar to a functional **icon rail** (kept reachable — Escape is bound only on player/settings scopes, so a fully-hidden sidebar would trap the user), and lets the active plugin screen fill `#v3-main`. Mirrors the existing `ss-follower-pre` chrome-hide pattern. Additive + opt-in: plugins without the flag are unaffected. Tests: `tests/test_plugins.py::test_fullscreen_flag_parsed_from_manifest`.
|
||||
- **Achievements wall sync — background drain worker (epic PR3, client side).** The bundled `achievements` plugin gains a dead-letter sync worker that POSTs queued Feat unlocks (and removals) to the hosted **feedback-achievements** wall service (separate repo). Idle unless a wall URL is configured (`FEEDBACK_ACHIEVEMENTS_WALL_URL`); uses `requests` with the baked-in client-token header, mirroring `lib/lyrics_transcribe`'s outbound pattern (explicit timeout, no raise on non-2xx). **Dead-letter, never drop** (pure `engine.drain_decision`): network error / `429` / `5xx` → keep `pending` (retry); other `4xx` → `dead_letter` (diagnosable, replayable); `2xx` → delete on server ack. A row leaves the queue only on ack or a user opt-out. `remove-me` now enqueues a wall removal keyed by the reused `player_hash`. Verified by an end-to-end staging round-trip (earn a Feat → drains onto the wall with name + short hash → `remove-me` → wall empties) with **no IP** in tables or access logs. Tests: `tests/plugins/achievements/test_sync.py` (decision table + ack/retry/dead-letter retention + four-field payload on the wire). The hosted service itself (FastAPI + SQLite-on-disk, Feats-only, hidden-until-first-global-unlock, profanity filter, in-memory rate limit, Render blueprint, migration tool) lives in the new `feedback-achievements` repo.
|
||||
- **Achievements wall — opt-in, privacy controls & data-minimization gate (epic PR2).** Sharing earned **Feats** on the (forthcoming) public wall is strictly opt-in. A new **onboarding step** (`static/v3/profile.js`, inserted after song-directory / before instrument paths — the wizard is now five steps) presents a plain-language card: it publishes only your display name and the Feats you earn, never songs/skills/scores, and is **off by default**. The bundled plugin's Settings panel (`plugins/achievements/settings.html`, mounted under the **System** tab via `settings.category`) carries the same toggle plus a **"Remove me from the wall"** button (`POST /api/plugins/achievements/remove-me` — wipes local synced state offline + enqueues a wall removal). Core adds `achievements_enabled` (bool, default `false`) to `_default_settings()` + the `/api/settings` validation block + `_RESETTABLE_SETTINGS_KEYS` in `server.py`, mirrored to `localStorage` in `app.js loadSettings()`. **Data-minimization contract (binding, code-enforced):** every outbound payload is built by a single explicit-dict serializer (`engine.build_wall_payload`, never `dict(row)`/`**model`) whose key-set is **exactly** `{display_name, player_hash, achievement_id, unlocked_at}` with `achievement_id` always a **Feat** id — a unit test asserts the four-field set and goes red on a fifth. Enqueue is doubly gated: it happens only when opted-in **and** a profile identity (name + the reused `player_hash`) exists; **competency unlocks never enqueue** (integration law). Tests: `tests/plugins/achievements/test_datamin.py` (key-set, opt-out/identity/competency gating) + `tests/test_settings_api.py` (flag persists/validates/resettable).
|
||||
- **Achievements & Feats of Power — local engine + tabbed Profile (epic PR1).** The Profile screen (`static/v3/profile.js`) becomes **tabbed** exactly like the v3 Settings page (`.fb-tabbar` / `.fb-tab[data-tab]` / `.fb-tabpanel[data-tab]`, active-tab persisted in `localStorage 'v3-profile-tab'`): a **Profile** (main) tab carrying the existing header + best-scores cards plus a new **Feats of Power** trophy shelf mount (`#v3-profile-feats-slot`, earned-only / hidden-until-earned), and an **Achievements** tab with a plugin mount (`#v3-profile-achievements-mount`) + `[data-empty-for]` empty note. Core dispatches a new **`v3:profile-rendered`** event after every render (mirrors `v3:settings-rendered`) so the plugin re-injects on each profile entry. A new bundled **`plugins/achievements/`** plugin owns the engine: SQLite under `<config_dir>/achievements/achievements.db` (`unlocks` / `counters` / `comp_ledger` / `sync_queue`), pure threshold/criterion math in the testable sibling `engine.py` (P-V), and routes under `/api/plugins/achievements/` (`activity`, `report-unlock`, `report-criterion`, `catalog`, `earned`, `feats`, `remove-me`). **Two surfaces, one engine, structurally separated (integration law):** **Feats** (activity/volume — Note Hunter, Marathon, Untouchable, Road Warrior, Time Served, Encore, two 🥚 secrets) read activity counters only, evaluated from a batched `song:ended` activity POST (notes only when **notedetect** is present — graceful degradation, no fake progress); **competency Achievements** (baseline: First Steps / Ascendant / Steady Hands / Renaissance + per-instrument Apprentice·Journeyman·Master / Personal Best / Challenger) are evaluated from **progression events only** and never re-derived from activity. The Achievements catalogue is always shown (locked = greyed), grouped by a secondary pill row over the **real progression paths** (Global / Guitar / Bass / Drums / Keys — auto-extends to new paths) with a per-category "X / Y earned" badge, defaulting to the player's primary path. Source plugins contribute their own competency defs and report unlocks through a versioned **`window.feedBack.achievements`** API (`register`/`registerAll`/`unlock`/`progress`), load-order-safe via the `window.__feedBackAchievementsPending` queue + an `achievements:ready` event (minigames pending-queue pattern); an absent source contributes nothing (no dead greyed rows). Opt-in publishing to a hosted Feats wall, the Settings privacy toggle, and the data-minimization gate land in epic PR2/PR3. Tests: `tests/plugins/achievements/test_engine.py` + `test_routes.py` (incl. the integration-law assertion that a competency unlock never reaches the Feats shelf).
|
||||
- **v3 settings page redesigned as a tabbed, card-row layout.** The single long scrolling settings screen becomes a horizontal tab bar (Gameplay / Audio / Graphics / Keybinds / Progression / Mic / Plugins / System) over card rows — each a leading icon + title + description with the control (toggle/dropdown/slider) on the right, plus a per-category "Reset" action. The markup lives in `static/v3/index.html` (so existing element ids keep hydrating through the unchanged `app.js` `loadSettings()`/`persistSetting()` path); a new `static/v3/settings.js` owns tab switching + active-tab persistence (`localStorage 'v3-settings-tab'`), the per-category reset, and a read-only **Keybinds** reference built from the live shortcut registry (`window.getAllShortcuts()`); styling is plain CSS in `static/v3/v3.css` (no Tailwind rebuild). **Plugins choose their settings tab** via a new optional `settings.category` field in `plugin.json` (`plugins/__init__.py` surfaces it as `settings_category`; `app.js` mounts each plugin's `<details>` panel into `#plugin-settings-<category>`, falling back to the generic Plugins tab) — `highway_3d` ships `category: "graphics"`; the out-of-repo notedetect/progression plugins should declare `"mic"` / `"progression"`. **New gameplay settings:** **Countdown before song** (a four-beat count-in before playback, wired end-to-end via the existing count-in engine + the song-start autostart path; key `countdown_before_song`, default off); **Miss penalty** (`miss_penalty`) and **Fail behavior** (`fail_behavior`) are persisted now but not yet consumed by scoring (shown with a "Not yet active" badge). "Note highway speed" surfaces the existing `master_difficulty` and stays in sync with the player-popover difficulty slider. New `POST /api/settings/reset` clears chosen keys back to defaults. Tests: `tests/test_settings_api.py` (new keys + reset), `tests/test_plugins.py::test_settings_category_parsed_from_manifest`, `tests/browser/settings-tabbed.spec.ts`.
|
||||
- **Full-mix audio exposed alongside stems for the stem mixer's auto-switch.** `lib/sloppak.py::load_song` now parses the optional manifest `original_audio:` key (the single pre-separation mixdown, e.g. `original/full.ogg`) into a new `LoadedSloppak.original_audio` field, with the same path-traversal guard and permissive "missing → disabled" posture as the `drum_tab` loader. The highway WS `song_info` frame additively carries three new fields next to `stems`: `original_audio_url` (served by the existing `/api/sloppak/{filename}/file/{rel_path}` endpoint, `None` when the pack ships stems only), `has_original_audio`, and `has_stems` (mirroring the `has_drum_tab`/`has_keys` flag convention). The stems plugin consumes `original_audio_url` to play the untouched single file while every stem slider is at unity and switch to the separate stems the moment one drops below 100%. **Migration notes:** the `song_info` message shape is a stable contract — these are purely additive; all existing fields are unchanged. `audio_url` still points at stem[0] when stems exist (it is only the degraded native fallback); the one behavioural change is that a stem-less, full-mix-only sloppak now sets `audio_url` to the full mix instead of emitting `audio_error`, so it plays natively.
|
||||
- **Autoplay & auto-exit — a global "click it, it plays; finish, you're back at the menu" option (default ON).** New single Settings toggle (`autoplayExit` in `localStorage`, surfaced in both the v3 and classic settings screens; absence of the key = enabled) that closes the friction at both ends of the play loop. **Autoplay:** `playSong()` previously loaded a chart paused, requiring a Play press; a one-shot flag armed per fresh load is now consumed by the next `song:ready` (highway.js) to auto-start via the existing `togglePlay()` path (HTML5 + `_juceMode` + count-in). Arrangement switches / seeks reuse the same `song:ready` event but never arm the flag, so they don't auto-restart. **Auto-exit:** on `song:ended`, core returns to the launching menu after a short grace delay — unless a visible full-screen results/dialog overlay is on top (detected via `[role=dialog][aria-modal]` / `.fixed.inset-0` with a `getClientRects()` visibility test that works for `position:fixed`), in which case the return is deferred so that score screen's own Close button (calling `window.closeCurrentSong()`) drives the exit. A plugin can also defer explicitly via the new `window.feedBack.holdAutoExit()` (called synchronously from its own `song:ended` handler — core's listener runs first). Both paths mean **no external plugin PR is required** for a results screen to be respected. **Context-aware destination:** the player's remembered origin (`_playerOriginScreen`) now honours any real launch screen instead of clamping to library/home/favorites, and a one-shot `window.feedBack.setReturnScreen(id)` override lets the lessons catalog (`static/v3/lessons.js`) send a finished lesson back to the lessons screen — not the song library — even though the external tutorials plugin owns the `playSong` call. Also exposes a read-only `window.feedBack.autoplayExit` getter for plugins. Songs and lessons share the same `playSong` → highway path, so both inherit the behaviour. Core-only (`static/app.js`, `static/v3/lessons.js`, both `index.html`s); the end-of-song score screen itself remains a plugin. Optional polish (not required — the overlay heuristic already covers it): external scoring/note-detection plugins (e.g. SlopScale) may call `holdAutoExit()` + `closeCurrentSong()` for an exact, heuristic-free handoff.
|
||||
- **"Song Editor" promoted to a first-class v3 sidebar item.** The editor
|
||||
plugin (`id: editor`) now gets its own dedicated sidebar entry — under the
|
||||
Library group, just below Songs — via the existing `PROMOTED_PLUGINS`
|
||||
mechanism in `static/v3/shell.js`, instead of being reachable only through
|
||||
the generic Plugins gallery. Gated on the plugin actually being installed
|
||||
(`renderPromotedNav` checks `/api/plugins`), so it appears only when the
|
||||
editor is loaded. The displayed label comes from the plugin's manifest
|
||||
`nav.label`.
|
||||
- **Guitar Pro → notation importer (`lib/gp2notation.py`)** (feedBack#825 WS4b, epic #828). Piano/keys tracks imported from Guitar Pro (GPIF: `.gpx` GP6 / `.gp` GP7-8) now produce real Sloppak Notation Format data (sloppak-spec §5.3) alongside the `midi = string*24 + fret` guitar wire encoding. `gp2rs_gpx.convert_file` writes a `<stem>.notation.json` sidecar next to each keys arrangement XML (best-effort — a notation bug never breaks the RS-XML conversion), and `gp2notation.attach_notation_to_sloppak()` is the assembly-side helper that renames it into `notation_<id>.json` + adds the per-arrangement `notation:` manifest sub-key. Voice→staff routing salvages the logic from PR #703 (whose `stf` wire-field approach this supersedes): GP voice position 0 → `rh` staff (`G2`), positions ≥ 1 → `lh` (`F4`); a forced-LH track (the merged `Piano LH` partner from `_find_piano_pairs`, or a standalone track named `… LH`) routes everything to `lh` — preserving authored hand crossings instead of inferring hands from pitch. Emits measures with absolute `t` from the bar-indexed tempo map, change-only `ts`/`tempo`/`ks`, and `beat_groups` for compound/irregular meters (6/8 → `[3,3]`, 9/8 → `[3,3,3]`, 5/8 → `[2,3]`, 7/8 → `[2,2,3]` — cf. the feedBack#261 denominator pitfalls); beats carry `dur`/`dot`/`tu`/`rest` from GP rhythms and notes carry absolute `midi` (String+Fret resolves via the string template's concert pitches, Tone+Octave via `(octave+1)*12 + step`) with `tied` continuations kept as real beats (engraving needs the tied notehead — unlike the RS-XML walk, which drops them and extends sustain). Timing reuses the `gp2rs_gpx` machinery (bar-indexed tempo map, per-beat rhythm durations, `_note_midi`) so notation lines up with the RS XML the highway plays — with one deliberate divergence: double dots advance time ×1.75 (vs. the RS-XML walk's single-dot ×1.5 approximation) so a written `dot: 2` agrees with the emitted beat times; sharing the walk itself stays tracked in feedBack#618. Tests: `tests/test_gp2notation.py`.
|
||||
- **Legacy keys → notation lifter (`scripts/lift_keys_notation.py`)** (feedBack#825 WS4c, epic #828). One-time batch converter that lifts existing **directory-form** piano/keys sloppaks from the legacy guitar wire encoding (`midi = s*24 + f`) into real Sloppak Notation Format files (sloppak-spec §5.3). Candidates are arrangements whose name matches `\b(keys|piano|keyboard|synth)\b` (case-insensitive); each gets a `notation_<id>.json` plus the per-arrangement `notation:` manifest sub-key. Measures derive from the song-level `beats` downbeats (`measure >= 0`; `song_timeline.json` preferred, first-arrangement fallback), with per-measure tempo from downbeat spacing (emitted only on a > 1 BPM change). Durations come from the wire sustain (`sus`, legacy `l` alias) when present, else the gap to the next onset in the same hand — quantized to the nearest plain/single-dotted `{1,2,4,8,16,32}` denominator at the local tempo, floored at a 32nd. Hands are split heuristically: onsets within 10 ms form a group; a group spanning > 12 semitones splits at its largest internal interval gap (low side → `lh`), otherwise the whole group goes by mean pitch vs middle C — single-staff output when everything lands on one hand. Idempotent (arrangements already carrying `notation:` are skipped; an orphan `notation_<id>.json` without the manifest key is refused, not overwritten) with `--dry-run` support; every payload is checked via `notation.validate_notation` before write. Honest caveat: the manifest is round-tripped through PyYAML (`safe_load` + `safe_dump(sort_keys=False)`) — key order survives, YAML comments/custom formatting do not (the script warns when comments are present). Zip-form `.sloppak` files are reported and skipped. Tests: `tests/test_lift_keys_notation.py`.
|
||||
- **Notation schema v1 freeze — completeness batch** (feedBack#822, epic #828). Adds the low-hanging-fruit fields ahead of content production: top-level credits `rights`/`lyricist`/`arranger`; measure `pickup` (anacrusis); beat `arp` (arpeggiate), `ferm` (fermata), and **typed grace notes** — `grace: "a"` (acciaccatura, MusicXML `grace/@slash=yes`) / `"p"` (appoggiatura); note `stem` (`"up"`/`"down"` force). Pedal is settled as the existing `spd`/`sph`/`spu` trio with a documented MusicXML `<pedal start|change|stop>` mapping — no separate `ped` field. A new "v1 non-features" spec subsection pins the accepted limitations (microtonal, figured bass, mid-measure key/time/clef changes, `ott`/`barline`/ornaments/`trem`/`glis`) as additive-v1.x territory. `lib/notation.py` gains the `GRACE_TYPES`, `STEM_DIRECTIONS`, and `DYNAMICS` vocabularies; the validator stays permissive by design.
|
||||
- **Notation format — standard musical notation as a first-class sloppak type.** Promotes keys, piano, violin, and any other staff-notation instrument out of the guitar wire format and into their own data structure, following the same promotion path used for drums (feedBack#344). New `lib/notation.py` defines the canonical vocabulary (`CLEFS`, `DURATIONS`, `SCHEMA_VERSION`), a permissive `validate_notation()` check, and `measures_to_wire()` / `measure_to_wire()` wire helpers. `lib/sloppak.py::load_song` reads a new per-arrangement `notation:` sub-key from each arrangement entry in the manifest (Option B: per-arrangement, not song-wide), applies path-traversal guards, validates the parsed JSON via `validate_notation()`, and surfaces all notation payloads on `LoadedSloppak.notation_by_id` (a `dict[str, dict]` keyed by arrangement id). A failed or missing notation file for one arrangement does not abort or skip the arrangement itself — partial-failure isolation mirrors the drum tab loader. `file:` is now optional when `notation:` is present: the loader creates a minimal stub arrangement so a notation-only arrangement entry does not require a guitar wire format JSON. `/ws/highway/{filename}` gains two new message types — `notation_info` (staves, instrument, total measure count) and chunked `notation_measures` (32 measures per chunk) — streamed after `sections` and before `anchors`; `song_info` carries a new `has_notation: bool` flag so viz pickers can auto-activate the notation plugin regardless of arrangement name. The notation file schema is measure-structured (`measure → staff → voice → beat → note`), uses MIDI for pitch (no string/fret/tuning indirection), and carries the full set of effects that alphaTab can render. See `docs/sloppak-spec.md` §5.3 for the full schema. Open questions resolved per the piano/keys epic (feedBack#828 / #822): Option B (per-arrangement `notation:` sub-key) and `file:`-optional-when-`notation:`-present are the endorsed design.
|
||||
- **Guitar Pro → notation importer (`lib/gp2notation.py`)** (slopsmith#825 WS4b, epic #828). Piano/keys tracks imported from Guitar Pro (GPIF: `.gpx` GP6 / `.gp` GP7-8) now produce real Sloppak Notation Format data (sloppak-spec §5.3) alongside the `midi = string*24 + fret` guitar wire encoding. `gp2rs_gpx.convert_file` writes a `<stem>.notation.json` sidecar next to each keys arrangement XML (best-effort — a notation bug never breaks the RS-XML conversion), and `gp2notation.attach_notation_to_sloppak()` is the assembly-side helper that renames it into `notation_<id>.json` + adds the per-arrangement `notation:` manifest sub-key. Voice→staff routing salvages the logic from PR #703 (whose `stf` wire-field approach this supersedes): GP voice position 0 → `rh` staff (`G2`), positions ≥ 1 → `lh` (`F4`); a forced-LH track (the merged `Piano LH` partner from `_find_piano_pairs`, or a standalone track named `… LH`) routes everything to `lh` — preserving authored hand crossings instead of inferring hands from pitch. Emits measures with absolute `t` from the bar-indexed tempo map, change-only `ts`/`tempo`/`ks`, and `beat_groups` for compound/irregular meters (6/8 → `[3,3]`, 9/8 → `[3,3,3]`, 5/8 → `[2,3]`, 7/8 → `[2,2,3]` — cf. the slopsmith#261 denominator pitfalls); beats carry `dur`/`dot`/`tu`/`rest` from GP rhythms and notes carry absolute `midi` (String+Fret resolves via the string template's concert pitches, Tone+Octave via `(octave+1)*12 + step`) with `tied` continuations kept as real beats (engraving needs the tied notehead — unlike the RS-XML walk, which drops them and extends sustain). Timing reuses the `gp2rs_gpx` machinery (bar-indexed tempo map, per-beat rhythm durations, `_note_midi`) so notation lines up with the RS XML the highway plays — with one deliberate divergence: double dots advance time ×1.75 (vs. the RS-XML walk's single-dot ×1.5 approximation) so a written `dot: 2` agrees with the emitted beat times; sharing the walk itself stays tracked in slopsmith#618. Tests: `tests/test_gp2notation.py`.
|
||||
- **Legacy keys → notation lifter (`scripts/lift_keys_notation.py`)** (slopsmith#825 WS4c, epic #828). One-time batch converter that lifts existing **directory-form** piano/keys sloppaks from the legacy guitar wire encoding (`midi = s*24 + f`) into real Sloppak Notation Format files (sloppak-spec §5.3). Candidates are arrangements whose name matches `\b(keys|piano|keyboard|synth)\b` (case-insensitive); each gets a `notation_<id>.json` plus the per-arrangement `notation:` manifest sub-key. Measures derive from the song-level `beats` downbeats (`measure >= 0`; `song_timeline.json` preferred, first-arrangement fallback), with per-measure tempo from downbeat spacing (emitted only on a > 1 BPM change). Durations come from the wire sustain (`sus`, legacy `l` alias) when present, else the gap to the next onset in the same hand — quantized to the nearest plain/single-dotted `{1,2,4,8,16,32}` denominator at the local tempo, floored at a 32nd. Hands are split heuristically: onsets within 10 ms form a group; a group spanning > 12 semitones splits at its largest internal interval gap (low side → `lh`), otherwise the whole group goes by mean pitch vs middle C — single-staff output when everything lands on one hand. Idempotent (arrangements already carrying `notation:` are skipped; an orphan `notation_<id>.json` without the manifest key is refused, not overwritten) with `--dry-run` support; every payload is checked via `notation.validate_notation` before write. Honest caveat: the manifest is round-tripped through PyYAML (`safe_load` + `safe_dump(sort_keys=False)`) — key order survives, YAML comments/custom formatting do not (the script warns when comments are present). Zip-form `.sloppak` files are reported and skipped. Tests: `tests/test_lift_keys_notation.py`.
|
||||
- **Notation schema v1 freeze — completeness batch** (slopsmith#822, epic #828). Adds the low-hanging-fruit fields ahead of content production: top-level credits `rights`/`lyricist`/`arranger`; measure `pickup` (anacrusis); beat `arp` (arpeggiate), `ferm` (fermata), and **typed grace notes** — `grace: "a"` (acciaccatura, MusicXML `grace/@slash=yes`) / `"p"` (appoggiatura); note `stem` (`"up"`/`"down"` force). Pedal is settled as the existing `spd`/`sph`/`spu` trio with a documented MusicXML `<pedal start|change|stop>` mapping — no separate `ped` field. A new "v1 non-features" spec subsection pins the accepted limitations (microtonal, figured bass, mid-measure key/time/clef changes, `ott`/`barline`/ornaments/`trem`/`glis`) as additive-v1.x territory. `lib/notation.py` gains the `GRACE_TYPES`, `STEM_DIRECTIONS`, and `DYNAMICS` vocabularies; the validator stays permissive by design.
|
||||
- **Notation format — standard musical notation as a first-class sloppak type.** Promotes keys, piano, violin, and any other staff-notation instrument out of the guitar wire format and into their own data structure, following the same promotion path used for drums (slopsmith#344). New `lib/notation.py` defines the canonical vocabulary (`CLEFS`, `DURATIONS`, `SCHEMA_VERSION`), a permissive `validate_notation()` check, and `measures_to_wire()` / `measure_to_wire()` wire helpers. `lib/sloppak.py::load_song` reads a new per-arrangement `notation:` sub-key from each arrangement entry in the manifest (Option B: per-arrangement, not song-wide), applies path-traversal guards, validates the parsed JSON via `validate_notation()`, and surfaces all notation payloads on `LoadedSloppak.notation_by_id` (a `dict[str, dict]` keyed by arrangement id). A failed or missing notation file for one arrangement does not abort or skip the arrangement itself — partial-failure isolation mirrors the drum tab loader. `file:` is now optional when `notation:` is present: the loader creates a minimal stub arrangement so a notation-only arrangement entry does not require a guitar wire format JSON. `/ws/highway/{filename}` gains two new message types — `notation_info` (staves, instrument, total measure count) and chunked `notation_measures` (32 measures per chunk) — streamed after `sections` and before `anchors`; `song_info` carries a new `has_notation: bool` flag so viz pickers can auto-activate the notation plugin regardless of arrangement name. The notation file schema is measure-structured (`measure → staff → voice → beat → note`), uses MIDI for pitch (no string/fret/tuning indirection), and carries the full set of effects that alphaTab can render. See `docs/sloppak-spec.md` §5.3 for the full schema. Open questions resolved per the piano/keys epic (slopsmith#828 / #822): Option B (per-arrangement `notation:` sub-key) and `file:`-optional-when-`notation:`-present are the endorsed design.
|
||||
- **`song_timeline.json` — beats and sections as a top-level file.** A new optional top-level file pointed at by a new manifest key (`song_timeline: song_timeline.json`) provides the correct home for song-wide beats and sections, replacing the legacy convention of embedding them in the first arrangement JSON. The loader in `lib/sloppak.py` reads and validates the file (must be a dict with `beats` and `sections` as lists), clears and repopulates `Song.beats` / `Song.sections` from it when present, and stores the raw dict on `LoadedSloppak.song_timeline`. The existing arrangement-JSON fallback is fully preserved: all existing sloppaks that omit `song_timeline:` continue to load without any change. This is a prerequisite for notation-only sloppaks, which may have no arrangement JSON at all and therefore no carrier for beats/sections data. New sloppaks should put beats/sections in `song_timeline.json` only. See `docs/sloppak-spec.md` §2 and §5.3.
|
||||
- **`note-detection` capability domain promoted — control plane (spec 009)** (feedBack#727/#728, epic #828). New core host `static/capabilities/note-detection.js`: provider registry (kinds `midi`/`engine`/`js`, primitives `pitch.estimate`/`verify.target`), requester-owned context-scoped detection bindings (`open-binding`/`close-binding`/`set-target`/`clear-target` — each binding carries its own redacted tuning context, independent of the host's loaded song, per spec-009 FR-003), and hit/miss/verdict observability events (consumers own judgment). The legacy chart-coupled `highway.setNoteStateProvider` surface keeps working and is wrapped for compatibility-shim hit accounting. Diagnostics (`feedBack.note_detection_capability.v1`) carry provider/binding summaries and bounded outcomes — no raw audio, device labels, or song identity. Migrating the chart path, Step Mode verify, minigames YIN, and the engine verifier onto bindings is the remainder of the spec-009 slice.
|
||||
- **`visualization` capability domain promoted (cap:6)** (feedBack#828). New core host `static/capabilities/visualization.js` registers a provider-coordinator owning the highway renderer surface: commands `inspect` / `list-providers` / `select-renderer` / `clear-renderer` (selection delegates to the existing picker so persistence, WebGL2 gating, and fallback stay single-sourced), events `providers-refreshed` / `renderer-changed` / `renderer-ready` / `renderer-failed`. Legacy discovery (`type: "visualization"` manifests, `window.feedBackViz_*` globals) keeps working unchanged and is accounted as compatibility shims with hit counts. `static/app.js` attributes every renderer change (auto-match / user-select / fallback) and auto-match outcomes into the domain. Diagnostics (`feedBack.visualization_capability.v1`) carry provider ids/labels/context types, active renderer + selection source, last auto-match outcome, and last failure — no song filenames/titles. Per-panel (splitscreen) selection is a tracked follow-up.
|
||||
- **Viz picker routes notation arrangements** (feedBack#826, epic #828). `window.feedBack.currentSong` gains `hasNotation` (sibling of `hasDrumTab`) from the `song_info` frame's `has_notation` flag, so notation viz plugins (Staff View, Keys Highway 3D) can gate `matchesArrangement` on data presence instead of arrangement-name heuristics. When a notation-only arrangement (no wire notes — `file:` omitted per sloppak-spec §5.3) falls through Auto with no notation plugin installed, the built-in highway still takes the canvas but the Auto label reads "no notation view installed" and a one-shot dismissable hint points at the visualization picker — never a silently blank board.
|
||||
- **Keys instrument path in progression** (feedBack#828). New `data/progression/paths/keys.json` (5 levels / 15 challenges at parity with the guitar path) plus keys-flavoured daily/weekly quest pool entries (`d.keys-one` "Ivory Tower", `w.keys-three` "Grand Recital"). `lib/progression.py::instrument_for_arrangement()` now attributes `type: piano|keys` arrangements — and names matching `keys`/`piano`/`keyboard`/`synth` on a word boundary — to the new `keys` instrument, so scored keys runs advance the path automatically. Purely content + attribution: no schema or API changes.
|
||||
- **v3 library: exact artist/album filters + scroll/page-depth restore** (feedBack#857). The v3 Songs toolbar gains Artist and Album dropdowns (Album populates from the selected artist and stays disabled until one is chosen), backed by new exact, case-insensitive (`COLLATE NOCASE`) `artist` / `album` query params threaded through `MetadataDB._build_where` → `query_page` / `query_artists` / `query_stats` and the `/api/library`, `/api/library/artists`, `/api/library/stats` endpoints (the free-text `q` search stays fuzzy and composes with the exact filters). The artist/album catalog is fetched independently of the active artist/album selection so the dropdowns always list the full set for the current provider/search. The toolbar is now sticky so filter controls stay reachable when browsing deep libraries, and returning from the player restores the previous scroll position **and** the loaded infinite-scroll page depth via a `sessionStorage` snapshot keyed by a filter/sort/view state hash (invalidated whenever those change, so a filter change still resets to the top). Tests: `tests/test_library_filters.py` (backend artist/album filters), `tests/js/v3_songs_scroll.test.js` (state-hash + snapshot helpers).
|
||||
- **`note-detection` capability domain promoted — control plane (spec 009)** (slopsmith#727/#728, epic #828). New core host `static/capabilities/note-detection.js`: provider registry (kinds `midi`/`engine`/`js`, primitives `pitch.estimate`/`verify.target`), requester-owned context-scoped detection bindings (`open-binding`/`close-binding`/`set-target`/`clear-target` — each binding carries its own redacted tuning context, independent of the host's loaded song, per spec-009 FR-003), and hit/miss/verdict observability events (consumers own judgment). The legacy chart-coupled `highway.setNoteStateProvider` surface keeps working and is wrapped for compatibility-shim hit accounting. Diagnostics (`slopsmith.note_detection_capability.v1`) carry provider/binding summaries and bounded outcomes — no raw audio, device labels, or song identity. Migrating the chart path, Step Mode verify, minigames YIN, and the engine verifier onto bindings is the remainder of the spec-009 slice.
|
||||
- **`visualization` capability domain promoted (cap:6)** (slopsmith#828). New core host `static/capabilities/visualization.js` registers a provider-coordinator owning the highway renderer surface: commands `inspect` / `list-providers` / `select-renderer` / `clear-renderer` (selection delegates to the existing picker so persistence, WebGL2 gating, and fallback stay single-sourced), events `providers-refreshed` / `renderer-changed` / `renderer-ready` / `renderer-failed`. Legacy discovery (`type: "visualization"` manifests, `window.slopsmithViz_*` globals) keeps working unchanged and is accounted as compatibility shims with hit counts. `static/app.js` attributes every renderer change (auto-match / user-select / fallback) and auto-match outcomes into the domain. Diagnostics (`slopsmith.visualization_capability.v1`) carry provider ids/labels/context types, active renderer + selection source, last auto-match outcome, and last failure — no song filenames/titles. Per-panel (splitscreen) selection is a tracked follow-up.
|
||||
- **Viz picker routes notation arrangements** (slopsmith#826, epic #828). `window.slopsmith.currentSong` gains `hasNotation` (sibling of `hasDrumTab`) from the `song_info` frame's `has_notation` flag, so notation viz plugins (Staff View, Keys Highway 3D) can gate `matchesArrangement` on data presence instead of arrangement-name heuristics. When a notation-only arrangement (no wire notes — `file:` omitted per sloppak-spec §5.3) falls through Auto with no notation plugin installed, the built-in highway still takes the canvas but the Auto label reads "no notation view installed" and a one-shot dismissable hint points at the visualization picker — never a silently blank board.
|
||||
- **Keys instrument path in progression** (slopsmith#828). New `data/progression/paths/keys.json` (5 levels / 15 challenges at parity with the guitar path) plus keys-flavoured daily/weekly quest pool entries (`d.keys-one` "Ivory Tower", `w.keys-three` "Grand Recital"). `lib/progression.py::instrument_for_arrangement()` now attributes `type: piano|keys` arrangements — and names matching `keys`/`piano`/`keyboard`/`synth` on a word boundary — to the new `keys` instrument, so scored keys runs advance the path automatically. Purely content + attribution: no schema or API changes.
|
||||
- **v3 library: exact artist/album filters + scroll/page-depth restore** (slopsmith#857). The v3 Songs toolbar gains Artist and Album dropdowns (Album populates from the selected artist and stays disabled until one is chosen), backed by new exact, case-insensitive (`COLLATE NOCASE`) `artist` / `album` query params threaded through `MetadataDB._build_where` → `query_page` / `query_artists` / `query_stats` and the `/api/library`, `/api/library/artists`, `/api/library/stats` endpoints (the free-text `q` search stays fuzzy and composes with the exact filters). The artist/album catalog is fetched independently of the active artist/album selection so the dropdowns always list the full set for the current provider/search. The toolbar is now sticky so filter controls stay reachable when browsing deep libraries, and returning from the player restores the previous scroll position **and** the loaded infinite-scroll page depth via a `sessionStorage` snapshot keyed by a filter/sort/view state hash (invalidated whenever those change, so a filter change still resets to the top). Tests: `tests/test_library_filters.py` (backend artist/album filters), `tests/js/v3_songs_scroll.test.js` (state-hash + snapshot helpers).
|
||||
|
||||
### Fixed
|
||||
- **GP8 multi-staff (piano/keys) tracks now import both hands — the bass stave was being silently dropped, and hand-splits landed on the wrong hand.** A GP8 grand-staff keyboard part is one `<Track>` with two `<Staff>` entries, and `MasterBar/Bars` lists one bar id per **stave**, not per track (`lib/gp2rs_gpx.py`). Two bugs fell out of assuming one stave per track: (1) the bar-column lookup used a raw `enumerate(Tracks)` index, so every track *after* a multi-stave track read the wrong column; (2) the string-tuning parse scanned all `.//Property` descendants and let the last stave's `<Tuning>` overwrite the first, so a treble note indexed against the 5-entry bass tuning fell out of range in `_note_midi` and was **dropped without a trace**. The importer now advances a bar-column counter by each track's stave count, reads tuning **per stave** (with a per-staff fall-back to the track-level property so an untuned staff never yields empty pitches), and folds **every** extra stave's notes into the arrangement (not just stave 1), keeping the `note_count` import-preview honest. A grand-staff track is now classified as keys end-to-end so the stave-0 and folded stave-1+ notes share one encoding. Separately, `notation_lift.split_hands` no longer forces a hard middle-C split when doing so produces a physically unplayable hand (e.g. a bass note under an Em7-shape voicing dipping below C4 would put a 19-semitone span in one hand) — it uses the middle-C boundary only when both resulting hands are within `HAND_SPLIT_SPAN_SEMITONES`, else falls back to the largest-gap heuristic. The GPX LH/RH pair merge and the GP8 stave fold now share one `_collect_column_notes` / `_merge_lh_notes` pair so the two formats can't drift in tie/timing/dedup handling. Companion editor change: got-feedback/feedBack-plugin-editor#38. Tests: `tests/test_gp2notation.py` (grand-staff fold + bar-column offset), `tests/test_notation_lift.py` (both middle-C split cases). Follow-up: `lib/gp_autosync.py` still carries the pre-fix bar-column + tuning logic (CLI/tests only, no production caller).
|
||||
- **Tuner auto-open is now opt-in and persists instead of flashing open-then-shut.** When you entered a song (or switched arrangement) whose tuning differed from the last, the tuner auto-opened and — for some testers — vanished ~1s later (reported macOS+Windows, 0.3.0). Root cause: the tuner closes itself on `song:play` (`plugins/tuner/utils/ui.js` — you don't tune while playing), so a **song switch** fired autoplay → `song:play` → the just-auto-opened tuner closed; an **arrangement switch** (which never arms autoplay) had no `song:play`, so it stayed open — exactly why two testers saw opposite behaviour (it wasn't the mic). Now: (1) the feature is a **new opt-in setting** ("Auto-open on tuning change", in the tuner's Settings panel, persisted as `autoOpenOnTuningChange`, **default OFF**); (2) an **auto**-opened tuner *persists* — it ignores the autoplay `song:play`, stray outside-clicks, and same-screen re-emits, closing only via the new in-panel **`×`** / **"Skip"** buttons or when you leave the song. A *manually* opened tuner keeps its classic click-away / play-to-close behaviour. The panel previously had no in-box close at all; this adds one (`×` + contextual Skip). All in the tuner plugin (`routes.py` config, `screen.js` gate + persist, `utils/ui.js` buttons + `song:play` guard, `settings.html` toggle) — **no core `app.js` changes**. Tests: `tests/js/tuner_auto_open.test.js` (opt-in gate, `{ auto: true }` persist mode, play/click-proofing). **Default (opt-in vs opt-out) is teed up for Byron to decide — flip one boolean.**
|
||||
- **Tuner auto-open is now tuning-coverage-aware — extended-range players aren't nagged for songs their instrument already covers.** With the opt-in auto-open on, it now prompts only when your **current physical tuning** (from your instrument selection in Settings) doesn't already cover the song. FeedBack is tune-to-song — the highway draws tab in the song's tuning — so the check aligns the song's open-string tuning string-for-string against your instrument: an **8-string F♯-standard** player gets **no** prompt for a 6- or 7-string standard song (its top strings already match those tunings), while a song needing an open string you don't have (e.g. a **Drop-A 7-string**, whose low A isn't an open string on an F♯ 8-string) **still** prompts. A whole-instrument reference difference also prompts — A440 vs A432, or an octave-down `centOffset` (which the auto-open now accounts for; it was previously ignored). The player's instrument is read from core **`/api/settings`** (the v3 instrument selector — a stable physical reference, not the tuner's song-tracking selection); when nothing's declared or the lookup is unavailable it falls back to a conservative prompt, so a real retune is never silently skipped. **v3-only** (the instrument selector is v3). All in the tuner plugin (`plugins/tuner/screen.js`) — **no core changes**. Tests: `tests/js/tuner_auto_open.test.js` (covered vs uncovered, the Drop-A case, reference-pitch mismatch, contiguous alignment). _Follow-up (E1.6): a passive "different tuning" badge cue that names the string(s) to retune, plus the splitscreen / no-usable-input guards._
|
||||
- **The tuner badge now passively flags when a song needs a different tuning — and names the retune.** Building on the coverage check: when you enter a song your current instrument doesn't cover, the topbar tuner badge gets an amber ring and a tooltip that **names the change** — e.g. *"retune B→A"* for a Drop-A song on an F♯ 8-string, or *"the reference pitch"* for an A440-vs-A432 mismatch. It's purely **advisory** (it never auto-opens the panel — tap the badge to tune), recomputed on `song:ready` and cleared when a new song loads or you leave the player. The retune diff comes from the tuner plugin's coverage report (`window._tunerAutoOpen.coverageReport` → `{ covered, retune: [{ from, to }], reference, cantCover }`); the cue is CSS-free (an inline ring + native tooltip — no Tailwind rebuild) and no-ops when the tuner plugin isn't installed. **v3-only.** Touches `static/v3/badges.js` (the cue) + `plugins/tuner/screen.js` (the report). Tests: `tests/js/tuner_auto_open.test.js` (the report names the strings; reference mismatch; the badge wiring). _(The splitscreen-suppress and no-usable-input guards move to the playback-gate stage, where they matter for its no-trap rule.)_
|
||||
- **Tuner auto-open can now gate playback until you've tuned — the "tune before you play" model — via a new core `holdAutoplay()` hook.** With the opt-in auto-open on, when a song needs a retune the tuner opens and **playback waits** for your choice — **Skip** (you've tuned → play, and record the song's tuning as your instrument's current working tuning), **Back to library** / **Esc** (leave the song; a gated retune is never a one-way trap), or press **Play** (always wins). For an auto-open the in-panel **×** is dropped — Skip / Back to library / Esc are its dismiss surface. Previously the song played with the tuner overlaid; now it holds — which also definitively kills the original flash, since autoplay's `song:play` can't fire while playback is held. Implemented as a small **core hook** `window.feedBack.holdAutoplay()` (mirrors the existing `holdAutoExit()`): a plugin claims it **synchronously on `song:loading`** (so it beats the `song:ready` autostart), and `release()` — or a **12-second fail-open backstop** — runs the deferred start. **Generation-guarded** (a new song invalidates a stale hold) and **fail-open** (a wedged or crashed plugin can never permanently strand a song); **manual Play always wins** (it doesn't flow through the autostart path). The tuner claims the gate only when the feature is on, and **releases it the instant** it decides not to open (song already covered / tuning unchanged) or when you Skip. Touches core `static/app.js` (the hook + an autostart refactor) and the tuner plugin (`plugins/tuner/screen.js` — the claim/release; `plugins/tuner/utils/ui.js` — the Skip / Back-to-library buttons, × dropped on auto-open); the hook is generic and shell-agnostic (a test asserts `app.js` still doesn't reference the tuner's internals). Tests: `tests/js/tuner_auto_open.test.js` (claim on `song:loading`, release on dismiss, feature-off no-claim, the core hook + fail-open backstop, the Skip / Back-to-library / Esc escape-hatch) + a `speed_reset.test.js` stub. ⚠️ **Needs a manual smoke-test before shipping** — this is a core playback change; verify on desktop that the tuner mic doesn't contend with note_detect's scoring input (ASIO/exclusive mode), per the design charrette.
|
||||
- **v3 Songs List View: favoriting a song now turns the heart red immediately (no re-search needed).** In the tree / "List View" (Songs → List → expand an artist), clicking the heart flipped the glyph ♡→♥ but it stayed dim grey until you re-searched the library — reported on macOS + Windows, open since 0.3.0 / 2026-06-25. One shared `wireCards()` `[data-fav]` handler (`static/v3/songs.js`) serves both the grid card and the List-View row, but the two render with different idle colours — grid `text-white`, List View `text-fb-textDim` — and the handler only ever removed the grid's `text-white`. So in List View the row kept `text-fb-textDim` alongside the freshly-added `text-fb-accent`, and the dim class won by CSS source order (glyph changed, colour didn't). Each heart now declares its idle colour via a `data-fav-idle` attribute and the handler swaps exactly that class, so only one colour class is ever present; the handler also writes the new state back onto the in-memory song model so a re-render / virtualized-grid recycle agrees instead of reverting. Tests: `tests/js/v3_favorites_toggle.test.js`.
|
||||
- **v3 Songs A–Z rail: taps now land reliably, a drag releases exactly on the let-go letter, and the rail is large enough to hit on hi-res displays.** Follow-up to the rail's debut (#634); three bugs reported on macOS + Windows (0.3.0, 2026-06-29): a tap often did nothing ("clicked O, nothing happened"), a drag "got you kind of there but where you release isn't where you get sent," and the rail was "way too small" at 1440p and didn't scale with resolution. Root causes & fixes, all in `static/v3/songs.js` + `static/v3/v3.css` (`bindRailOnce`/`jumpToLetter`/`.v3-azrail`): (1) **taps** — `pointerdown` calls `setPointerCapture`, after which the browser **retargets the follow-up `click` to the rail container**, so the click handler's `closest('.v3-azrail-letter')` resolved `null` and a plain tap (no `pointermove`) had no other path → no-op. The jump is now driven from `pointerdown` itself (seek on press); the `click` handler is reduced to **keyboard activation only** (`e.detail === 0`, Enter/Space). (2) **drag precision** — every letter crossed fired `jumpToLetter` with `behavior:'smooth'`; stacked smooth-scroll animations over the virtualized grid lagged and settled short of the release. `jumpToLetter(letter, smooth)` now scrolls **instantly while scrubbing** (`'auto'`) and only animates discrete taps/keyboard jumps, so the grid tracks the finger and the release lands on the let-go letter. (3) **size** — the letters were a fixed `.62rem` glued at `right:2px` (~13px-tall target on the screen edge); they now scale with the viewport (`clamp(.72rem, 1.4vh, 1.05rem)`), sit off the edge with taller/wider equal-width hit targets and a hover/active highlight so the scrub target is visible. Keyboard arrow-nav + the present-letter gating are unchanged. Reported by =Scr4tch= and MajorMokoto. Tests: `tests/js/v3_az_rail.test.js` (pointerdown-seek, keyboard-only click guard, instant-vs-smooth scroll).
|
||||
- **v3 player: opening another rail popover now closes the Section Practice popover (no more two stacked popovers).** Opening the **Practice** pill's popover and then clicking a different player-rail icon (e.g. **Plugins**) left the Practice popover open underneath the new one — looked broken (reported on macOS, 0.3.0 / 2026-06-28). The rail icons call `e.stopPropagation()` in their click handler (`static/v3/player-chrome.js`), which killed bubbling before it reached the Practice popover's outside-click dismiss bound on `document`. The dismiss (`_installSectionPracticeDismiss` in `static/app.js`) now binds in the **capture phase**, which runs before the target's handler so a descendant's `stopPropagation()` can't swallow it — mirroring how the audio-mixer popover already dismisses. Esc handling stays bubble-phase (the player's Escape-to-exit ordering is unchanged). v2 shares `app.js` and is only hardened (no rail `stopPropagation` there). Tests: `tests/js/section_practice_dismiss.test.js`.
|
||||
- **v3 UI no longer lets you accidentally text-select the chrome.** Dragging or double-clicking across the interface used to marquee-highlight buttons, labels, the sidebar, the transport, and the note-highway HUD — which looks broken (reported on Mac + Windows). The v3 shell now defaults to `user-select: none` on `html` (`static/v3/v3.css`), then opts *content* back in — so chrome is non-selectable but the text you actually copy still works. Decided by a 4-lens panel (UX / accessibility / dev-ops / plugin-ecosystem); the guardrails are deliberate: **form fields are always re-enabled** (never break the caret / IME — no `* { user-select:none }`, which trips a WebKit input bug); **plugin screens (`.screen[id^="plugin-"]`) stay selectable by default** so a plugin's copyable text (lyrics, chord names, results) — including community plugins that don't know about this — isn't silently locked; and **core read-only content opts back in by container** via a new hand-authored **`.fb-selectable`** class — applied to the whole **Settings** panel (paths, device names, version, diagnostics, About — answering "is settings still copyable?": yes), the **now-playing song metadata** (with `pointer-events` re-enabled so the HUD text is actually reachable), and the focused **modals / dialogs / toasts / scan banner** that carry copyable errors, IDs, paths, and file names. It's cosmetic only (it protects nothing) and never used to lock copy-worthy text — errors, IDs, paths, versions, and metadata stay selectable per WCAG 2.2 (copy-paste as a permitted mechanism). Dense card lists (library grid, dashboard, profile) stay non-selectable by design — making them selectable would reintroduce the marquee-mess across cards. **v3-only** (v2 unchanged); plain CSS, no Tailwind rebuild; no desktop changes (standard OS-framed window). Plugin authors: `.fb-selectable` is documented in `CLAUDE.md` for re-enabling copyable content rendered outside a plugin screen. Tests: `tests/js/v3_user_select_policy.test.js`.
|
||||
- **Input-setup wizard no longer collapses an audio device's driver-type variants into one entry.** On Windows the desktop engine enumerates the same interface once per host API (ASIO / Windows Audio / DirectSound), and the wizard's audio picker (`plugins/input_setup/screen.js`) de-duped the source list by display **label** — so the variants (which share a name) collapsed to a single choice, silently keeping whichever sorted first (often *not* the low-latency ASIO one the player wants). The audio-input capability already collapses true duplicates by `logicalSourceKey` (`_visibleInputSources` in `static/capabilities/audio-session.js`), and the variants each have a **distinct** key, so the wizard's extra label-collapse was redundant for real dupes and destructive for these — it also could drop the variant that was actually `selected`. Removed it; the picker now lists every selectable input. Pairs with feedBack-desktop's change to label each source with its driver type (e.g. "Focusrite (ASIO)") so the now-distinct entries are legible.
|
||||
- **3D Highway FPS counter no longer hides behind the v3 "Up Next" pill.** The on-highway FPS readout (Settings → Graphics → 3D Highway → Show FPS counter) is pinned to the top-right of the highway overlay — the same corner the v3 player chrome stacks its persistent **Up Next** pill and live-performance HUD into, on a higher layer that paints over the canvas. So the readout sat *behind* that chrome and couldn't be read — precisely when a tester had turned it on to judge performance (it also made the separate "Up Next won't turn off" complaint worse, since the default-on pill covered the counter regardless). The counter now stays top-right but drops just **below** whichever of that chrome is showing: `highway_3d`'s `screen.js` measures the lowest visible top-right v3 HUD element (`#v3-upnext` / `#v3-live-performance-hud` / `#hud-time`) and floors the FPS box's Y beneath it. Element refs are resolved once and cached (no per-frame `querySelector`, per the plugin perf rules) and only consulted while the counter is actually drawn; gated on `window.feedBack.uiVersion === 'v3'` so the classic (v2) UI is byte-for-byte unaffected. `plugins/highway_3d/plugin.json` version → `3.30.1` (cache-buster). (For reading raw perf numbers unobstructed, the core perf HUD — `localStorage.highwayPerfHud='1'` — still renders above all chrome and additionally shows the adaptive render-scale.)
|
||||
- **3D Highway fret-number row no longer clips off the bottom edge when the camera zooms in on a centred span.** The heat-coloured fret-number row is drawn as a band *below* the board (`sY(lowest) − S_GAP*1.4`), but the camera's self-correcting framing only anchors the board **centre** to the lower third of the screen — it reserved no headroom for that row. So a tight zoom on a centred active span (worst around mid-neck; fine when the span sits at either end of the neck, which is why testers saw it "only when centered" and "not every song") dropped the numbers past the bottom edge. Tilt can't fix it there (it would only trade a bottom clip for a top clip), so `camUpdate()` now **dollies the camera back just enough to bring the row back into frame**: it projects the row band with the final camera and, when it falls below a safe NDC line (`FRET_ROW_FIT_NDC_MIN`), raises a capped, hysteretic `_fretRowFitBoost` applied to the `curDist` lerp target (the span-driven zoom still owns zooming *in*). The boost rises promptly (proportional to the deficit), relaxes lazily past a deadband, and is capped (`FRET_ROW_FIT_BOOST_MAX`, +60%) so the zoom can't pop or hunt; it cooperates with the tilt loop (pull-back shrinks the scene, tilt keeps the centre anchored) and yields entirely to the Camera Director free-cam. Surgical: passages where the row is already visible never trigger it, so framing is unchanged everywhere it already worked. `plugins/highway_3d/plugin.json` version → `3.30.2` (cache-buster). Tests: `tests/js/highway_3d_camera_framing.test.js` (guard constants, the boosted `curDist` lerp, the projected-row hysteresis, free-cam yield).
|
||||
- **v3 Songs grid now refreshes after a Settings rescan / DLC-folder change — no app restart needed.** On a fresh install, pointing at a DLC folder in Settings and running a scan left the Songs section empty until a restart (the scan *did* populate the library — `_background_scan` re-reads `config.json` fresh — but the v3 grid never reloaded). The Settings **Rescan / Full Rescan** handlers only refreshed the classic (v2) library via `loadLibrary()`; the v3 grid (`static/v3/songs.js`) had no listener for a scan it didn't start itself (only its own upload path self-refreshed via `watchUploadScan`), so its cached, pre-DLC (empty) DOM/snapshot survived a sidebar return until a full reload. The rescan handlers now emit a **`library:changed`** event (`static/app.js`); the v3 grid listens and **reloads if it's the active screen, else marks itself dirty** so the next entry does a full re-fetch instead of restoring the stale snapshot (a `_libraryDirty` short-circuit ahead of every cached-DOM fast-path in `onV3SongsScreenEnter`). Tests: `tests/js/v3_library_refresh.test.js` (the emit + the reload/dirty wiring).
|
||||
- **Edit Metadata modal: the Year is now editable.** You could set a year when authoring a pak but the Songs → Edit Metadata modal had no Year field, so it could never be changed afterward. The backend (`POST /api/song/<f>/meta`) already accepted and normalized `year` (writes it into the file via `songmeta`, survives a rescan) — only the UI omitted it. Added a **Year** input to `openEditModal()` (populated from the song's existing year) and included `year` in `saveEditModal()`'s POST body (`static/app.js`). Both the v3 card menu and the legacy edit button already pass the year through, so both surfaces get the field.
|
||||
- **Edit Metadata modal no longer closes when a click-drag is released on the backdrop.** Selecting text inside a field and releasing the mouse past the modal's edge dismissed the form without warning (the `click` event's target resolved to the backdrop), discarding the edit. Backdrop dismissal now requires the **mousedown to have started on the backdrop** too — tracked per-modal and decided by a new pure `_editModalShouldClose(clickTarget, modalEl, downOnBackdrop)` helper (`static/app.js`). Cancel / ✕ still close on a normal click. Tests: `tests/js/edit_metadata_modal.test.js` (year in the POST body + the backdrop-close decision table).
|
||||
- **Built-in diagnostic sloppak rebranded "Slopsmith" → "FeedBack" in the song name.** PR #586 renamed the file to `feedBack-diagnostic-basic-guitar.sloppak` but never regenerated the archive, so the manifest inside still carried `title: Slopsmith Diagnostic — Basic Guitar` / `artist: Slopsmith` (and the same heading in `DIAGNOSTIC.md`) — the stale name testers saw in the library/player and the onboarding calibration step, even though the build script, server, and docs all already say "FeedBack Diagnostic — Basic Guitar". Regenerated `docs/diagnostics/feedBack-diagnostic-basic-guitar.sloppak` from `docs/diagnostics/build_diagnostic_basic_guitar.py` so the committed artifact matches its source generator (title/artist/heading now "FeedBack"; chart, stem, and `diagnostic:` metadata unchanged). No code change — the rename in #586 just needed the rebuild.
|
||||
- **v3 song/lesson accuracy badges now refresh on the first return from a song — no restart needed.** PR #574 added a `stats:recorded` → in-place badge repaint, but the repaint never matched a card. The event (like `song:loading`) carries the filename **`encodeURIComponent`'d** — exactly as `playCard` hands it to `playSong` (the highway WS `decodeURIComponent`s it back) — whereas library cards key on the **decoded** `localFilename` (`data-fn`), and `/api/stats/best` is server-canonicalized to that same decoded key (`server.py` `_canonical_song_filename`). So `repaintAccuracy`'s `data-fn !== key` check rejected every card and `state.accuracy[encoded]` was `undefined`, leaving the just-earned badge stale until a full `render()` (app restart / search / re-enter the screen) — which is why it "came back after a restart." `static/v3/songs.js` now decodes the `stats:recorded` filename back into the card / `state.accuracy` key space via a small `decFn` helper before marking dirty and repainting (idempotent for already-decoded names; falls back to the original on malformed input so a real filename containing a literal `%` is never corrupted), so both the immediate repaint and the `onV3SongsScreenEnter` deferred path land on the right card. Tests: `tests/js/v3_songs_score_badge_refresh.test.js`.
|
||||
- **Escape now exits a song (and leaves Settings) even when a transport/rail control button holds keyboard focus.** Clicking a player control (Play / FF / RW / Restart) left that `<button>` focused, and `_shortcutDispatchBlocked()` in `static/app.js` treats any focused `INPUT/SELECT/TEXTAREA/BUTTON` as an "interactive control" and bails before the shortcut registry runs — so the player-scope `Escape → Back` shortcut never fired until the user clicked empty canvas to blur the control ("Escape in song not consistent"). Space already had a player-screen carve-out (#593) that let it fire through a focused control; Escape did not. Generalized that carve-out to Escape, scoped to the player **and** settings screens (both register an `Escape = Back` shortcut, and settings had the identical latent bug). The earlier guards are preserved and still win: text inputs are exempted first (Escape there clears/blurs the field), the Section Practice popover already claims Escape before the carve-out, and a true modal layered over the screen (`[role="dialog"][aria-modal="true"]` / `.feedBack-modal`) still traps Escape so it closes the modal rather than ejecting past it. Escape becomes a reliable, focus-independent "Back" — making it monotonic groundwork for an optional exit-confirm. Plugins that register a player-scope `Escape` shortcut benefit identically (they were broken the same way). Tests: `tests/browser/keyboard-shortcuts.spec.ts` (focused-button repro, text-input no-exit, no-escape-past-modal, Section Practice popover, settings twin-bug).
|
||||
- **The v3 "Up Next" pill can now be turned off — new "Show 'Up Next'" gameplay toggle (default ON).** The v0.3.0 player chrome's persistent upcoming-section pill (`#v3-upnext`, drawn by `static/v3/player-chrome.js`'s `updateUpNext()`) shipped with no off switch, so it always showed during playback whenever a section was upcoming — overlapping the top-right FPS HUD and ignoring the 3D-highway "Show 'Up Next' section card" checkbox (a *different*, in-canvas widget that was demoted to default-off precisely because this pill is the canonical readout). Users reading the pill as the same setting saw "disabled in settings but still there." Adds a real core toggle following the `autoplayExit` idiom: a client-only `showUpNext` `localStorage` pref (absence = enabled), a **Show "Up Next"** switch in the Gameplay settings tab (`static/v3/index.html`), reader/writer + `loadSettings()` hydration + a read-only `window.feedBack.showUpNext` getter in `static/app.js`, and a gate at the top of `updateUpNext()` that hides the pill when off. Disabling mid-playback hides it immediately; re-enabling re-shows it on the next chrome tick (~6 Hz). Added to `RESET_MAP.gameplay.local` in `static/v3/settings.js` so the Gameplay "Reset" restores the default-on state. Default ON = zero change for existing users. No Tailwind rebuild (plain markup + existing classes).
|
||||
- **v3 list/tree view brought to parity with the grid: select mode, parts chips, and song actions — plus a stale-CSS Docker fix.** Re-lands a previously-reverted change. **Frontend (`static/v3/songs.js`):** entering select mode no longer collapses the tree — `loadTree()` now captures the expanded artist groups (`details[open]` keyed by `data-artist`) before the "Loading…" wipe and restores them on rebuild, so toggling select mode (which re-renders via `reload()`) keeps groups open and selection usable; tree rows gain a display-only checkbox + selection ring, the same fav / save-for-later / overflow-menu cluster as the grid card (always shown, all bound by `wireCards()`), and a capture-phase select guard mirroring the grid so clicking a row or arrangement chip in select mode selects instead of playing (`<summary>` headers sit outside `[data-fn]`, so native expand/collapse is untouched). **Docker fix (`static/tailwind.min.css`):** the committed Tailwind stylesheet was stale — `.sm\:flex` (and the other utilities behind #582's `hidden sm:flex` arrangement chips and the new action cluster) were never compiled in, so they rendered `display:none` on the Docker build (which serves the committed CSS as-is; Desktop rebuilds from source so it looked fine). Regenerated with the pinned `tailwindcss@3.4.19` via `scripts/build-tailwind.sh` so Docker matches Desktop and #582's chips render on every Docker deploy. Regression tests: `tests/browser/v3-tree-select.spec.ts`.
|
||||
- **Space bar now plays/pauses on the player screen even when a sidebar nav link or rail button has focus.** When any `<button>` in the player rail (viz, audio, mixer, lyrics, plugins, advanced), a sidebar nav link, or a popover control held keyboard focus, pressing Space was swallowed by `_shortcutDispatchBlocked` → `_isInsideInteractiveControl` (which treats `BUTTON`/`A` as interactive), so the Space shortcut never reached the dispatcher and `togglePlay()` never ran. `_shortcutDispatchBlocked` (`static/app.js`) now extends the same carve-out already used for the Section Practice bar: while the player screen is active, Space is always routed through the shortcut system — the dispatcher calls `e.preventDefault()` before invoking the handler, so the focused element does not also activate. Text inputs (`_isTextInput`) remain exempted first, so typing space in a search/input field still works normally, and focus inside a true modal dialog (`role="dialog" aria-modal="true"` / `.feedBack-modal`) layered over the player is also exempted so Space reaches the modal's focused control (e.g. its Close button) instead of toggling playback behind it — non-modal player popovers/toasts (loop A/B, arrangement pin) stay covered. Regression tests in `tests/browser/keyboard-shortcuts.spec.ts` cover the focused-rail-button play/pause, the text-input exemption, and the modal-dialog exemption.
|
||||
- **A song's accuracy badge now updates on its library card right after you play it — no restart needed.** The v3 library (`static/v3/songs.js`) loaded the best-accuracy map (`/api/stats/best`) once into `state.accuracy` at render time and only ever refreshed it on a full re-render; the play→return flow takes the screen-entry fast-path that restores the cached grid DOM without re-fetching, so a just-earned score stayed invisible until the next restart re-ran `render()`. The `stats-recorder` now emits a `stats:recorded` event (carrying `filename`/`arrangement`) once the scored `POST /api/stats` resolves on the server — the correct moment, since `song:stop` fires before the POST completes. `songs.js` listens: if the library is the active screen it re-fetches `/api/stats/best` and patches the affected card/row badge in place; otherwise it marks the filename dirty and `onV3SongsScreenEnter` applies it on return (a failed fetch keeps the entry dirty to retry). Badge markup was factored into a shared `accuracyBadge(filename, variant)` (grid pill + tree-row percentage, both tagged `.fb-acc-badge`) so the in-place `repaintAccuracy` can find and replace them without a full list re-render (scroll/pagination preserved). The old empty `song:stop` "refresh lazily next render" placeholder is replaced.
|
||||
- **Changing Settings → 3D Highway → Fret spacing no longer ejects you to the home screen.** The `highway_3d` plugin's `h3dSetFretSpacing` was the lone 3D-highway setting that called `location.reload()` to apply — and since the SPA boots with `#home` as the active screen (`index.html` `.screen.active`), the reload dropped the user out of Settings onto the homescreen. It now applies live like every other 3D-highway setting: it rebinds the module-scope `_h3dFretUniform` flag (so panels mounted later this session pick up the new mode), recomputes the two `fretX`-derived scalars that were baked at init (`_fretLabelScaleRefW` for fret-label sprite scaling, `FRET_WIDTH_MID` for camera hysteresis), and broadcasts a `fretSpacing` change over the existing `_bgEmitChange` pub-sub so every mounted panel rebuilds its board via `buildBoard()`. Per-frame note geometry already reads `fretX` live and needs no rebuild. No page reload, so the Settings screen stays put. Source-level regression tests in `tests/js/highway_3d_fret_spacing.test.js` now pin the no-reload / live-rebuild behavior.
|
||||
- **v3 library scroll-restore no longer breaks the classic v2 UI or drops off-screen searches** (feedBack#857). Two regressions in the scroll-restore work above: (1) `playSong` remapped `home`-launched songs to return to the `#v3-songs` screen unconditionally, but `static/app.js` is shared with the v2 UI (served at `/v2` / `FEEDBACK_UI=v2`) where that screen does not exist — Esc-from-player then called `showScreen('v3-songs')`, which threw on the missing element and stranded the user on a blank screen with playback still running; the remap now applies only when `#v3-songs` is present. (2) The Songs screen-entry fast-path skips reloading to preserve scroll, but the global topbar search routed through it, so once Songs had been visited, searching from another screen navigated there without applying the new query; the screen now tracks the state hash each fetch reflects and refetches when it has drifted, keeping the scroll-preserving no-op only when nothing changed.
|
||||
- **An active custom highway renderer is no longer starved of `draw()` when it hides the canvas** (#819). The per-frame draw gate in `static/highway.js` bailed on `if (!_lastVisible) return`, which conflated two different "hidden" states: a genuine off-screen canvas (`offsetParent === null` — navigate-away / `display:none` splitscreen panel, #246) versus a renderer-set *override-hide* (`setVisible(false)`, where an opaque overlay covers the canvas but the active renderer keeps painting its own surface). The gate now only pauses everything for the off-screen case (and still pauses the default 2D renderer on an override-hide); the **active custom renderer** keeps receiving `draw()` through its own override-hide. The `highway:visibility` event still fires before the gate, so sibling overlay renderers (e.g. 3D Highway's `.h3d-wrap`) still pause. This is the core-side root cause behind the Tab View cursor freezing in single-player (feedBack#734; worked around plugin-side in feedBack-plugin-tabview#25).
|
||||
- **Screensaver no longer kicks in during windowed-mode playback** (#686). While a song is playing, `static/app.js` now holds a [Screen Wake Lock](https://developer.mozilla.org/en-US/docs/Web/API/Screen_Wake_Lock_API) (`navigator.wakeLock.request('screen')`) so the OS display/screensaver stays awake even though only audio + the highway animation are active and the keyboard/mouse are idle. The lock is acquired on `song:play`/`song:resume` and released on `song:pause`/`song:ended`/`song:stop` (kept only while actually playing), and re-acquired on `visibilitychange` when the tab refocuses (the API auto-releases a lock whenever the page is hidden). Both the HTML5 `<audio>` and JUCE desktop playback paths emit the same `song:*` events, so the fix covers both. In feedBack-desktop (Electron), where `navigator.wakeLock` is unreliable, it also drives a native `powerSaveBlocker` bridge via the optional `window.feedBackDesktop.power.setScreenAwake` hook when present; both calls degrade silently where unsupported. Note: the browser Wake Lock API is secure-context only, so in a plain browser this is active on `localhost` / HTTPS only — a session opened over plain HTTP to a LAN IP (e.g. `http://192.168.1.100:8000`) won't keep the screen awake; front it with HTTPS or use the desktop app (see README → reverse-proxy notes).
|
||||
- **v3 library scroll-restore no longer breaks the classic v2 UI or drops off-screen searches** (slopsmith#857). Two regressions in the scroll-restore work above: (1) `playSong` remapped `home`-launched songs to return to the `#v3-songs` screen unconditionally, but `static/app.js` is shared with the v2 UI (served at `/v2` / `SLOPSMITH_UI=v2`) where that screen does not exist — Esc-from-player then called `showScreen('v3-songs')`, which threw on the missing element and stranded the user on a blank screen with playback still running; the remap now applies only when `#v3-songs` is present. (2) The Songs screen-entry fast-path skips reloading to preserve scroll, but the global topbar search routed through it, so once Songs had been visited, searching from another screen navigated there without applying the new query; the screen now tracks the state hash each fetch reflects and refetches when it has drifted, keeping the scroll-preserving no-op only when nothing changed.
|
||||
- **An active custom highway renderer is no longer starved of `draw()` when it hides the canvas** (#819). The per-frame draw gate in `static/highway.js` bailed on `if (!_lastVisible) return`, which conflated two different "hidden" states: a genuine off-screen canvas (`offsetParent === null` — navigate-away / `display:none` splitscreen panel, #246) versus a renderer-set *override-hide* (`setVisible(false)`, where an opaque overlay covers the canvas but the active renderer keeps painting its own surface). The gate now only pauses everything for the off-screen case (and still pauses the default 2D renderer on an override-hide); the **active custom renderer** keeps receiving `draw()` through its own override-hide. The `highway:visibility` event still fires before the gate, so sibling overlay renderers (e.g. 3D Highway's `.h3d-wrap`) still pause. This is the core-side root cause behind the Tab View cursor freezing in single-player (slopsmith#734; worked around plugin-side in slopsmith-plugin-tabview#25).
|
||||
- **Screensaver no longer kicks in during windowed-mode playback** (#686). While a song is playing, `static/app.js` now holds a [Screen Wake Lock](https://developer.mozilla.org/en-US/docs/Web/API/Screen_Wake_Lock_API) (`navigator.wakeLock.request('screen')`) so the OS display/screensaver stays awake even though only audio + the highway animation are active and the keyboard/mouse are idle. The lock is acquired on `song:play`/`song:resume` and released on `song:pause`/`song:ended`/`song:stop` (kept only while actually playing), and re-acquired on `visibilitychange` when the tab refocuses (the API auto-releases a lock whenever the page is hidden). Both the HTML5 `<audio>` and JUCE desktop playback paths emit the same `song:*` events, so the fix covers both. In slopsmith-desktop (Electron), where `navigator.wakeLock` is unreliable, it also drives a native `powerSaveBlocker` bridge via the optional `window.slopsmithDesktop.power.setScreenAwake` hook when present; both calls degrade silently where unsupported. Note: the browser Wake Lock API is secure-context only, so in a plain browser this is active on `localhost` / HTTPS only — a session opened over plain HTTP to a LAN IP (e.g. `http://192.168.1.100:8000`) won't keep the screen awake; front it with HTTPS or use the desktop app (see README → reverse-proxy notes).
|
||||
|
||||
### Changed
|
||||
- **Practice plugin first-class sidebar slot now points at Virtuoso.** The bundled practice plugin was rebranded/re-homed from the SlopScale fork (`id: slopscale`) to `got-feedback/feedBack-plugin-virtuoso` (`id: virtuoso`); the desktop bundle swap is feedBack-desktop#31. `static/v3/shell.js` still promoted `slopscale`, whose id no longer ships — so `renderPromotedNav()` (gated on the plugin appearing in `/api/plugins`) would have found no match and the dedicated sidebar slot would have gone dark, dropping Virtuoso to the generic Plugins gallery. Update the NAV entry + `PROMOTED_PLUGINS` slot `slopscale` → `virtuoso` (`screen: plugin-virtuoso`, label "Virtuoso - Practice", same FeedBarcade anchor + `target` icon) so the practice plugin keeps its first-class entry. Also clear the now-dead `slopscale` id from the Plugins-gallery curated category map (`static/v3/plugins-page.js`) and add `virtuoso: 'practice'` as a defensive fallback (the manifest's `category: "practice"` is authoritative, so it lands on the practice board regardless), and refresh the stale SlopScale references in `README.md` + `docs/plugin-capability-inventory.md`. Must land with the bundle swap or the practice plugin regresses in the UI.
|
||||
- **3D highway: realistic curved metal frets.** The fret wires are now bowed `TubeGeometry` (the middle strings push away from the camera so the row of frets reads as wrapping a cylindrical neck — a depth cue) rendered with a lit `MeshStandardMaterial` instead of the old flat, straight `MeshBasicMaterial` boxes, so the scene's ambient + directional light glints across the rounded surface for a polished-steel look. The existing per-frame highlight is preserved unchanged: frets inside the active anchor still turn gold (`0xD8A636`), which under the metallic shading reads as brass. Metalness is kept moderate (0.4, not full-metal) because the scene has no envMap — a PBR full-metal surface would reflect black — with a dim emissive floor so frets stay legible down the fogged neck. Backported from the `highway_babylon` plugin's "hit-zone fret bars". All knobs (`FRET_BOW_DZ`, metalness/roughness/emissive) are tunable constants. `plugins/highway_3d` v3.25.0.
|
||||
- **3D highway: section + tone HUD cards now default OFF.** The v0.3.0 player chrome carries a persistent "Up Next" pill, making the in-canvas section card redundant by default (it doubled the readout, feedBack feedback); the tone HUD follows the same less-is-more default. Both remain available in Settings → 3D Highway (visibility/position/size unchanged); users who previously toggled either explicitly keep their stored preference — only the untouched default flips. `plugins/highway_3d` v3.24.1.
|
||||
- **Perf**: replace runtime Tailwind Play CDN with a prebuilt static stylesheet (`static/tailwind.min.css`). The Play CDN's runtime JIT scanned the DOM ~1.8x/sec on the main thread (~37 ms blocking spans), dropping ~26% of frames in long playback sessions with the 3D highway as default. Theme extensions (dark/accent/gold colors, Inter font) move to `tailwind.config.js`; regen via `bash scripts/build-tailwind.sh`. No runtime build step — the generated CSS is committed. Fixes feedBack-desktop#110.
|
||||
- **3D highway: section + tone HUD cards now default OFF.** The v0.3.0 player chrome carries a persistent "Up Next" pill, making the in-canvas section card redundant by default (it doubled the readout, slopsmith feedback); the tone HUD follows the same less-is-more default. Both remain available in Settings → 3D Highway (visibility/position/size unchanged); users who previously toggled either explicitly keep their stored preference — only the untouched default flips. `plugins/highway_3d` v3.24.1.
|
||||
- **Perf**: replace runtime Tailwind Play CDN with a prebuilt static stylesheet (`static/tailwind.min.css`). The Play CDN's runtime JIT scanned the DOM ~1.8x/sec on the main thread (~37 ms blocking spans), dropping ~26% of frames in long playback sessions with the 3D highway as default. Theme extensions (dark/accent/gold colors, Inter font) move to `tailwind.config.js`; regen via `bash scripts/build-tailwind.sh`. No runtime build step — the generated CSS is committed. Fixes slopsmith-desktop#110.
|
||||
- **Perf**: reduce per-frame allocations in the 2D highway chord + lyric render paths. `_ensureChordRenderCache` now also caches `sortedNotes` / `nonZeroNotes` / `nonZeroFrets` / `allMuted` / `hasMultipleNotes` (computed once per chord, invalidated on `src` / `_inverted` / `chordTemplates` change — the third key catches a stale `isOpen`-derived classification when the WS `chord_templates` message lands after the final `chords` chunk), so `drawChords` no longer re-sorts / re-filters / spreads min-max per visible chord per frame. The in-chord unison bend classification is folded inline (no `chordPositions.filter` × 2 per frame). `drawLyrics` memoizes `ctx.measureText` results in a two-level `Map<fontSize, Map<text, width>>` so cache hits don't allocate a composite string key. Lit-sustain shimmer in `drawSustains` swaps the 4 per-note-per-frame `Math.random()` calls for a 64-entry precomputed jitter LUT (xorshift32-seeded — the LUT contents are reload-stable and test-reproducible; rendered shimmer is deterministic per `createHighway()` instance, since the seed includes that instance's `_frameIdx`) indexed by `(frameIdx + n.s + ⌊n.t·60⌋)`, visually indistinguishable and allocation-free.
|
||||
- **Perf**: the load-adaptive render scale (`_adaptRenderScale`, #654) no longer visibly hunts up/down on passages that hover near the frame budget (testers saw "quality going up and down" with the 3D highway). Downscaling stays prompt to protect the frame rate, but **upscaling is now lazy**: a smaller step (×1.06 vs ×1.1) on a longer cooldown (`_AUTO_UPSCALE_COOLDOWN_MS` 2500 ms vs the 600 ms adjust cooldown), reset on any downscale, and gated by a predictive guard — it only upscales when the projected cost *after* the step (≈ cost × step², since draw cost tracks the pixel count) still clears the high budget. The scale therefore settles just inside the 7–12 ms deadband instead of oscillating across it. No new public API; the `_autoScaleMin` "Min res" floor is unchanged.
|
||||
|
||||
### Removed
|
||||
- **`c` library hotkey ("Convert to .sloppak") removed from core.** Core hardcoded a plugin-specific shortcut: a documentation-only `registerShortcut({ key: 'c', scope: 'library' })` no-op plus a `c → button.sloppak-convert-btn` entry in the library keydown handler that fired the Sloppak Converter plugin's button. Per the plugins-own-their-behavior principle, core no longer ships this hotkey — the convert button still works by click, and the Sloppak Converter plugin can register its own `c` shortcut via `window.registerShortcut()` if keyboard access is wanted. The `f` (favorite) and `e` (edit) library hotkeys, which drive core buttons, are unchanged. Help-modal/registry tests in `tests/browser/keyboard-shortcuts.spec.ts` updated to drop the `c` assertions.
|
||||
|
||||
### Added
|
||||
- **Player progression: Mastery Rank, instrument-path challenges, daily/weekly quests, Decibels currency, cosmetics shop (spec 010).** Onboarding gains two steps: pick one or more **instrument paths** (Guitar / Bass / Drums — data-driven, more can ship as content) and a **calibration challenge** offer (play the bundled FeedBack Diagnostic with note detection at 100% accuracy — or skip; either way you reach **Mastery Rank 1**, and a skipped calibration can still be completed later from the Progress screen). Each path levels by completing a content-defined number of **challenges** (any order) from that level's set; Mastery Rank = onboarding rank + the sum of path levels, starting at 0 on a fresh install. The existing unified XP backend is untouched but the frontend renames it to **Decibels (dB)** — a spendable currency earned ONLY by playing (songs, FeedBarcade rounds, quest rewards; no real-money path exists or may be added) — with spend tracked in a separate wallet so lifetime earnings stay monotonic. Rotating **daily/weekly quests** (deterministic per period, lazy instantiation, local-midnight / Monday resets) award dB and feed `quest_completed` challenges. A new **Progress** screen (rank hero, per-path checklists, quest countdowns, add-a-path) and **Shop** screen (themes via CSS-variable swaps under `html[data-fb-theme]`, avatar frames; atomic balance-checked purchases — 402 on insufficient dB, 409 on re-buy) join the v3 nav, and the topbar badge now shows Rank + challenge-set progress + dB balance. All definitions live in `data/progression/` JSON (paths/levels/challenges, quest pools, shop catalog) — adding a rank, challenge, quest, or cosmetic is a content edit + restart, never code; invalid content degrades to logged warnings. New tables (additive + idempotent): `progression_state`, `player_paths`, `challenge_progress`, `quest_state`, `wallet`, `shop_owned`, `shop_equipped`. New endpoints: `GET /api/progression`, `POST /api/progression/paths|onboarding|events` (events whitelists `minigame_run`; `song_completed` stays server-derived inside `POST /api/stats`, which now resolves the instrument server-side and reports an additive `progression` outcome key), `GET /api/shop`, `POST /api/shop/buy|equip`; equipped cosmetics ride along on `GET /api/profile`. A new **`progression` capability domain** (core-owned, kind: command, safety: safe — `inspect`, `record-event`, `list-shop`, `buy-item`/`equip-item` gated on user action) emits `challenge-completed`/`quest-completed`/`path-level-up`/`rank-changed`/`db-changed`/`calibration-completed`/`cosmetic-equipped`, mirrored as `progression:*` window events, with a redaction-safe diagnostics contributor; backend plugins get the symmetric `record_progression_event` context hook (the bundled minigames hub reports runs through it, guarded for standalone). Spec: `specs/010-progression-domain/`. Tests: `tests/test_progression.py`, `tests/test_progression_api.py`. **Migration notes:** existing XP totals carry over as lifetime dB (balance = lifetime − spent); resetting a per-source XP contribution (e.g. a minigames profile reset) after spending can clamp the spendable balance to 0 until new dB is earned; drums-path v1 content uses currently-satisfiable goals (arcade rounds, quests, any-instrument plays) until drums scoring lands.
|
||||
- **3D highway score FX (notedetect game-scoring layer).** The bundled `highway_3d` renderer now visualizes the scoring layer shipped in feedBack-plugin-notedetect ≥1.13: floating **"+N" score pops** above each judged gem (sourced from the note-state provider's new `{ points, mult, popKey }` verdict fields — chord members share the chord-level `popKey`, so a chord pops once, not once per string), plus session-level FX from the new `notedetect:fx` event — a particle burst at the strike line on streak milestones (25/50/every 100), an expanding ring pulse on multiplier tier-ups (×2/×3/×4), and a brief red wash when a ≥10 streak breaks. Colors and the pop font follow the user's notedetect scoring-UI skin (`feedBack_notedetect_skin`: neon/esports/metal, refreshed live on the `notedetect:skin` bus event). Everything renders on the existing 2D overlay canvas from fixed-size slot pools — no Three.js geometry, no text-sprite cache traffic, near-zero cost when idle — and degrades to a silent no-op with older notedetect builds (the new fields/events simply never arrive). Splitscreen panels scope FX to their own detector instance via the bubbling per-panel `notedetect:fx` dispatch. `plugins/highway_3d` v3.24.1.
|
||||
- **Player progression: Mastery Rank, instrument-path challenges, daily/weekly quests, Decibels currency, cosmetics shop (spec 010).** Onboarding gains two steps: pick one or more **instrument paths** (Guitar / Bass / Drums — data-driven, more can ship as content) and a **calibration challenge** offer (play the bundled Slopsmith Diagnostic with note detection at 100% accuracy — or skip; either way you reach **Mastery Rank 1**, and a skipped calibration can still be completed later from the Progress screen). Each path levels by completing a content-defined number of **challenges** (any order) from that level's set; Mastery Rank = onboarding rank + the sum of path levels, starting at 0 on a fresh install. The existing unified XP backend is untouched but the frontend renames it to **Decibels (dB)** — a spendable currency earned ONLY by playing (songs, FeedBarcade rounds, quest rewards; no real-money path exists or may be added) — with spend tracked in a separate wallet so lifetime earnings stay monotonic. Rotating **daily/weekly quests** (deterministic per period, lazy instantiation, local-midnight / Monday resets) award dB and feed `quest_completed` challenges. A new **Progress** screen (rank hero, per-path checklists, quest countdowns, add-a-path) and **Shop** screen (themes via CSS-variable swaps under `html[data-fb-theme]`, avatar frames; atomic balance-checked purchases — 402 on insufficient dB, 409 on re-buy) join the v3 nav, and the topbar badge now shows Rank + challenge-set progress + dB balance. All definitions live in `data/progression/` JSON (paths/levels/challenges, quest pools, shop catalog) — adding a rank, challenge, quest, or cosmetic is a content edit + restart, never code; invalid content degrades to logged warnings. New tables (additive + idempotent): `progression_state`, `player_paths`, `challenge_progress`, `quest_state`, `wallet`, `shop_owned`, `shop_equipped`. New endpoints: `GET /api/progression`, `POST /api/progression/paths|onboarding|events` (events whitelists `minigame_run`; `song_completed` stays server-derived inside `POST /api/stats`, which now resolves the instrument server-side and reports an additive `progression` outcome key), `GET /api/shop`, `POST /api/shop/buy|equip`; equipped cosmetics ride along on `GET /api/profile`. A new **`progression` capability domain** (core-owned, kind: command, safety: safe — `inspect`, `record-event`, `list-shop`, `buy-item`/`equip-item` gated on user action) emits `challenge-completed`/`quest-completed`/`path-level-up`/`rank-changed`/`db-changed`/`calibration-completed`/`cosmetic-equipped`, mirrored as `progression:*` window events, with a redaction-safe diagnostics contributor; backend plugins get the symmetric `record_progression_event` context hook (the bundled minigames hub reports runs through it, guarded for standalone). Spec: `specs/010-progression-domain/`. Tests: `tests/test_progression.py`, `tests/test_progression_api.py`. **Migration notes:** existing XP totals carry over as lifetime dB (balance = lifetime − spent); resetting a per-source XP contribution (e.g. a minigames profile reset) after spending can clamp the spendable balance to 0 until new dB is earned; drums-path v1 content uses currently-satisfiable goals (arcade rounds, quests, any-instrument plays) until drums scoring lands.
|
||||
- **3D highway score FX (notedetect game-scoring layer).** The bundled `highway_3d` renderer now visualizes the scoring layer shipped in slopsmith-plugin-notedetect ≥1.13: floating **"+N" score pops** above each judged gem (sourced from the note-state provider's new `{ points, mult, popKey }` verdict fields — chord members share the chord-level `popKey`, so a chord pops once, not once per string), plus session-level FX from the new `notedetect:fx` event — a particle burst at the strike line on streak milestones (25/50/every 100), an expanding ring pulse on multiplier tier-ups (×2/×3/×4), and a brief red wash when a ≥10 streak breaks. Colors and the pop font follow the user's notedetect scoring-UI skin (`slopsmith_notedetect_skin`: neon/esports/metal, refreshed live on the `notedetect:skin` bus event). Everything renders on the existing 2D overlay canvas from fixed-size slot pools — no Three.js geometry, no text-sprite cache traffic, near-zero cost when idle — and degrades to a silent no-op with older notedetect builds (the new fields/events simply never arrive). Splitscreen panels scope FX to their own detector instance via the bubbling per-panel `notedetect:fx` dispatch. `plugins/highway_3d` v3.24.1.
|
||||
- **3D highway: slide direction arrows + gem-follow animation.** Slide notes now show a ›/‹ arrow indicating which way the slide goes — on the note/gem itself, as an early preview on the neck before the note arrives, and (optionally) chained further ahead for multi-leg slides — each independently toggleable in Settings → 3D Highway (`slideArrowApproachVisible`, `slideArrowNeckVisible`, `slideArrowChainPreviewVisible`). The note gem also now visually glides from its starting fret to the slide's destination over the note's sustain and holds there through the brief post-sustain linger, instead of snapping back to the starting fret — most noticeable on unpitched "slide to nothing" notes. `plugins/highway_3d` v3.25.2.
|
||||
- **3D highway: up to 3 upcoming-note ghost previews per string, with fade-in/grow.** Each string now previews up to 3 upcoming notes (was 1) on a fixed 0.6 s fade-in/grow ramp, so tight same-string runs no longer pop in at full size right before impact and the player can read note order ahead of time. `isBlocked` (the pre-impact ghost suppression in a note's last 150 ms) is now scoped to chord notes only — for lead notes it had been blinking the ghost out right before each sustained note in dense runs. (Slide notes stay excluded too, per the slide-arrow work above, since their gem glides off the start fret.) `plugins/highway_3d` v3.26.0.
|
||||
- **Enable/disable plugins from the v3 Pedalboard (footswitch backend).** Every `/api/plugins` entry now carries an `enabled` boolean (default `true`), and a new `POST /api/plugins/{plugin_id}/enabled` endpoint (`{"enabled": <bool>}` → `{"id", "enabled"}`) persists the choice to `CONFIG_DIR/plugin_state.json` (only non-default `enabled:false` entries are stored; a missing/corrupt file is tolerated and never crashes startup). The loader **skips disabled plugins at startup** — no requirements install, no `routes.setup()`, no screen/nav/capabilities — while still surfacing them in `/api/plugins` as a disabled entry (`status:"disabled"`, `enabled:false`) so the UI can show an "off" pedal you can switch back on. Toggling persists immediately and flips the in-memory flag so the next `/api/plugins` reflects it at once (a runtime-disabled plugin's already-mounted routes/screen remain until the next restart; re-enabling a startup-skipped plugin mounts on restart). A disabled plugin is **excluded from the capability pipeline** — its capability metadata is emptied in `/api/plugins`. Guard rails keep `capability_inspector` and `app_tour_*` always enabled (disable → `400`); unknown id → `404`; missing/non-boolean `enabled` → `400`. Backend only; the v3 Pedalboard frontend consumes this contract. Docs: `docs/plugin-v3-ui.md`.
|
||||
- **fee[dB]ack v0.3.0 rebrand + UI redesign (opt-in, isolated).** The visible product is being renamed from **FeedBack** to **fee[dB]ack** (the `[dB]` is a decibel pun on the practice "feedback" loop) alongside a full dashboard-style UI redesign. **Rebrand scope is the app + docs wordmark only** — the repository, Python package, ghcr Docker image, `CONFIG_DIR`, and `FEEDBACK_*` env vars all keep the `feedBack` name, so existing deployments and data are unaffected. The redesigned UI is additive and served behind a feature flag: `FEEDBACK_UI=v3` flips the `/` route to the new `static/v3/` shell, and `GET /v3` always serves it; the default `/` stays byte-identical to 0.2.9 until 0.3.0 flips the default. This release adds the `static/v3/` scaffold (navy app shell + styled fee[dB]ack wordmark, brand SVG + favicon + PWA manifest with 192/512 icons), an additive `fb` Tailwind color palette (legacy `dark`/`accent`/`gold` retained) with `static/v3/**` in the content globs, and the regenerated `static/tailwind.min.css`. Vanilla JS, prebuilt Tailwind, no Play CDN (Principle II). Shell wiring, screens, profile/scoring backends, and capability-runtime integration land in subsequent v0.3.0 changes.
|
||||
- **fee[dB]ack v0.3.0 rebrand + UI redesign (opt-in, isolated).** The visible product is being renamed from **Slopsmith** to **fee[dB]ack** (the `[dB]` is a decibel pun on the practice "feedback" loop) alongside a full dashboard-style UI redesign. **Rebrand scope is the app + docs wordmark only** — the repository, Python package, ghcr Docker image, `CONFIG_DIR`, and `SLOPSMITH_*` env vars all keep the `slopsmith` name, so existing deployments and data are unaffected. The redesigned UI is additive and served behind a feature flag: `SLOPSMITH_UI=v3` flips the `/` route to the new `static/v3/` shell, and `GET /v3` always serves it; the default `/` stays byte-identical to 0.2.9 until 0.3.0 flips the default. This release adds the `static/v3/` scaffold (navy app shell + styled fee[dB]ack wordmark, brand SVG + favicon + PWA manifest with 192/512 icons), an additive `fb` Tailwind color palette (legacy `dark`/`accent`/`gold` retained) with `static/v3/**` in the content globs, and the regenerated `static/tailwind.min.css`. Vanilla JS, prebuilt Tailwind, no Play CDN (Principle II). Shell wiring, screens, profile/scoring backends, and capability-runtime integration land in subsequent v0.3.0 changes.
|
||||
- **fee[dB]ack v0.3.0 app shell (sidebar + topbar + routing).** The v3 shell (`static/v3/index.html`) is now a re-chromed copy of the legacy app: the new left **sidebar** (HOME / LIBRARY groups) and **topbar** (secondary nav, search, Support, badge-cluster mount points) replace the hidden legacy navbar, and new `#v3-*` screens (dashboard/plugins/profile/playlists/saved) are added — while all legacy screens (`#home` library, `#favorites`, `#settings`, `#player`, `#audio`, plugin nav containers) are kept verbatim so `static/app.js` boots **unmodified** and the whole engine (player/highway, plugin loader, capabilities, audio, library, settings) is reused as-is. Navigation is the shared `window.showScreen` across `#v3-*`, reused legacy, and `#plugin-*` screens, with a responsive hamburger and a `localStorage`/`v3:`-namespaced shell. Plugin nav is mirrored into the sidebar from `/api/plugins` (UI placement is a deferred capability domain, so this uses the legacy loader, not capability dispatch). `static/v3/shell.js` wraps `window.showScreen` via the idempotent rehydration pattern to keep sidebar/topbar active-state in sync.
|
||||
- **fee[dB]ack v0.3.0 player profile + first-run onboarding + unified XP + streak.** Adds a single-user core **profile** (`profile`/`profile_progress`/`xp_profile` tables in `web_library.db`, additive + idempotent): display name + avatar, a stable `player_hash` (SHA-256 of the first name + a once-generated salt — stable across later renames; a future-leaderboard label, never auth), and a **streak** (any session on a calendar day keeps it; a missed day resets to 1). New endpoints: `GET/POST /api/profile`, `POST /api/profile/avatar` (base64, re-encoded to a ≤512px PNG under `CONFIG_DIR/avatars/`), `GET /api/profile/avatar/{name}` (safe-joined), `GET /api/profile/avatars` (bundled defaults under `static/v3/avatars/`), `GET /api/profile/progress` (one call for the badge), and `POST /api/xp/award`. **Unified XP:** `lib/xp.py` is the single XP curve (same math the minigames plugin shipped); the core `xp_profile` store is the one source of truth the profile badge reads, exposed to plugins via `context["award_xp"]`/`get_xp_progress`/`seed_xp`. The bundled **minigames** plugin now delegates XP to the core store (seeding once from its existing `profile.json` so earned levels carry over) — so song-play, minigames, and tutorials all feed one level. Frontend: a blocking first-run onboarding overlay (name + avatar grid + upload), the topbar profile badge (avatar, 🔥 streak, level + XP bar), and the `#v3-profile` screen. Tests: `tests/test_xp.py`, `tests/test_profile_api.py`.
|
||||
- **fee[dB]ack v0.3.0 song-stats store (best score + accuracy, plays, resume position).** A core `song_stats` table (`web_library.db`, additive + idempotent, PK `(filename, arrangement)`) records per-song/arrangement best/last score + accuracy, play count, and last position. Endpoints: `POST /api/stats` (scored session → `plays += 1`, `best_*` = max, `last_*` = new, plus **unified-XP award** `xp_for_run(score)` and a **streak** bump, both behind try/except so a side-effect failure never drops the stat write; or position-only `lastPlayPosition` → resume touch with no `plays` change), `GET /api/stats/{filename}` (aggregated across arrangements), `GET /api/stats/recent` (joined to song title/artist/art for "Jump back in"). Scoring stays frontend-driven: `static/v3/stats-recorder.js` tallies the `note:hit`/`note:miss` events the optional `feedBack-plugin-notedetect` already emits (and also accepts an explicit `note_detect:session-ended` summary), then POSTs on song end; it also persists resume position on pause/stop. No note-detect edit required — note-detection is a deferred capability domain, so the recorder uses those legacy events and degrades to "no accuracy" when the plugin isn't installed. Score/accuracy math is shared with the server via `lib/song_score.py`. Tests: `tests/test_song_score.py`, `tests/test_song_stats_api.py`.
|
||||
- **fee[dB]ack v0.3.0 song-stats store (best score + accuracy, plays, resume position).** A core `song_stats` table (`web_library.db`, additive + idempotent, PK `(filename, arrangement)`) records per-song/arrangement best/last score + accuracy, play count, and last position. Endpoints: `POST /api/stats` (scored session → `plays += 1`, `best_*` = max, `last_*` = new, plus **unified-XP award** `xp_for_run(score)` and a **streak** bump, both behind try/except so a side-effect failure never drops the stat write; or position-only `lastPlayPosition` → resume touch with no `plays` change), `GET /api/stats/{filename}` (aggregated across arrangements), `GET /api/stats/recent` (joined to song title/artist/art for "Jump back in"). Scoring stays frontend-driven: `static/v3/stats-recorder.js` tallies the `note:hit`/`note:miss` events the optional `slopsmith-plugin-notedetect` already emits (and also accepts an explicit `note_detect:session-ended` summary), then POSTs on song end; it also persists resume position on pause/stop. No note-detect edit required — note-detection is a deferred capability domain, so the recorder uses those legacy events and degrades to "no accuracy" when the plugin isn't installed. Score/accuracy math is shared with the server via `lib/song_score.py`. Tests: `tests/test_song_score.py`, `tests/test_song_stats_api.py`.
|
||||
- **fee[dB]ack v0.3.0 playlists, Saved for Later, and Continue-Playing.** Core playlist management (`playlists` + `playlist_songs` tables in `web_library.db`, additive + idempotent): create/rename/delete, add/remove/reorder songs, plus a reserved **Saved for Later** system playlist (created on first use; protected from rename/delete). Endpoints: `GET/POST /api/playlists`, `GET/PATCH/DELETE /api/playlists/{id}`, `POST /api/playlists/{id}/songs`, `DELETE /api/playlists/{id}/songs/{filename}`, `POST /api/playlists/{id}/reorder`, `POST /api/saved/toggle`, and `GET /api/session/continue` (derives the resume song + last position from `song_stats`, no new table). Frontend `static/v3/playlists.js` renders the `#v3-playlists` list + detail (drag-reorder, play, remove) and `#v3-saved`, and exposes `window.v3Saved.toggle()` for a "Save for later" affordance on song cards. Favorites reuse the existing favorites screen/API. Core REST, no capability domain. Tests: `tests/test_playlists_api.py`.
|
||||
- **fee[dB]ack v0.3.0 Dashboard / Home.** The `#v3-home` dashboard (matching the v0.3.0 design target) composes the new backends: a "Welcome back, {name}!" banner with a patch-notes link (`/api/version`), a hero card (Start Playing / Create Lobby), a **Continue-Playing** card (`/api/session/continue` → art, tuning chip, 4-segment progress; click resumes via `playSong` + best-effort seek), a stats row (audio-routing widget placeholder until prompt 18, library count from `/api/library/stats`, plugins count from `/api/plugins` where `status==="ready"`), and a **Recently Played** grid (`/api/stats/recent`) with per-song accuracy badges (good/mid/low ramp). Each widget fetches + renders independently and degrades gracefully (missing/empty endpoint → placeholder, never blocks first paint). `static/v3/dashboard.js`; re-renders on return to Home and on profile update.
|
||||
- **fee[dB]ack v0.3.0 tuner + instrument topbar badges.** The topbar gains an **instrument selector** (guitar/bass + string count + tuning + reference pitch) persisted via additive `/api/settings` fields (`reference_pitch` clamped 430–450, `instrument`, `string_count` 4–8, `tuning` name or semitone offsets); changing it emits `instrument:changed` so the note_detect scorer can re-tune (consumed once the external plugin adopts it). A **live tuner badge** stays idle until the user enables the mic (explicit gesture; `getUserMedia`), then shows a YIN note readout with a cents needle (green within ±5¢) using a new dependency-free `static/v3/tuner-core.js` (YIN + frequency→note/cents, honoring the reference pitch); clicking opens the full `feedBack-plugin-tuner` screen when installed. CPU-friendly (~20 Hz, paused when the tab is hidden, respects `prefers-reduced-motion`). Tests: `tests/test_settings_instrument.py`, `tests/js/tuner_core.test.js`.
|
||||
- **fee[dB]ack v0.3.0 tuner + instrument topbar badges.** The topbar gains an **instrument selector** (guitar/bass + string count + tuning + reference pitch) persisted via additive `/api/settings` fields (`reference_pitch` clamped 430–450, `instrument`, `string_count` 4–8, `tuning` name or semitone offsets); changing it emits `instrument:changed` so the note_detect scorer can re-tune (consumed once the external plugin adopts it). A **live tuner badge** stays idle until the user enables the mic (explicit gesture; `getUserMedia`), then shows a YIN note readout with a cents needle (green within ±5¢) using a new dependency-free `static/v3/tuner-core.js` (YIN + frequency→note/cents, honoring the reference pitch); clicking opens the full `slopsmith-plugin-tuner` screen when installed. CPU-friendly (~20 Hz, paused when the tab is hidden, respects `prefers-reduced-motion`). Tests: `tests/test_settings_instrument.py`, `tests/js/tuner_core.test.js`.
|
||||
- **fee[dB]ack v0.3.0 audio-routing widget (dashboard).** The dashboard's audio stat tile now reads the live audio session **through the capability runtime** — `audio-mix inspect` (route + faders + required kinds), `audio-input list-sources` (selected/available input), `audio-monitoring inspect` — and renders **Audio Input → VST/NAM/IR → Audio Output** with per-node state dots and a Connected/Not Connected line. It never touches `audio-mixer.js` internals or `nam_tone` routes directly; "Not Connected" is the honest browser default (no native route), and it degrades on `no-owner`/`no-handler`/`failed` or absent capabilities. Refreshes on `instrument:changed`, play/stop, capability audio events, and each Home visit. `static/v3/audio-routing.js`.
|
||||
- **fee[dB]ack v0.3.0 Plugins page.** The `#v3-plugins` screen renders the enriched `/api/plugins`: a "{N} active" header (`status==="ready"`), a card grid per plugin (icon, name, version, status pill with the error on failed, capability summary badges — declared domains / validation warnings / unsupported versions / shim hits / bundled / type), an **Open →** action that navigates to the plugin's injected `#plugin-<id>` screen, and All/Bundled/Visualizations filters. Surfaces a deep-link to the bundled **Capability Inspector** rather than re-implementing the graph. No new backend. `static/v3/plugins-page.js`.
|
||||
- **fee[dB]ack v0.3.0 Songs / Library screen (`#v3-songs`).** A native vanilla-JS library browser over the existing `/api/library*` endpoints: provider selector (via the `library` capability, not DOM scraping), grid + tree views, sort, format filter, a tri-state filter drawer (arrangements / stems / lyrics / tunings), topbar-driven search (debounced), infinite scroll, fb song cards with **accuracy badges** (good/mid/low ramp, batched via a new `GET /api/stats/best`), favorite + save-for-later affordances, and upload (reuses the existing uploader). The "Songs" sidebar nav now opens this screen. No regression to `/api/library*`. `static/v3/songs.js`.
|
||||
- **`ui.library-card-injection` capability + native song-card actions (fee[dB]ack v0.3.0).** New core capability (`static/capabilities/library-card-actions.js`, owner `core.ui.library-card-injection`, exposed as `window.feedBack.libraryCardActions`) lets plugins **register** per-song library-card actions (id, label, placement, applicability, enabled state, run handler) with `action-registered`/`action-result` events — replacing the legacy `.song-card` DOM-injection pattern (roadmap domain #9, now delivered as a frontend host). The native Songs grid renders registered actions in each card's "⋮" menu; the built-in **Edit metadata** and **Convert to E Standard (retune)** actions ship through it (`static/v3/card-actions-core.js`, calling the existing `openEditModal`/`retuneSong` globals). Songs cards also gain **arrangement chips** (play a specific arrangement) and a **multi-select** mode with batch **Add to playlist** / **Save for Later**. Recipe in `docs/capability-recipes.md`; tests in `tests/js/library_card_actions.test.js`. Migrating the external card-action plugins (Sloppak Converter, Find More, editor) onto `register(...)` is a follow-up.
|
||||
- **`ui.library-card-injection` capability + native song-card actions (fee[dB]ack v0.3.0).** New core capability (`static/capabilities/library-card-actions.js`, owner `core.ui.library-card-injection`, exposed as `window.slopsmith.libraryCardActions`) lets plugins **register** per-song library-card actions (id, label, placement, applicability, enabled state, run handler) with `action-registered`/`action-result` events — replacing the legacy `.song-card` DOM-injection pattern (roadmap domain #9, now delivered as a frontend host). The native Songs grid renders registered actions in each card's "⋮" menu; the built-in **Edit metadata** and **Convert to E Standard (retune)** actions ship through it (`static/v3/card-actions-core.js`, calling the existing `openEditModal`/`retuneSong` globals). Songs cards also gain **arrangement chips** (play a specific arrangement) and a **multi-select** mode with batch **Add to playlist** / **Save for Later**. Recipe in `docs/capability-recipes.md`; tests in `tests/js/library_card_actions.test.js`. Migrating the external card-action plugins (Sloppak Converter, Find More, editor) onto `register(...)` is a follow-up.
|
||||
- **`centOffset` exposed via `getSongInfo()`** — the arrangement `<centOffset>` field (float, cents) is now parsed from all chart sources (loose folder XML, sloppak wire format) and sent as `centOffset` in the `song_info` WebSocket message. Plugins can read `getSongInfo().centOffset` to obtain the arrangement's pitch-shift offset — commonly `-1200.0` for extended-range bass (one octave down) or a small non-zero value for true-tuned content (e.g. A443 ≈ +11.8 cents). Defaults to `0.0` when absent.
|
||||
- **`highway.getPhrases()` and `highway.getMastery()` public plugin API** — exposes phrase timing windows (`[{ index, start_time, end_time, max_difficulty }]`) and the current mastery slider value (`0..1`) as documented, stable plugin API. Both values were already in memory and reachable via internal names; this surfaces them with intent so plugins can implement section-aware logic (e.g. tracking accuracy per phrase, suppressing difficulty changes during a hard solo) without reaching into undocumented internals. Returns `null` when the song has no phrase data (GP imports, single-difficulty charts). Pair with the existing `hasPhraseData()` to gate phrase-aware code paths.
|
||||
- **Tailwind freshness guard + wider plugin scan.** A new `tailwind-fresh` CI job (`.github/workflows/tests.yml`) rebuilds `static/tailwind.min.css` with the pinned `tailwindcss@3.4.19` and hard-fails on any diff, so the committed prebuilt stylesheet can no longer silently lag source (after PR #411 removed the runtime Play CDN, a stale file shipped unstyled elements with no guard). The `tailwind.config.js` plugin content glob is widened to `./plugins/**/*.{js,html}`, which also scans non-`screen.js` plugin JS (e.g. `plugins/app_tour_*/script.js`) that was previously invisible to the build. Regenerating under the wider glob is a no-op for runtime behaviour — it only adds classes that were already used in source. Groundwork for the plugin `styles` capability (constitution 1.1.0, Principle II): runtime-installed plugins ship their own compiled CSS rather than relying on core's build-time scan.
|
||||
- **Plugin capability pipelines** — adds the first versioned capability coordination layer for plugin authors and support tooling. `/api/plugins` now exposes validated capability declarations, validation warnings, unsupported-version metadata, UI/runtime domain declarations, and compatibility shim summaries for legacy `nav` / `screen` / `settings` / `routes` / visualization surfaces. The browser runtime now tracks manifest participants separately from live handlers, explicit dispatch outcomes (`no-owner`, `no-handler`, `unsupported-command`, `incompatible-version`), claim lifecycle cleanup, manual override precedence, deterministic ownership conflicts, multi-provider ordering, shim hit counts, and a redaction-safe diagnostics snapshot capped at 64 KB. A bundled Capability Inspector plugin shows the live graph, and new docs cover the manifest schema, recipes, safety matrix, lifecycle cleanup, and diagnostics contract.
|
||||
- **Constitution 1.2.0 codifies capability-first plugins.** Principle III now makes `capability-pipelines.v1` manifest declarations the default Slopsmith-facing plugin contract, treats wrappers/private globals as compatibility bridges or capability gaps, and adds a concrete spec-kit Constitution Check gate for plugin capability metadata.
|
||||
- **Audio graph/session capability slice** — promotes `audio-mix`, `audio-input`, `audio-monitoring`, and coordinated `stems` diagnostics into the capability runtime. The new audio session host records song route/fader state, redaction-safe input sources, monitoring lifecycle outcomes, stem automation claims/overrides/orphans, and compatibility bridge hits for legacy faders, song volume, Stems master volume, 3D Highway analyser taps, audio startup barriers, and input source handoffs. `core.audio.session` coordinates `stems` without replacing the Stems plugin as the owner of actual stem playback/state.
|
||||
- **Audio-mix control plane** — makes `audio-mix` the player mixer source of truth. Core now exposes `list-faders`, `get-fader-value`, `set-fader-value`, `inspect-route`, and `inspect-analyser` through the capability runtime, routes native and compatibility-backed fader provider operations with a 2-second timeout, reports committed values back to the mixer UI, suppresses matching legacy faders when a native participant owns the same logical control, and expands audio-session diagnostics/Capability Inspector rendering for fader availability, source modes, bridge hits, route/analyser summaries, and timeout failures.
|
||||
- **Audio-input control plane** — makes `audio-input` the redaction-safe source of truth for instrument input discovery and lifecycle. Core now exposes `list-sources`, `select-source`, `open-source`, and `close-source` through the capability runtime, persists selected logical sources, keeps inspect/list/select prompt-free, routes provider `source.open`/`source.close` operations with bounded outcomes, shares compatible open sessions across requesters, suppresses compatibility-backed duplicate sources when a native provider owns the same logical key, and expands audio-session diagnostics/Capability Inspector rendering for selected input, open sessions, bridge hits, storage status, and permission/device failures without exposing raw device labels or live audio handles.
|
||||
- **Audio-monitoring control plane** — makes `audio-monitoring` the shared live-monitoring coordinator. Core now exposes provider registration/list/selection, explicit user-action `start`, requester-counted `stop`, prompt-free `inspect`/`monitoring.status`, and `set-direct-monitor` through the capability runtime. Monitoring starts integrate with selected `audio-input` readiness, background requesters can only attach to active compatible sessions, active sessions survive song/playback stops without auto-resuming after reload, native providers suppress compatibility-backed legacy monitor paths, and diagnostics/Capability Inspector now show providers, sessions, requesters, direct-monitor state, bridge hits, and distinct safe outcomes (`provider-selection-required`, `user-action-required`, `incompatible`, `unavailable`, `stopped`, etc.) without exposing raw audio/device data.
|
||||
- **Playback control plane** — promotes `playback` to an active core capability domain for song transport, timing, loop, route, requester/observer, bridge, and diagnostics state. Core now exposes `inspect`, user-authorized `start`, `pause`, `resume`, `stop`, `seek`, `set-loop`, and `clear-loop` through the capability runtime while `static/app.js` keeps raw `<audio>`/JUCE handles private behind a redaction-safe adapter. Playback diagnostics use pseudonymous targets in exported bundles, local display labels only in the Capability Inspector, bounded recent outcomes/events, and bridge accounting for `window.playSong`, legacy `song:*` events, `window.feedBack` transport helpers, loop helpers, and browser/native route handoff.
|
||||
- **Playback control plane** — promotes `playback` to an active core capability domain for song transport, timing, loop, route, requester/observer, bridge, and diagnostics state. Core now exposes `inspect`, user-authorized `start`, `pause`, `resume`, `stop`, `seek`, `set-loop`, and `clear-loop` through the capability runtime while `static/app.js` keeps raw `<audio>`/JUCE handles private behind a redaction-safe adapter. Playback diagnostics use pseudonymous targets in exported bundles, local display labels only in the Capability Inspector, bounded recent outcomes/events, and bridge accounting for `window.playSong`, legacy `song:*` events, `window.slopsmith` transport helpers, loop helpers, and browser/native route handoff.
|
||||
- **3D highway — Tone HUD, fret dividers, chord-diagram toggle, FPS counter.** The bundled `plugins/highway_3d` gains an amber **Tone-change HUD** (shows the active tone and the next scheduled tone change; position / size / visibility configurable in settings), a **fret-dividers** toggle (vertical dividers on the highway, on by default, via `h3dBgSetFretDividersVisible`), a **chord-diagram visibility** toggle (`h3dBgSetChordDiagramVisible`), and an **FPS counter** setting migrated to `BG_DEFAULTS.fpsVisible` (drops the legacy `h3d_showFps` localStorage key). Chord-diagram position is restricted to `tl`/`tr`; legacy `bl`/`br` values are coerced on load. Perf: accent-halo shell descriptors are pre-built per string in `initScene()` and the chord-verdict cache key is encoded as a number, eliminating per-frame allocations in the `drawNote()` and chord hot paths.
|
||||
- **Sloppak assembly preserves a short preview clip.** When a source chart carries a separate short browser-preview audio clip alongside the full song, the sloppak assembler now decodes it to `preview.ogg` at the sloppak root and records it under a new top-level `preview:` manifest key (POSIX relpath, same shape as `lyrics`/`cover`). A failed preview decode is logged at debug and skipped without aborting the overall build. Sources with no separate preview are unaffected. Older sloppak readers ignore the unknown `preview` key, so the change is purely additive (sloppak-spec.md §5.5 backward-compat). Documented in `docs/sloppak-spec.md` §2 alongside the other optional top-level keys. Enables [`feedBack-plugin-song-preview`](https://github.com/got-feedback/feedBack-plugin-song-preview) to render hover-to-listen previews for sloppaks without seeking into the full audio.
|
||||
- **Sloppak assembly preserves a short preview clip.** When a source chart carries a separate short browser-preview audio clip alongside the full song, the sloppak assembler now decodes it to `preview.ogg` at the sloppak root and records it under a new top-level `preview:` manifest key (POSIX relpath, same shape as `lyrics`/`cover`). A failed preview decode is logged at debug and skipped without aborting the overall build. Sources with no separate preview are unaffected. Older sloppak readers ignore the unknown `preview` key, so the change is purely additive (sloppak-spec.md §5.5 backward-compat). Documented in `docs/sloppak-spec.md` §2 alongside the other optional top-level keys. Enables [`slopsmith-plugin-song-preview`](https://github.com/got-feedback/feedback-plugin-song-preview) to render hover-to-listen previews for sloppaks without seeking into the full audio.
|
||||
- **Generic plugin asset route** — `GET /api/plugins/{plugin_id}/assets/{path}` serves arbitrary static files a plugin bundles under its own `assets/` directory (AudioWorklet modules, WASM, images, etc.), so plugins can self-host browser-fetchable assets without a CDN (Principle II). Containment is enforced by `lib/safepath.safe_join` against `<plugin>/assets/`, so `..` traversal, absolute paths, and NUL bytes cannot escape `assets/` to reach a plugin's Python modules. `.js` is served as `application/javascript`. First consumer: the stems plugin's pitch-preserving time-stretch worklet.
|
||||
- **Minigames framework — bundled as a core plugin (`plugins/minigames/`).** Promotes the upstream [`feedBack-plugin-minigames`](https://github.com/got-feedback/feedBack-plugin-minigames) repo into the core bundle so every FeedBack install gets the framework out of the box (same promotion path used for `highway_3d`). The plugin adds a top-level **Minigames** nav link (alongside Library / Favorites / Upload — not buried in the Plugins dropdown), a library-style card grid of installed minigame plugins, and a shared profile layer (XP, level, per-game leaderboards, cross-minigame unlocks) persisted under `CONFIG_DIR/minigames/` and opted into the settings export. Other plugins that want to ship a minigame add a `minigame` block to their `plugin.json` and call `window.feedBackMinigames.register(spec)`; the SDK exposes scoring (`createContinuous` runs a self-contained YIN tracker; `createDiscrete` / `createChord` wrap `note_detect`'s `createNoteDetector`), HUD primitives, run persistence, and a scheduler so individual minigames don't need their own DSP or backend. Backend endpoints live under `/api/plugins/minigames/{runs,profile,registry}`. The framework is plugin-shaped (not core code) per Principle III, but bundled so it ships with every install. First consumer: [`feedBack-plugin-flappy-bend`](https://github.com/got-feedback/feedBack-plugin-flappy-bend), shipped separately.
|
||||
- **Minigames framework — bundled as a core plugin (`plugins/minigames/`).** Promotes the upstream [`slopsmith-plugin-minigames`](https://github.com/got-feedback/feedback-plugin-minigames) repo into the core bundle so every Slopsmith install gets the framework out of the box (same promotion path used for `highway_3d`). The plugin adds a top-level **Minigames** nav link (alongside Library / Favorites / Upload — not buried in the Plugins dropdown), a library-style card grid of installed minigame plugins, and a shared profile layer (XP, level, per-game leaderboards, cross-minigame unlocks) persisted under `CONFIG_DIR/minigames/` and opted into the settings export. Other plugins that want to ship a minigame add a `minigame` block to their `plugin.json` and call `window.slopsmithMinigames.register(spec)`; the SDK exposes scoring (`createContinuous` runs a self-contained YIN tracker; `createDiscrete` / `createChord` wrap `note_detect`'s `createNoteDetector`), HUD primitives, run persistence, and a scheduler so individual minigames don't need their own DSP or backend. Backend endpoints live under `/api/plugins/minigames/{runs,profile,registry}`. The framework is plugin-shaped (not core code) per Principle III, but bundled so it ships with every install. First consumer: [`slopsmith-plugin-flappy-bend`](https://github.com/got-feedback/feedback-plugin-flappy-bend), shipped separately.
|
||||
- **Alpha-build heads-up banner** — when `/api/version` reports a version string containing "alpha" (case-insensitive), an amber banner appears at the top of the library section warning users that the build is in active development and may have bugs or breaking changes. The banner stays hidden on stable / beta / RC builds. No persistence or dismiss state — it's a passive notice, not a modal.
|
||||
- **Drum vocabulary expanded to 18 pieces** — adds `stack` (MIDI 30, from GM's extended-percussion range, unused by real drum-kit MIDIs) and `bell` (MIDI 80 "Mute Triangle", also unused in real drum-kit MIDIs) to `lib/drums.py` PIECES. Inserted in the iteration order so the editor / highway lane ordering is *hi-hat → stack → crash → … → ride bell → bell*. Both are cymbals; default shape `circle_jagged` (stack) / `circle_dot` (bell). Old drum tabs round-trip unchanged — the schema is permissive and existing piece-ids are untouched.
|
||||
- **GP / MIDI drum import surfaces unmapped notes** — `convert_drum_track_to_drumtab` (`lib/gp2rs.py`) and `convert_drum_track_from_midi` (`lib/midi_import.py`) gain an optional keyword-only `out_unmapped` parameter. Callers that pass an empty dict receive a per-MIDI record of every silently-skipped percussion note (`{midi: {"count": int, "times": [float, ...]}}`, times capped at 100 samples per note). This lets the editor plugin show a warning + manual-mapping UI on import instead of silently dropping unmapped notes. Default behavior unchanged for callers that don't opt in.
|
||||
|
||||
- **Drum support from scratch** — drums are now a first-class arrangement type with their own JSON payload on disk and their own WS stream to the highway. New `lib/drums.py` defines the closed piece-id vocabulary (kick, snare, snare_xstick, hh_closed/open/pedal, tom_hi/mid/low/floor, crash_l/r, splash, china, ride, ride_bell), default GM-MIDI mappings, three preset lane configurations, and a permissive `drum_tab.json` validator. `lib/sloppak.py::load_song` reads the manifest's optional top-level `drum_tab:` key, parses + validates the JSON, and surfaces it on `LoadedSloppak.drum_tab`; the load stays permissive so a missing or malformed tab silently disables drums rather than failing the sloppak load. `/ws/highway/{filename}` gains two new message types — `drum_tab` (metadata + kit legend) and chunked `drum_hits` (500 hits per frame, same chunking as notes) — exposed to renderers via `bundle.drumTab`. `song_info` carries a `has_drum_tab` flag so viz pickers can auto-activate the drums highway regardless of which guitar arrangement is selected. `lib/gp2rs.py::convert_drum_track_to_drumtab` converts a Guitar Pro drum track to a `drum_tab.json` dict, preserving velocity verbatim, mapping hi-hat openness through the canonical piece-ids, and flagging flam / ghost / cymbal-choke articulations from GP effects. `lib/midi_import.py` gains `list_drum_tracks` + `convert_drum_track_from_midi` (channel-9 only) with heuristic flam-collapse (≤30 ms same-piece) and choke detection (cymbal note-off ≤120 ms). `docs/sloppak-spec.md` §5.3 promotes drum_tab from worked-example to canonical with the closed piece-id table and wire format. Sloppaks without a drum_tab are unaffected; legacy drums-as-guitar-notes sloppaks keep playing via the drums plugin's fallback decoder.
|
||||
- **Loose folder support** — a directory containing an audio file + arrangement XMLs, with optional `manifest.json` and album art, is now discovered, indexed, and playable as a first-class library format alongside Sloppak. The scanner walks `DLC_DIR` for non-preview audio files and treats each parent directory that also contains XMLs as a loose song. Metadata follows a `manifest.json` → XML tags → folder-name priority chain (see `lib/loosefolder.py`). Songs are tagged `format: "loose"`, render an amber `FOLDER` badge in the library, and are filterable via the new "Folder" option in the format dropdown. Audio uses the shared vgmstream/`convert_wem` pipeline, cached under `AUDIO_CACHE_DIR`. The chart `<offset>` from the first non-vocals XML is now propagated to the frontend via `song_info.offset` and applied in `highway.setTime()` so loose folders authored against non-silence-padded audio stay in sync. Pairs with the companion `feedBack-plugin-loosefolder` plugin which adds an in-player Fix Sync UI for nudging and saving offset corrections.
|
||||
- Highway note-state hook (#254). New `highway.setNoteStateProvider(fn)` lets a scorer plugin publish a per-note judgment (`'hit'` / `'active'` for a sustain currently held correctly / `'miss'`, or `{ state, alpha, color }`) so the renderer lights up the **gem itself** on a correct hit and keeps a sustain trail glowing while it's still being played right — instead of a separate overlay ring near the note. The built-in 2D highway honors it in `drawNote` / `drawSustains` / the chord-frame path (bright string colour + additive halo on hits, bright vs dim sustain trail, faint red wash on misses); the bundled 3D highway reads the same data via `bundle.getNoteState` (bright string-tinted outline + bright body + glowing sustain on hit/active, red outline + suppressed body on miss). Custom renderers opt in by calling `bundle.getNoteState(note, chartTime)`. note_detect registers the provider (and still owns its HUD / diagnostic miss markers / "currently detected" indicator); renderers that ignore the hook simply don't light gems. On a confirmed hit/active the renderers add a contained "sparkle/sizzle" on the note — the 2D highway: additive throbbing halo + flickering hot core + crackling spark lines (+ an expanding shockwave ring on a fresh strike) on the gem and a glowing/jittery sustain trail; the 3D highway: a few twinkling bright dots and short crackling arc segments hugging the note's rectangle (no bloom past the note), drawn on its overlay and projected through the camera so they ride the note. Also adds `highway.isDefaultRenderer()` so overlays that position with the 2D-highway helpers (`project` / `fretX`) can skip rendering when a custom renderer is active — fixes note_detect's miss markers appearing in random places over the 3D highway. New 3D-highway setting **Show note preview on the fretboard** (on by default) toggles the board-projection ghost — the translucent preview of the upcoming note on the fretboard surface. (Note: the companion change in the **note_detect plugin repo** turns its full-screen green/red edge flash off by default and adds a toggle to re-enable it — ships separately with note_detect, not in this feedBack release.)
|
||||
- Diagnostic bundle export (#166). New "Export Diagnostics" + "Preview Bundle" buttons in Settings produce a single redacted zip combining server logs (tail of `LOG_FILE`), system info (Python/OS/version), hardware probe (CPU model + cores + freq + RAM, GPU via `nvidia-smi`/`rocm-smi`/`system_profiler`, container/Electron/bare runtime detection), full plugin inventory with git SHA + remote URL (read directly from `.git/HEAD` so it works in minimal runtime images without `git` installed) + orphan/failed-to-load detection, the browser console transcript (all levels: log/info/warn/error/debug + window.onerror + unhandledrejection, 500-entry ring buffer), browser hardware (WebGL/WebGPU adapter info, navigator + userAgentData), filtered localStorage, and per-plugin contributed diagnostics. Top-level `manifest.json` lists every file with its versioned schema id (`system.hardware.v1`, `client.console.v1`, etc.) so AI agents can dispatch by schema. Redaction is on by default: DLC paths, song filenames (`<song:HASH8>` stable per-bundle), IPv4/IPv6 addresses, bearer tokens, and `key=`/`token=` query strings are replaced. Plugins opt their backend diagnostics in via a new `diagnostics` manifest field (`server_files` allowlist mirroring `settings.server_files` semantics, plus an optional `callable: "<module>:<function>"` resolved lazily via `load_sibling`). Frontend plugins push diagnostics via `window.feedBack.diagnostics.contribute(plugin_id, payload)`. Three new endpoints: `POST /api/diagnostics/export`, `GET /api/diagnostics/preview`, `GET /api/diagnostics/hardware`. Full bundle format spec in `docs/diagnostics-bundle-spec.md`.
|
||||
- **Loose folder support** — a directory containing an audio file + arrangement XMLs, with optional `manifest.json` and album art, is now discovered, indexed, and playable as a first-class library format alongside Sloppak. The scanner walks `DLC_DIR` for non-preview audio files and treats each parent directory that also contains XMLs as a loose song. Metadata follows a `manifest.json` → XML tags → folder-name priority chain (see `lib/loosefolder.py`). Songs are tagged `format: "loose"`, render an amber `FOLDER` badge in the library, and are filterable via the new "Folder" option in the format dropdown. Audio uses the shared vgmstream/`convert_wem` pipeline, cached under `AUDIO_CACHE_DIR`. The chart `<offset>` from the first non-vocals XML is now propagated to the frontend via `song_info.offset` and applied in `highway.setTime()` so loose folders authored against non-silence-padded audio stay in sync. Pairs with the companion `slopsmith-plugin-loosefolder` plugin which adds an in-player Fix Sync UI for nudging and saving offset corrections.
|
||||
- Highway note-state hook (#254). New `highway.setNoteStateProvider(fn)` lets a scorer plugin publish a per-note judgment (`'hit'` / `'active'` for a sustain currently held correctly / `'miss'`, or `{ state, alpha, color }`) so the renderer lights up the **gem itself** on a correct hit and keeps a sustain trail glowing while it's still being played right — instead of a separate overlay ring near the note. The built-in 2D highway honors it in `drawNote` / `drawSustains` / the chord-frame path (bright string colour + additive halo on hits, bright vs dim sustain trail, faint red wash on misses); the bundled 3D highway reads the same data via `bundle.getNoteState` (bright string-tinted outline + bright body + glowing sustain on hit/active, red outline + suppressed body on miss). Custom renderers opt in by calling `bundle.getNoteState(note, chartTime)`. note_detect registers the provider (and still owns its HUD / diagnostic miss markers / "currently detected" indicator); renderers that ignore the hook simply don't light gems. On a confirmed hit/active the renderers add a contained "sparkle/sizzle" on the note — the 2D highway: additive throbbing halo + flickering hot core + crackling spark lines (+ an expanding shockwave ring on a fresh strike) on the gem and a glowing/jittery sustain trail; the 3D highway: a few twinkling bright dots and short crackling arc segments hugging the note's rectangle (no bloom past the note), drawn on its overlay and projected through the camera so they ride the note. Also adds `highway.isDefaultRenderer()` so overlays that position with the 2D-highway helpers (`project` / `fretX`) can skip rendering when a custom renderer is active — fixes note_detect's miss markers appearing in random places over the 3D highway. New 3D-highway setting **Show note preview on the fretboard** (on by default) toggles the board-projection ghost — the translucent preview of the upcoming note on the fretboard surface. (Note: the companion change in the **note_detect plugin repo** turns its full-screen green/red edge flash off by default and adds a toggle to re-enable it — ships separately with note_detect, not in this slopsmith release.)
|
||||
- Diagnostic bundle export (#166). New "Export Diagnostics" + "Preview Bundle" buttons in Settings produce a single redacted zip combining server logs (tail of `LOG_FILE`), system info (Python/OS/version), hardware probe (CPU model + cores + freq + RAM, GPU via `nvidia-smi`/`rocm-smi`/`system_profiler`, container/Electron/bare runtime detection), full plugin inventory with git SHA + remote URL (read directly from `.git/HEAD` so it works in minimal runtime images without `git` installed) + orphan/failed-to-load detection, the browser console transcript (all levels: log/info/warn/error/debug + window.onerror + unhandledrejection, 500-entry ring buffer), browser hardware (WebGL/WebGPU adapter info, navigator + userAgentData), filtered localStorage, and per-plugin contributed diagnostics. Top-level `manifest.json` lists every file with its versioned schema id (`system.hardware.v1`, `client.console.v1`, etc.) so AI agents can dispatch by schema. Redaction is on by default: DLC paths, song filenames (`<song:HASH8>` stable per-bundle), IPv4/IPv6 addresses, bearer tokens, and `key=`/`token=` query strings are replaced. Plugins opt their backend diagnostics in via a new `diagnostics` manifest field (`server_files` allowlist mirroring `settings.server_files` semantics, plus an optional `callable: "<module>:<function>"` resolved lazily via `load_sibling`). Frontend plugins push diagnostics via `window.slopsmith.diagnostics.contribute(plugin_id, payload)`. Three new endpoints: `POST /api/diagnostics/export`, `GET /api/diagnostics/preview`, `GET /api/diagnostics/hardware`. Full bundle format spec in `docs/diagnostics-bundle-spec.md`.
|
||||
- Structured logging bootstrap (phase 1 of #155). Three new environment variables control server log output: `LOG_LEVEL` (default `INFO`), `LOG_FORMAT` (`text` for coloured console, `json` for one-JSON-object-per-line suitable for Loki/ELK/Promtail), and `LOG_FILE` (optional path, rotated at 10 MB with 5 backups). HTTP responses now include a `X-Request-ID` correlation header (via `CorrelationIdMiddleware`); the same request ID appears as `request_id` in structured log lines emitted via the stdlib `logging` / `structlog` APIs during that request.
|
||||
- Structured logging migration completed (phase 2 of #155, #159, #242). The 42 `print()` calls and 6 `traceback.print_exc()` calls across `server.py` and `lib/` have been migrated to levelled `feedBack.*` loggers. Silent `except: pass` blocks in `lib/sloppak.py` and `lib/sloppak_convert.py` now surface as `log.warning` / `log.debug` with the exception attached. WebSocket handlers (`highway_ws`, `ws_retune`) bind a `ws_conn_id` contextvar at accept time so every log line within a session carries a connection ID. A CI grep guard in `.github/workflows/tests.yml` fails the build if either `print(` or `traceback.print_exc(` reappears in `server.py` or `lib/`.
|
||||
- Structured logging migration completed (phase 2 of #155, #159, #242). The 42 `print()` calls and 6 `traceback.print_exc()` calls across `server.py` and `lib/` have been migrated to levelled `slopsmith.*` loggers. Silent `except: pass` blocks in `lib/sloppak.py` and `lib/sloppak_convert.py` now surface as `log.warning` / `log.debug` with the exception attached. WebSocket handlers (`highway_ws`, `ws_retune`) bind a `ws_conn_id` contextvar at accept time so every log line within a session carries a connection ID. A CI grep guard in `.github/workflows/tests.yml` fails the build if either `print(` or `traceback.print_exc(` reappears in `server.py` or `lib/`.
|
||||
- **Lyrics Karaoke plugin** — end-to-end karaoke setup for Sloppak songs in one workflow. The setup screen shows a per-song checklist (vocals stem / synced lyrics / per-syllable pitch) and a single "Build Karaoke" button that runs whatever's missing: Whisper alignment of pasted lyric text against the vocals stem, then `librosa.pyin` pitch extraction. Both artifacts persist inside the Sloppak (`lyrics.json`, `vocal_pitch.json`). In the player, a "Karaoke" toggle swaps the text-lyrics overlay for a horizontal pitch ribbon (one bar per syllable, vertically positioned by pitch, sweeping playhead).
|
||||
- Settings export/import (#113). Two buttons on the Settings page bundle server config, browser localStorage, and opted-in plugin server-side files into a single versioned JSON file for backup, migration, or sharing a calibrated setup. Server-side import is all-or-nothing for safety-critical failures: phase-1 validates the entire bundle (schema, path-traversal, encoding) before any disk writes; phase-2 commits each file via temp+rename. Plugin-state mismatches between export and import are handled leniently: files referenced for a plugin that isn't loaded are skipped with a warning, files referenced for a plugin whose manifest no longer declares them are skipped with a warning, and localStorage is merged (not cleared) so first-run defaults from plugins installed after the export are preserved. Path-traversal, absolute paths, schema mismatch, and decode failures remain hard refusals. Plugins opt their server-side files in by declaring `settings.server_files` in `plugin.json` (list of relpaths under `CONFIG_DIR`; trailing `/` denotes a directory).
|
||||
- Library filtering by parts present or missing (#129, #69). New right-side Filters drawer (single button next to the format/sort row, with active-filter count badge and dismissible chips below) lets you require or exclude arrangements (Lead/Rhythm/Bass/Combo), specific stems on Sloppaks (drums/bass/vocals/piano/other), lyrics, and tuning. Multi-select within an axis is OR (Lead OR Rhythm); cross-axis is AND. State persists across reloads. New endpoint `GET /api/library/tuning-names` returns distinct tunings present in the library, ordered by musical distance.
|
||||
@@ -267,17 +78,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- **`highway.getLyrics()` accessor.** `createHighway()` now exposes the parsed timed lyric syllables (`[{t, d, w}]`) via `getLyrics()`, mirroring `getBeats()`/`getSections()`, so overlay plugins can render karaoke without opening a second highway WebSocket. Pure accessor; no behavior change.
|
||||
|
||||
### Changed
|
||||
- **Perf (3D highway, feedBack#226)**: pre-warm `plugins/highway_3d/screen.js` object pools at board init. Previously the pool factory grew lazily on first `.get()` past the high-water mark, allocating a fresh `T.Mesh` mid-rAF on dense 7/8-string charts and stalling those frames; the meshes were then permanently added to `noteG` (the pool only hides on `reset()`, never removes), bloating the scene graph for the rest of the session. Pre-warming spends the cost up front. Fold the per-frame `updateStringHighlights()` per-string loop with the post-call `mGlow`/`mAccentCore` emissive writes — one walk over the per-string scratch arrays instead of two. Replace `longestConsecutiveRun`'s per-call array allocations with a `{start, len}` index pair (trades two per-call sub-array allocations for one small 2-key object — net reduction in per-visible-chord allocation churn). Opt-in perf bench harness via `?h3dbench=1` URL param: `console.log` p50/p95/max for six update() segments every 5 seconds; when the URL flag is absent the mark helpers are bound to empty functions at renderer-instance init (each `createHighway()` panel re-checks the flag), so the hot-path calls are no-ops with negligible overhead (typically JIT-inlined).
|
||||
- **Perf (3D highway, slopsmith#226)**: pre-warm `plugins/highway_3d/screen.js` object pools at board init. Previously the pool factory grew lazily on first `.get()` past the high-water mark, allocating a fresh `T.Mesh` mid-rAF on dense 7/8-string charts and stalling those frames; the meshes were then permanently added to `noteG` (the pool only hides on `reset()`, never removes), bloating the scene graph for the rest of the session. Pre-warming spends the cost up front. Fold the per-frame `updateStringHighlights()` per-string loop with the post-call `mGlow`/`mAccentCore` emissive writes — one walk over the per-string scratch arrays instead of two. Replace `longestConsecutiveRun`'s per-call array allocations with a `{start, len}` index pair (trades two per-call sub-array allocations for one small 2-key object — net reduction in per-visible-chord allocation churn). Opt-in perf bench harness via `?h3dbench=1` URL param: `console.log` p50/p95/max for six update() segments every 5 seconds; when the URL flag is absent the mark helpers are bound to empty functions at renderer-instance init (each `createHighway()` panel re-checks the flag), so the hot-path calls are no-ops with negligible overhead (typically JIT-inlined).
|
||||
- **License**: Relicensed to AGPL-3.0-only. Prior versions claimed MIT in the README, but the bundled desktop build statically links JUCE 8 (AGPL-3.0), so AGPL terms have effectively governed the desktop distribution since JUCE was added. AGPL-3.0-only is now the canonical license for the project — see [LICENSE](LICENSE) and [CONTRIBUTING.md](CONTRIBUTING.md) (DCO sign-off + plugin licensing policy). Bundled and vendored third-party code keeps its original license.
|
||||
- Tuning sort is now ordered by musical distance from E Standard (#22) instead of alphabetical: E Standard first, then Drop D / F Standard at distance 2, then Eb Standard / F# Standard at distance 6, etc. Within a magnitude tier, down-tuned variants come before up-tuned, then alphabetical.
|
||||
- Settings page restructured into separate "FeedBack" (core) and "Plugins" sections, with each plugin's settings rendered as a collapsible panel (collapsed by default). "Plugin Updates" moved into the Plugins section.
|
||||
- Settings page restructured into separate "Slopsmith" (core) and "Plugins" sections, with each plugin's settings rendered as a collapsible panel (collapsed by default). "Plugin Updates" moved into the Plugins section.
|
||||
- **Lyrics Sync** is now a redirect stub. Its alignment + save endpoints moved into the new Lyrics Karaoke plugin alongside the pitch extraction. Existing nav entries and bookmarks land on a "moved" page that auto-redirects to the merged plugin.
|
||||
|
||||
### Security
|
||||
- **Path traversal in archive extractors and library path resolution.** `lib/sloppak.py::_unpack_zip` and `server.py::_resolve_dlc_path` previously concatenated attacker-controlled entry names or filenames directly onto the extraction or library directory, so a crafted sloppak zip member or library filename with `..` segments, an absolute path, or backslash separators could write or read outside the intended directory. Any code path that unpacks a user-supplied archive (library upload, click-to-play, retune) or resolves a library path was reachable. Both locations now delegate to a new `lib/safepath.py::safe_join` helper that resolves each destination once and rejects entries that don't fall under the target directory; rejected entries are logged and skipped, the rest of the archive still extracts. The stem-split paths in `lib/sloppak_convert.py::split_stems` and `scripts/split_stems.py` previously called `ZipFile.extractall()` directly on user-supplied sloppaks; both now delegate to the same hardened `lib/sloppak.py::_unpack_zip` so every sloppak-unzip site in the codebase shares one containment guarantee. Tests in `tests/test_archive_traversal.py` and `tests/test_safepath.py` pin the contract for `../`, deep traversal, absolute paths, mixed `subdir/../../` forms, Windows-style separators, NUL bytes, names that resolve to the unpack root, and symlinked roots.
|
||||
|
||||
### Fixed
|
||||
- E Standard retune now stays metadata-consistent across a chart's arrangement files (feedBack-plugin-notedetect#50). Previously the retune path could shift the audio and update manifests while leaving some arrangement metadata untouched, so `load_song()` later exposed the *original* tuning at runtime. `lib/retune.py` now updates every arrangement's tuning metadata consistently before applying the E Standard tuning, and raises on a partial update instead of silently packing split tuning metadata. EStd files generated before this fix should be re-converted so their metadata is consistent.
|
||||
- E Standard retune now stays metadata-consistent across a chart's arrangement files (slopsmith-plugin-notedetect#50). Previously the retune path could shift the audio and update manifests while leaving some arrangement metadata untouched, so `load_song()` later exposed the *original* tuning at runtime. `lib/retune.py` now updates every arrangement's tuning metadata consistently before applying the E Standard tuning, and raises on a partial update instead of silently packing split tuning metadata. EStd files generated before this fix should be re-converted so their metadata is consistent.
|
||||
- Keyboard shortcut help now opens from the Player/3D Highway context when Linux/Electron reports Shift+Slash as `key="/"`, including while player controls such as the visualization picker are focused (#598).
|
||||
- 3D Highway left-handed mode now has regression coverage for fret-axis mirroring, board rebuilds on runtime lefty changes, and mirrored camera state including the lookahead target and shoulder offset; the maintainer guide no longer claims the renderer ignores `bundle.lefty` (#321).
|
||||
- Chord-level `fretHandMute` is now parsed into each note's `fret_hand_mute` (wire `fhm`) instead of being folded into `mute` (`mt`), matching `_parse_note` and preserving wire-format fidelity for both the template-expanded (synthetic-note) and explicit-`chordNote` paths. The 3D highway renders the fret-hand-mute X for `mt` *or* `fhm` notes, so the muted-chord overlay still shows. Also fixes the per-note fret-connector label vanishing exactly at the hit line (the fade now holds full opacity through `dt = 0`).
|
||||
@@ -286,8 +97,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- Demucs stem split failing on Windows desktop with `OSError: Could not load this library: libtorchcodec_core4.dll` or `ImportError: TorchCodec is required for save_with_torchcodec`. The demucs subprocess now bootstraps a `torchaudio.save` → `soundfile.write` shim before importing demucs, sidestepping the torchcodec dependency entirely. The override stays in place across torchaudio versions — soundfile's WAV writes are behaviorally equivalent for demucs's float32 outputs.
|
||||
- Splitscreen pop-out windows briefly flashed the library/song grid before showing the popped panel. A popup loads the full app (whose default screen, `#home`, is the library) and only swaps to the player once the splitscreen plugin loads; app init now detects `?ssFollower=1` and switches to the player screen up front, so the popup shows player chrome the whole time.
|
||||
- Sloppak assembly dropped all tone data — affected sloppaks showed no signal chain in the Tones plugin and no tone-change markers on the highway. The assembler (`lib/sloppak_convert.py`) now lifts each arrangement's tones from the source chart via the new `lib/tones.py` helper and embeds them inline in the arrangement JSON under a `tones` key (`base`, `changes`, `definitions` — see `docs/sloppak-spec.md` §3.9). The highway WebSocket reads `base`/`changes` for sloppaks, and the Tones plugin (≥ 1.1.0) reads `definitions` to render the gear chain. Sloppaks built before this release carry no tone data and must be rebuilt from their source chart to gain it.
|
||||
- Tab View (feedBack-plugin-tabview ≥ 3.0.1): the bottom row of tablature was permanently hidden behind the player controls bar (#336). The overlay reserved 60px at the *top* (clearing the transparent HUD) and extended all the way to the bottom of `#player`, where the opaque `#player-controls` (z-index 10) drew over the last row. The overlay now measures `#player-hud` and `#player-controls` dynamically and insets both edges; a `ResizeObserver` on the controls bar re-runs the inset when it wraps to a second row on narrow viewports.
|
||||
- Tab View (feedBack-plugin-tabview ≥ 3.0.1): the cursor highlight led playback by roughly one beat (#336). alphaTab snaps `tickPosition` to the start of the *next* beat, so the cursor would race ahead by 500ms+ at typical tempos. The plugin now sends `tickPosition` one beat earlier so the snap lands on the current beat, and the highlight overlay tracks the bar cursor (`.at-cursor-bar`) instead of the next-beat cursor (`.at-cursor-beat`).
|
||||
- Tab View (slopsmith-plugin-tabview ≥ 3.0.1): the bottom row of tablature was permanently hidden behind the player controls bar (#336). The overlay reserved 60px at the *top* (clearing the transparent HUD) and extended all the way to the bottom of `#player`, where the opaque `#player-controls` (z-index 10) drew over the last row. The overlay now measures `#player-hud` and `#player-controls` dynamically and insets both edges; a `ResizeObserver` on the controls bar re-runs the inset when it wraps to a second row on narrow viewports.
|
||||
- Tab View (slopsmith-plugin-tabview ≥ 3.0.1): the cursor highlight led playback by roughly one beat (#336). alphaTab snaps `tickPosition` to the start of the *next* beat, so the cursor would race ahead by 500ms+ at typical tempos. The plugin now sends `tickPosition` one beat earlier so the snap lands on the current beat, and the highlight overlay tracks the bar cursor (`.at-cursor-bar`) instead of the next-beat cursor (`.at-cursor-beat`).
|
||||
|
||||
### Migration notes
|
||||
- **Constitution amended to 1.1.0 (Principle II — Vanilla Frontend).** Prebuilt Tailwind (`static/tailwind.min.css`) is now codified as non-negotiable: no Play CDN / runtime CSS JIT anywhere, core or plugin. Plugin authors: a plugin that uses Tailwind classes not guaranteed in core — especially arbitrary values like `w-[37px]` — MUST ship its own compiled stylesheet via the new `styles` manifest key, built with `corePlugins.preflight = false`. Plugins that use only core-guaranteed utilities, or that ship no Tailwind at all, need no change. Contributors: after adding any Tailwind class to core or a bundled plugin, run `bash scripts/build-tailwind.sh` and commit the regenerated CSS, or the `tailwind-fresh` CI job fails.
|
||||
|
||||
@@ -1,654 +1,22 @@
|
||||
# FeedBack — AI Agent Guide
|
||||
# CLAUDE.md
|
||||
|
||||
FeedBack is a self-hosted web app for browsing, playing, and practicing interactive music notation, built around its own open `.sloppak` chart format. Charts come from importing Guitar Pro (GP5/GP8) or MusicXML, or from authoring in the built-in editor. It runs as a Docker container with a FastAPI backend (`server.py`), vanilla JavaScript frontend (`static/`), shared Python libraries (`lib/`), and an extensive plugin system (`plugins/`). There are no frontend frameworks — everything is plain JS, HTML, and Tailwind CSS.
|
||||
Claude Code memory file. This repo's canonical project orientation lives in [`AGENTS.md`](AGENTS.md) - the cross-tool standard read by Cursor, Copilot, Codex, Aider, Cline, and others. The line below uses Claude Code's `@`-import to inline AGENTS.md into this memory file, so there's a single source of truth and no drift between two near-duplicate files.
|
||||
|
||||
## Architecture Quick Reference
|
||||
@AGENTS.md
|
||||
|
||||
```
|
||||
server.py FastAPI app — library API, WebSocket highway, plugin loading
|
||||
static/
|
||||
app.js Main frontend — screens, library views, player, plugin loader
|
||||
highway.js Canvas note highway renderer (createHighway factory)
|
||||
index.html Single-page app shell
|
||||
style.css Custom CSS loaded alongside Tailwind
|
||||
lib/
|
||||
song.py Core data models (Note, Chord, Arrangement, Song)
|
||||
sloppak.py Sloppak format support
|
||||
loosefolder.py Loose-folder XML chart support
|
||||
audio.py OGG/MP3 audio handling
|
||||
retune.py Pitch-shifting logic
|
||||
tunings.py Tuning name/offset utilities
|
||||
gp2rs.py Guitar Pro to arrangement XML conversion
|
||||
gp2midi.py Guitar Pro to MIDI
|
||||
plugins/
|
||||
__init__.py Plugin discovery, loading, requirements install
|
||||
<plugin_name>/ Each plugin is its own directory (often a git submodule)
|
||||
tests/
|
||||
test_*.py pytest test suite
|
||||
```
|
||||
## Claude-specific surfaces
|
||||
|
||||
## Plugin System
|
||||
The rest of this file is content that *only* makes sense for Claude Code (other AI tools have their own incompatible automation mechanisms). Skills, subagent, rule, and settings live under [`.claude/`](.claude/):
|
||||
|
||||
Plugins are the primary extension point. Each plugin lives in `plugins/<name>/` with a `plugin.json` manifest. Plugins are typically their own git repositories — see [CONTRIBUTING.md](CONTRIBUTING.md) for the licensing policy (curated plugins should be AGPL-3.0 or AGPL-compatible: MIT, BSD, Apache-2.0).
|
||||
- [`.claude/skills/plugin-scaffold/`](.claude/skills/plugin-scaffold/SKILL.md) - generates a plugin skeleton with capability-pipelines metadata.
|
||||
- [`.claude/skills/plugin-validate/`](.claude/skills/plugin-validate/SKILL.md) - validates `plugin.json` against `schema/plugin.schema.json` locally before push.
|
||||
- [`.claude/skills/speckit-*/`](.claude/skills/) - spec-kit skills (auto-generated from `.specify/`; don't edit manually).
|
||||
- [`.claude/rules/plugin-author.md`](.claude/rules/plugin-author.md) - glob-scoped to `plugins/**`; encodes the contracts from `docs/PLUGIN_AUTHORING.md` so suggestions don't drift from them.
|
||||
- [`.claude/agents/slopsmith-reviewer.md`](.claude/agents/slopsmith-reviewer.md) - plugin-aware code-review subagent. Invoke with `@slopsmith-reviewer`.
|
||||
- [`.claude/settings.json`](.claude/settings.json) - repo defaults (no hooks enabled by default; commented opt-in example for `plugin.json` validation on save).
|
||||
|
||||
```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",
|
||||
"styles": "assets/plugin.css",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
```
|
||||
See [`.claude/README.md`](.claude/README.md) for conventions when adding more.
|
||||
|
||||
All fields except `id` and `name` are optional. Plugins can have any combination of frontend (screen/script), backend (routes), and settings.
|
||||
## Why this file is short
|
||||
|
||||
`version` and `private` are advisory metadata — the plugin loader does not currently consume them, but plugins commonly include them for publishing/tooling purposes.
|
||||
|
||||
`description`, `category`, and `icon` are **optional, additive v3 Pedalboard metadata** (surfaced in `/api/plugins`, consumed by the v3 Plugins page `static/v3/plugins-page.js`). `description` is a short one-sentence summary shown under the pedal name. `category` (`audio | creation | practice | game | tools`, free-form; unknown/absent → curated default → `"other"`) picks which pedalboard the plugin sits on. `icon` is an assets-relative thumbnail path (e.g. `"assets/thumb.png"`, ~square ~256×256, same containment rule as `styles`, served via `/api/plugins/<id>/assets/...`); if omitted the loader auto-detects `assets/thumb.png`, and plugins with no thumbnail get a default pedal graphic. All three are backward-compatible — omit them and the plugin still loads. See [docs/plugin-v3-ui.md](docs/plugin-v3-ui.md).
|
||||
|
||||
`styles` is the **opt-in** for self-hosted CSS (Principle II — prebuilt Tailwind, no Play CDN). Core's `static/tailwind.min.css` only contains classes scanned from core source at build time, so a plugin installed at runtime (community / NAS) that uses classes core didn't scan — especially arbitrary values like `text-[11px]` — renders unstyled. Declaring `styles` makes the frontend inject one versioned `<link rel="stylesheet">` into `<head>` (covering the plugin's screen *and* its settings panel) pointing at the plugin's own compiled stylesheet. The value is a **plugin-root-relative path that must live under `assets/`** (e.g. `"assets/plugin.css"`) so it serves through the sandboxed `/api/plugins/<id>/assets/...` route. Build it with `corePlugins: { preflight: false }` (utilities only — core ships the single base reset; don't duplicate it) and **never** the Tailwind Play CDN. Plugins that use only core-guaranteed utilities, or ship no Tailwind, omit `styles` and are byte-for-byte unaffected. Full authoring guide + scaffold: [docs/plugin-styles.md](docs/plugin-styles.md).
|
||||
|
||||
`settings.server_files` is the **opt-in** for the unified Settings export/import flow (feedBack#113). It's a list of relpaths under `context["config_dir"]` that the plugin wants included in user-triggered backups. A trailing `/` denotes a directory (recurse). 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. 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.
|
||||
|
||||
`diagnostics` is the **opt-in** for the troubleshooting bundle (feedBack#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.
|
||||
|
||||
Plugins that omit the field contribute nothing to the bundle from the backend side. Frontend plugins can independently push state via `window.feedBack.diagnostics.contribute(plugin_id, payload)` from their `screen.js` before the user hits Export. Bundle layout + per-file schemas: [docs/diagnostics-bundle-spec.md](docs/diagnostics-bundle-spec.md).
|
||||
|
||||
Best practices:
|
||||
- Embed your own `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 user secrets, API keys, or session tokens. The bundle is shared with maintainers / posted to GitHub issues.
|
||||
|
||||
`type` is an optional role hint (feedBack#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.feedBackViz_<id>` factory exporting the setRenderer contract below.
|
||||
- Absent → no declared role; plugin is loaded and its script runs, but it doesn't appear in role-specific UIs.
|
||||
|
||||
**Backend routes** — `routes.py` must export a `setup(app, context)` function. The `context` dict provides:
|
||||
- `config_dir` — persistent config path
|
||||
- `get_dlc_dir()` — returns the DLC folder Path
|
||||
- `extract_meta()` — metadata extraction callable
|
||||
- `meta_db` — shared MetadataDB instance
|
||||
- `library_providers` — shared library provider registry for source-aware browsing
|
||||
- `register_library_provider(provider)` — register a plugin-provided library source. Providers expose `id`, `label`, optional `kind`/`capabilities`, and callable `query_page`, `query_artists`, `query_stats`, and `tuning_names` methods. Providers with `art.read` may also expose `get_art(song_id)` returning one of: a `Response` object (any media type, served as-is); raw `bytes` or `bytearray` (**assumed PNG** — use a `Response` or a `dict` with `content`+`media_type` keys for JPEG/WebP or other formats); a URL string (http/https → 302 redirect; other schemes are rejected with 400); a filesystem path string or `Path` (served as a file with auto-detected media type); or a `dict` with a `url`, `path`, or `content` key. Providers with `song.sync` may expose `sync_song(song_id)` returning `None` (success with no local file) or a `dict` — the dict is passed through as the JSON response and should include `filename`/`local_filename` if a local playable file was produced.
|
||||
- `unregister_library_provider(provider_id)` — remove a plugin-provided library source by id. The built-in `local` provider cannot be removed.
|
||||
- `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 "Sibling imports" below.
|
||||
- `log` — stdlib `logging.Logger` namespaced to `feedBack.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 "Backend plugin logging" below.
|
||||
|
||||
**Sibling imports — use `load_sibling`, not bare imports** (feedBack#33). 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 is `context["load_sibling"](name)`, which loads the sibling under a namespaced module name (`plugin_<id>.<name>`, where plugin_id is bijectively encoded so reverse-DNS-style ids like `com.example.foo` work without colliding: `_` -> `_5f_`, `.` -> `_2e_`) so each plugin gets its own copy:
|
||||
|
||||
```python
|
||||
def setup(app, context):
|
||||
helper = context["load_sibling"]("helper")
|
||||
HelperClass = helper.HelperClass
|
||||
# …
|
||||
```
|
||||
|
||||
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.
|
||||
- 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. (Don't mix bare imports and `load_sibling` for the same module — they'd execute the file twice and split module-level state.)
|
||||
|
||||
**Frontend scripts** — `screen.js` runs in the global scope via a `<script>` tag. It can access `window.playSong`, `window.showScreen`, `window.createHighway`, the `<audio>` element, and the `window.feedBack` event emitter.
|
||||
|
||||
**ES-module plugins (`scriptType:"module"`)** — a plugin may instead ship a native ES-module graph with **no build step**: set `"scriptType": "module"` in `plugin.json`, make `screen.js` a one-line `import './src/main.js'`, and put the module tree under `src/` (served by the sandboxed `/api/plugins/<id>/src/{path}` route). The host injects it as `<script type="module">`, whose `onload` fires only after the whole static-import graph evaluates — so the loader's completion-by-`onload` + `_loadingPluginId` + `playSong` wrapper-chain ordering all hold. Resolve your own asset URLs (worklets, WASM) with `import.meta.url` — `document.currentScript` is `null` in a module. Module top-level code does **not** re-run when the user re-enters the screen at the same version (the host loads screen.js once and `showScreen` re-injects nothing), so keep per-visit re-init in a `screen:changed` handler, exactly as classic plugins do. Classic global-scope `screen.js` remains fully supported. See `docs/plugin-modules.md`.
|
||||
|
||||
**The playSong wrapper chain** — Plugins commonly wrap `window.playSong` to hook into song playback. Plugins load alphabetically, so the last-loaded (alphabetically later) wrapper runs first, while the alphabetically first plugin runs closest to the original. Be aware that `await` calls in inner wrappers yield to the event loop — WebSocket messages can arrive before outer wrappers finish setup.
|
||||
|
||||
## Plugin Best Practices
|
||||
|
||||
### v3 UI (fee[dB]ack v0.3.0) — player-chrome contract
|
||||
|
||||
v0.3.0 ships a redesigned UI behind a flag (`FEEDBACK_UI=v3` or the `/v3` route);
|
||||
the classic UI (v2) stays the default until 0.3.0 ships, so **plugins must work in
|
||||
both**. v3 reuses the same engine (`server.py`, `app.js`, `highway.js`, `playSong`,
|
||||
`showScreen`, capabilities, library providers, the `window.feedBackViz_<id>` /
|
||||
`setRenderer` contract), so a plugin's **backend, capabilities, `nav`/`screen`,
|
||||
visualization renderers, diagnostics, and settings export work unchanged** — v3
|
||||
surfaces `nav` in its sidebar and mounts screens exactly as v2 does.
|
||||
|
||||
**The only thing that changed is the player chrome.** If your plugin injects a
|
||||
control into it, you must adapt:
|
||||
|
||||
- v2's wide always-visible `#player-controls` bar is, in v3, a **minimal
|
||||
auto-hiding transport** (fades ~2.5 s after the pointer stills during playback)
|
||||
plus a hover-reveal left icon rail. So injecting into `#player-controls` the
|
||||
legacy way means your control **auto-hides**, and the legacy insertion anchors
|
||||
(`insertBefore` the `span.text-gray-700` separator, or `button:last-child` / ✕
|
||||
Close) **don't exist in v3** → it lands wrong / unreachable.
|
||||
- **Detect v3** with `window.feedBack.uiVersion === 'v3'` and **mount into
|
||||
`window.feedBack.ui.playerControlSlot()`** (a stable, always-reachable container
|
||||
— the "Plugins" rail popover) instead of `#player-controls`. Drop the dead
|
||||
anchors (append), and guard re-injection against the *actual* container
|
||||
(`controls.contains(myBtn)`), not a hard-coded `#player-controls`.
|
||||
- A host `MutationObserver` re-homes legacy `#player-controls` children into the
|
||||
slot as a fallback, but it **breaks plugins that guard on
|
||||
`#player-controls.contains()`** (the moved node fails the check → re-inject every
|
||||
song). Mount into the slot yourself; don't rely on the shim.
|
||||
- v3 uses `fb-*` tokens (`fb-card`, `fb-text`, `fb-textDim`, `fb-primary`,
|
||||
`fb-border`) vs v2's `dark-*`/`accent`; legacy classes still render acceptably.
|
||||
Keep `#player` overlay `z-index` ≤ the chrome layers (transport/HUD 20, rail 30,
|
||||
popovers 40).
|
||||
|
||||
Full guide + the canonical snippet: **[docs/plugin-v3-ui.md](docs/plugin-v3-ui.md)**.
|
||||
Verify any player-injecting plugin in **both** `/` (v2) and `/v3`.
|
||||
|
||||
### Performance — never run DOM queries on a per-frame path
|
||||
|
||||
Plugins share the main thread with the highway's 60 fps render loop, and during
|
||||
playback the highway + note detectors mutate the DOM ~60×/s — so anything that
|
||||
*reacts* to DOM changes runs that often too. Work that looks cheap in isolation
|
||||
becomes the dominant cost when it runs every frame. A profiled "the 3D highway is
|
||||
laggy" report turned out to be **three plugins doing per-frame `querySelectorAll`**
|
||||
(~18% of main-thread CPU + NodeList GC churn), not the renderer. The GPU was idle.
|
||||
|
||||
- **Never call `querySelector` / `querySelectorAll` inside `draw()`, a
|
||||
`requestAnimationFrame` loop, a short `setInterval`, or a `MutationObserver`
|
||||
callback.** Resolve the element(s) **once** when your UI mounts and cache the
|
||||
references; re-resolve only when the cached node is gone (`!el.isConnected`).
|
||||
`querySelectorAll` also allocates a fresh `NodeList` every call → GC pressure at
|
||||
60 fps. (notedetect #75 — a VU meter that `querySelector`'d its bar every tick.)
|
||||
|
||||
- **Scope `MutationObserver`s narrowly — never `observe(document.body, { subtree:
|
||||
true })` just to notice your own UI's container mount.** A body-subtree observer
|
||||
fires on *every* DOM mutation anywhere, including the per-frame highway churn, so
|
||||
a callback that then scans the document is a per-frame full-DOM scan. Observe the
|
||||
specific container; if it's swapped on screen changes, observe a stable parent,
|
||||
or **cheaply early-bail** (one `getElementById` / a screen-state check) *before*
|
||||
the expensive work. (sloppak-converter #32 — a body-subtree observer re-ran
|
||||
whole-document inject sweeps on every frame of playback.)
|
||||
|
||||
- **Stop playback-tied loops when their UI is hidden.** An rAF/interval meter (VU,
|
||||
etc.) that keeps drawing while its panel is closed is pure waste — gate it on
|
||||
visibility, or stop and restart it on open/close.
|
||||
|
||||
- **Per-instance, not global.** Under splitscreen a viz/detector plugin runs
|
||||
multiple instances. Cache refs and resolve panels against your *own* instance's
|
||||
container, never a global `document.querySelector` that could grab a sibling
|
||||
instance's node. (notedetect #75 follow-up.)
|
||||
|
||||
These are cheap to get right up front and expensive to retrofit. Profile the
|
||||
**main thread**, not the GPU, when a renderer "feels laggy" — the offender is
|
||||
usually an unrelated plugin's per-frame DOM work.
|
||||
|
||||
### Visualization plugins — two complementary contracts
|
||||
|
||||
FeedBack 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.
|
||||
|
||||
**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.
|
||||
|
||||
#### 1. setRenderer contract (feedBack#36) — preferred
|
||||
|
||||
Plugins that want to replace the main highway's draw function (per panel, per session) export a renderer factory on `window.feedBackViz_<id>` where `<id>` matches the `id` in `plugin.json` (`type: "visualization"` required). The factory returns an object matching this shape:
|
||||
|
||||
```js
|
||||
window.feedBackViz_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).
|
||||
// The bundle OBJECT is reused across frames (mutated in
|
||||
// place — no per-frame allocation): never cache it or
|
||||
// compare its identity between frames; field values are
|
||||
// only valid for the current draw call. Array FIELDS still
|
||||
// swap reference when chart data changes, so field-identity
|
||||
// caches (`myRef !== bundle.chords`) remain valid.
|
||||
// Windowed-iteration helpers (stable fn refs): bundle
|
||||
// .lowerBoundT(arr, time) is a lower-bound binary search on
|
||||
// `.t` (notes/chords); bundle.lowerBoundTime(arr, time) on
|
||||
// `.time` (beats/anchors/sections). Use these to cull to
|
||||
// the visible window instead of full-scanning chart arrays
|
||||
// per frame.
|
||||
// `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) (feedBack#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.feedBackViz_<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.feedBack` and re-acquire / re-register. `window.feedBack.emit` dispatches a `CustomEvent`, so the payload `{ oldCanvas, newCanvas, contextType }` lives on `event.detail`, not on the event object itself:
|
||||
```js
|
||||
window.feedBack.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.feedBack` 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.feedBack.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 feedBack 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.feedBackViz_piano = function () { /* ... */ };
|
||||
window.feedBackViz_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`, `centOffset`, `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.
|
||||
|
||||
**Per-instance settings for host plugins (feedBack#849).** A viz provider may declare per-instance controls a consuming host (e.g. splitscreen's per-panel popover) renders generically, by adding a `settings` array to its `capabilities.visualization` manifest block: `[{ key, label, type: "toggle" | "range" | "select", default, min?, max?, step?, options? }]`. This is the capability-native, declarative replacement for the ad-hoc `factory.panelControls` static. The validated list is surfaced through the visualization host's `list-providers` snapshot, so a host reads it without knowing the plugin. **A provider that declares `settings` MUST implement `applySetting(key, value)` on its renderer instance** — the host calls it on the specific per-panel instance, which is inherently per-panel (no canvas→panel resolution, no shared global localStorage keys). `getSetting(key)` is optional (the host falls back to the declared `default`); the host owns persistence. `factory.panelControls` remains read as a legacy fallback for hosts that still consume it, but new viz should declare `settings` + `applySetting`.
|
||||
|
||||
#### 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()` — raw arrays containing every note/chord in the chart regardless of the current difficulty level
|
||||
- `highway.getFilteredNotes()` / `highway.getFilteredChords()` — difficulty-filtered variants. Returns the master-difficulty-filtered arrays when the song has phrase-level data (slider active); falls through to the raw arrays for songs with a single difficulty level (slider disabled). Plugins that process only the notes the player is currently expected to play should use these instead of `getNotes()` / `getChords()`
|
||||
- `highway.hasPhraseData()` — returns `true` when the current song has phrase-level difficulty ladder data (i.e. the mastery slider is active and `getFilteredNotes()` / `getFilteredChords()` return a filtered subset). Use this to gate logic that only makes sense when difficulty filtering is available
|
||||
- `highway.getPhrases()` — phrase timing windows `[{ index, start_time, end_time, max_difficulty }]` for the current song's difficulty ladder. Returns `null` when phrase data is absent (GP imports, single-difficulty charts). Read-only; do not mutate. Pair with `hasPhraseData()` to gate phrase-aware logic.
|
||||
- `highway.getMastery()` — current master-difficulty slider value as a fraction `0..1`. Reflects the same value the mastery slider is set to; meaningful only when `hasPhraseData()` is true.
|
||||
- `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. arrangement 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 arrangement 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/got-feedback/feedBack-plugin-fretboard) — canonical overlay implementation (navbar toggle, own canvas, 80ms active-note window).
|
||||
|
||||
**Why two?** 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 (feedBack#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.
|
||||
|
||||
### Audio mixer fader registration (feedBack#87)
|
||||
|
||||
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.
|
||||
|
||||
```js
|
||||
function _registerFader() {
|
||||
const api = window.feedBack && window.feedBack.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.feedBack && window.feedBack.audio) {
|
||||
_registerFader();
|
||||
} else {
|
||||
window.addEventListener('feedBack:audio:ready', _registerFader, { once: true });
|
||||
}
|
||||
```
|
||||
|
||||
The plugin owns persistence — the registry calls `getValue()` when the popover opens, and also after each `setValue()` during slider drags to re-sync the displayed value. Keep `getValue()` cheap and side-effect-free, and make sure `setValue()` updates whatever backing state `getValue()` reads synchronously. Pair `setValue` 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.
|
||||
|
||||
### Backend plugin logging
|
||||
|
||||
Use `context["log"]` for all backend plugin output. It is a stdlib `logging.Logger` namespaced to `feedBack.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.
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
For CLI entry points (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")
|
||||
```
|
||||
|
||||
### Diagnostics contribution from frontend (feedBack#166)
|
||||
|
||||
Plugins that hold useful debug state in the browser (active model name, last user input, internal counters) can push it into the diagnostics bundle by calling `window.feedBack.diagnostics.contribute(plugin_id, payload)` at any time. The contribution API is 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`.
|
||||
|
||||
```js
|
||||
window.feedBack.diagnostics.contribute('my_plugin', {
|
||||
schema: 'my_plugin.client_diag.v1',
|
||||
active_preset: getActivePreset(),
|
||||
last_error: _lastError,
|
||||
});
|
||||
```
|
||||
|
||||
Loaded from `static/diagnostics.js` ASAP in `<head>` so the console-wrap is in place before any other script runs. Available on the `window.feedBack.diagnostics` namespace alongside `snapshotConsole()`, `snapshotHardware()`, `snapshotUa()`, `snapshotLocalStorage()`, `snapshotContributions()`. Keep your payload small (< 100 KB) and don't include secrets — bundles are shared with maintainers.
|
||||
|
||||
### Keyboard Shortcuts
|
||||
|
||||
Plugins can register keyboard shortcuts via the global `window.registerShortcut()` function. Shortcuts appear in the `?` help panel.
|
||||
|
||||
```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** 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 (it returns the panel object — keep the 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** 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`).
|
||||
|
||||
**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) |
|
||||
|
||||
**Debugging:** Open browser console and type `_listShortcuts()` to inspect registered shortcuts.
|
||||
|
||||
### General plugin 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.feedBack.emit()` / `window.feedBack.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()`.
|
||||
|
||||
## Song Formats
|
||||
|
||||
FeedBack supports two song formats:
|
||||
|
||||
### Loose folder (XML charts)
|
||||
A directory containing arrangement XML plus an audio file (and optional `manifest.json` + album art). Discovered, indexed, and played directly — see `lib/loosefolder.py`. Metadata follows a `manifest.json` → XML tags → folder-name priority chain. Songs are tagged `format: "loose"` in the library.
|
||||
|
||||
### Sloppak (open format)
|
||||
An open, hand-editable song package designed for FeedBack. Exists in two interchangeable forms:
|
||||
- **Zip archive** (`.sloppak` file) — distribution form
|
||||
- **Directory** (`.sloppak/` folder) — authoring form
|
||||
|
||||
**Contents:**
|
||||
```
|
||||
manifest.yaml Song metadata (title, artist, album, duration, tuning, arrangement IDs, ...)
|
||||
arrangements/
|
||||
lead.json Note/chord/anchor data in wire format (see song.py)
|
||||
rhythm.json Files here are driven by manifest.yaml arrangement entries
|
||||
... (e.g. arrangements/<arrangement-id>.json)
|
||||
stems/
|
||||
full.ogg Mixed audio (always present)
|
||||
guitar.ogg Individual stems (optional, from Demucs split)
|
||||
bass.ogg
|
||||
drums.ogg
|
||||
vocals.ogg
|
||||
piano.ogg
|
||||
other.ogg
|
||||
cover.jpg Album art (optional)
|
||||
lyrics.json Syllable-level lyrics (optional)
|
||||
```
|
||||
|
||||
Sloppak is the preferred format for new features. The [Stems plugin](https://github.com/topkoa/slopsmith-plugin-stems) provides live stem mixing for sloppak songs.
|
||||
|
||||
**Full developer reference:** the authoritative format spec now lives in its own repo —
|
||||
[got-feedback/feedpak-spec](https://github.com/got-feedback/feedpak-spec)
|
||||
([`spec/feedpak-v1.md`](https://github.com/got-feedback/feedpak-spec/blob/main/spec/feedpak-v1.md)):
|
||||
manifest schema, arrangement wire format, and how to extend the format with new data types (drum
|
||||
tab, key/scale annotations, etc.). Published as **feedpak**; this codebase still uses the legacy
|
||||
**sloppak** name internally — same on-disk format. [docs/sloppak-spec.md](docs/sloppak-spec.md) is
|
||||
a local pointer + code map.
|
||||
|
||||
**Key code:**
|
||||
- `lib/sloppak.py` — format detection, zip/directory resolution, metadata extraction, song loading
|
||||
- `lib/sloppak_convert.py` — sloppak assembly pipeline, Demucs stem splitting
|
||||
- `lib/song.py` — shared data models (`Note`, `Chord`, `Arrangement`, `Song`) and wire format serialization used by both formats
|
||||
|
||||
## Frontend Conventions
|
||||
|
||||
- **No frameworks** — vanilla JS, fetch API, DOM manipulation
|
||||
- **Globals** — `highway`, `audio`, `playSong()`, `showScreen()`, `createHighway()`, `window.feedBack`
|
||||
- **Storage** — `localStorage` for all user preferences
|
||||
- **Styling** — Tailwind CSS utility classes, dark theme (`bg-dark-600`, `text-gray-300`, accent `#4080e0`, gold `#e8c040`). Tailwind is served as a **prebuilt** stylesheet (`static/tailwind.min.css`, regenerated by `bash scripts/build-tailwind.sh`), **never** the runtime Play CDN — the CDN's on-the-fly JIT rescanned the DOM on the main thread and dropped ~26% of frames with the 3D highway (feedBack-desktop#110). The committed CSS only contains classes the build scanner saw, so CI (`tailwind-fresh`) rebuilds and diffs it; run the build script and commit when you add new classes. A plugin that uses classes not guaranteed in core (notably arbitrary values like `w-[37px]`) MUST ship its own compiled stylesheet via the `styles` manifest key, built with `corePlugins.preflight = false` (utilities only — core ships the one base reset). Plugins MUST NOT load the Tailwind Play CDN or any runtime CSS JIT. See constitution Principle II.
|
||||
- **Naming** — camelCase for JS functions, kebab-case for CSS classes, snake_case for plugin IDs
|
||||
- **Text selection (v3)** — the v3 UI defaults to `user-select: none` on `html` (in `static/v3/v3.css`) so accidental drag/double-click selection of chrome never looks broken. Form fields are always re-enabled, and a **plugin's mounted screen subtree (`.screen[id^="plugin-"]`) stays selectable by default**, so a plugin's copy-worthy text (lyrics, chord names, results, diagnostics) is unaffected — *unless your plugin renders copyable content OUTSIDE its `plugin-<id>` screen* (e.g. injected into the player chrome / a HUD overlay), which inherits the non-select default. Opt such content back in with the core-served **`.fb-selectable`** class (it sets `user-select: text` on the element + descendants; works for runtime-installed plugins since it's hand-authored in core CSS, not a scanned Tailwind utility). Never use a `* { user-select: none }` rule (breaks input carets/IME), and never use `user-select: none` to "lock" text — keep errors, IDs, paths, versions, and metadata selectable. (v2 is unchanged.)
|
||||
- **Player layout** — `#player` is `display:flex; flex-direction:column; position:fixed; inset:0`. `#highway` is `flex:1`. `#player-controls` sits at the bottom. Hiding the highway collapses the layout — use `margin-top: auto` on controls if you need to hide it.
|
||||
|
||||
## Backend Conventions
|
||||
|
||||
- **Framework** — FastAPI with uvicorn
|
||||
- **Imports** — flat imports from `lib/` (no package `__init__.py`): `from song import Song`
|
||||
- **Database** — SQLite via MetadataDB class with `threading.Lock` for thread safety
|
||||
- **WebSocket** — JSON frames, try/except `WebSocketDisconnect`
|
||||
- **Error handling** — graceful fallbacks (audio conversion errors don't crash the song, missing art returns placeholder)
|
||||
- **Type hints** — used sparingly (`Path | None`, `dict`, `list`)
|
||||
- **Docstrings** — minimal; code is self-documenting
|
||||
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
pytest # Run all tests
|
||||
pytest tests/test_song.py -v # Specific file
|
||||
pytest -k "round_trip" -v # Pattern match
|
||||
```
|
||||
|
||||
- Framework: pytest
|
||||
- Config: `pyproject.toml` sets `pythonpath = [".", "lib"]` and `testpaths = ["tests"]`
|
||||
- CI: GitHub Actions runs pytest on push/PR to main (Python 3.12)
|
||||
- Test dependencies: `requirements-test.txt`
|
||||
|
||||
## Tuning the note_detect plugin
|
||||
|
||||
Detection quality is hard to judge by eye — a player UI that "feels worse" after a code change isn't a regression you can defend in review. The plugin ships with a record-replay-sweep workflow so changes to the detector, the matcher, or the user's environment (A/V offset, latency, channel) can be measured against a single reference take.
|
||||
|
||||
Quick orientation:
|
||||
- **Reference recording** lives in the gear popover on the player (gated behind Settings → Note Detection → "Detection tuning (advanced)"). Arm before pressing Play; auto-saves a WAV to `static/note_detect_recordings/` on song-end. The directory is bind-mounted, so the host-side harness can read it without a copy step.
|
||||
- **Benchmark sloppak** ships in-tree at [docs/benchmarks/note_detect_v1/note_detect_benchmark_v1.sloppak](docs/benchmarks/note_detect_v1/note_detect_benchmark_v1.sloppak) — 8 sections each isolating a different failure mode (low-freq mono, sustained holds, hammer/pull, power chords, dense open chords, bends). Drop it directly into your sloppak DLC folder to install (don't rename — feedBack keys off the `.sloppak` suffix even though the file is a zip under the hood). The unzipped form lands at `static/sloppak_cache/note_detect_benchmark_v1.sloppak/` after first play. Builder: [docs/benchmarks/note_detect_v1/build_benchmark.py](docs/benchmarks/note_detect_v1/build_benchmark.py).
|
||||
- **Headless harness** at [`tools/harness.js`](https://github.com/got-feedback/feedBack-plugin-notedetect/blob/main/tools/harness.js) in the note_detect plugin's own repo (cloned into `plugins/note_detect/` locally) runs the same `processFrame` / `matchNotes` / `checkMisses` code path off Node, in seconds per run. Same `note_detect.diagnostic.v1` schema as the in-app Download Diagnostic button.
|
||||
- **A/V auto-calibrate** (Settings → Note Detection) reads `timing_error_ms_hits.median` and proposes the av-offset that drives it to zero. Iterative: usually converges in 2–3 Apply rounds.
|
||||
|
||||
**Always record at 1.0× playback speed** — half-speed takes produce all-miss garbage because chart times are absolute. **Always use `timing_error_ms_hits` (not all-matched) as a calibration signal** — the all-matched median pins near a constant when the offset is wrong, because the matcher silently snaps to neighbouring chart notes.
|
||||
|
||||
Full developer reference (workflow recipes, harness flag table, diagnostic schema, common pitfalls): [docs/note-detect-tuning.md](docs/note-detect-tuning.md).
|
||||
|
||||
## Versioning
|
||||
|
||||
- **`VERSION`** (repo root) — single source of truth; plain semver string (e.g. `0.2.4`). Bind-mounted into the container and copied by the Dockerfile so it's always available at `/app/VERSION`.
|
||||
- **`GET /api/version`** — returns `{"version": "<contents of VERSION>", "source_url": "...", "license_url": "..."}`. The version drives the navbar badge; `source_url` / `license_url` populate the Settings → About links. `source_url` is configurable via the `APP_SOURCE_URL` env var (default `https://github.com/got-feedback/feedBack`); `license_url` falls back to `source_url + "/blob/main/LICENSE"` (GitHub-style, default branch `main`) and is overridable via the `APP_LICENSE_URL` env var — set it explicitly when the source is hosted on a non-GitHub forge (GitLab/Gitea/self-hosted) or under a non-`main` default branch. Both env values must be `http(s)`; non-http(s) values are rejected and fall back to the safe default to prevent `javascript:`/`data:` hrefs.
|
||||
- **Auto-sync** — `.github/workflows/sync-version.yml` rewrites `VERSION` via a `repository_dispatch` (`desktop-released`) fired from `feedBack-desktop`'s release job. As an explicit automation-only exception to the "Never push directly to main" rule in Git Workflow below, the sync job commits straight to `main` as `github-actions[bot]` (version bumps are mechanical; the PR round-trip adds no signal). Human contributors must still go through feature branches + PRs. No manual VERSION edits needed. Use the workflow's `workflow_dispatch` trigger with `version: X.Y.Z` for manual runs (recovery / out-of-band bumps).
|
||||
- **`CHANGELOG.md`** — follows [Keep a Changelog](https://keepachangelog.com/) format. Update the `[Unreleased]` section with each PR; when `feedBack-desktop` cuts a release, rename `[Unreleased]` to the new version + date (the VERSION bump itself is automated).
|
||||
|
||||
## Git Workflow
|
||||
|
||||
- **Never push directly to main** — always create a feature branch and open a PR
|
||||
- **Upstream remote** — set `upstream` to the canonical FeedBack repository; `origin` is your fork
|
||||
- **Plugins are gitlinks** — each plugin in `plugins/` is typically its own git repo (submodule or clone). Branch switches on the main repo can clobber plugin directories. Use `git update-index --assume-unchanged` for plugin dirs if needed.
|
||||
- **Commit style** — short imperative subject line, blank line, then body explaining *why*
|
||||
|
||||
## WebSocket Protocol Reference
|
||||
|
||||
The highway WebSocket at `/ws/highway/{filename}?arrangement={index}` streams these messages in 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, centOffset, 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). `centOffset` is a float (cents) from the RS2014 `<centOffset>` field — commonly `-1200.0` for extended-range bass (one octave down), small non-zero values for true-tuned content (e.g. A443 ≈ +11.8 cents), `0.0` when absent. Available via `getSongInfo().centOffset`. |
|
||||
| `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 }], source }` | Syllables: `w`=word, `t`=time, `d`=duration. `-` joins to previous, `+` = line break. `source` is one of `"xml"`, `"whisperx"`, `"user"` — UI can use it to render an "auto-transcribed" badge for `whisperx`. Sloppaks always include `source` (legacy sloppaks without a `lyrics_source` manifest key default to `"xml"` at load time). Loose folders set it based on which extractor matched. Absent only when no lyrics fired the message at all |
|
||||
| `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: [{ difficulty, notes, chords, anchors, handshapes }] }], total }` | Optional — per-phrase difficulty ladder for master-difficulty slider (feedBack#48). Only sent when the source chart carries multi-level phrase data (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 |
|
||||
|
||||
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`.
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
1. **playSong wrapper race condition** — The wrapper chain runs outermost-first (last-loaded wrapper runs first). If an inner plugin (e.g. `3dhighway`) does `await import(CDN)`, it yields to the event loop. WebSocket messages (`song_info`, `ready`) can arrive before outer plugins set their callbacks. Use `getSongInfo()` as a fallback rather than relying solely on `_onReady`.
|
||||
|
||||
2. **Plugin gitlinks** — Plugins are separate git repos cloned into `plugins/`. Switching branches on the main repo can delete or clobber these directories. Be careful with `git checkout` and `git clean`.
|
||||
|
||||
3. **Highway flex layout** — `#highway` has `flex:1` in the player. Hiding it with `display:none` removes the flex child, causing `#player-controls` to float to the top. If you hide the highway, add `margin-top: auto` to the controls div to keep it at the bottom.
|
||||
|
||||
4. **Multiple WebSocket connections** — The server supports many simultaneous WebSocket connections to the same song. Split screen panels, lyrics panes, and jumping tab panes each open their own. This is by design — don't try to multiplex.
|
||||
|
||||
5. **Plugin load order** — Plugins load alphabetically by directory name. This determines the `playSong` wrapper chain order and which plugin's UI elements appear first. If your plugin depends on another's globals, check at runtime (`typeof window.X === 'function'`), not at load time.
|
||||
Everything you'd expect to find here - architecture, running the app, testing, conventions, plugin authoring, first-hour pitfalls - is imported above via `@AGENTS.md`. Updates go in `AGENTS.md`. This file only carries Claude-Code-specific automation references.
|
||||
|
||||
+7
-7
@@ -1,10 +1,10 @@
|
||||
# Contributing to FeedBack
|
||||
# Contributing to Slopsmith
|
||||
|
||||
Thanks for wanting to contribute! This document covers the legal and workflow expectations for code, plugins, and documentation contributions.
|
||||
|
||||
## License
|
||||
|
||||
FeedBack is licensed under [AGPL-3.0-only](LICENSE). Contributions you submit (PRs, patches, documentation, plugin entries in the curated list) are licensed inbound under the same terms — **inbound = outbound**. By opening a pull request, you agree that your contribution may be distributed under AGPL-3.0-only as part of FeedBack.
|
||||
Slopsmith is licensed under [AGPL-3.0-only](LICENSE). Contributions you submit (PRs, patches, documentation, plugin entries in the curated list) are licensed inbound under the same terms — **inbound = outbound**. By opening a pull request, you agree that your contribution may be distributed under AGPL-3.0-only as part of Slopsmith.
|
||||
|
||||
## Developer Certificate of Origin (DCO)
|
||||
|
||||
@@ -26,7 +26,7 @@ If you forget to sign off, amend the most recent commit with `git commit --amend
|
||||
|
||||
## Plugin licensing
|
||||
|
||||
Plugins live in their own repositories and are loaded at runtime — see the [Plugin System section in CLAUDE.md](CLAUDE.md) for the technical contract, and [Plugin Best Practices](CLAUDE.md) for the conventions every plugin should follow (v2/v3 player chrome, the visualization contracts, and the **performance rules** — no per-frame DOM queries or broad `document.body` `MutationObserver`s — that keep the 60 fps highway smooth). Plugins are not subject to AGPL by being loaded into FeedBack (the loader runs them as separate Python modules / browser scripts), but for the **curated plugin list** to accept your plugin we ask that it be released under an AGPL-3.0-compatible license:
|
||||
Plugins live in their own repositories and are loaded at runtime — see [`docs/PLUGIN_AUTHORING.md`](docs/PLUGIN_AUTHORING.md) for the technical contract and [`schema/plugin.schema.json`](schema/plugin.schema.json) for the manifest schema. Plugins are not subject to AGPL by being loaded into Slopsmith (the loader runs them as separate Python modules / browser scripts), but for the **curated plugin list** to accept your plugin we ask that it be released under an AGPL-3.0-compatible license:
|
||||
|
||||
- AGPL-3.0-only or AGPL-3.0-or-later
|
||||
- GPL-3.0-only or GPL-3.0-or-later
|
||||
@@ -37,16 +37,16 @@ Plugins live in their own repositories and are loaded at runtime — see the [Pl
|
||||
- ISC
|
||||
- Unlicense / CC0-1.0 / 0BSD
|
||||
|
||||
Plugins under GPL-2.0-only, LGPL-2.1-only, CDDL, EPL, or proprietary terms will not be added to the curated list. You're still free to publish and self-distribute them — FeedBack will load any plugin a user installs locally — but they won't be promoted from the main project.
|
||||
Plugins under GPL-2.0-only, LGPL-2.1-only, CDDL, EPL, or proprietary terms will not be added to the curated list. You're still free to publish and self-distribute them — Slopsmith will load any plugin a user installs locally — but they won't be promoted from the main project.
|
||||
|
||||
## Workflow
|
||||
|
||||
Standard PR workflow described in [CLAUDE.md → Git Workflow](CLAUDE.md):
|
||||
Standard PR workflow described in [AGENTS.md → Git workflow](AGENTS.md#git-workflow):
|
||||
- Never push directly to `main`.
|
||||
- Create a feature branch on your fork.
|
||||
- Open a PR against `got-feedback/feedBack:main`.
|
||||
- Open a PR against `got-feedback/feedback:main`.
|
||||
- Keep commits scoped and well-described; short imperative subject + `Signed-off-by` trailer.
|
||||
|
||||
## Questions
|
||||
|
||||
Open an issue or start a [Discussion](https://github.com/got-feedback/feedBack/discussions) if you're unsure whether a contribution fits — much better to ask early than to find out after the work is done.
|
||||
Open an issue or start a [Discussion](https://github.com/got-feedback/feedback/discussions) if you're unsure whether a contribution fits — much better to ask early than to find out after the work is done.
|
||||
|
||||
+15
-15
@@ -47,11 +47,11 @@ RUN cmake -S /tmp/vgmstream -B /tmp/vgmstream/build \
|
||||
# and update FFMPEG_RELEASE + both SHA256 ARGs below.
|
||||
FROM alpine:3.20 AS ffmpeg-fetcher
|
||||
ARG TARGETARCH
|
||||
ARG FFMPEG_RELEASE=autobuild-2026-07-03-13-21
|
||||
ARG FFMPEG_BUILD_AMD64=ffmpeg-n7.1.5-1-g7d0e842004-linux64-gpl-7.1.tar.xz
|
||||
ARG FFMPEG_BUILD_ARM64=ffmpeg-n7.1.5-1-g7d0e842004-linuxarm64-gpl-7.1.tar.xz
|
||||
ARG FFMPEG_SHA256_AMD64=1390e1c320a1e38dae106d6d0b05a6f08eb8b30f732bc1aa0d45a4aa17f13795
|
||||
ARG FFMPEG_SHA256_ARM64=53b2e30df04d56932b7782234c9bc97abfe0bb242192ca50346474a41b100ab0
|
||||
ARG FFMPEG_RELEASE=autobuild-2026-06-01-15-02
|
||||
ARG FFMPEG_BUILD_AMD64=ffmpeg-n7.1.4-7-gadcf20da26-linux64-gpl-7.1.tar.xz
|
||||
ARG FFMPEG_BUILD_ARM64=ffmpeg-n7.1.4-7-gadcf20da26-linuxarm64-gpl-7.1.tar.xz
|
||||
ARG FFMPEG_SHA256_AMD64=afde55344990650c117fbb7cb36b38d2ab6790b06beb06a9c43a9300c9ce277a
|
||||
ARG FFMPEG_SHA256_ARM64=03c8a7d9a7cf48d017a22a7c31acfdc8e76c5cb193923f883b0338c7baf0bd28
|
||||
RUN apk add --no-cache curl xz \
|
||||
&& arch="${TARGETARCH:-$(apk --print-arch)}" \
|
||||
&& case "$arch" in \
|
||||
@@ -70,7 +70,7 @@ RUN apk add --no-cache curl xz \
|
||||
# ── Stage 1d: Build the Tailwind stylesheet over the FULL plugin set ──────
|
||||
# The committed static/tailwind.min.css is generated against only the in-tree
|
||||
# plugins. Rather than ship it as-is (leaving baked-in plugins' classes
|
||||
# unstyled now that the Play CDN's runtime JIT is gone — feedBack#411),
|
||||
# unstyled now that the Play CDN's runtime JIT is gone — slopsmith#411),
|
||||
# rebuild it here, after static/ + plugins/ are present, so the sheet covers
|
||||
# whatever plugins are baked into the image. Runs in a throwaway node stage so
|
||||
# this build-time toolchain never lands in the final image; the runtime node
|
||||
@@ -94,9 +94,9 @@ FROM python:3.12-slim
|
||||
# Re-declare the ffmpeg ARGs so their values are available to LABEL below.
|
||||
# ARG values don't cross stage boundaries in multi-stage builds; defaults
|
||||
# must be repeated here to take effect when no --build-arg is supplied.
|
||||
ARG FFMPEG_RELEASE=autobuild-2026-07-03-13-21
|
||||
ARG FFMPEG_BUILD_AMD64=ffmpeg-n7.1.5-1-g7d0e842004-linux64-gpl-7.1.tar.xz
|
||||
ARG FFMPEG_BUILD_ARM64=ffmpeg-n7.1.5-1-g7d0e842004-linuxarm64-gpl-7.1.tar.xz
|
||||
ARG FFMPEG_RELEASE=autobuild-2026-06-01-15-02
|
||||
ARG FFMPEG_BUILD_AMD64=ffmpeg-n7.1.4-7-gadcf20da26-linux64-gpl-7.1.tar.xz
|
||||
ARG FFMPEG_BUILD_ARM64=ffmpeg-n7.1.4-7-gadcf20da26-linuxarm64-gpl-7.1.tar.xz
|
||||
|
||||
# Apply latest security updates to base packages (clears glibc deb13u3 and
|
||||
# similar). Done first so any subsequent installs resolve against the
|
||||
@@ -112,7 +112,7 @@ RUN apt-get update \
|
||||
# package drags in the full codec + TLS + graphics dependency tree
|
||||
# (mbedtls, gnutls28, mesa, x264, tiff, openjpeg2, libcaca, harfbuzz,
|
||||
# cairo, openldap, libcdio…), almost all of which has unfixed CVEs and
|
||||
# none of which FeedBack uses. We pull a static ffmpeg binary further
|
||||
# none of which Slopsmith uses. We pull a static ffmpeg binary further
|
||||
# down instead.
|
||||
#
|
||||
# vgmstream-cli is also built with -DUSE_FFMPEG=OFF (see stage 1b), so
|
||||
@@ -142,7 +142,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
&& rm -rf /var/lib/apt/lists/* /var/cache/apt/archives/*
|
||||
|
||||
# Node + the pinned Tailwind CLI for RUNTIME stylesheet regeneration. When a
|
||||
# plugin is installed into FEEDBACK_PLUGINS_DIR at runtime (or discovered
|
||||
# plugin is installed into SLOPSMITH_PLUGINS_DIR at runtime (or discovered
|
||||
# there on startup), the server rebuilds static/tailwind.min.css so the
|
||||
# plugin's classes are styled — the image-baked sheet only covered in-tree
|
||||
# plugins (see lib/tailwind_rebuild.py). tailwindcss is installed globally so
|
||||
@@ -176,10 +176,10 @@ COPY --from=ffmpeg-fetcher /out/LICENSE.txt /usr/share/doc/ffmpeg/LICENSE.txt
|
||||
RUN chmod +x /usr/local/bin/ffmpeg /usr/local/bin/ffprobe
|
||||
# Record provenance so the exact BtbN source can be located for GPL compliance
|
||||
# or debugging. Inspect with: docker inspect <image> | grep -A5 ffmpeg
|
||||
LABEL org.feedBack.ffmpeg.release="${FFMPEG_RELEASE}" \
|
||||
org.feedBack.ffmpeg.source.amd64="${FFMPEG_BUILD_AMD64}" \
|
||||
org.feedBack.ffmpeg.source.arm64="${FFMPEG_BUILD_ARM64}" \
|
||||
org.feedBack.ffmpeg.upstream="https://github.com/BtbN/FFmpeg-Builds"
|
||||
LABEL org.slopsmith.ffmpeg.release="${FFMPEG_RELEASE}" \
|
||||
org.slopsmith.ffmpeg.source.amd64="${FFMPEG_BUILD_AMD64}" \
|
||||
org.slopsmith.ffmpeg.source.arm64="${FFMPEG_BUILD_ARM64}" \
|
||||
org.slopsmith.ffmpeg.upstream="https://github.com/BtbN/FFmpeg-Builds"
|
||||
|
||||
# Native vgmstream-cli built against the image's own libraries
|
||||
COPY --from=vgmstream-builder /out/vgmstream-cli /usr/local/bin/vgmstream-cli
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
# fee[dB]ack
|
||||
|
||||
## Plugins
|
||||
|
||||
| Plugin | Description | Install |
|
||||
|------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------|
|
||||
| [Create from Tab](https://github.com/got-feedback/feedback-plugin-ug) | Search Ultimate Guitar for GP tabs and convert to playable songs | `git clone ...slopsmith-plugin-ug.git ultimate_guitar` |
|
||||
| [Import Tab](https://github.com/got-feedback/feedback-plugin-tabimport) | Drag and drop Guitar Pro files to create songs | `git clone ...slopsmith-plugin-tabimport.git tab_import` |
|
||||
| [Practice Journal](https://github.com/got-feedback/feedback-plugin-practice) | Auto-track practice time, speed, loops. Dashboard with charts | `git clone ...slopsmith-plugin-practice.git practice_journal` |
|
||||
| [Setlist Builder](https://github.com/got-feedback/feedback-plugin-setlist) | Create ordered playlists with sequential playback | `git clone ...slopsmith-plugin-setlist.git setlist` |
|
||||
| [Metronome](https://github.com/got-feedback/feedback-plugin-metronome) | Audible click and visual beat flash synced to song tempo | `git clone ...slopsmith-plugin-metronome.git metronome` |
|
||||
| [Tone Player](https://github.com/got-feedback/feedback-plugin-tones) | View amp/pedal/cab signal chains with gear artwork | `git clone ...slopsmith-plugin-tones.git tones` |
|
||||
| [Fretboard View](https://github.com/got-feedback/feedback-plugin-fretboard) | Live fretboard overlay showing active notes in real-time | `git clone ...slopsmith-plugin-fretboard.git fretboard` |
|
||||
| [Tab View](https://github.com/got-feedback/feedback-plugin-tabview) | Scrolling guitar tablature notation via alphaTab | `git clone ...slopsmith-plugin-tabview.git tab_view` |
|
||||
| [MIDI Amp Control](https://github.com/got-feedback/feedback-plugin-midi) | Auto-switch amp/modeler presets via MIDI on tone changes | `git clone ...slopsmith-plugin-midi.git midi_amp` |
|
||||
| [Section Map](https://github.com/got-feedback/feedback-plugin-sectionmap) | Color-coded song structure minimap with clickable navigation | `git clone ...slopsmith-plugin-sectionmap.git section_map` |
|
||||
| [Arrangement Editor](https://github.com/got-feedback/feedback-plugin-editor) | DAW-like visual editor for creating and editing song note charts | `git clone ...slopsmith-plugin-editor.git editor` |
|
||||
| [MIDI Capo](https://github.com/masc0t/slopsmith-plugin-midi-capo) | MIDI capo control for real-time transposition | `git clone ...slopsmith-plugin-midi-capo.git midi_capo` |
|
||||
| [Note Detection](https://github.com/got-feedback/feedback-plugin-notedetect) | Real-time pitch detection and scoring against highway notes | `git clone ...slopsmith-plugin-notedetect.git note_detect` |
|
||||
| [Find More](https://github.com/masc0t/slopsmith-plugin-find-more) | Search for more songs by the same artist | `git clone ...slopsmith-plugin-find-more.git find_more` |
|
||||
| [Piano Highway](https://github.com/got-feedback/feedback-plugin-piano) | Scrolling piano/keyboard view for Keys arrangements with MIDI input | `git clone ...slopsmith-plugin-piano.git piano` |
|
||||
| [Studio](https://github.com/got-feedback/feedback-plugin-studio) | Collaborative band recording and multi-track mixing | `git clone ...slopsmith-plugin-studio.git studio` |
|
||||
| [Drum Highway](https://github.com/got-feedback/feedback-plugin-drums) | Lane-based drum highway with MIDI drum pad input and built-in sounds | `git clone ...slopsmith-plugin-drums.git drums` |
|
||||
| [Split Screen](https://github.com/topkoa/slopsmith-plugin-splitscreen) | 2-4 highway panels side-by-side for multi-arrangement practice | `git clone ...slopsmith-plugin-splitscreen.git splitscreen` |
|
||||
| [Stems Mixer](https://github.com/topkoa/slopsmith-plugin-stems) | Per-stem mute/volume controls for .sloppak songs | `git clone ...slopsmith-plugin-stems.git stems` |
|
||||
| [Invert Highway](https://github.com/masc0t/slopsmith-plugin-invert-highway) | Flip the highway note direction | `git clone ...slopsmith-plugin-invert-highway.git invert_highway` |
|
||||
| [Jumping Tab](https://github.com/renanboni/slopsmith-plugin-jumpingtab) | Yousician-style 2D horizontal tab with trajectory arcs and hopping ball | `git clone ...slopsmith-plugin-jumpingtab.git jumpingtab` |
|
||||
| [Step Mode](https://github.com/got-feedback/feedback-plugin-stepmode) | Step-by-step practice mode — highway freezes at each note until played (via Note Detection) or Space | `git clone ...slopsmith-plugin-stepmode.git step_mode` |
|
||||
| [Lyrics Sync](https://github.com/got-feedback/feedback-plugin-lyrics-sync) | Generate synced LRC lyrics from text + vocals stem via Whisper alignment | `git clone ...slopsmith-plugin-lyrics-sync.git lyrics_sync` |
|
||||
| [Lyrics Karaoke](https://github.com/got-feedback/feedback-plugin-lyrics-karaoke) | Per-syllable karaoke pitch ribbon for sloppak songs (Whisper alignment + librosa pYIN) | `git clone ...slopsmith-plugin-lyrics-karaoke.git lyrics_karaoke` |
|
||||
| [NAM Tone Engine](https://github.com/got-feedback/feedback-plugin-nam-tone) | In-browser amp modeling with NAM WASM, cabinet IRs, tone auto-switching | `git clone ...slopsmith-plugin-nam-tone.git nam_tone` |
|
||||
| [Guitar Theory Lab](https://github.com/topkoa/slopsmith-plugin-guitar-theory) | Explore scales, chords, intervals, tunings, and voicings on a fully interactive fretboard | `git clone ...slopsmith-plugin-nam-tone.git guitar-theory-lab` |
|
||||
| [Themes](https://github.com/masc0t/slopsmith-plugin-themes) | Offers several basic recolorings of the interface | `git clone ...slopsmith-plugin-themes.git themes` |
|
||||
| [Update Manager](https://github.com/masc0t/slopsmith-update-manager) | Installs, updates, and uninstalls other plugins and the slopsmith core itself | `git clone ...slopsmith-update-manager.git update_manager` |
|
||||
| [Tuner](https://github.com/OmikronApex/slopsmith-plugin-tuner) | Floating tuner with customizable tunings | `git clone ...slopsmith-plugin-tuner.git tuner` |
|
||||
| [Simplify Chords](https://github.com/bkranendonk/slopsmith-plugin-simplify-chords) | Changes complex chords on the note highway to simpler ones. Inspired by Ultimate Guitar's Simplify button. | `git clone ...slopsmith-plugin-simplify-chords.git simplify-chords` |
|
||||
| [Key Bindings](https://github.com/jackipicco/slopsmith-plugin-key-bindings) | Highway key bindings for keyboard and TV remote | `git clone ...slopsmith-plugin-key-bindings.git key_bindings` |
|
||||
| [Folder Organizer](https://github.com/Elit3d/slopsmith-plugin-folder-organizer) | Organize your sloppak DLC songs into a folder tree view, grouped by subfolder name | `git clone ...slopsmith-plugin-folder-organizer.git folder-organizer` |
|
||||
| [SlopScale](https://github.com/ChrisBeWithYou/slopsmith-plugin-slopscale) | Scale, arpeggio, and sweep-arpeggio practice routines with 3D highway, 2D highway, and tab renderers. Pathway selector, CAGED shape-run arpeggios, and generated audio backing. | `git clone ...slopsmith-plugin-slopscale.git slopscale` |
|
||||
| [NAM Rig Builder](https://github.com/Jafz2001/slopsmith-plugin-nam-rig-builder) | Map tones to chained NAM neural-amp rigs (tone3000 captures + IRs) — full pedal→amp→cab playback, per-stage bypass, and a gear catalog | `git clone ...slopsmith-plugin-nam-rig-builder.git nam_rig_builder` |
|
||||
| [Audio Preview](https://github.com/saleemk/slopsmith-plugin-audio-preview) | Quick audio previews from library cards with configurable start time, volume, and duration | `git clone ...slopsmith-plugin-audio-preview.git audio_preview` |
|
||||
| [Song Mastery](https://github.com/jamesgaiser/slopsmith-plugin-song-mastery) | Auto-adjusts difficulty based on your rolling note accuracy and saves the slider position per song | `git clone ...slopsmith-plugin-song-mastery.git song_mastery` |
|
||||
| [Song Preview](https://github.com/DeathlySin/slopsmith-plugin-song-preview) | Quickly hear previews of songs in your library with a clean visual indicator of what's playing. Supports .sloppak and loose folders song formats, with the visual indicator matching up to whatever theme you are using! | `git clone ...slopsmith-plugin-song-preview.git song_preview` |
|
||||
| [Mobile Note Highway](https://github.com/saleemk/slopsmith-plugin-mobile-note-highway) | Touch-optimized player with collapsible controls, highway gestures, and device-adaptive layouts for phones and tablets | `git clone ...slopsmith-plugin-mobile-note-highway.git mobile_note_highway` |
|
||||
| [Shuffle](https://github.com/Erikcb91/Slopsmith-Shuffle-Mode) | Random playback from your library — artist & tuning filters, auto-advance with countdown popup, note_detect compatible | `git clone https://github.com/Erikcb91/Slopsmith-Shuffle-Mode.git shuffle` |
|
||||
|
||||
Install any plugin by cloning it into your `plugins/` directory and restarting:
|
||||
|
||||
```bash
|
||||
cd plugins
|
||||
git clone https://github.com/got-feedback/feedback-plugin-ug.git ultimate_guitar
|
||||
docker compose restart
|
||||
```
|
||||
+2
-2
@@ -1,8 +1,8 @@
|
||||
# Supporters
|
||||
|
||||
FeedBack's development is supported by these generous people. Thank you. ❤️
|
||||
Slopsmith's development is supported by these generous people. Thank you. ❤️
|
||||
|
||||
Want to be listed here? See [Support FeedBack](README.md#support-feedBack).
|
||||
Want to be listed here? See [Support Slopsmith](README.md#support-slopsmith).
|
||||
|
||||
## Patrons
|
||||
|
||||
|
||||
+12
-12
@@ -9,8 +9,8 @@
|
||||
# sudo bash build-proxmox-ct.sh [TARGETARCH] [OUTPUT_NAME]
|
||||
#
|
||||
# Examples:
|
||||
# sudo bash build-proxmox-ct.sh amd64 feedBack-ct
|
||||
# sudo bash build-proxmox-ct.sh arm64 feedBack-ct
|
||||
# sudo bash build-proxmox-ct.sh amd64 slopsmith-ct
|
||||
# sudo bash build-proxmox-ct.sh arm64 slopsmith-ct
|
||||
#
|
||||
# The resulting container ships empty; mount or copy your .sloppak /
|
||||
# loose-folder library into /dlc inside the CT after import.
|
||||
@@ -26,13 +26,13 @@
|
||||
# sudo apt install debootstrap systemd-container tar zstd curl unzip git
|
||||
#
|
||||
# On Proxmox, after transfer:
|
||||
# pct restore <VMID> feedBack-ct.tar.zst --storage local-lvm --rootfs 8 --unprivileged 1
|
||||
# pct restore <VMID> slopsmith-ct.tar.zst --storage local-lvm --rootfs 8 --unprivileged 1
|
||||
# =============================================================================
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
TARGETARCH="${1:-amd64}"
|
||||
OUTPUT_NAME="${2:-feedBack-ct}"
|
||||
OUTPUT_NAME="${2:-slopsmith-ct}"
|
||||
|
||||
# OUTPUT_NAME is a positional arg that flows into BUILD_BASE (interpolated into
|
||||
# `mkdir -p` / `rm -rf` paths) and into the final tarball name. Reject anything
|
||||
@@ -104,7 +104,7 @@ VENV_DIR="/opt/app-venv"
|
||||
PIP_VERSION="26.1.1"
|
||||
DLC_DIR="/dlc"
|
||||
CONFIG_DIR="/config"
|
||||
SVC_USER="feedBack"
|
||||
SVC_USER="slopsmith"
|
||||
|
||||
# Coloured logging
|
||||
info() { echo -e "\033[1;34m[INFO]\033[0m $*"; }
|
||||
@@ -420,7 +420,7 @@ ok "Build dependencies removed."
|
||||
# =============================================================================
|
||||
# 5d. Tailwind CLI for runtime stylesheet regeneration
|
||||
# =============================================================================
|
||||
# When a plugin is installed into FEEDBACK_PLUGINS_DIR at runtime (or
|
||||
# When a plugin is installed into SLOPSMITH_PLUGINS_DIR at runtime (or
|
||||
# discovered there on startup), the server rebuilds static/tailwind.min.css
|
||||
# so the plugin's classes are styled — the image-baked sheet only covers
|
||||
# in-tree plugins (see lib/tailwind_rebuild.py). tailwindcss is installed
|
||||
@@ -523,11 +523,11 @@ info "Creating service user '${SVC_USER}' …"
|
||||
r "useradd --system --home-dir ${APP_DIR} --shell /usr/sbin/nologin ${SVC_USER}"
|
||||
ok "User '${SVC_USER}' created."
|
||||
|
||||
info "Installing feedBack-server.service …"
|
||||
info "Installing slopsmith-server.service …"
|
||||
mkdir -p "${ROOTFS}/etc/systemd/system"
|
||||
cat > "${ROOTFS}/etc/systemd/system/feedBack-server.service" <<EOF
|
||||
cat > "${ROOTFS}/etc/systemd/system/slopsmith-server.service" <<EOF
|
||||
[Unit]
|
||||
Description=FeedBack uvicorn server
|
||||
Description=Slopsmith uvicorn server
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
@@ -547,8 +547,8 @@ EOF
|
||||
|
||||
# Enable by symlinking (avoids running systemctl inside nspawn)
|
||||
mkdir -p "${ROOTFS}/etc/systemd/system/multi-user.target.wants"
|
||||
ln -sf /etc/systemd/system/feedBack-server.service \
|
||||
"${ROOTFS}/etc/systemd/system/multi-user.target.wants/feedBack-server.service"
|
||||
ln -sf /etc/systemd/system/slopsmith-server.service \
|
||||
"${ROOTFS}/etc/systemd/system/multi-user.target.wants/slopsmith-server.service"
|
||||
ok "Service enabled."
|
||||
|
||||
# =============================================================================
|
||||
@@ -662,6 +662,6 @@ cat <<DONE
|
||||
--start 1
|
||||
|
||||
Then check the server:
|
||||
pct exec 200 -- systemctl status feedBack-server
|
||||
pct exec 200 -- systemctl status slopsmith-server
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
DONE
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -7,17 +7,17 @@ services:
|
||||
- "8000:8000"
|
||||
volumes:
|
||||
# Song library folder on NAS
|
||||
- /volume1/music/feedBack:/dlc
|
||||
- /volume1/music/slopsmith:/dlc
|
||||
# Persistent config, cache, favorites, loops, practice data
|
||||
- feedBack-config:/config
|
||||
- slopsmith-config:/config
|
||||
environment:
|
||||
- DLC_DIR=/dlc
|
||||
- CONFIG_DIR=/config
|
||||
# Logging (optional)
|
||||
# - LOG_LEVEL=INFO # DEBUG | INFO | WARNING | ERROR (default: INFO)
|
||||
# - LOG_FORMAT=json # json | text (default: text)
|
||||
# - LOG_FILE=/config/feedBack.log # also write to a persistent file
|
||||
# - LOG_FILE=/config/slopsmith.log # also write to a persistent file
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
feedBack-config:
|
||||
slopsmith-config:
|
||||
|
||||
+3
-3
@@ -7,7 +7,7 @@ services:
|
||||
# Mount your song library folder (adjust path for your system)
|
||||
- ${LIBRARY_PATH:-./library}:/dlc
|
||||
# Persistent config and cache
|
||||
- feedBack-config:/config
|
||||
- slopsmith-config:/config
|
||||
# Mount source for live reload during development
|
||||
- ./static:/app/static
|
||||
- ./server.py:/app/server.py
|
||||
@@ -28,10 +28,10 @@ services:
|
||||
# Logging (optional)
|
||||
# - LOG_LEVEL=DEBUG # DEBUG | INFO | WARNING | ERROR (default: INFO)
|
||||
# - LOG_FORMAT=json # json | text (default: text — coloured console)
|
||||
# - LOG_FILE=/config/feedBack.log # also write to a persistent file
|
||||
# - LOG_FILE=/config/slopsmith.log # also write to a persistent file
|
||||
dns:
|
||||
- 8.8.8.8
|
||||
- 1.1.1.1
|
||||
|
||||
volumes:
|
||||
feedBack-config:
|
||||
slopsmith-config:
|
||||
|
||||
@@ -9,10 +9,10 @@ Depends on: `docs/NOTE_FAILURE_SPEC.md` (read that first)
|
||||
**Goal:** Working note detection plugin streaming detected notes via WebSocket.
|
||||
|
||||
This phase was previously tracked in a separate NOTE_DETECTION_PLUGIN_PLAN
|
||||
document (in the `feedBack-plugin-notedetect` repository). The relevant scope
|
||||
document (in the `slopsmith-plugin-notedetect` repository). The relevant scope
|
||||
is summarized here to avoid relying on an internal git-only reference:
|
||||
|
||||
- [ ] Plugin skeleton: `feedBack-plugin-notedetect/` with plugin.json, routes.py, screen.js
|
||||
- [ ] Plugin skeleton: `slopsmith-plugin-notedetect/` with plugin.json, routes.py, screen.js
|
||||
- [ ] Port TonalRecall YIN detection (aubio + sounddevice) to routes.py
|
||||
- [ ] WebSocket at `/api/plugins/note_detect/stream` streaming `{ note, freq, confidence, time }`
|
||||
- [ ] Device selection UI in screen.html
|
||||
@@ -138,7 +138,7 @@ shows the correct diagnostic labels.
|
||||
```
|
||||
Displayed for 1.5s, then fades.
|
||||
- [ ] Track `bestIteration` across all iterations for "Best" display
|
||||
- [ ] Emit `loop:complete` event via `window.feedBack.emit()` so other plugins
|
||||
- [ ] Emit `loop:complete` event via `window.slopsmith.emit()` so other plugins
|
||||
(practice journal) can record the data
|
||||
- [ ] Reset loop history when loop boundaries change or loop is cleared
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ late, wrong pitch, or not played at all.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
This feature depends on the **note detection plugin** (`feedBack-plugin-notedetect`),
|
||||
This feature depends on the **note detection plugin** (`slopsmith-plugin-notedetect`),
|
||||
which provides real-time pitch detection via server-side aubio/YIN over WebSocket.
|
||||
The detection plugin streams `DetectedNote` events; this spec describes the
|
||||
**matching, judgment, and rendering** layer that consumes those events.
|
||||
@@ -55,10 +55,10 @@ Guitar → USB Adapter → sounddevice (server)
|
||||
Wire format: `{ note: "A2", freq: 110.0, confidence: 0.92, time: 1.234 }`
|
||||
|
||||
> **Plugin naming note:** The detection plugin's repository is named
|
||||
> `feedBack-plugin-notedetect`, but the plugin registers with the id
|
||||
> `slopsmith-plugin-notedetect`, but the plugin registers with the id
|
||||
> `note_detect` (snake_case). Its HTTP/WebSocket routes therefore appear
|
||||
> under `/api/plugins/note_detect/…`. There is no `window.feedBackPlugin_*`
|
||||
> global pattern in FeedBack — to check whether the detection plugin is
|
||||
> under `/api/plugins/note_detect/…`. There is no `window.slopsmithPlugin_*`
|
||||
> global pattern in Slopsmith — to check whether the detection plugin is
|
||||
> available at runtime, attempt a fetch to `/api/plugins/note_detect/status`
|
||||
> (or similar) or consult the `/api/plugins` list. Use the repo name only
|
||||
> in documentation links.
|
||||
@@ -335,7 +335,7 @@ The tracker must handle A-B looping:
|
||||
| `loopA`, `loopB` | Current A-B loop boundaries |
|
||||
| `audio.currentTime` | Actual audio playback position |
|
||||
|
||||
### New Events Emitted (via `window.feedBack.emit`)
|
||||
### New Events Emitted (via `window.slopsmith.emit`)
|
||||
|
||||
| Event | Payload |
|
||||
|------------------------------|------------------------------------------|
|
||||
@@ -373,7 +373,7 @@ There are three distinct threshold tiers — keep them conceptually separate:
|
||||
| `hitGlowDuration` | 0.5 | Green glow fade time (sec) |
|
||||
|
||||
Persist these settings in plugin-local storage (e.g. `localStorage` prefixed
|
||||
with the plugin id). Do **not** assume they can be saved through FeedBack's
|
||||
with the plugin id). Do **not** assume they can be saved through Slopsmith's
|
||||
`/api/settings` endpoint under a `notedetect_feedback` key — the current server
|
||||
only persists a fixed set of known settings keys. If backend support for a
|
||||
dedicated persisted key is added later, this plugin may migrate to `/api/settings`.
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
# Plugin Authoring Guide
|
||||
|
||||
Slopsmith's plugin system is the primary extension point. Each plugin lives in `plugins/<name>/` with a `plugin.json` manifest that declares the capability domains, UI contributions, settings metadata, diagnostics, and runtime files the plugin participates in.
|
||||
|
||||
This guide is the entry point. Each topic below has a dedicated doc — read what's relevant to what you're building.
|
||||
|
||||
## Quickstart
|
||||
|
||||
```text
|
||||
plugins/my_plugin/
|
||||
├── plugin.json Manifest (required) — see docs/plugin-manifest.md
|
||||
├── screen.html Optional — UI declared through `ui` contributions
|
||||
├── screen.js Optional — hydrates declared frontend capabilities
|
||||
├── routes.py Optional — backend provider/requester implementation
|
||||
├── settings.html Optional — settings UI declared through `ui.settings`
|
||||
└── requirements.txt Optional — pip deps auto-installed on load
|
||||
```
|
||||
|
||||
Start every plugin by describing its Slopsmith-facing behavior in the manifest with `standards: ["capability-pipelines.v1"]`, native `capabilities`, and redaction-safe `ui` metadata. A plugin with no app-facing behavior beyond metadata can still be this small:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "my_plugin",
|
||||
"name": "My Plugin",
|
||||
"version": "0.1.0"
|
||||
}
|
||||
```
|
||||
|
||||
Capability declarations are the source of truth for diagnostics, the Capability Inspector, and plugin tooling. Treat missing capability metadata as an intentional exception for metadata-only or transitional plugin manifests.
|
||||
|
||||
## Topics
|
||||
|
||||
| Topic | Doc | When to read |
|
||||
|---|---|---|
|
||||
| **Manifest reference** | [plugin-manifest.md](plugin-manifest.md) | Field-by-field reference for `plugin.json`. Read first. |
|
||||
| **Capability declarations** | [plugin-manifest.md#capabilities](plugin-manifest.md#capabilities) | Declaring provider/requester/observer intent with `capability-pipelines.v1`. |
|
||||
| **Capability domains** | [capability-domains.md](capability-domains.md) | Active domains, planned domains, and promotion rules. |
|
||||
| **Capability recipes** | [capability-recipes.md](capability-recipes.md) | Copyable manifest patterns for provider/requester/observer plugins. |
|
||||
| **Visualization contracts** | [plugin-visualization-contracts.md](plugin-visualization-contracts.md) | Building a highway renderer (setRenderer), an overlay layer, or a note-state provider. |
|
||||
| **Plugin styles** | [plugin-styles.md](plugin-styles.md) | Shipping a plugin-owned prebuilt stylesheet via `styles: "assets/plugin.css"`. |
|
||||
| **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. |
|
||||
| **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'; ... })();`
|
||||
- Declare `standards: ["capability-pipelines.v1"]` and native `capabilities` for the plugin's Slopsmith-facing behavior.
|
||||
- Use `ui` / `ui_contributions` for plugin-owned UI surfaces so the host can attribute them in diagnostics and support bundles.
|
||||
- Use `localStorage` for user-facing settings, prefixed with your plugin id.
|
||||
- Prefer native capability commands, events, and provider registration over private globals. If a domain you need is not active yet, document the gap in the PR instead of baking in a new private integration.
|
||||
|
||||
## Licensing for curated plugins
|
||||
|
||||
Plugins submitted for inclusion in Slopsmith must use AGPL-3.0-only. 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).
|
||||
|
||||
Add an `SPDX-License-Identifier: AGPL-3.0-only` comment to plugin source files (`screen.js`, `routes.py`, helper modules, and similar authored code) so the manifest license and source-file metadata stay aligned.
|
||||
@@ -1,4 +1,4 @@
|
||||
# FeedBack Note Detect Bass Benchmark — v1
|
||||
# Slopsmith Note Detect Bass Benchmark — v1
|
||||
|
||||
A bass-focused companion to the guitar benchmarks
|
||||
(note_detect_v1 + note_detect_v2). Tests `note_detect` against bass-
|
||||
|
||||
@@ -20,11 +20,11 @@ the guitar one:
|
||||
than guitar E2 at ~82 Hz. The benchmark should exercise that
|
||||
regime explicitly so we can spot regressions there.
|
||||
|
||||
How to run inside the feedBack container:
|
||||
How to run inside the slopsmith container:
|
||||
|
||||
docker cp docs/benchmarks/note_detect_bass_v1/build_benchmark.py \\
|
||||
feedBack-web-1:/tmp/build_benchmark_bass.py
|
||||
docker exec feedBack-web-1 python /tmp/build_benchmark_bass.py \\
|
||||
slopsmith-web-1:/tmp/build_benchmark_bass.py
|
||||
docker exec slopsmith-web-1 python /tmp/build_benchmark_bass.py \\
|
||||
/app/static/sloppak_cache/note_detect_benchmark_bass_v1.sloppak
|
||||
|
||||
After regenerating, copy the zip output to the tracked path with the
|
||||
@@ -351,7 +351,7 @@ def build(out_dir: Path):
|
||||
|
||||
arrangement = {
|
||||
'name': 'Bass',
|
||||
# Pad to 6 slots even on bass — feedBack's `tuning_name()` only
|
||||
# Pad to 6 slots even on bass — slopsmith's `tuning_name()` only
|
||||
# recognises named tunings (E Standard, Drop D, etc.) on 6-element
|
||||
# arrays, so a 4-element array shows up in the library card as the
|
||||
# raw numeric form ("0 0 0 0") instead of "E Standard". The
|
||||
@@ -371,7 +371,7 @@ def build(out_dir: Path):
|
||||
|
||||
manifest = {
|
||||
'title': 'Note Detect Bass Benchmark v1',
|
||||
'artist': 'FeedBack',
|
||||
'artist': 'Slopsmith',
|
||||
'album': 'Note Detection Benchmark',
|
||||
'year': 2026,
|
||||
'duration': round(end_t, 3),
|
||||
@@ -389,7 +389,7 @@ def build(out_dir: Path):
|
||||
{'id': 'full', 'file': 'stems/full.ogg', 'default': True},
|
||||
],
|
||||
'benchmark': {
|
||||
'id': 'feedBack-note-detect-benchmark-bass',
|
||||
'id': 'slopsmith-note-detect-benchmark-bass',
|
||||
'version': 1,
|
||||
},
|
||||
}
|
||||
@@ -461,7 +461,7 @@ def _build_zip(src_dir: Path):
|
||||
|
||||
|
||||
def _benchmark_readme(duration_s):
|
||||
return f"""# FeedBack Note Detect Bass Benchmark — v1
|
||||
return f"""# Slopsmith Note Detect Bass Benchmark — v1
|
||||
|
||||
A bass-focused companion to the guitar benchmarks
|
||||
(note_detect_v1 + note_detect_v2). Tests `note_detect` against bass-
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# FeedBack Note Detect Benchmark — v1
|
||||
# Slopsmith Note Detect Benchmark — v1
|
||||
|
||||
A short test piece for tuning FeedBack's `note_detect` plugin. Eight
|
||||
A short test piece for tuning Slopsmith's `note_detect` plugin. Eight
|
||||
exercises, each isolating a specific detection failure mode. Run with
|
||||
**Detect** enabled, play through, then export the diagnostic JSON
|
||||
(Settings → Plugins → Note Detection → Download Diagnostic JSON, or
|
||||
@@ -43,4 +43,4 @@ Share the JSON (schema `note_detect.diagnostic.v1`). It includes:
|
||||
## Source
|
||||
|
||||
Built by `docs/benchmarks/note_detect_v1/build_benchmark.py` in the
|
||||
feedBack repo. Tweak the exercise list there and regenerate.
|
||||
slopsmith repo. Tweak the exercise list there and regenerate.
|
||||
|
||||
@@ -5,12 +5,12 @@ short exercises designed to isolate specific failure modes (open-string
|
||||
mono, fretted positions, octaves, sustained held notes, hammer-on /
|
||||
pull-off, sparse power chords, dense open chords, bends).
|
||||
|
||||
How to run inside the feedBack container (recommended — has ffmpeg +
|
||||
How to run inside the slopsmith container (recommended — has ffmpeg +
|
||||
pyyaml already):
|
||||
|
||||
docker cp docs/benchmarks/note_detect_v1/build_benchmark.py \
|
||||
feedBack-web-1:/tmp/build_benchmark.py
|
||||
docker exec feedBack-web-1 python /tmp/build_benchmark.py \
|
||||
slopsmith-web-1:/tmp/build_benchmark.py
|
||||
docker exec slopsmith-web-1 python /tmp/build_benchmark.py \
|
||||
/app/static/sloppak_cache/note_detect_benchmark_v1.sloppak
|
||||
|
||||
The output sloppak lands under `static/sloppak_cache/` on the host
|
||||
@@ -26,7 +26,7 @@ import sys
|
||||
import wave
|
||||
from pathlib import Path
|
||||
|
||||
import yaml # bundled with the feedBack image
|
||||
import yaml # bundled with the slopsmith image
|
||||
|
||||
# ── Benchmark parameters ────────────────────────────────────────────────
|
||||
BPM = 90.0
|
||||
@@ -411,7 +411,7 @@ def build(out_dir: Path):
|
||||
|
||||
manifest = {
|
||||
'title': 'Note Detect Benchmark v1',
|
||||
'artist': 'FeedBack',
|
||||
'artist': 'Slopsmith',
|
||||
'album': 'Note Detection Benchmark',
|
||||
'year': 2026,
|
||||
'duration': round(end_t, 3),
|
||||
@@ -430,7 +430,7 @@ def build(out_dir: Path):
|
||||
# Non-standard key — picked up by future tooling that wants to
|
||||
# detect "this is the benchmark, schema v1". The loader ignores it.
|
||||
'benchmark': {
|
||||
'id': 'feedBack-note-detect-benchmark',
|
||||
'id': 'slopsmith-note-detect-benchmark',
|
||||
'version': 1,
|
||||
},
|
||||
}
|
||||
@@ -545,9 +545,9 @@ def _build_zip(src_dir: Path):
|
||||
|
||||
|
||||
def _benchmark_readme(duration_s):
|
||||
return f"""# FeedBack Note Detect Benchmark — v1
|
||||
return f"""# Slopsmith Note Detect Benchmark — v1
|
||||
|
||||
A short test piece for tuning FeedBack's `note_detect` plugin. Eight
|
||||
A short test piece for tuning Slopsmith's `note_detect` plugin. Eight
|
||||
exercises, each isolating a specific detection failure mode. Run with
|
||||
**Detect** enabled, play through, then export the diagnostic JSON
|
||||
(Settings → Plugins → Note Detection → Download Diagnostic JSON, or
|
||||
@@ -590,7 +590,7 @@ Share the JSON (schema `note_detect.diagnostic.v1`). It includes:
|
||||
## Source
|
||||
|
||||
Built by `docs/benchmarks/note_detect_v1/build_benchmark.py` in the
|
||||
feedBack repo. Tweak the exercise list there and regenerate.
|
||||
slopsmith repo. Tweak the exercise list there and regenerate.
|
||||
"""
|
||||
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# FeedBack Note Detect Benchmark — v2
|
||||
# Slopsmith Note Detect Benchmark — v2
|
||||
|
||||
A slower-paced companion to v1, focused on what players can actually
|
||||
land cleanly. Half-note spacing throughout (~1.33 s between events at
|
||||
|
||||
@@ -16,11 +16,11 @@ Goals vs v1:
|
||||
technique handling is the next algorithm focus, separate from
|
||||
measuring "do basic single notes + chords score correctly?"
|
||||
|
||||
How to run inside the feedBack container:
|
||||
How to run inside the slopsmith container:
|
||||
|
||||
docker cp docs/benchmarks/note_detect_v2/build_benchmark.py \\
|
||||
feedBack-web-1:/tmp/build_benchmark_v2.py
|
||||
docker exec feedBack-web-1 python /tmp/build_benchmark_v2.py \\
|
||||
slopsmith-web-1:/tmp/build_benchmark_v2.py
|
||||
docker exec slopsmith-web-1 python /tmp/build_benchmark_v2.py \\
|
||||
/app/static/sloppak_cache/note_detect_benchmark_v2.sloppak
|
||||
|
||||
After regenerating, copy the zip output to the tracked path with the
|
||||
@@ -375,7 +375,7 @@ def build(out_dir: Path):
|
||||
|
||||
manifest = {
|
||||
'title': 'Note Detect Benchmark v2',
|
||||
'artist': 'FeedBack',
|
||||
'artist': 'Slopsmith',
|
||||
'album': 'Note Detection Benchmark',
|
||||
'year': 2026,
|
||||
'duration': round(end_t, 3),
|
||||
@@ -392,7 +392,7 @@ def build(out_dir: Path):
|
||||
{'id': 'full', 'file': 'stems/full.ogg', 'default': True},
|
||||
],
|
||||
'benchmark': {
|
||||
'id': 'feedBack-note-detect-benchmark',
|
||||
'id': 'slopsmith-note-detect-benchmark',
|
||||
'version': 2,
|
||||
},
|
||||
}
|
||||
@@ -466,7 +466,7 @@ def _build_zip(src_dir: Path):
|
||||
|
||||
|
||||
def _benchmark_readme(duration_s):
|
||||
return f"""# FeedBack Note Detect Benchmark — v2
|
||||
return f"""# Slopsmith Note Detect Benchmark — v2
|
||||
|
||||
A slower-paced companion to v1, focused on what players can actually
|
||||
land cleanly. Half-note spacing throughout (~1.33 s between events at
|
||||
|
||||
+55
-99
@@ -1,6 +1,6 @@
|
||||
# Capability Domains
|
||||
|
||||
Capability domains are FeedBack-wide coordination surfaces for core, bundled first-party plugins, external plugins, and future adapters. Plugins declare the runtime surfaces they use in `plugin.json`; core declares and owns host workflows directly in the runtime. These declarations let diagnostics and support tools reason about behavior without relying on private globals.
|
||||
Capability domains are Slopsmith-wide coordination surfaces for core, bundled first-party plugins, external plugins, and future adapters. Plugins declare the runtime surfaces they use in `plugin.json`; core declares and owns host workflows directly in the runtime. These declarations let diagnostics and support tools reason about behavior without relying on private globals.
|
||||
|
||||
## Standards
|
||||
|
||||
@@ -16,7 +16,7 @@ Only declare `plugin-runtime-idempotent.v1` when repeated script hydration canno
|
||||
|
||||
## UI Contributions
|
||||
|
||||
Legacy `nav`, `screen`, and `settings` fields still work through the existing plugin loader. PR1 keeps UI capability domains out of the runtime graph, so migrated plugins should not treat `ui.navigation`, `ui.plugin-screens`, or `settings` as active capability contracts yet. Their candidate manifest shape is reserved for a future UI-host PR:
|
||||
New plugin UI should declare stable `ui` contribution metadata so diagnostics and support tools can attribute surfaces before runtime code hydrates. The runtime host for UI placement is still promoted by a future UI-host PR, but manifests can use the candidate shape now:
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -28,25 +28,30 @@ Legacy `nav`, `screen`, and `settings` fields still work through the existing pl
|
||||
}
|
||||
```
|
||||
|
||||
Core continues to load legacy UI fields normally. It does not emit PR1 compatibility shim entries for UI placement or visualization `type`; the PR that promotes those domains will own their shim accounting and tests.
|
||||
|
||||
## Runtime Domains
|
||||
|
||||
Declare non-UI runtime surfaces under `domains` or `runtime_domains`:
|
||||
Declare non-UI runtime surfaces under `capabilities`:
|
||||
|
||||
```json
|
||||
{
|
||||
"domains": {
|
||||
"library": { "role": "provider" }
|
||||
"capabilities": {
|
||||
"library": {
|
||||
"roles": ["provider"],
|
||||
"operations": ["query-page", "query-artists", "query-stats"],
|
||||
"mode": "active",
|
||||
"compatibility": "none",
|
||||
"safety": "safe",
|
||||
"version": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
If a plugin still uses `routes`, the backend loader continues to load `routes.py` normally. PR1 does not expose that legacy surface as `backend.routes`; the backend route domain is deferred until a future PR has a concrete route/provider workflow and privilege review.
|
||||
Backend route metadata should support a declared capability domain. A generic `backend.routes` domain is deferred until a future PR has a concrete route/provider workflow and privilege review.
|
||||
|
||||
Plugins that call `context["register_library_provider"](...)` are attributed to the loading plugin id in `/api/library/providers` as `owner_plugin_id`. The browser library capability module at [static/capabilities/library.js](../static/capabilities/library.js) owns the `library` domain as a `provider-coordinator`: it refreshes `/api/library/providers`, registers the built-in `local` provider as `core.library.local`, and registers plugin-backed providers under their `owner_plugin_id` when one is known. Provider manifests should still declare the `library` capability so diagnostics and the bundled inspector can show intended relationships before the backend route code runs.
|
||||
|
||||
Route-only external plugins that participate in library workflows without registering a browsable provider should declare requester/observer intent instead of provider ownership when they adopt this contract in their own repositories. This PR documents the generic shape only: such plugins use `library` requester/observer `requests` and `observes` declarations and do not appear as providers, owners, or separate `backend.routes` domains.
|
||||
Route-only external plugins that participate in library workflows without registering a browsable provider declare requester/observer intent instead of provider ownership when they adopt this contract in their own repositories. This PR documents the generic shape only: such plugins use `library` requester/observer `requests` and `observes` declarations and do not appear as providers, owners, or separate `backend.routes` domains.
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -63,27 +68,25 @@ Route-only external plugins that participate in library workflows without regist
|
||||
}
|
||||
```
|
||||
|
||||
The frontend exposes the current source list through `window.feedBack.capabilities.command('library', 'list-providers')`. Public owner commands (`list-providers`, `refresh-providers`, `get-current`, `select-provider`, `sync-song`, `inspect`) are distinct from provider operations (`query-page`, `query-artists`, `query-stats`, `tuning-names`, `get-art`, `sync-song`). The app-owned handler delegates to the existing provider registry and source selector, so plugins should not scrape the `#lib-provider` dropdown.
|
||||
The frontend exposes the current source list through `window.slopsmith.capabilities.command('library', 'list-providers')`. Public owner commands (`list-providers`, `refresh-providers`, `get-current`, `select-provider`, `sync-song`, `inspect`) are distinct from provider operations (`query-page`, `query-artists`, `query-stats`, `tuning-names`, `get-art`, `sync-song`). The app-owned handler delegates to the existing provider registry and source selector, so plugins should not scrape the `#lib-provider` dropdown.
|
||||
|
||||
Capability declarations may include a short `description`. The bundled Capability Inspector shows that text on expanded domain owner cards; when it is omitted, the inspector falls back to a compact generated owner summary.
|
||||
|
||||
## Audio Graph/Session Domains
|
||||
|
||||
The audio graph/session slice promotes four player-audio domains into the runtime graph: `audio-mix`, `audio-input`, `audio-monitoring`, and `stems`. The browser module at [static/capabilities/audio-session.js](../static/capabilities/audio-session.js) owns the active session boundary, contributes diagnostics under `feedBack.audio_session.diagnostics.v1`, and records compatibility bridge hits for legacy audio surfaces.
|
||||
The audio graph/session slice promotes four player-audio domains into the runtime graph: `audio-mix`, `audio-input`, `audio-monitoring`, and `stems`. The browser module at [static/capabilities/audio-session.js](../static/capabilities/audio-session.js) owns the active session boundary, contributes diagnostics under `slopsmith.audio_session.diagnostics.v1`, and records compatibility bridge hits for older audio surfaces.
|
||||
|
||||
`audio-mix`, `audio-input`, and `audio-monitoring` are core-owned provider-coordinator domains. They expose bounded inspect/register/start/stop style commands, redaction-safe diagnostics, and bridge accounting for legacy fader, analyser, input, and monitoring handshakes.
|
||||
`audio-mix`, `audio-input`, and `audio-monitoring` are core-owned provider-coordinator domains. They expose bounded inspect/register/start/stop style commands, redaction-safe diagnostics, and bridge accounting for older fader, analyser, input, and monitoring handshakes.
|
||||
|
||||
For `audio-mix`, native fader providers register mix participants with stable `participantId`, `kind`, `sourceMode`, optional `logicalFaderKey`, and `fader` metadata. The public command surface is `inspect`, `list-faders`, `get-fader-value`, `set-fader-value`, `inspect-route`, `inspect-analyser`, `register-participant`, and `unregister-participant`; provider operations are `fader.get-value`, `fader.set-value`, `route.get-current`, and `analyser.get-summary`. Providers own persistence for plugin faders and must return committed values from set operations so the player mixer can display the value that actually applied.
|
||||
|
||||
Legacy `window.feedBack.audio.registerFader(...)` remains supported as an audio-mix compatibility bridge. The bridge registers a compatibility-backed participant, wraps legacy `getValue`/`setValue` callbacks as provider operations, and preserves `window.feedBack.audio.getFaders()` for external callers. If a native participant and a legacy fader share the same logical fader key, the native participant owns the visible control; the legacy participant is retained for diagnostics with `supersededBy` and an `overshadowed` bridge hit. Removal gates for the bridge are: native providers cover bundled mixer integrations, diagnostics show no unexpected legacy hits in normal playback, and repeated plugin hydration does not create duplicate faders.
|
||||
|
||||
Audio-mix diagnostics live under `feedBack.audio_session.diagnostics.v1`. The `audio-mix` domain snapshot includes session state, participants, visible fader summaries, required participant-kind coverage, route summary, analyser summary, bridge hits, and bounded recent outcomes. Fader outcomes include operation name, participant id, fader id, status such as `committed`, `normalized`, `unavailable`, or `timeout`, and a bounded reason. Diagnostics must not include raw audio buffers, FFT arrays, device labels, stable hardware identifiers, secrets, or unredacted local paths; route/analyser payloads are summaries only.
|
||||
Audio-mix diagnostics live under `slopsmith.audio_session.diagnostics.v1`. The `audio-mix` domain snapshot includes session state, participants, visible fader summaries, required participant-kind coverage, route summary, analyser summary, bridge hits, and bounded recent outcomes. Fader outcomes include operation name, participant id, fader id, status such as `committed`, `normalized`, `unavailable`, or `timeout`, and a bounded reason. Diagnostics must not include raw audio buffers, FFT arrays, device labels, stable hardware identifiers, secrets, or unredacted local paths; route/analyser payloads are summaries only.
|
||||
|
||||
For `audio-input`, native providers register source summaries with `sourceId`, `providerId`, `logicalSourceKey`, `kind`, redaction-safe label/pseudonym, `availability`, `channelSummary`, `sourceMode`, and provider operations. The public command surface is `inspect`, `list-sources`, `register-source`, `unregister-source`, `select-source`, `open-source`, and `close-source`; provider operations are `source.enumerate`, `source.describe`, `source.open`, and `source.close`. `inspect`, `list-sources`, and `select-source` are prompt-free and never open live input or call enumeration. `source.enumerate` runs only when explicitly requested by provider/user discovery. `open-source` is the permission boundary: it routes to `source.open`, attributes the requester, checks the selected source and requested channel shape, and records `handled`, `denied`, `degraded`, `failed`, `no-owner`, `no-handler`, `unsupported-command`, or `incompatible-version` outcomes.
|
||||
|
||||
Selected input is persisted by `logicalSourceKey` when browser storage is available. If storage is unavailable, the in-memory selection remains usable for the current session and diagnostics report the storage status. Start/stop/song switches preserve selected input independently of playback transport while clearing live open sessions. Compatible requesters share one open session per logical source and channel shape; requester references are released via `close-source`, and the provider receives `source.close` only after the last requester releases.
|
||||
|
||||
Compatibility-backed input sources should record `sourceMode: "compatibility"` plus `compatibilitySource` and, when applicable, an `audio-input.legacy-source` bridge hit. If a native provider and a compatibility-backed source share the same logical source key, the native source owns the visible source list and the compatibility source is retained in diagnostics with `supersededBy`. Removal gates for input bridges are: native providers cover bundled source discovery/open flows, diagnostics show no unexpected compatibility hits in normal playback, denied/unavailable/failure outcomes are distinguishable, repeated hydration does not create duplicate sources, and support snapshots contain no raw device labels, hardware ids, paths, secrets, live handles, buffers, samples, or waveform data.
|
||||
Compatibility-backed input sources are retained for diagnostics only when a native provider supersedes the same logical source key. Removal gates for input bridges are: native providers cover bundled source discovery/open flows, diagnostics show no unexpected compatibility hits in normal playback, denied/unavailable/failure outcomes are distinguishable, repeated hydration does not create duplicate sources, and support snapshots contain no raw device labels, hardware ids, paths, secrets, live handles, buffers, samples, or waveform data.
|
||||
|
||||
For `audio-monitoring`, native providers register monitoring summaries with `providerId`, `logicalMonitoringKey`, redaction-safe label/pseudonym, `availability`, `sourceMode`, provider operations, `directMonitor`, and `latencySummary`. The public command surface is `inspect`, `list-providers`, `register-provider`, `unregister-provider`, `select-provider`, `start`, `stop`, and `set-direct-monitor`; provider operations are `monitoring.start`, `monitoring.stop`, `monitoring.status`, and `monitoring.set-direct-monitor`. `inspect`, `list-providers`, `select-provider`, and `monitoring.status` are prompt-free and must not open audio input or start monitoring.
|
||||
|
||||
@@ -93,25 +96,11 @@ Monitoring sessions are keyed by provider, selected source, required channel sha
|
||||
|
||||
Direct-monitor state is user-authoritative. `set-direct-monitor` updates the user's/default preference and applies provider control to active sessions only when the provider supports it. Requester `directMonitorRequirement` values are advisory constraints: when they conflict with the user's preference or provider support, the requester/session is marked degraded or unsupported, but the stored user/default preference is not changed.
|
||||
|
||||
Compatibility-backed monitoring providers should record `sourceMode: "compatibility"` plus `compatibilitySource` (which becomes the bridge id, defaulting to `audio-monitoring.legacy-provider` when unset) and, when applicable, the `audio-monitoring.audio-barrier` startup-barrier bridge hit. If a native provider and compatibility-backed provider share a logical monitoring key, the native provider owns the visible provider list and the compatibility provider is retained in diagnostics with `supersededBy`. Removal gates for monitoring bridges are: native providers cover bundled start/stop/status/direct-monitor flows, normal playback shows no unexpected legacy hits, background requesters cannot silently start live monitoring, repeated hydration does not duplicate providers or sessions, and support snapshots contain no raw device labels, hardware ids, paths, secrets, live handles, buffers, samples, waveform data, recordings, or provider-private payloads.
|
||||
Compatibility-backed monitoring providers are retained for diagnostics only when a native provider supersedes the same logical monitoring key. Removal gates for monitoring bridges are: native providers cover bundled start/stop/status/direct-monitor flows, normal playback shows no unexpected compatibility hits, background requesters cannot silently start live monitoring, repeated hydration does not duplicate providers or sessions, and support snapshots contain no raw device labels, hardware ids, paths, secrets, live handles, buffers, samples, waveform data, recordings, or provider-private payloads.
|
||||
|
||||
`stems` is different: `core.audio.session` is a coordinator, not the semantic owner of stem playback. The Stems plugin, or another active stem provider, remains the provider/owner of actual stem state, mute/restore mechanics, and per-song availability. The session coordinator records the active provider via `registerStemOwner(...)`, brokers claim/override/orphan diagnostics, and returns `no-owner` when no stem provider is available.
|
||||
|
||||
New bundled audio code should use the session host or native capability dispatch instead of adding new globals, private stem-state reads, direct analyser ownership, or plugin-specific handshakes. Existing legacy paths remain supported through named compatibility bridges until their migration notes and removal gates are satisfied.
|
||||
|
||||
## Audio Effects Domain
|
||||
|
||||
The audio-effects slice promotes `audio-effects` as a core-owned provider-coordinator domain implemented by [static/capabilities/audio-effects.js](../static/capabilities/audio-effects.js). The host owns provider selection, compatible executor selection, route state, fallback accounting, redaction-safe diagnostics, and the constrained chain-plan schema. Providers do not call executors. Provider code proposes plans and, for execution requests, returns a provider-private trusted asset map to the host; the host immediately hands that private request to a compatible executor such as trusted Desktop native audio or NAM Tone's browser/WASM executor.
|
||||
|
||||
The public command surface is `inspect`, `list-providers`, `list-executors`, `register-provider`, `unregister-provider`, `register-executor`, `unregister-executor`, `select-chain`, `resolve-plan`, `load-plan`, `inspect-route`, `list-mappings`, `upsert-mapping`, `delete-mapping`, `activate-mapping`, `clear-active-mapping`, `bypass`, `restore`, `fallback`, `activate-segment`, `set-stage-bypass`, `set-stage-parameter`, and `record-bridge-hit`. Provider operations are `chain.resolve`, `chain.inspect`, `mapping.list`, `mapping.upsert`, `mapping.delete`, `mapping.activate`, `mapping.clear-active`, `segment.activate`, `stage.set-bypass`, `stage.set-parameter`, `route.bypass`, and `route.restore`; executor operations are `loadChainPlan`, `activateSegment`, `setStageBypass`, and `setStageParameter`. Fresh chain selection and route bypass/restore require `authorization: "user-action"` or `authorization: "restore-selection"`; physical loading through `load-plan` requires `authorization: "user-action"`, `authorization: "restore-selection"`, or `authorization: "playback-session"`. Background requesters may inspect the current route and resolve an already selected compatible provider.
|
||||
|
||||
Core also owns the durable public mapping index at `/api/audio-effects/mappings`. A mapping answers "for this song/tone, this provider has an addressable effect plan"; it does not contain the provider's preset or chain data. Rows are keyed by `song_key + tone_key + provider_id`, carry an opaque `provider_ref`, and may be marked as the active mapping for that song/tone. Providers CRUD their own rows through the audio-effects host and resolve `provider_ref` inside their own storage when `chain.resolve` runs. This lets NAM Tone and Rig Builder coexist for the same song/tone while core owns arbitration and fallback order. `song_key` should be the playback domain's redaction-safe settings key when available; `filename` is optional legacy/debug context for migration and display.
|
||||
|
||||
Providers register stable `providerId`, `pluginId`, `routeKey`, priority, availability, source mode, operations, and operation handlers. Executors register stable `executorId`, `pluginId`, `routeKey`, priority, availability, source mode, supported provider ids, supported stage kinds, optional maximum stage count, operations, and handlers. The host chooses the highest-priority enabled provider for a route unless the caller requests a specific provider, then chooses the highest-priority compatible executor for that provider and resolved plan. Compatibility means both provider-compatible and plan-compatible: a browser/WASM NAM executor can advertise `providerIds: ["nam-tone"]`, `supportedKinds: ["nam", "ir"]`, and `maxStages: 2`, so it will not be asked to execute a Rig Builder VST/full-chain plan. If a selected provider has no compatible executor and the caller supplies a fallback provider, the host may fall back to that provider; if the caller explicitly requested the original provider, the host reports `unavailable` instead of silently changing providers. The initial default route is `desktop-main`, matching the desktop native executor path planned for full-chain NAM/IR/VST playback while still allowing browser executors for non-Desktop runtimes.
|
||||
|
||||
`chain.resolve` returns schema `feedBack.audio_effects.chain_plan.v1`. A valid plan includes `planId`, `routeKey`, `providerId`, `stages`, optional `segments`, and optional redaction-safe summaries. Each stage exposes only stable opaque `stageId`, `kind` (`nam`, `ir`, `vst`, `utility`, or `bypass`), `role` (`pre-pedal`, `amp`, `cab`, `rack`, `master-pre`, etc.), opaque `assetRef`, optional opaque `stateRef`, bypass state, gain summary, and safe summary metadata. Raw file paths, URLs, model filenames, IR filenames, VST state blobs, native preset JSON, callbacks, handles, DOM nodes, audio buffers, samples, and waveform data are rejected or omitted.
|
||||
|
||||
Diagnostics live under `feedBack.audio_effects.diagnostics.v1`. The snapshot includes provider summaries, executor summaries, route summaries, bridge hits, bounded recent outcomes, limits, and redaction notes. It intentionally omits full chain plans, stage asset references, provider-private mapping payloads, raw filenames, and song keys; diagnostics should explain which provider/executor/route failed without leaking local library structure or licensed asset names. Legacy NAM Tone/Rig Builder fetch interception, direct Desktop `loadPreset` calls, legacy tone controls, old `nam_tone.db` `tone_mappings` access, and MIDI/external effect handoffs are attributed through `audio-effects.legacy-nam-routing`, `audio-effects.legacy-native-load`, `audio-effects.legacy-tone-controls`, `audio-effects.legacy-tone-db`, and `audio-effects.legacy-midi-amp` bridge records while providers migrate.
|
||||
New bundled audio code should use the session host or native capability dispatch instead of adding new globals, private stem-state reads, direct analyser ownership, or plugin-specific handshakes.
|
||||
|
||||
## Playback Control Plane
|
||||
|
||||
@@ -119,51 +108,7 @@ The playback slice promotes `playback` as a core-owned command domain implemente
|
||||
|
||||
`static/app.js` remains the transport data plane. It registers a private playback adapter that can start songs, pause/resume/stop, seek, and manage loops, but the capability snapshot never exposes the `<audio>` element, JUCE player object, raw audio buffers, native route handles, samples, waveforms, recordings, local file paths, or URL payloads. Exported diagnostics use pseudonymous `target-*` ids for arrangement-scoped identity and hashed `settings-*` keys for per-song plugin settings; the local Capability Inspector may show visible title, artist, and arrangement labels for the active song.
|
||||
|
||||
Legacy playback surfaces remain supported during migration and are attributed through bridges such as `playback.window-play-song`, `playback.song-events`, `playback.window-feedBack-transport`, `playback.loop-api`, and native route handoff records. Fresh audible `start` commands require `authorization: "user-action"`; background requesters may inspect or control an existing session, but user-priority pause/stop decisions block lower-priority automation until a user action resumes or starts a new session.
|
||||
|
||||
## Progression Domain
|
||||
|
||||
The progression slice (spec 010) promotes `progression` as a core-owned command domain implemented by [static/v3/progression-core.js](../static/v3/progression-core.js). Core owns the player's mastery rank (onboarding calibration + instrument-path levels), the challenge/quest engine, the Decibels wallet, and the cosmetics shop; all definitions are bundled content under `data/progression/` so new paths, levels, challenges, quests, and shop items are JSON edits, never code.
|
||||
|
||||
The public command surface is `inspect`, `record-event`, `list-shop`, `buy-item`, and `equip-item`. `record-event` accepts only whitelisted externally-postable event types (`minigame_run` in v1); `song_completed` is server-derived inside `/api/stats` so scored-session authority stays in one place and is denied at this surface. `buy-item` and `equip-item` require `authorization: "user-action"`. Backend plugins use the symmetric plugin-context hook `record_progression_event` (the minigames hub reports runs through it), which trusts backend code and skips the HTTP whitelist.
|
||||
|
||||
The domain emits `challenge-completed`, `quest-completed`, `path-level-up`, `rank-changed`, `db-changed`, `calibration-completed`, and `cosmetic-equipped` on the capability surface, mirrored as `progression:*` events on `window.feedBack` for non-capability consumers. Diagnostics live under `feedBack.progression.diag.v1` and contain content-load warnings, rank/path-level/quest counts, and wallet totals only — no song filenames or display names.
|
||||
|
||||
Decibels are earned exclusively by playing (songs, minigame runs, quest rewards); there is no real-money acquisition path and none may be added. The wallet tracks spend separately from the monotonic lifetime-earned total, so per-source XP resets and `db_earned` goals stay correct. A deferred release slice adds a `contributor` role so plugins can ship their own challenge/quest content (e.g. a drums plugin contributing drums challenges); content stays core-bundled until then.
|
||||
|
||||
## Visualization Domain
|
||||
|
||||
The visualization slice (cap:6) promotes `visualization` as a core-owned provider-coordinator implemented by [static/capabilities/visualization.js](../static/capabilities/visualization.js). Viz plugins are providers of the highway renderer surface; the core picker/auto-match machinery in `static/app.js` stays the selection workflow and attributes every renderer change into the domain.
|
||||
|
||||
The public command surface is `inspect`, `list-providers`, `select-renderer`, and `clear-renderer`. Selection delegates to the existing picker (`setViz`) so localStorage persistence, WebGL2 gating, and fallback semantics have exactly one implementation. The domain emits `providers-refreshed`, `renderer-changed` (with a `source` of `auto-match`, `user-select`, `fallback`, or `command:<requester>`), `renderer-ready`, and `renderer-failed`.
|
||||
|
||||
Provider discovery is still the legacy surface — `type: "visualization"` manifests populate the picker and `window.feedBackViz_*` factory globals carry the renderer contract — and both are registered as compatibility shims (`visualization:type-visualization-manifest`, `visualization:window.feedBackViz_*`) with hit accounting, so the Inspector shows exactly how much of the domain still rides the bridge. Plugins migrate by declaring a `visualization` provider capability in their manifests; the renderer factory contract (`init`/`draw`/`resize`/`destroy`, `contextType`, `matchesArrangement`) is unchanged.
|
||||
|
||||
**Per-instance provider settings (#849).** A provider may declare a `settings` array on its `visualization` capability — generic control descriptors (`{ key, label, type: "toggle" | "range" | "select", default, min/max/step, options }`) the capability-pipelines schema validates on *any* domain (the field lives on the shared `capabilityDeclaration`, not a visualization-only spot — see `docs/plugin-manifest.schema.json`). Descriptors flow through the **generic participant model**: the backend validates them for `/api/plugins` (`plugins/__init__.py`), `static/capabilities.js` normalizes + preserves them on the registered participant (so generic `inspect('visualization')` carries them), and the visualization owner reads them back from the participant by id — no app.js/picker side channel. They surface in the `list-providers` snapshot (each provider's `settings`, deep-frozen) plus a `provider_policy.hasSettings` flag in diagnostics, so a consuming host — splitscreen's per-panel control popover — can render the controls generically without per-plugin hardcoding. The visualization domain's **apply contract**: a provider that declares `settings` MUST implement `applySetting(key, value)` on its renderer instance (the host calls it on the specific per-panel instance, which is inherently per-panel — no canvas→panel resolution, no shared global keys); `getSetting(key)` is optional (the host falls back to the declared `default`). The host owns persistence. **This core slice lands the declarative surface + participant plumbing only** — no bundled provider declares `settings` yet (`highway_3d` still ships the legacy `factory.panelControls` static). The `highway_3d` migration and the splitscreen generic consumer are the remaining #849 follow-ups.
|
||||
|
||||
Diagnostics live under `feedBack.visualization_capability.v1` and contain provider ids/labels/context types, the active renderer id and its selection source, the last auto-match outcome (resolved id + whether any predicate claimed the song), and the last failure (provider id + reason) — never song filenames, titles, or arrangement names. Per-panel selection (splitscreen #90) and per-panel provider settings (#849) are tracked follow-ups; the domain currently models the primary highway surface.
|
||||
|
||||
## Note-Detection Domain
|
||||
|
||||
The note-detection slice (spec 009, issues #727/#728) promotes `note-detection` as a core-owned provider-coordinator implemented by [static/capabilities/note-detection.js](../static/capabilities/note-detection.js). Doctrine per [specs/009-note-detection-domain/spec.md](../specs/009-note-detection-domain/spec.md): the domain exposes detection PRIMITIVES through requester-owned, context-scoped bindings — a monophonic pitch estimate and a polyphonic "is this note set ringing now?" verification — and consumers own all judgment semantics (hit windows, streaks, accuracy, tiers). Hit/miss/verdict results flow through the domain as observability events, never as domain-owned scoring.
|
||||
|
||||
The public command surface is `inspect`, `register-provider`, `unregister-provider`, `open-binding`, `close-binding`, `set-target`, and `clear-target`. Providers declare a kind — `midi` (a digital instrument producing exact verdicts, e.g. the keys highway's Web-MIDI input), `engine` (the desktop JUCE verifier), or `js` (the browser harmonic-comb / YIN fallback) — and the primitives they serve (`pitch.estimate`, `verify.target`). Each binding carries its requester's own redacted context summary (arrangement kind, string count, capo, MIDI range), independent of whatever song the host highway has loaded; concurrent bindings never perturb one another (spec 009 FR-003). With no provider registered, `open-binding` reports `unavailable` — consumers degrade, never block.
|
||||
|
||||
The legacy chart-coupled surface — `highway.setNoteStateProvider(fn)`, the single-global-detector path spec 009 retires — keeps working unchanged and is wrapped for compatibility-shim hit accounting (`note-detection:highway.setNoteStateProvider`). Migrating the chart `note_detect` consumer, Step Mode verify, minigames YIN scoring, and the `setVerifyTarget` bridge onto real bindings — and wiring per-binding tuning contexts into the engine verifier — is the remainder of the spec-009 slice and lands behind the Spec 003 migration gate.
|
||||
|
||||
Diagnostics live under `feedBack.note_detection_capability.v1` and contain provider ids/labels/kinds, binding summaries (requester, provider, redacted context, target size), availability, and the last bounded outcome (event, binding, provider, MIDI number, hit flag) — never raw audio buffers, sample data, device labels, or song identity.
|
||||
|
||||
## MIDI-Input Domain
|
||||
|
||||
The MIDI-input slice (spec 012, issues #873/#880) promotes `midi-input` as a **core-owned** provider-coordinator implemented by [static/capabilities/midi-input.js](../static/capabilities/midi-input.js) — the MIDI analog of `audio-input`. It is deliberately separate from `audio-input` (whose source/`open` contract is audio-frame-centric: channel shapes, sample buffers) because MIDI carries discrete messages, not audio; and it is **not** owned by any feature plugin, so the device-access boundary outlives the input-setup wizard (exactly as `audio-input` is `core.audio.session`-owned). Consumers — the `input_setup` onboarding wizard, the `piano`/keys and `drums` plugins, and (as a follow-up, #881) note-detection's Web-MIDI provider — converge here on ONE device-access boundary: one permission prompt, one source list, one redaction boundary, retiring private per-plugin `navigator.requestMIDIAccess()` calls.
|
||||
|
||||
Native providers register source summaries with `providerId`, a stable `sourceId`, a derived redaction-safe `logicalSourceKey` (`providerId::sourceId`), `kind: "midi"`, a label, and `availability`. The public command surface is `inspect`, `list-sources`, `discover`, `select-source`, `open-source`, and `close-source`; provider operations are `source.enumerate`, `source.describe`, `source.open`, and `source.close`. `inspect`, `list-sources`, and `select-source` are prompt-free and never request MIDI access. Unlike audio (where `getUserMedia` gates labels and `open-source` is the prompt), Web-MIDI's `requestMIDIAccess()` gates the whole input list, so **`discover` is the permission boundary** and records `denied`/`unavailable` outcomes; `open-source` then attaches a shared listener session and never re-prompts.
|
||||
|
||||
Selected input is persisted by `logicalSourceKey` (`feedBack.midiInput.selectedLogicalSourceKey`) when browser storage is available. Compatible requesters share one open session per source; each later calls `close-source`, and the provider receives `source.close` only after the last requester releases. Live MIDI message delivery (for the "play a note / hit a pad" calibration check) is exposed to in-page consumers through the public `window.feedBack.midiInput` session handle only — never as raw capability events or diagnostics.
|
||||
|
||||
The reserved `midi-control` domain is the planned **sibling** for control mappings (CC/pitchbend/note → action routing) and will consume `midi-input` for device access (spec 013 / #882); this slice carves the device control plane out so `midi-control` can stay mappings-only. `midi-control` stays RESERVED (documentation-only) until a concrete mapping consumer + tests exist, per the future-domain governance.
|
||||
|
||||
Diagnostics live under `feedBack.midi_input.diagnostics.v1` and contain provider ids, source ids/keys/kinds/availability, the selected key, and open-session keys — **device labels are redacted** and no raw MIDI messages are ever included.
|
||||
Fresh audible `start` commands require `authorization: "user-action"`; background requesters may inspect or control an existing session, but user-priority pause/stop decisions block lower-priority automation until a user action resumes or starts a new session.
|
||||
|
||||
## Capability Roles
|
||||
|
||||
@@ -185,23 +130,23 @@ Use capability declarations for provider/requester/observer relationships:
|
||||
|
||||
Future app-level workflows can then express intent through capability domains instead of hard-coding plugin-private implementation details.
|
||||
|
||||
Core registers manifest capability declarations from `/api/plugins` before plugin scripts hydrate. Runtime owners can then re-register the same participant with command handlers, event handlers, and current availability state. The merged participant view is visible through `window.feedBack.capabilities.snapshotDiagnostics()` and `getDiagnostics()`.
|
||||
Core registers manifest capability declarations from `/api/plugins` before plugin scripts hydrate. Runtime owners can then re-register the same participant with command handlers, event handlers, and current availability state. The merged participant view is visible through `window.slopsmith.capabilities.snapshotDiagnostics()` and `getDiagnostics()`.
|
||||
|
||||
Core domains include review metadata in diagnostics:
|
||||
|
||||
- `active`: wired to current FeedBack behavior and expected to work as an integration point.
|
||||
- `active`: wired to current Slopsmith behavior and expected to work as an integration point.
|
||||
- `diagnostic`: support/inspection-only runtime surfaces.
|
||||
|
||||
PR1 includes only the delivered domains listed in [capability-roadmap.md](capability-roadmap.md): `pipeline`, `diagnostics`, and `library`. The follow-up audio graph/session slice promotes `audio-mix`, `audio-input`, `audio-monitoring`, and a coordinated `stems` surface. The playback slice promotes `playback` as an active transport control plane. The audio-effects slice promotes provider-selected effect-chain planning while leaving physical processor loading to compatible executors such as trusted Desktop native audio or a browser/WASM executor. The visualization slice promotes `visualization` as the highway renderer provider-coordinator, and the note-detection slice (spec 009) promotes `note-detection` as the detection-binding control plane. Backend routes, app UI, settings, and other hardware-facing domains remain documented in the roadmap and safety matrix until their own host workflow/provider slice exists.
|
||||
PR1 includes only the delivered domains listed in [capability-roadmap.md](capability-roadmap.md): `pipeline`, `diagnostics`, and `library`. The follow-up audio graph/session slice promotes `audio-mix`, `audio-input`, `audio-monitoring`, and a coordinated `stems` surface. The playback slice promotes `playback` as an active transport control plane. Backend routes, app UI, settings, visualization, note-detection, and other hardware-facing domains remain documented in the roadmap and safety matrix until their own host workflow/provider slice exists.
|
||||
|
||||
Capability metadata is versioned by the `capability-pipelines.v1` standard. Invalid roles, commands, operations, requests, observes, emits, events, owner kinds, compatibility modes, ownership policies, safety classes, or version fields are excluded from the capability graph and surfaced through `capability_validation_warnings`; legacy plugin fields continue to load through their existing app paths. Plugins that declare a future `capability-pipelines` version are reported through `capability_unsupported_versions` and their runtime handlers are marked incompatible.
|
||||
Capability metadata is versioned by the `capability-pipelines.v1` standard. Invalid roles, commands, operations, requests, observes, emits, events, owner kinds, compatibility modes, ownership policies, safety classes, or version fields are excluded from the capability graph and surfaced through `capability_validation_warnings`. Plugins that declare a future `capability-pipelines` version are reported through `capability_unsupported_versions` and their runtime handlers are marked incompatible.
|
||||
|
||||
`diagnostics` and `pipeline` are adjacent support domains. `diagnostics` is the read-only snapshot/export surface: `snapshot` returns the redaction-safe state used by support bundles and the Capability Inspector. `pipeline` is the graph operations surface: `inspect`, `validate`, and `participant.set-enabled` operate on the capability graph itself and emit graph lifecycle events such as `resolved`, `runtime.validated`, and `participant.state-changed`.
|
||||
|
||||
Requesters should use the public claim/dispatch/release flow instead of mutating another plugin's globals:
|
||||
|
||||
```js
|
||||
const api = window.feedBack.capabilities;
|
||||
const api = window.slopsmith.capabilities;
|
||||
const releaseClaim = api.claim({ capability: 'example.plugin-domain', claimId: 'example.automation-active', requester: 'example_requester' });
|
||||
await api.dispatch({
|
||||
capability: 'example.plugin-domain',
|
||||
@@ -229,7 +174,7 @@ Owner participants use a `kind` that describes how the domain is coordinated:
|
||||
- `diagnostic`: read-only support and inspector surfaces.
|
||||
- `privileged`: command execution needs an explicit enforcement plan before shipping.
|
||||
|
||||
Legacy `ownership` remains accepted in manifests for compatibility and diagnostics, but new domains should prefer `kind` plus participant roles. Ownership is derived for core owners where possible: `provider-coordinator` behaves like a multi-provider domain, diagnostics are diagnostic-only, privileged owners are privileged, and command/event owners are exclusive by default.
|
||||
New domains should prefer `kind` plus participant roles. Ownership is derived for core owners where possible: `provider-coordinator` behaves like a multi-provider domain, diagnostics are diagnostic-only, privileged owners are privileged, and command/event owners are exclusive by default.
|
||||
|
||||
The compatibility ownership vocabulary remains:
|
||||
|
||||
@@ -244,9 +189,9 @@ Dispatch results use explicit outcomes: `handled`, `transformed`, `denied`, `fai
|
||||
|
||||
## Deferred Core Adapters
|
||||
|
||||
UI placement and settings contributions are real FeedBack surfaces, but they are not PR1 capability contracts (visualization is active as of the cap:6 slice; note-detection as of the spec-009 slice). Audio mixer/session domains are active as of the audio graph/session slice, playback is active as of the playback control-plane slice, and audio-effects is active as a provider/route/chain-plan coordinator; plugins should keep using current documented APIs for remaining areas until the corresponding domain PR ships the host workflow, command/event contract, compatibility shims, diagnostics fields, and tests.
|
||||
UI placement, settings contributions, visualization, and note-detection are real Slopsmith surfaces, but they are not PR1 capability contracts. Audio mixer/session domains are active as of the audio graph/session slice, and playback is active as of the playback control-plane slice. For remaining areas, document the capability gap and avoid adding new private integration surfaces until the corresponding domain PR ships the host workflow, command/event contract, diagnostics fields, and tests.
|
||||
|
||||
The library provider workflow is the PR1 core adapter and is implemented natively as the `library` capability module. Provider refresh, selection, and sync run through `library` owner commands; backend provider registration remains the way providers enter the library registry, and the browser module turns that registry into provider participants. The app event bus continues to dispatch local `window.feedBack` events for legacy listeners; playback now mirrors song transport, route, seek, and loop lifecycle into `playback`, and visualization attributes renderer selection/failure, while navigation, note, and route-only surfaces remain outside capability domains until their own slices land.
|
||||
The library provider workflow is the PR1 core adapter and is implemented natively as the `library` capability module. Provider refresh, selection, and sync run through `library` owner commands; backend provider registration remains the way providers enter the library registry, and the browser module turns that registry into provider participants. Playback mirrors song transport, route, seek, and loop lifecycle into `playback`, while navigation, note, visualization, route-only, and highway rendering surfaces remain outside capability domains until their own slices land.
|
||||
|
||||
The direct `window.highway` object remains the renderer data plane. Per-frame reads such as notes, chords, beats, and renderer hooks should not be moved behind asynchronous capability commands until there is a dedicated chart/render facade.
|
||||
|
||||
@@ -254,13 +199,13 @@ The direct `window.highway` object remains the renderer data plane. Per-frame re
|
||||
|
||||
Large management surfaces should prefer plugin-owned UI over crowding normal Settings. First-party management plugins can contribute screens and settings panels while core keeps shared services and diagnostics contracts centralized.
|
||||
|
||||
The bundled Capability Inspector plugin is the support surface for the current graph. It reads `window.feedBack.capabilities.snapshotDiagnostics()`, filters by domain, and summarizes manifest participants, runtime participants, conflicts, unsupported versions, safety classes, expected legacy event surfaces, and compatibility shim hits without rendering raw runtime objects. Domains are grouped in review order: application/library, player/audio runtime, plugin-defined surfaces, then capability runtime. In the all-domains view, each domain starts collapsed with a domain-specific icon plus compact summary badges for participant-lane count, endpoint count, observed links, shimmed links, and status; badge labels live in tooltips/ARIA labels so the header stays scannable. Clicking the domain label expands or collapses the domain, opening the same graph view used by the single-domain filter. The graph places owner details and right-aligned command/event groups on the left, with short owner descriptions bottom-aligned as the final part of that pane. Participant usage is grouped the same way on the right, with observed or shimmed links between border-aligned endpoint ports. In multi-provider domains, links to provider participants use provider-family colors: purple for owner-to-provider command delegation and a lighter violet for provider events. Provider participants, including `library` sources, stay on the right lane and show a provider icon in their header. Headers show role-aware core/non-core origin badges such as Core owner, Core provider, or Non-core participant; owner headers place the origin badge directly after the owner icon, and the built-in local library provider is marked as core-origin. Observer and requester roles are implied by the command/event links rather than separate header badges. Participant cards are shown only when the plugin or runtime source has visible command or event usage for the current graph filter; domains with no such usage show zero participants, and attribution-only shims with no matching endpoint stay out of the lane. Command and event groups can collapse; when collapsed, all links for that side and group converge on the single group port. Hovering a participant, endpoint, or command/event group emphasizes the matching links and dims unrelated links; owner-side labels outside the current focus de-emphasize so the active source endpoints are easy to track. Expanded domain graphs progressively enhance to Cytoscape.js overlays that route bezier links between measured DOM endpoint ports, while keeping the HTML lanes as the fallback and readable data surface. Its Plugins-menu entry is hidden by default; enable **Capability Inspector → Show in Plugins menu** from Settings when reviewing or debugging capability behavior.
|
||||
The bundled Capability Inspector plugin is the support surface for the current graph. It reads `window.slopsmith.capabilities.snapshotDiagnostics()`, filters by domain, and summarizes manifest participants, runtime participants, conflicts, unsupported versions, safety classes, expected compatibility event surfaces, and compatibility shim hits without rendering raw runtime objects. Domains are grouped in review order: application/library, player/audio runtime, plugin-defined surfaces, then capability runtime. In the all-domains view, each domain starts collapsed with a domain-specific icon plus compact summary badges for participant-lane count, endpoint count, observed links, shimmed links, and status; badge labels live in tooltips/ARIA labels so the header stays scannable. Clicking the domain label expands or collapses the domain, opening the same graph view used by the single-domain filter. The graph places owner details and right-aligned command/event groups on the left, with short owner descriptions bottom-aligned as the final part of that pane. Participant usage is grouped the same way on the right, with observed or shimmed links between border-aligned endpoint ports. In multi-provider domains, links to provider participants use provider-family colors: purple for owner-to-provider command delegation and a lighter violet for provider events. Provider participants, including `library` sources, stay on the right lane and show a provider icon in their header. Headers show role-aware core/non-core origin badges such as Core owner, Core provider, or Non-core participant; owner headers place the origin badge directly after the owner icon, and the built-in local library provider is marked as core-origin. Observer and requester roles are implied by the command/event links rather than separate header badges. Participant cards are shown only when the plugin or runtime source has visible command or event usage for the current graph filter; domains with no such usage show zero participants, and attribution-only shims with no matching endpoint stay out of the lane. Command and event groups can collapse; when collapsed, all links for that side and group converge on the single group port. Hovering a participant, endpoint, or command/event group emphasizes the matching links and dims unrelated links; owner-side labels outside the current focus de-emphasize so the active source endpoints are easy to track. Expanded domain graphs progressively enhance to Cytoscape.js overlays that route bezier links between measured DOM endpoint ports, while keeping the HTML lanes as the fallback and readable data surface. Its Plugins-menu entry is hidden by default; enable **Capability Inspector → Show in Plugins menu** from Settings when reviewing or debugging capability behavior.
|
||||
|
||||
## Diagnostics Contract
|
||||
|
||||
Capability diagnostics use schema `feedBack.capabilities.diagnostics.v1`. Snapshots are redaction-safe and capped at 64 KB by trimming older `recentDecisions` first while preserving current participants, active or orphaned claims, conflicts, domain review metadata, shim summaries, safety notes, and unsupported-version reports. Server diagnostics bundles include plugin manifest capability metadata, validation warnings, unsupported-version metadata, and compatibility shim summaries.
|
||||
Capability diagnostics use schema `slopsmith.capabilities.diagnostics.v1`. Snapshots are redaction-safe and capped at 64 KB by trimming older `recentDecisions` first while preserving current participants, active or orphaned claims, conflicts, domain review metadata, shim summaries, safety notes, and unsupported-version reports. Server diagnostics bundles include plugin manifest capability metadata, validation warnings, unsupported-version metadata, and compatibility shim summaries.
|
||||
|
||||
Compatibility shim entries include `shimId`, `source`, `capability`, `legacySurface`, `status`, `reason`, and optional hit fields. A shim with `hitCount > 0` means legacy behavior was observed, not merely declared. The `library` domain no longer uses compatibility shims for provider registration or source selection; provider attribution comes from `owner_plugin_id` and runtime provider participants. Future domains should add expected shim entries only in the PR that implements their actual legacy bridge.
|
||||
Compatibility shim entries include `shimId`, `source`, `capability`, `legacySurface`, `status`, `reason`, and optional hit fields. A shim with `hitCount > 0` means compatibility behavior was observed, not merely declared. The `library` domain no longer uses compatibility shims for provider registration or source selection; provider attribution comes from `owner_plugin_id` and runtime provider participants. Future domains should add expected shim entries only in the PR that implements their actual compatibility bridge.
|
||||
|
||||
## Expected Future Domains
|
||||
|
||||
@@ -272,25 +217,36 @@ Release slices should stay reviewable. The domain-level roadmap, PR1 domain set,
|
||||
|
||||
Future privileged domains must state user value, included and excluded commands, safety class, diagnostics fields, failure recovery, and tests proving disabled or incompatible participants cannot execute handlers before implementation begins.
|
||||
|
||||
## Rehydration Pattern
|
||||
## Runtime Idempotency Pattern
|
||||
|
||||
Plugins that wrap shared functions such as `window.playSong` or `window.showScreen` should store wrapper state on a stable `window.__feedBack...Hooks` object. Re-running the script should replace the implementation object and return before installing another wrapper.
|
||||
Plugins that declare `plugin-runtime-idempotent.v1` must tolerate repeated script hydration without duplicating participants, listeners, timers, DOM roots, jobs, media nodes, or diagnostics contributors. Use stable ids and replace the current implementation behind the participant instead of registering a second copy.
|
||||
|
||||
```js
|
||||
const hookState = window.__feedBackMyPluginHooks || (window.__feedBackMyPluginHooks = {});
|
||||
hookState.impl = { afterPlaySong(filename) { /* current implementation */ } };
|
||||
if (hookState.installed) return;
|
||||
hookState.installed = true;
|
||||
hookState.basePlaySong = window.playSong;
|
||||
window.playSong = async function(filename, arrangement) {
|
||||
await hookState.basePlaySong.call(this, filename, arrangement);
|
||||
hookState.impl?.afterPlaySong?.(filename, arrangement);
|
||||
const runtime = window.__slopsmithMyPluginRuntime || (window.__slopsmithMyPluginRuntime = {});
|
||||
runtime.impl = {
|
||||
async inspect() {
|
||||
return { ready: true };
|
||||
},
|
||||
};
|
||||
|
||||
if (!runtime.registered) {
|
||||
runtime.registered = true;
|
||||
window.slopsmith.capabilities.registerParticipant('my_plugin.runtime', {
|
||||
'example.plugin-domain': {
|
||||
roles: ['provider'],
|
||||
commands: ['inspect'],
|
||||
handlers: {
|
||||
inspect: () => runtime.impl.inspect(),
|
||||
},
|
||||
runtime: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## Validation Commands
|
||||
|
||||
From the `feedBack/` directory:
|
||||
From the `slopsmith/` directory:
|
||||
|
||||
```bash
|
||||
node --check static/app.js
|
||||
|
||||
+14
-222
@@ -1,6 +1,6 @@
|
||||
# Capability Authoring Recipes
|
||||
|
||||
Use these examples as small manifest fragments when migrating plugin-facing integrations to capability pipelines. The capability model is system-wide; these recipes focus on plugin manifests because core-owned domains are registered by FeedBack itself. Each example is intentionally complete enough to pass the loader contract in [plugin-manifest.schema.json](plugin-manifest.schema.json).
|
||||
Use these examples as small manifest fragments when migrating plugin-facing integrations to capability pipelines. The capability model is system-wide; these recipes focus on plugin manifests because core-owned domains are registered by Slopsmith itself. Each example is intentionally complete enough to pass the loader contract in [plugin-manifest.schema.json](plugin-manifest.schema.json).
|
||||
|
||||
> **Self-hosted CSS?** If your plugin uses Tailwind classes core doesn't ship (notably arbitrary values like `text-[11px]`), declare a `styles` key and bundle your own preflight-off stylesheet — see [plugin-styles.md](plugin-styles.md). That is separate from the capability-pipeline recipes below.
|
||||
|
||||
@@ -100,7 +100,7 @@ A plugin that registers a remote client or generated library source declares its
|
||||
|
||||
## Library Requester And Observer
|
||||
|
||||
A route-only wrapper that uses the library capability without registering a browsable provider should declare requester/observer intent instead of provider ownership. This is a generic manifest shape for external plugins to adopt in their own repositories; it does not make the wrapper part of this PR's delivered domain set.
|
||||
A route-only wrapper that uses the library capability without registering a browsable provider declares requester/observer intent instead of provider ownership. This is a generic manifest shape for external plugins to adopt in their own repositories; it does not make the wrapper part of this PR's delivered domain set.
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -124,7 +124,7 @@ A route-only wrapper that uses the library capability without registering a brow
|
||||
|
||||
## Audio Mix Fader Provider
|
||||
|
||||
Existing plugins can keep using `window.feedBack.audio.registerFader(spec)` while migrating. The compatibility bridge records the fader as an `audio-mix` participant. New bundled code should prefer a native participant declaration plus the audio-session helper once available in its integration point.
|
||||
Existing plugins can keep using `window.slopsmith.audio.registerFader(spec)` while migrating. The compatibility bridge records the fader as an `audio-mix` participant. New bundled code should prefer a native participant declaration plus the audio-session helper once available in its integration point.
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -147,121 +147,9 @@ Existing plugins can keep using `window.feedBack.audio.registerFader(spec)` whil
|
||||
|
||||
Native audio-mix fader providers should register a stable participant id and fader id, return the committed value from every set operation, and settle get/set operations within two seconds. The player mixer displays the committed value rather than the raw requested value. If the fader is temporarily unavailable, keep the participant registered with unavailable/disabled state so the mixer can render a disabled control and diagnostics can explain why it cannot be changed.
|
||||
|
||||
During migration, a plugin may still call `window.feedBack.audio.registerFader(spec)`. Core maps that legacy fader into a compatibility-backed audio-mix participant and records bridge hits. If a native participant and a legacy fader represent the same logical source, the native participant owns the visible control and the legacy path is reported as compatibility-backed/overshadowed.
|
||||
|
||||
## Audio Effects Provider
|
||||
|
||||
Plugins that can provide guitar/bass processing chains should declare `audio-effects` as a provider and register at runtime with `window.feedBack.audioEffects.registerProvider(...)`. The provider returns opaque chain plans; it must not expose local filenames, URLs, native preset JSON, VST state blobs, or raw handles through diagnostics or public route state.
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "rig_builder",
|
||||
"name": "Rig Builder",
|
||||
"standards": ["capability-pipelines.v1", "plugin-runtime-idempotent.v1"],
|
||||
"capabilities": {
|
||||
"audio-effects": {
|
||||
"roles": ["provider"],
|
||||
"operations": ["chain.resolve", "chain.inspect", "segment.activate", "stage.set-bypass", "stage.set-parameter"],
|
||||
"events": ["provider-registered", "route-selected", "plan-resolved", "changed", "fallback", "bridge-hit"],
|
||||
"mode": "active",
|
||||
"compatibility": "shim-allowed",
|
||||
"safety": "sensitive",
|
||||
"version": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```js
|
||||
const effects = window.feedBack && window.feedBack.audioEffects;
|
||||
effects.registerProvider({
|
||||
providerId: 'rig-builder',
|
||||
pluginId: 'rig_builder',
|
||||
routeKey: 'desktop-main',
|
||||
priority: 40,
|
||||
operations: ['chain.resolve', 'segment.activate', 'stage.set-bypass', 'stage.set-parameter'],
|
||||
operationHandlers: {
|
||||
'chain.resolve': request => ({
|
||||
outcome: 'handled',
|
||||
plan: {
|
||||
schema: 'feedBack.audio_effects.chain_plan.v1',
|
||||
planId: 'song-tone-plan',
|
||||
routeKey: request.routeKey,
|
||||
providerId: 'rig-builder',
|
||||
stages: [
|
||||
{ stageId: 'amp', kind: 'nam', role: 'amp', assetRef: 'rig-builder:asset:amp-main' },
|
||||
{ stageId: 'cab', kind: 'ir', role: 'cab', assetRef: 'rig-builder:asset:cab-main' }
|
||||
],
|
||||
segments: [{ segmentId: 'base', stageIds: ['amp', 'cab'] }],
|
||||
summary: { stageCount: 2 }
|
||||
}
|
||||
})
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
User-facing controls should dispatch through the domain instead of mutating another plugin's private state:
|
||||
|
||||
```js
|
||||
await window.feedBack.capabilities.dispatch({
|
||||
capability: 'audio-effects',
|
||||
command: 'select-chain',
|
||||
source: 'rig_builder',
|
||||
payload: { routeKey: 'desktop-main', providerId: 'rig-builder', authorization: 'user-action' }
|
||||
});
|
||||
|
||||
const resolved = await window.feedBack.capabilities.dispatch({
|
||||
capability: 'audio-effects',
|
||||
command: 'resolve-plan',
|
||||
source: 'nam_tone',
|
||||
payload: { routeKey: 'desktop-main', target: { settingsKey: 'settings-v1-...' } }
|
||||
});
|
||||
```
|
||||
|
||||
Providers should store public song/tone routing through the host-owned mapping index and keep their own preset or chain rows private. The mapping's `provider_ref` is opaque to core: NAM Tone can use a preset id, Rig Builder can use a chain/preset id, and each provider resolves that reference in `chain.resolve`.
|
||||
|
||||
```js
|
||||
await window.feedBack.audioEffects.upsertMapping({
|
||||
song_key: playbackTarget.settingsKey,
|
||||
filename: playbackTarget.filename, // optional migration/debug context
|
||||
tone_key: 'Dist',
|
||||
provider_id: 'rig-builder',
|
||||
provider_ref: 'chain:99',
|
||||
label: 'Full Rig Builder chain',
|
||||
source: 'manual',
|
||||
active: true
|
||||
});
|
||||
|
||||
const mappings = await window.feedBack.audioEffects.listMappings({
|
||||
song_key: playbackTarget.settingsKey,
|
||||
tone_key: 'Dist'
|
||||
});
|
||||
```
|
||||
|
||||
Only one mapping is active for a `song_key + tone_key` at a time, but multiple providers may have rows for the same song/tone. The active row decides which provider core asks first; provider fallback remains explicit through provider priority and `fallbackProviderId` during `loadPlan(...)`.
|
||||
|
||||
Browser or native executors should declare both provider scope and plan scope. A NAM-only browser executor should not claim Rig Builder plans just because it can load NAM files:
|
||||
|
||||
```js
|
||||
window.feedBack.audioEffects.registerExecutor({
|
||||
executorId: 'nam-tone-browser-wasm',
|
||||
pluginId: 'nam_tone',
|
||||
routeKey: 'desktop-main',
|
||||
providerIds: ['nam-tone'],
|
||||
supportedKinds: ['nam', 'ir'],
|
||||
maxStages: 2,
|
||||
sourceMode: 'browser',
|
||||
loadChainPlan: request => loadNamToneWasmPlan(request)
|
||||
});
|
||||
```
|
||||
|
||||
Trusted Desktop can advertise broader support, while provider-specific browser executors should keep their `providerIds`, `supportedKinds`, and `maxStages` as narrow as the runtime actually supports.
|
||||
|
||||
The desktop executor is the trust boundary for physical loading. It should treat `assetRef` and `stateRef` values as opaque provider references, validate them through provider-owned lookup code, enforce local policy, then load or reject processor stages. Browser diagnostics should report route/provider/outcome summaries only.
|
||||
|
||||
## Audio Input And Monitoring Requester
|
||||
|
||||
Plugins that need live instrument input should declare requester/observer intent and let the host expose redaction-safe source identity. Diagnostics must not contain raw device labels, stable hardware ids, or audio buffers.
|
||||
Plugins that need live instrument input declare requester/observer intent and let the host expose redaction-safe source identity. Diagnostics must not contain raw device labels, stable hardware ids, or audio buffers.
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -274,7 +162,7 @@ Plugins that need live instrument input should declare requester/observer intent
|
||||
"requests": ["inspect", "list-sources", "select-source", "open-source", "close-source"],
|
||||
"observes": ["source-registered", "source-selected", "source-opened", "source-open-degraded", "source-closed", "permission-denied"],
|
||||
"mode": "active",
|
||||
"compatibility": "shim-allowed",
|
||||
"compatibility": "none",
|
||||
"ownership": "requester-only",
|
||||
"safety": "sensitive",
|
||||
"version": 1
|
||||
@@ -284,7 +172,7 @@ Plugins that need live instrument input should declare requester/observer intent
|
||||
"requests": ["inspect", "list-providers", "select-provider", "start", "stop", "set-direct-monitor"],
|
||||
"observes": ["provider-registered", "provider-selected", "provider-selection-required", "monitoring-started", "monitoring-degraded", "monitoring-unavailable", "monitoring-failed", "monitoring-denied", "monitoring-stopped", "direct-monitor-changed"],
|
||||
"mode": "active",
|
||||
"compatibility": "shim-allowed",
|
||||
"compatibility": "none",
|
||||
"ownership": "requester-only",
|
||||
"safety": "sensitive",
|
||||
"version": 1
|
||||
@@ -296,7 +184,7 @@ Plugins that need live instrument input should declare requester/observer intent
|
||||
Requesters should list or inspect sources before opening them. `inspect`, `list-sources`, and `select-source` are prompt-free and must not call provider enumeration or open live input. When a requester needs audio, it dispatches `open-source` with a purpose and required channel shape. The requester identity is taken from the dispatch `source` (the authenticated caller) — a payload-supplied `requesterId` is ignored, so a requester cannot spoof another's identity or release a shared session it does not own. Compatible requesters share one open session; each requester later dispatches `close-source`, and the provider is closed only after the last requester releases it.
|
||||
|
||||
```js
|
||||
const api = window.feedBack.capabilities;
|
||||
const api = window.slopsmith.capabilities;
|
||||
await api.dispatch({ capability: 'audio-input', command: 'select-source', source: 'user', payload: { logicalSourceKey: 'browser:instrument:primary' } });
|
||||
const opened = await api.dispatch({
|
||||
capability: 'audio-input',
|
||||
@@ -400,7 +288,7 @@ The Stems plugin remains the provider/owner of actual stem playback state. `core
|
||||
"operations": ["stem.get-state", "stem.apply-automation", "stem.restore-automation"],
|
||||
"events": ["owner-available", "automation-applied", "automation-restored", "automation-overridden", "claim-orphaned"],
|
||||
"mode": "active",
|
||||
"compatibility": "shim-allowed",
|
||||
"compatibility": "none",
|
||||
"ownership": "exclusive-owner",
|
||||
"safety": "safe",
|
||||
"version": 1
|
||||
@@ -411,7 +299,7 @@ The Stems plugin remains the provider/owner of actual stem playback state. `core
|
||||
|
||||
## Playback Requester And Observer
|
||||
|
||||
Plugins that need to inspect or coordinate song transport should declare `playback` requester/observer intent and use the capability dispatch surface instead of wrapping `window.playSong` or scraping the `<audio>` element. Raw media handles stay private to core; diagnostics expose only pseudonymous targets, sanitized timing, route, loop, requester, observer, and recent outcome summaries.
|
||||
Plugins that need to inspect or coordinate song transport declare `playback` requester/observer intent and use the capability dispatch surface. Raw media handles stay private to core; diagnostics expose only pseudonymous targets, sanitized timing, route, loop, requester, observer, and recent outcome summaries.
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -424,7 +312,7 @@ Plugins that need to inspect or coordinate song transport should declare `playba
|
||||
"requests": ["inspect", "pause", "resume", "seek", "set-loop", "clear-loop"],
|
||||
"observes": ["ready", "started", "paused", "resumed", "seeking", "seeked", "stopped", "loop-set", "loop-cleared"],
|
||||
"mode": "active",
|
||||
"compatibility": "shim-allowed",
|
||||
"compatibility": "none",
|
||||
"ownership": "requester-only",
|
||||
"safety": "safe",
|
||||
"version": 1
|
||||
@@ -436,7 +324,7 @@ Plugins that need to inspect or coordinate song transport should declare `playba
|
||||
Fresh audible starts require a user action. Background plugins should call `inspect` first and attach to an existing compatible session; if a plugin needs to offer a play/start action, wire it to a visible user gesture and pass `authorization: "user-action"`.
|
||||
|
||||
```js
|
||||
const api = window.feedBack.capabilities;
|
||||
const api = window.slopsmith.capabilities;
|
||||
|
||||
const state = await api.dispatch({
|
||||
capability: 'playback',
|
||||
@@ -455,106 +343,10 @@ if (state.status !== 'idle') {
|
||||
}
|
||||
```
|
||||
|
||||
During migration, legacy uses of `window.playSong`, `song:*` events, `window.feedBack.seek`, and loop helpers remain available and are recorded as playback bridge hits. Treat bridge hits as migration telemetry: native capability requests should eventually cover normal plugin workflows so unexpected legacy hits disappear from diagnostics.
|
||||
|
||||
## Progression Requester And Observer
|
||||
|
||||
Plugins that report gameplay outcomes or react to player progression (spec 010) should declare `progression` requester/observer intent and use capability dispatch instead of private fetches. Externally postable event types are whitelisted (`minigame_run` in v1); `song_completed` is server-derived inside `/api/stats` and is denied at this surface. Backend plugin code can use the plugin-context hook `record_progression_event` instead (the minigames hub does).
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "my_minigame",
|
||||
"name": "My Minigame",
|
||||
"standards": ["capability-pipelines.v1"],
|
||||
"capabilities": {
|
||||
"progression": {
|
||||
"roles": ["requester", "observer"],
|
||||
"requests": ["inspect", "record-event"],
|
||||
"observes": ["challenge-completed", "quest-completed", "path-level-up", "rank-changed", "db-changed"],
|
||||
"mode": "active",
|
||||
"compatibility": "none",
|
||||
"ownership": "requester-only",
|
||||
"safety": "safe",
|
||||
"version": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`buy-item` and `equip-item` require a visible user gesture (`authorization: "user-action"`). Decibels are play-earned only; plugins must not present any purchase path.
|
||||
|
||||
```js
|
||||
const api = window.feedBack.capabilities;
|
||||
|
||||
const result = await api.dispatch({
|
||||
capability: 'progression',
|
||||
command: 'record-event',
|
||||
source: 'my_minigame',
|
||||
payload: { type: 'minigame_run', payload: { game_id: 'my-minigame', score: 420 } },
|
||||
});
|
||||
// result.payload lists challenges/quests completed by this event (toast UX).
|
||||
|
||||
window.feedBack.on('progression:quest-completed', (e) => {
|
||||
console.log('quest done:', e.detail.title, '+' + e.detail.reward_db + ' dB');
|
||||
});
|
||||
```
|
||||
|
||||
## Future Expansion Domains
|
||||
|
||||
Some domain names are reserved for expected future contracts, but they are not registered in the runtime graph yet. For example, `ui.player-panels` is documented as a likely panel-host surface, but FeedBack does not currently expose a capability command for panel contributions. See [capability-roadmap.md](capability-roadmap.md) for the PR1 domain set and deferred-domain checklist.
|
||||
Some domain names are reserved for expected future contracts, but they are not registered in the runtime graph yet. For example, `ui.player-panels` is documented as a likely panel-host surface, but Slopsmith does not currently expose a capability command for panel contributions. See [capability-roadmap.md](capability-roadmap.md) for the PR1 domain set and deferred-domain checklist.
|
||||
|
||||
Plugins should not declare future expansion domains until the corresponding host workflow ships. For current integrations, prefer active domains such as `library`, `playback`, `audio-mix`, `audio-input`, `audio-monitoring`, or `stems` intent matching the recipes above.
|
||||
Do not declare future expansion domains until the corresponding host workflow ships. For current integrations, use active domains such as `library`, `playback`, `audio-mix`, `audio-input`, `audio-monitoring`, or `stems` intent matching the recipes above.
|
||||
|
||||
Invalid capability metadata is excluded from the capability graph, but legacy manifest fields still load through their existing app paths. The `library` workflow is native in PR1 and does not use compatibility shim metadata. Unsupported `capability-pipelines` versions are reported as incompatible and their runtime handlers must not execute.
|
||||
|
||||
## Library Card Action (`ui.library-card-injection`)
|
||||
|
||||
Delivered in fee[dB]ack v0.3.0 (frontend host). Plugins add per-song actions to
|
||||
the library cards by REGISTERING them instead of DOM-injecting onto
|
||||
`.song-card`. The library renders applicable actions in each card's action
|
||||
menu, dispatches the handler on click, and emits `action-result` events;
|
||||
the owner is visible in the Capability Inspector.
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "my_card_action",
|
||||
"name": "My Card Action",
|
||||
"standards": ["capability-pipelines.v1"],
|
||||
"capabilities": {
|
||||
"ui.library-card-injection": {
|
||||
"roles": ["provider"],
|
||||
"operations": ["action.run"],
|
||||
"events": ["action-registered", "action-result"],
|
||||
"mode": "active",
|
||||
"compatibility": "none",
|
||||
"safety": "safe",
|
||||
"version": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Register the action from the plugin's `screen.js`:
|
||||
|
||||
```js
|
||||
window.feedBack.libraryCardActions.register({
|
||||
id: 'my_card_action.run',
|
||||
pluginId: 'my_card_action',
|
||||
label: 'Do the thing',
|
||||
placement: 'menu', // 'menu' | 'inline' | 'overlay'
|
||||
order: 50,
|
||||
applies: (song) => song.format === 'sloppak', // shown only when relevant
|
||||
enabled: (song) => true,
|
||||
run: async (song, ctx) => { // ctx.source identifies the surface
|
||||
await fetch('/api/plugins/my_card_action/run', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ filename: song.filename }),
|
||||
});
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
`register(spec)` returns an `unregister()` fn. The host owns rendering,
|
||||
applicability, enabled state, and `action-result` events — plugins do not touch
|
||||
library DOM. Legacy `.song-card` DOM injection still works in the 0.2.x UI;
|
||||
migrate to this for the v0.3.0 native library.
|
||||
Invalid capability metadata is excluded from the capability graph. The `library` workflow is native in PR1 and does not use compatibility shim metadata. Unsupported `capability-pipelines` versions are reported as incompatible and their runtime handlers must not execute.
|
||||
|
||||
@@ -38,7 +38,7 @@ The audio graph/session and effects slices promote these domains after PR1:
|
||||
|
||||
`core.audio.session` is the runtime coordinator for all four domains. It owns `audio-mix`, `audio-input`, and `audio-monitoring`; for `stems`, it coordinates the active Stems provider without replacing the Stems plugin as the owner of actual stem playback/state.
|
||||
|
||||
The focused audio-mix control-plane slice promotes fader discovery, read/write operations, committed-value events, native-over-legacy duplicate handling, route/analyser inspection, and compatibility removal gates into `audio-mix`. During migration, `window.feedBack.audio.registerFader(...)` remains available as a compatibility adapter, but the player mixer consumes the audio-mix control plane as its source of truth.
|
||||
The focused audio-mix control-plane slice promotes fader discovery, read/write operations, committed-value events, native-over-legacy duplicate handling, route/analyser inspection, and compatibility removal gates into `audio-mix`. During migration, `window.slopsmith.audio.registerFader(...)` remains available as a compatibility adapter, but the player mixer consumes the audio-mix control plane as its source of truth.
|
||||
|
||||
The focused audio-input control-plane slice promotes source listing, prompt-free selection/inspection, explicit provider enumeration, open/close dispatch, channel-shape compatibility, selected-source persistence, shared requester sessions, and redaction-safe failure diagnostics into `audio-input`. During migration, legacy browser, desktop, or plugin-specific input handoffs should be recorded as `audio-input.legacy-source` bridge hits. Native providers own the visible source when they share a logical source key with a compatibility-backed source; the compatibility source remains diagnostics-only until normal playback shows no unexpected legacy hits.
|
||||
|
||||
@@ -50,7 +50,7 @@ The focused audio-effects control-plane slice promotes provider registration, us
|
||||
|
||||
The playback slice promotes `playback` from a deferred domain to an active exclusive-owner core domain. It owns transport commands (`start`, `pause`, `resume`, `stop`, `seek`, `set-loop`, `clear-loop`, `inspect`), lifecycle events (`playback:requested`, `playback:loading`, `playback:ready`, `playback:started`, `playback:paused`, `playback:resumed`, `playback:seeking`, `playback:seeked`, `playback:ended`, `playback:stopped`, route events, bridge hits, and loop events), and redaction-safe diagnostics for session, target, timing, route, loop, requester, observer, bridge, and recent outcome state.
|
||||
|
||||
The implementation deliberately keeps raw transport handles in `static/app.js`: the domain host registers a private adapter and receives sanitized snapshots instead of exposing the `<audio>` element, JUCE player, decoded audio buffers, waveform data, or native route handles. Playback targets expose a pseudonymous arrangement-scoped `targetId` plus a hashed per-song `settingsKey` so observers can store local per-song settings without reading raw filenames or paths. Compatibility bridges currently account for `window.playSong`, `window.feedBack` transport helpers, legacy song events, loop helpers, media snapshots, route switching, and native-route handoff. Fresh audible starts require `authorization: "user-action"`; background requesters can inspect or control only an existing compatible session according to the command conflict policy.
|
||||
The implementation deliberately keeps raw transport handles in `static/app.js`: the domain host registers a private adapter and receives sanitized snapshots instead of exposing the `<audio>` element, JUCE player, decoded audio buffers, waveform data, or native route handles. Playback targets expose a pseudonymous arrangement-scoped `targetId` plus a hashed per-song `settingsKey` so observers can store local per-song settings without reading raw filenames or paths. Compatibility bridges currently account for `window.playSong`, `window.slopsmith` transport helpers, legacy song events, loop helpers, media snapshots, route switching, and native-route handoff. Fresh audible starts require `authorization: "user-action"`; background requesters can inspect or control only an existing compatible session according to the command conflict policy.
|
||||
|
||||
Playback bridge removal gates are: bundled and first-party plugins use native playback dispatch for normal requester/observer workflows; normal play/pause/seek/loop/route smoke runs show no unexpected bridge hits beyond compatibility-only listeners; playback diagnostics distinguish denied, no-target, stale, cancelled, degraded, unavailable, failed, and stopped outcomes; repeated plugin hydration does not duplicate requesters, observers, wrappers, or bridge entries; and exported support snapshots contain no raw song filenames, paths, URLs, media handles, buffers, waveforms, samples, or recordings.
|
||||
|
||||
@@ -82,7 +82,7 @@ This is the recommended order for UI/UX capability work only. It excludes audio
|
||||
| 5 | Player controls | `ui.player-controls` | Direct player control DOM edits, control popovers, button/slider globals | Ordered player-control regions with stable command buttons, popovers, sliders, disabled states, and contribution teardown | Player controls can be added/removed/reordered without plugins mutating the control bar directly. |
|
||||
| 6 | Player overlays | `ui.player-overlays`, `tours` | Overlay canvases, tour overlays, highway visibility listeners, direct z-index management | Overlay host with anchors, z-order, hit-testing, renderer compatibility flags, visibility events, and cleanup | Fretboard, section map, tours, transpose, step mode, and similar overlays can coexist without private layering rules. |
|
||||
| 7 | Player panels | `ui.player-panels` | Splitscreen panel DOM, panel-local highway instances, panel-local shortcuts | Panel host with layout slots, active-panel focus, per-panel renderer selection, per-panel shortcuts, visibility, and teardown | Splitscreen-style panels can be composed through host APIs instead of wrapping playback/screen globals. |
|
||||
| 8 | Visualization UX | `visualization` | `type: "visualization"`, `window.feedBackViz_*`, viz picker state, auto-match hooks | Renderer provider registry with picker integration, auto-match ordering, context-type metadata, fallback/revert events, and per-panel selection | Renderer selection and failure recovery are fully attributed in diagnostics; picker options no longer depend on global scans. |
|
||||
| 8 | Visualization UX | `visualization` | `type: "visualization"`, `window.slopsmithViz_*`, viz picker state, auto-match hooks | Renderer provider registry with picker integration, auto-match ordering, context-type metadata, fallback/revert events, and per-panel selection | Renderer selection and failure recovery are fully attributed in diagnostics; picker options no longer depend on global scans. |
|
||||
| 9 | Library and guided UX extensions | `ui.library-card-injection`, `tours` | Library card buttons, tour registration globals, target selectors | Contribution APIs for library card actions and guided-tour steps with applicability, target resolution, and action-result events | Library actions and tours can be inspected, disabled, and tested independently of plugin-private DOM injection. |
|
||||
| 10 | Theme and polish surfaces | `settings` or candidate `ui.theme` | Global theme settings, direct stylesheet/class mutation | Theme contribution metadata for tokens, selected theme, preview/apply/restore lifecycle, and diagnostics without user secrets | Themes are reversible and attributable, and visual changes do not depend on hidden global state. |
|
||||
|
||||
@@ -108,7 +108,7 @@ These domains are planned but should stay out of the runtime graph until a host
|
||||
| `ui.player-overlays` | exclusive-owner | safe | Overlay contributions layered over player or highway surfaces. | Overlay placement and z-order rules that coexist with legacy overlays. |
|
||||
| `plugins` | exclusive-owner | privileged | Plugin enable/disable/install/update workflows. | Visible user confirmation, rollback, and disabled-handler enforcement. |
|
||||
| `jobs` | multi-provider | privileged | Long-running jobs, cancellation, status, failures. | Scheduling limits, cancellation semantics, and user-visible failures. |
|
||||
| `midi-control` | multi-provider | sensitive | MIDI control mappings only (CC/pitchbend/note → action routing), consuming `midi-input` for device access. Device discovery/selection/open is split out to the delivered `midi-input` domain (spec 012). | A concrete mapping/routing workflow on top of the `midi-input` device plane (#882). |
|
||||
| `midi-control` | multi-provider | sensitive | MIDI device providers and control mappings. | Device consent and redacted diagnostics. |
|
||||
| `audio-input` | multi-provider | sensitive | Audio input device providers, source selection, open/close lifecycle, shared sessions, and redacted failure diagnostics. | Promoted by the audio graph/session slice and implemented by the audio-input control-plane slice. |
|
||||
| `tempo-clock` | multi-provider | safe | Tempo/clock provider registration and consumers. | A concrete tempo source and consumer workflow. |
|
||||
|
||||
@@ -127,7 +127,7 @@ These candidate domains were surfaced by the included plugin inventory but are n
|
||||
| `recording` | multi-provider | sensitive | Arm/start/stop capture, take upload/import, capture-source binding, latency metadata, and storage cleanup. | Studio and karaoke workflows need capture/session semantics distinct from raw audio input. |
|
||||
| `practice-session` | multi-provider | safe | Practice session lifecycle, goals, score/progress events, chart segment focus, and journal persistence boundaries. | Practice Journal, Minigames, Guitar Theory, Flappy Bend, and Note Detect imply practice/progression state. |
|
||||
| `collaboration` | multi-provider | sensitive | Room/session lifecycle, participant identity redaction, shared playback sync, conflict policy, and disconnect recovery. | Multiplayer is a distinct real-time coordination surface. |
|
||||
| `external-services` | diagnostic or privileged metadata | privileged | Network/download/subprocess integration inventory, endpoint attribution, confirmation policy, and failure diagnostics. | Update Manager, Find More, Sloppak Converter, and media jobs reach outside local FeedBack state. |
|
||||
| `external-services` | diagnostic or privileged metadata | privileged | Network/download/subprocess integration inventory, endpoint attribution, confirmation policy, and failure diagnostics. | Update Manager, Find More, Sloppak Converter, and media jobs reach outside local Slopsmith state. |
|
||||
|
||||
Candidate domains can also remain as safety metadata on existing domains. For example, `external-services` may be more useful as a cross-cutting review tag than as a dispatchable runtime capability.
|
||||
|
||||
@@ -141,7 +141,7 @@ PR1 does not add per-domain versioning. The `capability-pipelines.v1` standard v
|
||||
- Changing command payloads, return payloads, or dispatch outcomes incompatibly is breaking.
|
||||
- A breaking change requires either a future `capability-pipelines` version or a clearly new domain name if parallel support is needed.
|
||||
|
||||
Per-domain versions should wait until FeedBack has a concrete need for multiple incompatible versions of the same domain to coexist.
|
||||
Per-domain versions should wait until Slopsmith has a concrete need for multiple incompatible versions of the same domain to coexist.
|
||||
|
||||
## Future Domain PR Checklist
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
Capability declarations include a safety class so reviewers can decide whether a domain can ship as a normal plugin contract or needs extra enforcement first.
|
||||
|
||||
Core domains also have a review scope. **Active contract** domains are wired to current FeedBack behavior and should be tested as working integration points. Expected future domains are documented below, but are intentionally not registered in the runtime graph until FeedBack ships the corresponding host UI or provider workflow.
|
||||
Core domains also have a review scope. **Active contract** domains are wired to current Slopsmith behavior and should be tested as working integration points. Expected future domains are documented below, but are intentionally not registered in the runtime graph until Slopsmith ships the corresponding host UI or provider workflow.
|
||||
|
||||
| Domain | Owner Kind | Safety Class | Stable Commands | Provider Operations | Notes |
|
||||
|--------|------------|--------------|-----------------|---------------------|-------|
|
||||
@@ -14,15 +14,13 @@ Core domains also have a review scope. **Active contract** domains are wired to
|
||||
| audio-monitoring | provider-coordinator | sensitive | inspect, list-providers, register-provider, unregister-provider, select-provider, start, stop, set-direct-monitor | monitoring.start, monitoring.stop, monitoring.status, monitoring.set-direct-monitor | Inspect/list/select/status are prompt-free. Fresh monitoring start requires explicit user action; background requesters may only attach to an active compatible session. Outcomes distinguish handled, stopped, denied, unavailable, degraded, failed, no-owner, no-handler, unsupported-command, incompatible, incompatible-version, provider-selection-required, and user-action-required. Diagnostics redact raw device labels, hardware ids, paths, secrets, live handles, buffers, samples, waveforms, and recordings. |
|
||||
| stems | coordinator plus plugin provider | safe | inspect, mute, restore | stem.get-state, stem.apply-automation, stem.restore-automation | Core coordinates claims/overrides; the active Stems provider owns actual stem state/playback. |
|
||||
| playback | exclusive-owner | safe | inspect, start, pause, resume, stop, seek, set-loop, clear-loop, register-requester, register-observer | none | Core owns the transport control plane while `app.js` keeps raw media handles private. Fresh audible starts require explicit user action. Diagnostics expose pseudonymous targets, sanitized route/timing/loop state, requester/observer summaries, bridge hits, bounded recent outcomes, and no audio elements, native handles, decoded buffers, samples, waveforms, or recordings. |
|
||||
| progression | exclusive-owner | safe | inspect, record-event, list-shop, buy-item, equip-item | none | Core owns mastery rank, the challenge/quest engine, the Decibels wallet, and the cosmetics shop (spec 010). `record-event` accepts whitelisted types only (`minigame_run`); `song_completed` is server-derived in `/api/stats` and denied here. `buy-item`/`equip-item` require explicit user action. Decibels are play-earned only — no real-money path exists or may be added. Diagnostics (`feedBack.progression.diag.v1`) carry content warnings, rank/level/quest counts, and wallet totals; no song filenames or display names. |
|
||||
| progression | exclusive-owner | safe | inspect, record-event, list-shop, buy-item, equip-item | none | Core owns mastery rank, the challenge/quest engine, the Decibels wallet, and the cosmetics shop (spec 010). `record-event` accepts whitelisted types only (`minigame_run`); `song_completed` is server-derived in `/api/stats` and denied here. `buy-item`/`equip-item` require explicit user action. Decibels are play-earned only — no real-money path exists or may be added. Diagnostics (`slopsmith.progression.diag.v1`) carry content warnings, rank/level/quest counts, and wallet totals; no song filenames or display names. |
|
||||
| audio-effects | provider-coordinator | sensitive | inspect, list-providers, register-provider, unregister-provider, select-chain, resolve-plan, inspect-route, bypass, restore, fallback, activate-segment, set-stage-bypass, set-stage-parameter, record-bridge-hit | chain.resolve, chain.inspect, segment.activate, stage.set-bypass, stage.set-parameter, route.bypass, route.restore | Core owns provider selection, route state, chain-plan schema validation, fallback accounting, and diagnostics. Providers propose opaque NAM/IR/VST/utility chain plans; trusted desktop/native code validates and loads processors. Chain selection and route bypass/restore require explicit user action or restored selection. Diagnostics omit raw paths, filenames, URLs, model/IR names, native preset JSON, VST state blobs, handles, callbacks, DOM nodes, audio buffers, samples, and waveforms. |
|
||||
|
||||
| visualization | provider-coordinator | safe | inspect, list-providers, select-renderer, clear-renderer | renderer.create, renderer.destroy | Highway renderer provider registry, picker-delegated selection, auto-match attribution, and failure fallback. `renderer.create` maps to the legacy `window.feedBackViz_*` factory `init(canvas, ctx)` call; `renderer.destroy` maps to the factory `destroy()` teardown. Legacy `type: "visualization"` manifests and `window.feedBackViz_*` globals are accounted compatibility shims. Diagnostics carry provider ids/labels, selection source, last auto-match outcome, and last failure — no song filenames, titles, or arrangement names. |
|
||||
| visualization | provider-coordinator | safe | inspect, list-providers, select-renderer, clear-renderer | renderer.create, renderer.destroy | Highway renderer provider registry, picker-delegated selection, auto-match attribution, and failure fallback. `renderer.create` maps to the legacy `window.slopsmithViz_*` factory `init(canvas, ctx)` call; `renderer.destroy` maps to the factory `destroy()` teardown. Legacy `type: "visualization"` manifests and `window.slopsmithViz_*` globals are accounted compatibility shims. Diagnostics carry provider ids/labels, selection source, last auto-match outcome, and last failure — no song filenames, titles, or arrangement names. |
|
||||
|
||||
| note-detection | provider-coordinator | sensitive | inspect, register-provider, unregister-provider, open-binding, close-binding, set-target, clear-target | pitch.estimate, verify.target | Detection-binding control plane (spec 009): providers (midi/engine/js) serve primitives; each requester binds its own redacted tuning context; consumers own judgment, hit/miss flow as observability events. Legacy `highway.setNoteStateProvider` is an accounted shim. Diagnostics carry provider/binding summaries and bounded outcomes — no raw audio, sample data, device labels, or song identity. |
|
||||
|
||||
| midi-input | provider-coordinator | sensitive | inspect, list-sources, discover, select-source, open-source, close-source | source.enumerate, source.describe, source.open, source.close | Core-owned MIDI device control plane (spec 012), the MIDI analog of `audio-input`. Inspect/list/select are prompt-free; `discover` is the Web-MIDI permission boundary (`requestMIDIAccess()` gates the whole input list) and records denied/unavailable outcomes; `open-source` attaches a shared listener session and never re-prompts. Selection persists by redaction-safe `logicalSourceKey`. Diagnostics redact device labels and never include raw MIDI messages or live handles. |
|
||||
|
||||
Privileged commands are roadmap-only until they have: a visible user confirmation path, diagnostics redaction rules, failure recovery, and tests that prove disabled or incompatible participants cannot execute handlers.
|
||||
|
||||
## Expected Future Domains
|
||||
@@ -40,9 +38,9 @@ These domains are expected future capability contracts, not current runtime grap
|
||||
| ui.player-overlays | exclusive-owner | safe | register-contribution, mount, unmount, set-visible, reorder-by-policy, inspect | Needs overlay placement rules that coexist with legacy highway overlays. |
|
||||
| plugins | exclusive-owner | privileged | enable, disable, install-missing, update, inspect | Needs explicit user confirmation for writes/install/update. |
|
||||
| jobs | multi-provider | privileged | register, inspect, cancel | Needs scheduling limits, cancellation semantics, and user-visible failures. |
|
||||
| midi-control | multi-provider | sensitive | list-mappings, get-mapping, set-mapping, delete-mapping, activate-mapping, inspect | Mappings ONLY — CC/pitchbend/note → semantic action routing (spec 013). Device discovery/selection/open is NOT this domain's job: it consumes the delivered `midi-input` domain for device access. Needs a concrete mapping consumer (the MIDI control plugin / drums learn-mode) + redacted diagnostics (no raw MIDI streams) before promotion. |
|
||||
| midi-control | multi-provider | sensitive | register, inspect | Needs device consent and redacted diagnostics. |
|
||||
| tempo-clock | multi-provider | safe | register, inspect | Needs a concrete provider and consumer workflow. |
|
||||
|
||||
Planned domains should also stay out of the runtime graph until FeedBack ships the corresponding user-facing workflows.
|
||||
Planned domains should also stay out of the runtime graph until Slopsmith ships the corresponding user-facing workflows.
|
||||
|
||||
When promoting a planned domain, use [capability-review-preflight.md](capability-review-preflight.md) before opening the PR. The preflight captures recurring review requirements for identity, redaction, outcome propagation, diagnostics freshness, schema consistency, and teardown.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# FeedBack Diagnostics Bundle — Format Specification
|
||||
# Slopsmith Diagnostics Bundle — Format Specification
|
||||
|
||||
This document is the authoritative reference for the `feedBack-diag-*.zip`
|
||||
file produced by Settings → Export Diagnostics (feedBack#166).
|
||||
This document is the authoritative reference for the `slopsmith-diag-*.zip`
|
||||
file produced by Settings → Export Diagnostics (slopsmith#166).
|
||||
|
||||
The bundle is consumed by humans (maintainers reading bug reports) **and**
|
||||
AI agents (auto-triage, code-aware assistants). Every JSON file inside
|
||||
@@ -15,17 +15,17 @@ version without guessing.
|
||||
A diagnostic bundle is a plain ZIP archive. The default filename is:
|
||||
|
||||
```
|
||||
feedBack-diag-<feedBack-version>-<YYYYMMDD-HHMMSS>.zip
|
||||
slopsmith-diag-<slopsmith-version>-<YYYYMMDD-HHMMSS>.zip
|
||||
```
|
||||
|
||||
Top-level layout:
|
||||
|
||||
```
|
||||
feedBack-diag-0.2.4-20260503-143022.zip
|
||||
slopsmith-diag-0.2.4-20260503-143022.zip
|
||||
├── manifest.json AI-friendly index, schema 1
|
||||
├── README.txt Human-friendly: what's in here, how to read
|
||||
├── system/
|
||||
│ ├── version.json feedBack + python + OS
|
||||
│ ├── version.json slopsmith + python + OS
|
||||
│ ├── env.json allowlisted env vars only (no secrets)
|
||||
│ ├── hardware.json backend hardware (container-limited if Docker)
|
||||
│ └── plugins.json loaded + orphan plugins, with git info
|
||||
@@ -53,7 +53,7 @@ logs, console, plugins). Missing sections are not represented in
|
||||
{
|
||||
"schema": 1, // bundle schema; bump = breaking change
|
||||
"exported_at": "2026-05-03T14:30:22Z",
|
||||
"feedBack_version": "0.2.4",
|
||||
"slopsmith_version": "0.2.4",
|
||||
"runtime": "docker", // "docker" | "electron" | "bare"
|
||||
"redacted": true, // were redactions applied?
|
||||
"files": [
|
||||
@@ -94,7 +94,7 @@ Field semantics:
|
||||
```jsonc
|
||||
{
|
||||
"schema": "system.version.v1",
|
||||
"feedBack_version": "0.2.4",
|
||||
"slopsmith_version": "0.2.4",
|
||||
"python": { "version": "3.12.4", "implementation": "CPython", "executable": "/usr/bin/python" },
|
||||
"os": { "system": "Linux", "release": "6.5.0", "machine": "x86_64" },
|
||||
"exported_at": "2026-05-03T14:30:22Z"
|
||||
@@ -109,13 +109,13 @@ Field semantics:
|
||||
"vars": {
|
||||
"LOG_LEVEL": "INFO",
|
||||
"LOG_FORMAT": "json",
|
||||
"FEEDBACK_RUNTIME": "electron"
|
||||
"SLOPSMITH_RUNTIME": "electron"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Allowlisted env var keys only (see `ENV_ALLOWLIST` in `lib/diagnostics_bundle.py`):
|
||||
`LOG_LEVEL`, `LOG_FORMAT`, `LOG_FILE`, `FEEDBACK_RUNTIME`, `PORT`, `HOST`,
|
||||
`LOG_LEVEL`, `LOG_FORMAT`, `LOG_FILE`, `SLOPSMITH_RUNTIME`, `PORT`, `HOST`,
|
||||
`TZ`, `PYTHONUNBUFFERED`, `DEMUCS_SERVER_URL`. New entries require an
|
||||
allowlist edit; secrets must never be added.
|
||||
|
||||
@@ -187,7 +187,7 @@ entry explaining why.
|
||||
"version": "0.1.0",
|
||||
"loaded": false,
|
||||
"dir": "broken",
|
||||
"path": "/home/user/.config/feedBack/plugins/broken"
|
||||
"path": "/home/user/.config/slopsmith/plugins/broken"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -223,7 +223,7 @@ appear in `capability_unsupported_versions` and should be treated as
|
||||
non-executable runtime intent.
|
||||
|
||||
Client-side capability snapshots contributed under `plugins/capabilities/client.json`
|
||||
use schema `feedBack.capabilities.diagnostics.v1`. They include current
|
||||
use schema `slopsmith.capabilities.diagnostics.v1`. They include current
|
||||
pipelines, participants, conflicts, missing providers, user overrides, active
|
||||
or orphaned claims, claim lifecycle records, compatibility shim hit counts,
|
||||
unsupported-version reports, and recent decisions. The runtime caps this
|
||||
@@ -235,7 +235,7 @@ current graph state.
|
||||
```jsonc
|
||||
{
|
||||
"schema": "logs.server.v1",
|
||||
"log_file": "/data/log/feedBack.log",
|
||||
"log_file": "/data/log/slopsmith.log",
|
||||
"exists": true,
|
||||
"size_bytes": 8388608,
|
||||
"tail_bytes": 5242880,
|
||||
@@ -341,7 +341,7 @@ serialized as `"[circular]"`.
|
||||
`runtime.kind` rules:
|
||||
|
||||
- `"electron"` if `navigator.userAgent` contains `Electron/`. Versions
|
||||
populated when the desktop launcher exposes `window.feedBackElectron`
|
||||
populated when the desktop launcher exposes `window.slopsmithElectron`
|
||||
via a preload `contextBridge`.
|
||||
- `"browser"` otherwise.
|
||||
|
||||
@@ -367,7 +367,7 @@ typically prefix their keys with their `plugin_id`.
|
||||
{
|
||||
"schema": "client.ua.v1",
|
||||
"userAgent": "...",
|
||||
"url": "https://feedBack.local/",
|
||||
"url": "https://slopsmith.local/",
|
||||
"screen": { ... }
|
||||
}
|
||||
```
|
||||
@@ -406,10 +406,10 @@ dispatch by plugin schema.
|
||||
|
||||
Detection precedence (backend):
|
||||
|
||||
1. `FEEDBACK_RUNTIME` env var (`"electron"`/`"docker"`/`"bare"`)
|
||||
1. `SLOPSMITH_RUNTIME` env var (`"electron"`/`"docker"`/`"bare"`)
|
||||
2. `/.dockerenv` exists OR `/proc/1/cgroup` mentions `docker`/
|
||||
`containerd`/`kubepods` → `docker`
|
||||
3. Parent process name matches `electron` or `FeedBack` → `electron`
|
||||
3. Parent process name matches `electron` or `Slopsmith` → `electron`
|
||||
4. Default: `bare`
|
||||
|
||||
Detection (frontend): `Electron/` in user agent → `electron`, else
|
||||
@@ -430,7 +430,7 @@ between bundles):
|
||||
|--------------------|-----------------------------------------------------|
|
||||
| `<DLC_DIR>` | configured DLC root path |
|
||||
| `<HOME>` | user's home directory |
|
||||
| `<CONFIG_DIR>` | feedBack config directory |
|
||||
| `<CONFIG_DIR>` | slopsmith config directory |
|
||||
| `<song:HASH8>` | song filename / basename (8-char salted SHA-256) |
|
||||
| `<ip:HASH6>` | IPv4 / IPv6 address |
|
||||
| `<redacted>` | bearer token, `key=`/`token=`/`api_key=` query strings |
|
||||
@@ -508,7 +508,7 @@ machine.
|
||||
```
|
||||
|
||||
Frontend plugins push diagnostics by calling
|
||||
`window.feedBack.diagnostics.contribute(plugin_id, payload)` before the
|
||||
`window.slopsmith.diagnostics.contribute(plugin_id, payload)` before the
|
||||
user clicks Export. The payload is written to `plugins/<id>/client.json`
|
||||
(gated on the same "Plugin diagnostics" toggle as backend plugin files).
|
||||
|
||||
|
||||
+10
-10
@@ -1,4 +1,4 @@
|
||||
# FeedBack diagnostic sloppaks
|
||||
# Slopsmith diagnostic sloppaks
|
||||
|
||||
Generated, non-copyrighted mini-songs for technique-assessment style
|
||||
checks. Report-only — they do not change gameplay settings or detection
|
||||
@@ -6,7 +6,7 @@ thresholds.
|
||||
|
||||
## Basic Guitar (POC)
|
||||
|
||||
**Artifact:** `feedBack-diagnostic-basic-guitar.sloppak`
|
||||
**Artifact:** `slopsmith-diagnostic-basic-guitar.sloppak`
|
||||
|
||||
**Contents (~55 s):**
|
||||
|
||||
@@ -23,7 +23,7 @@ for future Technique Assessment integration).
|
||||
|
||||
## Rebuild
|
||||
|
||||
From the feedBack repo root (requires `ffmpeg`; the feedBack Docker image
|
||||
From the slopsmith repo root (requires `ffmpeg`; the slopsmith Docker image
|
||||
has `libvorbis`, Homebrew ffmpeg may use the built-in `vorbis` encoder):
|
||||
|
||||
```bash
|
||||
@@ -36,14 +36,14 @@ On library scan startup (and periodic rescans), the server copies bundled
|
||||
diagnostic sloppaks into the user DLC folder when missing or when the
|
||||
bundled source is newer:
|
||||
|
||||
`DLC_DIR/diagnostics-builtin/feedBack-diagnostic-basic-guitar.sloppak`
|
||||
`DLC_DIR/diagnostics-builtin/slopsmith-diagnostic-basic-guitar.sloppak`
|
||||
|
||||
Source: `docs/diagnostics/feedBack-diagnostic-basic-guitar.sloppak` (next to
|
||||
Source: `docs/diagnostics/slopsmith-diagnostic-basic-guitar.sloppak` (next to
|
||||
`server.py` in dev; must be included in the desktop bundle — see
|
||||
`feedBack-desktop/scripts/bundle-feedBack.sh`).
|
||||
`slopsmith-desktop/scripts/bundle-slopsmith.sh`).
|
||||
|
||||
Unlike `tutorials-builtin/`, `diagnostics-builtin/` **is** included in the
|
||||
library scan. Tracks appear under **FeedBack** /
|
||||
library scan. Tracks appear under **Slopsmith** /
|
||||
**Technique Assessment Diagnostics**.
|
||||
|
||||
Existing destination files are not overwritten unless the bundled source
|
||||
@@ -55,10 +55,10 @@ are never touched.
|
||||
Normally seeding is automatic once a DLC folder is configured. To test a
|
||||
custom copy or an unreleased build:
|
||||
|
||||
1. Copy `feedBack-diagnostic-basic-guitar.sloppak` into your FeedBack
|
||||
1. Copy `slopsmith-diagnostic-basic-guitar.sloppak` into your Slopsmith
|
||||
DLC folder (e.g. `diagnostics-test/` or any scanned path).
|
||||
2. Restart FeedBack or trigger a library rescan if the song does not appear.
|
||||
3. Load **FeedBack Diagnostic — Basic Guitar**.
|
||||
2. Restart Slopsmith or trigger a library rescan if the song does not appear.
|
||||
3. Load **Slopsmith Diagnostic — Basic Guitar**.
|
||||
4. Play the **Diagnostic Guitar** arrangement.
|
||||
5. Confirm the 3D highway shows open notes and power-chord gems.
|
||||
6. Turn **Detect** on — note_detect should push the chart to the desktop
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
"""Build the FeedBack Diagnostic — Basic Guitar sloppak (POC).
|
||||
"""Build the Slopsmith Diagnostic — Basic Guitar sloppak (POC).
|
||||
|
||||
A short, generated, non-copyrighted mini-song for technique-assessment
|
||||
style checks: open strings, one fretted note, and repeated E5 power chords.
|
||||
Click-track backing only — no external audio.
|
||||
|
||||
Run from the feedBack repo root:
|
||||
Run from the slopsmith repo root:
|
||||
|
||||
python3 docs/diagnostics/build_diagnostic_basic_guitar.py
|
||||
|
||||
Output (zip archive):
|
||||
|
||||
docs/diagnostics/feedBack-diagnostic-basic-guitar.sloppak
|
||||
docs/diagnostics/slopsmith-diagnostic-basic-guitar.sloppak
|
||||
|
||||
Pattern matches docs/benchmarks/note_detect_v1/build_benchmark.py.
|
||||
"""
|
||||
@@ -289,8 +289,8 @@ def build_chart():
|
||||
}
|
||||
|
||||
manifest = {
|
||||
'title': 'FeedBack Diagnostic — Basic Guitar',
|
||||
'artist': 'FeedBack',
|
||||
'title': 'Slopsmith Diagnostic — Basic Guitar',
|
||||
'artist': 'Slopsmith',
|
||||
'album': 'Technique Assessment Diagnostics',
|
||||
'year': 2026,
|
||||
'duration': round(end_t, 3),
|
||||
@@ -405,7 +405,7 @@ def build(output_zip: Path) -> dict:
|
||||
|
||||
|
||||
def _diagnostic_readme(duration_s: float) -> str:
|
||||
return f"""# FeedBack Diagnostic — Basic Guitar
|
||||
return f"""# Slopsmith Diagnostic — Basic Guitar
|
||||
|
||||
Short generated diagnostic track for technique-assessment style checks.
|
||||
Non-copyrighted click-track backing only.
|
||||
@@ -422,7 +422,7 @@ Built by docs/diagnostics/build_diagnostic_basic_guitar.py
|
||||
|
||||
def main():
|
||||
repo_root = Path(__file__).resolve().parents[2]
|
||||
default_out = Path(__file__).resolve().parent / 'feedBack-diagnostic-basic-guitar.sloppak'
|
||||
default_out = Path(__file__).resolve().parent / 'slopsmith-diagnostic-basic-guitar.sloppak'
|
||||
out = Path(sys.argv[1]) if len(sys.argv) > 1 else default_out
|
||||
if not out.is_absolute():
|
||||
out = repo_root / out
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -1,246 +0,0 @@
|
||||
# Host Theme Contract — design proposal
|
||||
|
||||
**Status:** proposal (charrette output, 2026-06-29) · **Owner area:** core v3 + plugin UI
|
||||
**Trigger:** a plugin UI feature accidentally "carved itself into a single theme."
|
||||
|
||||
## 1. Problem
|
||||
|
||||
A results-card feature in the `note_detect` plugin (a glow-ring hero button + a
|
||||
gradient-filled accuracy number) was built and visually verified against **only the
|
||||
default skin** ("neon"). On the other skins it broke: on "esports" — a deliberately
|
||||
glow-less, near-monochrome design language — the glow ring and the colour gradient
|
||||
simply **vanished**. The colours adapted (everything used CSS custom-property tokens),
|
||||
but the **visual devices themselves did not port**, because nothing in the system says
|
||||
"this theme does / doesn't do glow rings."
|
||||
|
||||
### Root cause (three findings)
|
||||
|
||||
1. **Themes are design *languages*, not palettes.** neon = glow + animation + gradients;
|
||||
esports = no-glow, square, near-monochrome amber; metal = brushed steel + hard bevels +
|
||||
drop-shadows. Tokens made *colour* portable; they never made a *device* portable.
|
||||
2. **Tokens are named by *device*, not *intent*.** e.g. `--nd-glow-*` holds a glow in neon
|
||||
but a **hard drop-shadow** in metal — the metal skin is already repurposing a
|
||||
device-named slot to express a different language. The cure is to finish that move:
|
||||
name slots by intent, with "off" (`none`) a legal value.
|
||||
3. **No "text-legible-on-accent" role.** White-on-accent was hardcoded in several places;
|
||||
on esports' amber accent that's a contrast failure. And `--nd-accent2` was
|
||||
**double-booked** (gradient-end *and* S-grade colour), so the hero gradient resolved
|
||||
amber→near-white and washed out.
|
||||
|
||||
A process gap compounds it: **verification covered one skin**, so the regression was
|
||||
invisible until a user switched themes. And this recurs ecosystem-wide — other plugins
|
||||
ship their own independent skin systems too.
|
||||
|
||||
## 2. Current state (two disconnected systems)
|
||||
|
||||
| System | What it is | Limits |
|
||||
| --- | --- | --- |
|
||||
| **Host themes** (`static/v3/theme-core.js`, `html[data-fb-theme]`) | Cosmetic "shop" themes that recolour `fb-*` Tailwind tokens (surfaces/text/borders). | Apply-only & recolour-only. `--fbv-*` vars exist **only while a theme is equipped** (nothing to read in the default state). No read API, no capability signal, no normalized `theme:changed` event. Comment explicitly says it *leaves decorative accents (rings/shadows) at defaults* → **devices are an ownerless gap.** |
|
||||
| **Plugin skins** (e.g. `note_detect` `data-nd-skin`) | Full per-plugin design languages (neon/esports/metal) as CSS-var blocks. | Each plugin reinvents the wheel; disconnected from host themes; a feature can't see both. |
|
||||
|
||||
## 3. Goals / non-goals
|
||||
|
||||
- **Goal:** a feature, authored once, renders correctly in **any** theme — including ones not
|
||||
yet invented — and degrades **intentionally** (neon ring → esports border), never accidentally.
|
||||
- **Goal:** the host owns a canonical contract so plugins consume instead of reinventing.
|
||||
- **Non-goal:** forcing every plugin skin to become a host theme. Skins stay plugin-local but
|
||||
**implement** the contract.
|
||||
- **Non-goal:** backward-compat with pre-v3 hosts. Everything here is additive + feature-detected.
|
||||
|
||||
## 4. The contract — three layers
|
||||
|
||||
### Layer 1 — Semantic colour **roles** (always present)
|
||||
|
||||
The host writes default `--fb-*` role tokens on `:root` **unconditionally** (not only under
|
||||
`[data-fb-theme]`), seeded from the canonical `fb` palette, so `var(--fb-accent, …)` always
|
||||
resolves — themed or not. Roles:
|
||||
|
||||
**Namespace (normative).** The public contract lives under one prefix, **`--fb-*`**, written on
|
||||
`:root` by a host-owned *contract stylesheet* (see §6 / §8) so it is present **themed or not**.
|
||||
The existing `--fbv-*` vars stay **internal plumbing** — `theme-core.js` uses them only to
|
||||
recolour the Tailwind `.bg-fb-*/.text-fb-*/.border-fb-*` utilities under `html[data-fb-theme]`;
|
||||
they are **not** part of this contract and plugins must not read them. (Implementation may seed
|
||||
`--fb-*` from the same source the `--fbv-*` overrides use, so an equipped theme moves both.)
|
||||
|
||||
**Value grammar (normative).** Colour roles are a **space-separated `r g b` triplet** (matching
|
||||
today's `--fbv-*` and the Tailwind utilities), consumed as `rgb(var(--fb-accent))` with optional
|
||||
alpha `rgb(var(--fb-accent) / .5)`. Recipe slots (Layer 2) hold **full CSS values** for their
|
||||
device (a `box-shadow`, a `border` shorthand, a length, a paint), with `none` legal **except**
|
||||
where noted.
|
||||
|
||||
**Normative role tokens** (all `--fb-*`, all always present):
|
||||
|
||||
| Role | Token | Notes |
|
||||
| --- | --- | --- |
|
||||
| surface / card / border | `--fb-surface` `--fb-card` `--fb-border` | structural |
|
||||
| text / dim | `--fb-text` `--fb-text-dim` | |
|
||||
| accent / second hue | `--fb-accent` `--fb-accent-2` | `accent-2` is **just a second hue** — never an assumed gradient end |
|
||||
| status | `--fb-good` `--fb-warn` `--fb-bad` | maps onto today's palette `good / mid / low` (mid→warn, low→bad) — implementation aliases both |
|
||||
| **on-fill (new)** | `--fb-on-accent` `--fb-on-good` `--fb-on-warn` `--fb-on-bad` | **Rule: every role used as a fill behind text gets a paired `--fb-on-*`** (fixes white-on-amber). Required + contrast-linted (§6). |
|
||||
| **focus (new)** | `--fb-focus-ring` | focus indicator independent of `accent`, so focus stays visible when `accent ≈ surface` |
|
||||
|
||||
### Layer 2 — Capability **recipes** (intent-named slots; "off" is legal)
|
||||
|
||||
A theme declares its design *language* by filling intent-named slots (all `--fb-*`-prefixed,
|
||||
same namespace as the roles). A feature applies the slot bundle **unconditionally**; it never
|
||||
branches on "is this theme glowy?". Atomic slots (renames-by-intent of today's tokens):
|
||||
`--fb-corner-radius`, `--fb-corner-clip`, `--fb-panel-shadow`, `--fb-text-emph-shadow`,
|
||||
`--fb-panel-texture`, `--fb-motion-decorative` (reduced-motion-gated). For these, `none` is legal.
|
||||
|
||||
Two **composite recipes** carry the load:
|
||||
|
||||
- **EMPHASIS** — how this theme makes a primary action special:
|
||||
`--fb-emph-fill / --fb-emph-border / --fb-emph-halo / --fb-emph-on`.
|
||||
neon → halo (glow ring); esports → border (solid accent); metal → fill + drop-shadow.
|
||||
Any individual slot may be `none` — but a theme **must** emphasise *somehow* (at least one of
|
||||
fill/border/halo non-`none`), so a primary action is never visually flat.
|
||||
- **ACCENT-TEXT** — how this theme fills a big accent number: `--fb-acc-text-fill`
|
||||
(decoupled from `accent-2`). neon/metal → a gradient; esports → a solid accent.
|
||||
**`--fb-acc-text-fill` is the one slot where `none` is illegal** — it is always a valid paint
|
||||
(solid colour or gradient), defaulting to `rgb(var(--fb-accent))`. Reason: the number is
|
||||
rendered with `background-clip: text` + transparent text-fill, so a `none` paint would make
|
||||
the digits **invisible** (transparent fill, nothing to clip) — which would violate the DoD
|
||||
"a device stays legible when its slot resolves to `none`". The feature also feature-detects
|
||||
`background-clip: text` and keeps a solid `color` base (see §5), so the digits are legible
|
||||
even where clip-text is unsupported.
|
||||
|
||||
> These generalize the interim per-skin tokens already shipped in `note_detect`
|
||||
> (`--nd-hero-ring-idle/on`, `--nd-hero-border`, `--nd-acc-fill`).
|
||||
|
||||
### Layer 3 — JS read API + reconciliation
|
||||
|
||||
**The JS API is only for renderers that can't use CSS (canvas / WebGL), never for DOM/CSS
|
||||
consumers** — those use the tokens and slots directly (§5). Critically, it exposes *resolved
|
||||
token values*, **not** theme-style booleans: a `glow:false` flag can't tell a canvas whether to
|
||||
draw a border, a bevel, a drop-shadow, or flat text, so there is **no** `capabilities()` of
|
||||
booleans. On the existing `window.feedBack` bus:
|
||||
|
||||
- `feedBack.theme.get()` → `{ id, isThemed, tokens }` where `tokens` is the **resolved** map of
|
||||
every `--fb-*` role + recipe slot (the computed values, so a canvas reads the actual device,
|
||||
e.g. the gradient stops for `--fb-acc-text-fill`, not a boolean).
|
||||
- `feedBack.theme.prefersReducedMotion()` → boolean (host wraps `matchMedia` once). **This is the
|
||||
single approved JS reduced-motion gate going forward** — existing direct `matchMedia` callers
|
||||
(`venue-mood-fx.js`, `pedal-cables.js`) migrate to it; `--fb-motion-decorative` covers the
|
||||
CSS-authored decorative motion.
|
||||
- `theme:changed` event → `{ id, tokens }`.
|
||||
|
||||
**Lifecycle (normative).** `get()` always returns the **current effective theme synchronously**
|
||||
and is valid at any time — before any theme is applied it returns the default/unthemed roles
|
||||
(which always exist on `:root`). Theme application is async (it follows a `/api/profile` refresh);
|
||||
`theme:changed` fires **only after** the DOM vars/classes are committed, and **once on initial
|
||||
hydration** so a late-mounting plugin isn't stuck on stale state. **Plugin rule:** read `get()`
|
||||
on mount, then subscribe to `theme:changed` — never assume an order between your mount and the
|
||||
first theme apply.
|
||||
|
||||
**Reconciliation rule (ends the two-disconnected-systems problem):** a plugin skin
|
||||
**derives surface/text/border from host tokens** (`--nd-bg: rgb(var(--fb-card))`, etc.) and
|
||||
**owns only its accent + its devices**, selecting the device via the recipe. A host theme then
|
||||
pulls plugin chrome along (one truth for surfaces), while the plugin layers identity on top and
|
||||
never imposes a device the active theme neutralizes.
|
||||
|
||||
**Propagation scope (normative).** The contract is **same-document light-DOM**: `:root` `--fb-*`
|
||||
inheritance and the central focus/motion rules (§6) reach any normal plugin screen. A plugin that
|
||||
renders into a **shadow root or iframe** is responsible for bridging — copy the resolved
|
||||
`get().tokens` into its sub-root and re-subscribe to `theme:changed` (host `:root` vars don't
|
||||
cross those boundaries).
|
||||
|
||||
## 5. Consumption pattern (the rule for feature authors)
|
||||
|
||||
> **A feature may reference a colour *role* or a recipe *slot*. It may never write a raw
|
||||
> device — no literal glow `box-shadow`, no literal `linear-gradient`, no hex.** Devices live
|
||||
> in slots; the theme owns the slots.
|
||||
|
||||
```css
|
||||
.hero-cta {
|
||||
background: var(--fb-emph-fill);
|
||||
border: var(--fb-emph-border);
|
||||
box-shadow: var(--fb-emph-halo); /* neon→ring · esports→none · metal→drop-shadow */
|
||||
color: var(--fb-emph-on); /* never hardcoded #fff again */
|
||||
border-radius: var(--fb-corner-radius);
|
||||
}
|
||||
.accuracy-number {
|
||||
/* Always-legible solid base; survives no-clip-text support too. */
|
||||
color: rgb(var(--fb-accent));
|
||||
}
|
||||
/* Apply the clipped paint ONLY where supported — and --fb-acc-text-fill is
|
||||
guaranteed a real paint (never `none`, per Layer 2), so the digits can't go
|
||||
invisible. */
|
||||
@supports ((background-clip: text) or (-webkit-background-clip: text)) {
|
||||
.accuracy-number {
|
||||
background: var(--fb-acc-text-fill);
|
||||
-webkit-background-clip: text; background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Where the contract physically lives.** A **host-owned static contract stylesheet** (e.g.
|
||||
`static/v3/theme-contract.css`, hand-authored, linked from `static/v3/index.html`) holds the
|
||||
always-present `:root --fb-*` defaults **plus** the two central a11y rules below. It is **not** a
|
||||
Tailwind file, so it never touches the prebuilt `static/tailwind.min.css` artifact the
|
||||
`tailwind-fresh` CI check diffs (and it's independent of `theme-core.js`, which keeps
|
||||
runtime-injecting only the `--fbv-*` utility overrides under `[data-fb-theme]`).
|
||||
|
||||
- **Reduced motion:** `--fb-motion-decorative` is the *only* place CSS decorative animation is
|
||||
named; one central rule in the contract sheet sets it to `none` under
|
||||
`@media (prefers-reduced-motion: reduce)`, so no theme can forget the gate. (JS-driven motion
|
||||
uses `feedBack.theme.prefersReducedMotion()` — §4.3.)
|
||||
- **Focus parity:** one contract-level `:focus-visible { outline: 2px solid rgb(var(--fb-focus-ring)) }`
|
||||
for contract consumers; themes recolour `--fb-focus-ring` but may not author their own focus
|
||||
styling. *Migration:* v3 already ships component-specific focus + reduced-motion rules in
|
||||
`v3.css`; those are reconciled onto the contract token (not magically replaced) as a tracked
|
||||
cleanup — "one rule" describes the end state, not day one.
|
||||
- **On-fill contrast:** every `--fb-on-*` is required and **lintable**
|
||||
(`contrast(on-X, X) ≥ 4.5:1`, 3:1 large) for each fill role (`accent / good / warn / bad`).
|
||||
Contrast is the theme's job, computed once — not re-judged per feature.
|
||||
|
||||
## 7. Verification gate (prevent recurrence)
|
||||
|
||||
- A committed **render-matrix** tool, driven off the runtime skin list, that renders the key
|
||||
surfaces (hero CTA, accent number, **and the canvas share-image card**) across **every skin ×
|
||||
key states** (rest / hover / focus / reduced-motion).
|
||||
- The gate is **computed-style invariant assertions** (deterministic, CI-safe) — e.g. "emphasis
|
||||
present and text legible in each theme" — **not** pixel-snapshot diffing (the animated ring +
|
||||
fonts + AA make snapshots flaky); a contact-sheet montage is the human backstop.
|
||||
- Triggered on the version bump that CSS changes already require; skins enumerated at runtime +
|
||||
a guard test so the matrix can't silently go stale.
|
||||
|
||||
**Definition-of-done for any theme-touching UI change** (the few items that would have caught this):
|
||||
expressed via tokens not hardcoded values · rendered across all skins · **a new visual *device*
|
||||
stays legible when its slot resolves to `none`** · reduced-motion + focus parity · on-accent contrast.
|
||||
|
||||
## 8. Back-compat & rollout
|
||||
|
||||
All additive: the new always-present `--fb-*` tokens (in the contract sheet, §6) + a new
|
||||
`feedBack.theme` namespace + a new event with no current listeners. Existing plugins (those
|
||||
reading `fb-*` Tailwind utility classes, or shipping their own skins) are untouched unless they
|
||||
opt in. On a host too old to ship the contract sheet, a consumer still degrades cleanly: the
|
||||
two-arg fallback `rgb(var(--fb-accent, 224 128 32))` resolves to the literal, and
|
||||
`window.feedBack?.theme?.get?.()` is feature-detected — so older hosts behave exactly as today.
|
||||
|
||||
**Workstream (sub-tasks):**
|
||||
1. **Host minimal surface** — the contract stylesheet's always-present default `--fb-*` tokens + `feedBack.theme.{get, prefersReducedMotion}` (`get().tokens` = resolved values; no boolean `capabilities()`) + `theme:changed`. *(the smallest thing that would have prevented the incident)*
|
||||
2. **note_detect refactor** — rename device tokens by intent (EMPHASIS + ACCENT-TEXT recipes), add `on-accent` + `focus-ring`, derive surfaces from host tokens.
|
||||
3. **Verification gate** — commit the render-matrix + DoD checklist; add the canvas share-card surface.
|
||||
4. **Ecosystem migration guide** — document the contract + the consumption rule for community plugin authors.
|
||||
|
||||
## 9. Cross-apply status (already done)
|
||||
|
||||
- `note_detect` results-card hero + accuracy number — fixed via per-skin device tokens
|
||||
(the Layer-2 prototype) and verified across neon/esports/metal.
|
||||
- The **canvas share-image card** — re-checked across all three skins: **theme-robust**
|
||||
(reads per-skin colour tokens via computed style, draws skin-neutral solid devices). Minor
|
||||
fidelity gap only: it uses flat `--nd-bg` and skips metal's brushed-steel *texture*.
|
||||
|
||||
## 10. Open questions
|
||||
|
||||
- Should plugin skins eventually become *selectable host themes* (one picker), or stay
|
||||
plugin-local forever? (This proposal assumes plugin-local + contract-implementing.)
|
||||
- Component-recipe **bundles** (per named component) are the richer end-state; intent-named
|
||||
slots are the right seed. When/whether to graduate.
|
||||
|
||||
*(Resolved during review and folded into the sections above: the token namespace + value grammar
|
||||
and normative role table (§4.1); the `none`-is-illegal carve-out for `--fb-acc-text-fill` (§4.2);
|
||||
JS exposes resolved tokens, not booleans (§4.3); `theme:changed` lifecycle + shadow/iframe
|
||||
propagation (§4.3); the physical home of the role tokens + central focus/motion rules — a
|
||||
host-owned contract stylesheet outside Tailwind (§6).)*
|
||||
+11
-11
@@ -13,7 +13,7 @@ Detection quality varies by guitar pickup, audio interface, monitor latency, the
|
||||
|
||||
## The benchmark sloppak
|
||||
|
||||
The distributable sloppak ships in-tree at [docs/benchmarks/note_detect_v1/note_detect_benchmark_v1.sloppak](benchmarks/note_detect_v1/note_detect_benchmark_v1.sloppak) — drop it directly in your library folder (e.g. `<your-library>/sloppak/`) and it shows up in the library. The file is a zip under the hood but feedBack's loader (`is_sloppak`) keys off the `.sloppak` suffix, so don't rename. After playing it once it ends up extracted under `static/sloppak_cache/note_detect_benchmark_v1.sloppak/`, which is where the harness reads its `arrangements/lead.json` from. 90 BPM, 8 numbered sections, ~2:20 total:
|
||||
The distributable sloppak ships in-tree at [docs/benchmarks/note_detect_v1/note_detect_benchmark_v1.sloppak](benchmarks/note_detect_v1/note_detect_benchmark_v1.sloppak) — drop it directly in your library folder (e.g. `<your-library>/sloppak/`) and it shows up in the library. The file is a zip under the hood but slopsmith's loader (`is_sloppak`) keys off the `.sloppak` suffix, so don't rename. After playing it once it ends up extracted under `static/sloppak_cache/note_detect_benchmark_v1.sloppak/`, which is where the harness reads its `arrangements/lead.json` from. 90 BPM, 8 numbered sections, ~2:20 total:
|
||||
|
||||
| Section | Notes | Isolates |
|
||||
|---|---|---|
|
||||
@@ -28,10 +28,10 @@ The distributable sloppak ships in-tree at [docs/benchmarks/note_detect_v1/note_
|
||||
|
||||
Every chart note has `sus > 0` — so anything you tune against this benchmark exercises the sustain path, not staccato detection. (If we add a staccato section later, the cleanest split is by section name; don't categorize by `sus` value on the event log — see the "Common pitfalls" section.)
|
||||
|
||||
To rebuild after edits to the exercise list, follow the docstring at the top of `build_benchmark.py`. The script writes both an unzipped directory (`.sloppak/`) and a zipped archive (`.sloppak.zip`). The feedBack library scanner (`lib/sloppak.py::is_sloppak()`) matches on the `.sloppak` suffix, **not** on `.sloppak.zip` — the directory form is usable as-is, but the zip output needs its suffix swapped before it'll be discovered. After regenerating, copy the zip output to the tracked path with the `.sloppak` suffix so it stays a drop-in install. Run from the feedBack repo root so the relative paths resolve:
|
||||
To rebuild after edits to the exercise list, follow the docstring at the top of `build_benchmark.py`. The script writes both an unzipped directory (`.sloppak/`) and a zipped archive (`.sloppak.zip`). The slopsmith library scanner (`lib/sloppak.py::is_sloppak()`) matches on the `.sloppak` suffix, **not** on `.sloppak.zip` — the directory form is usable as-is, but the zip output needs its suffix swapped before it'll be discovered. After regenerating, copy the zip output to the tracked path with the `.sloppak` suffix so it stays a drop-in install. Run from the slopsmith repo root so the relative paths resolve:
|
||||
|
||||
```bash
|
||||
# From the feedBack repo root.
|
||||
# From the slopsmith repo root.
|
||||
cp static/sloppak_cache/note_detect_benchmark_v1.sloppak.zip \
|
||||
docs/benchmarks/note_detect_v1/note_detect_benchmark_v1.sloppak
|
||||
```
|
||||
@@ -46,7 +46,7 @@ The typical cycle for one tuning hypothesis:
|
||||
2. **Arm a recording** from the gear popover next to the Detect button on the player. Arm before pressing Play.
|
||||
3. **Play through the benchmark** (or any song) at **1.0× playback speed**. Half-speed playback breaks audio↔chart alignment and produces all-miss garbage — see Pitfalls.
|
||||
4. **Auto-save fires on song end.** The WAV lands in `static/note_detect_recordings/note_detect_<slug>_<timestamp>.wav` (bind-mounted, so it's reachable from the host without a copy step).
|
||||
5. **Run the headless harness** with a known config. Paths below assume the note_detect plugin is cloned into `plugins/note_detect/` (see the feedBack README for the plugin-install flow — note_detect ships as a separate repo):
|
||||
5. **Run the headless harness** with a known config. Paths below assume the note_detect plugin is cloned into `plugins/note_detect/` (see the slopsmith README for the plugin-install flow — note_detect ships as a separate repo):
|
||||
```bash
|
||||
node plugins/note_detect/tools/harness.js \
|
||||
--audio static/note_detect_recordings/note_detect_<…>.wav \
|
||||
@@ -162,7 +162,7 @@ The same workflow works on any tuning change — A/V offset sweep, frame-size sw
|
||||
|
||||
### "Did my detector change improve things?" — ad hoc
|
||||
|
||||
Same recording, same chart, two harness runs. Recipe assumes you're at the feedBack repo root *and* that the Note Detection plugin is cloned at `plugins/note_detect/` per the README. The detector source lives in that nested plugin repo, which feedBack's `.gitignore` excludes via `plugins/*/`, so the stash dance has to run **inside** the plugin repo — `git stash` from the feedBack root would either bail out or, worse, stash unrelated feedBack edits.
|
||||
Same recording, same chart, two harness runs. Recipe assumes you're at the slopsmith repo root *and* that the Note Detection plugin is cloned at `plugins/note_detect/` per the README. The detector source lives in that nested plugin repo, which slopsmith's `.gitignore` excludes via `plugins/*/`, so the stash dance has to run **inside** the plugin repo — `git stash` from the slopsmith root would either bail out or, worse, stash unrelated slopsmith edits.
|
||||
|
||||
The stash dance below uses **`git stash push -u -m "..."`** to give the stash a known name *and* include untracked files. `-u` matters: if your detector change added a new module or fixture, an untracked-file-blind stash would leave it on disk during the "before" run and contaminate the baseline. The script then asserts a stash was actually created before popping (so a clean worktree doesn't silently pop someone else's WIP), wraps each step in **`set -euo pipefail`** so a failed `git stash pop` (e.g., conflict) aborts before the "after" harness records an invalid result, and uses `trap` to surface any failure with a clear message.
|
||||
|
||||
@@ -172,7 +172,7 @@ PLUGIN_DIR=plugins/note_detect
|
||||
HARNESS=$PLUGIN_DIR/tools/harness.js
|
||||
STASH_MSG="harness-before-$$"
|
||||
trap 'echo "harness recipe aborted — stash may still be in $PLUGIN_DIR (\"git -C $PLUGIN_DIR stash list\")" >&2' ERR
|
||||
# Stash the detector edits inside the plugin repo, not the feedBack root.
|
||||
# Stash the detector edits inside the plugin repo, not the slopsmith root.
|
||||
# -u also stashes untracked files (new modules, fixtures) so they don't
|
||||
# leak into the "before" baseline. `|| true` only swallows the
|
||||
# clean-worktree case, which the next line catches explicitly.
|
||||
@@ -223,9 +223,9 @@ Find the note's `t` in the chart, then grep the event log for entries near that
|
||||
|
||||
The Note Detection plugin lives in its own repository — these links go to the canonical source at github.com. If you've cloned the plugin into a local `plugins/note_detect/` next to this repo, the same files are at the equivalent path on disk.
|
||||
|
||||
- Plugin source: [`screen.js`](https://github.com/got-feedback/feedBack-plugin-notedetect/blob/main/screen.js) — `matchNotes`, `checkMisses`, `_diagTimingErrors` / `_diagTimingErrorsHits`, `getDiagnostic`.
|
||||
- Routes: [`routes.py`](https://github.com/got-feedback/feedBack-plugin-notedetect/blob/main/routes.py) — the `/api/plugins/note_detect/recording` and `/api/plugins/note_detect/live-judgment` endpoints.
|
||||
- Harness: [`tools/harness.js`](https://github.com/got-feedback/feedBack-plugin-notedetect/blob/main/tools/harness.js).
|
||||
- Regression driver: [`tools/regression.js`](https://github.com/got-feedback/feedBack-plugin-notedetect/blob/main/tools/regression.js).
|
||||
- Plugin source: [`screen.js`](https://github.com/got-feedback/feedback-plugin-notedetect/blob/main/screen.js) — `matchNotes`, `checkMisses`, `_diagTimingErrors` / `_diagTimingErrorsHits`, `getDiagnostic`.
|
||||
- Routes: [`routes.py`](https://github.com/got-feedback/feedback-plugin-notedetect/blob/main/routes.py) — the `/api/plugins/note_detect/recording` and `/api/plugins/note_detect/live-judgment` endpoints.
|
||||
- Harness: [`tools/harness.js`](https://github.com/got-feedback/feedback-plugin-notedetect/blob/main/tools/harness.js).
|
||||
- Regression driver: [`tools/regression.js`](https://github.com/got-feedback/feedback-plugin-notedetect/blob/main/tools/regression.js).
|
||||
- Benchmark builder: [docs/benchmarks/note_detect_v1/build_benchmark.py](benchmarks/note_detect_v1/build_benchmark.py).
|
||||
- Settings UI: [`settings.html`](https://github.com/got-feedback/feedBack-plugin-notedetect/blob/main/settings.html) — A/V auto-calibrate panel, tuning-mode toggle, diagnostic block.
|
||||
- Settings UI: [`settings.html`](https://github.com/got-feedback/feedback-plugin-notedetect/blob/main/settings.html) — A/V auto-calibrate panel, tuning-mode toggle, diagnostic block.
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
# Perf baseline — module-migration refactor
|
||||
|
||||
The refactor promises "measured runtime wins, no hand-waved perf claims" and
|
||||
"screen-entry and frame-time no worse." This is the baseline to hold it to.
|
||||
Rerun the harness after every phase (R0 → R3c) and compare.
|
||||
|
||||
## Running it
|
||||
|
||||
```
|
||||
# 1. start core against a library with real charts (see caveat below)
|
||||
CONFIG_DIR=… DLC_DIR=/path/to/songs PYTHONPATH=lib \
|
||||
python3 -m uvicorn server:app --host 127.0.0.1 --port 8000
|
||||
|
||||
# 2. capture (maintainer/CI-only; uses the committed Playwright chromium)
|
||||
node scripts/perf-baseline.mjs --base http://127.0.0.1:8000 --n 60 --soak 30
|
||||
```
|
||||
|
||||
The script prints a markdown block; paste it under "Results" below with the date
|
||||
and the commit it was taken at.
|
||||
|
||||
## What it measures
|
||||
|
||||
- **Server latency** — p50/p95/p99 over N requests for `/api/version`,
|
||||
`/api/plugins`, `/api/library`, `/api/library/artists`.
|
||||
- **Cold boot → interactive** — full page load to `networkidle`.
|
||||
- **JS heap** — `performance.memory.usedJSHeapSize` after load and after an idle
|
||||
soak (a leak signal across a session).
|
||||
- **Plugin-script shape** — how many plugin `<script>`s the loader injected (a
|
||||
"the app booted with its plugins" sanity signal).
|
||||
|
||||
**Not yet captured — needs a seeded library with charts** (fill in when run
|
||||
against a real environment): playback **frame-time p95** on the 2D and 3D
|
||||
highway, and **screen-entry** (plugin inject → interactive) for
|
||||
editor / notedetect / highway_3d with a chart loaded. These are the
|
||||
perf-sensitive numbers that gate the `highway.js` split (R3c); the harness has
|
||||
the hooks, they just need real songs in `DLC_DIR`.
|
||||
|
||||
## Results
|
||||
|
||||
### R0 baseline — 2026-07-08 (branch `feat/r0-plugin-module-rails`)
|
||||
|
||||
> ⚠️ A quick capture (`--n 50 --soak 8`) against an **empty** library (no charts
|
||||
> in `DLC_DIR`), so the `/api/library*` and boot numbers are floor values —
|
||||
> re-take on a seeded environment with the recommended `--n 60 --soak 30` for the
|
||||
> real R0 baseline before comparing R1+ against it. Recorded here to prove the
|
||||
> harness and lock the methodology.
|
||||
|
||||
Server latency (ms), n=50:
|
||||
|
||||
| Endpoint | status | p50 | p95 | p99 |
|
||||
|---|---|---|---|---|
|
||||
| `/api/version` | 200 | 0.9 | 1.8 | 22.3 |
|
||||
| `/api/plugins` | 200 | 1.6 | 2.1 | 3.4 |
|
||||
| `/api/library?limit=60` | 200 | 1.4 | 1.7 | 2.9 |
|
||||
| `/api/library/artists` | 200 | 1.3 | 1.8 | 2.7 |
|
||||
|
||||
Client:
|
||||
|
||||
| Metric | Value |
|
||||
|---|---|
|
||||
| Cold boot → networkidle | 1268 ms |
|
||||
| JS heap after load | 10.1 MB |
|
||||
| JS heap after idle soak | 10.1 MB (no idle growth) |
|
||||
| Plugin scripts injected | 12 |
|
||||
|
||||
No plugin has migrated yet, so all 12 are classic. When the R1 pilot (stems)
|
||||
lands, cold-boot / heap should not regress.
|
||||
@@ -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
|
||||
@@ -1,6 +1,6 @@
|
||||
# Plugin Capability Inventory
|
||||
|
||||
This report inventories the currently included plugins staged in `plugins/` and maps their observed behavior to FeedBack capability domains. It is intended to inform the capability roadmap and the next migration specs now that PR1, the audio graph/session slice, playback, and audio-effects are active capability surfaces.
|
||||
This report inventories the currently included plugins staged in `plugins/` and maps their observed behavior to Slopsmith capability domains. It is intended to inform the capability roadmap and the next migration specs now that PR1, the audio graph/session slice, playback, and audio-effects are active capability surfaces.
|
||||
|
||||
## Scope And Method
|
||||
|
||||
@@ -8,7 +8,7 @@ This report inventories the currently included plugins staged in `plugins/` and
|
||||
- Verification pass: the original bundled-plugin scan found 25 plugins with backend `routes.py` and 14 plugins with `settings.html`. First-party plugin repos outside `plugins/` were checked separately from their current manifests and handoff docs.
|
||||
- Most bundled plugin entries below are still inferred/recommended declarations. Current first-party manifests now declare active capability intent for `diagnostics`, `pipeline`, `library`, `audio-mix`, `audio-input`, `audio-monitoring`, `stems`, `playback`, `audio-effects`, `jobs`, and privileged capability inventory surfaces where their repos have already migrated.
|
||||
- Manifest fields such as `nav`, `screen`, `settings`, `routes`, and `type: "visualization"` were treated as high-confidence evidence.
|
||||
- Code patterns such as `window.feedBackViz_*`, `window.playSong` wrappers, `window.showScreen` wrappers, `window.registerShortcut`, `window.feedBackTour.register`, `window.feedBack.audio.registerFader`, `highway.setNoteStateProvider`, and route/WebSocket handlers were treated as behavior evidence.
|
||||
- Code patterns such as `window.slopsmithViz_*`, `window.playSong` wrappers, `window.showScreen` wrappers, `window.registerShortcut`, `window.slopsmithTour.register`, `window.slopsmith.audio.registerFader`, `highway.setNoteStateProvider`, and route/WebSocket handlers were treated as behavior evidence.
|
||||
|
||||
## Roadmap Baseline
|
||||
|
||||
@@ -80,7 +80,7 @@ The plugin inventory confirms these planned domains are directionally right. The
|
||||
| `section_map` | `ui.player-overlays`, `playback` | overlay provider, observer | Planned | High | Highway section overlay behavior. |
|
||||
| `setlist` | `library`, `playback`, `ui.plugin-screens`, `backend.routes` | requester/provider, screen provider, route provider | Library/playback active; UI/routes planned | High | Setlist screen/routes and song selection/playback workflow. |
|
||||
| `sloppak_converter` | `media-import-export`, `jobs`, `library`, `ui.plugin-screens`, `backend.routes`, `ui.library-card-injection` | conversion provider, job provider, route provider | Library active; jobs/UI/routes planned; media/card missing | High | Converter routes, queue UI, library card actions, conversion jobs. |
|
||||
| `virtuoso` | `ui.plugin-screens`, `backend.routes`, `settings`, `visualization` | screen provider, route provider, observer | Active | High | Contained practice studio (scale/technique/rhythm drills, workouts, jam backing); borrows the 3D highway visualization. |
|
||||
| `slopscale` | `ui.plugin-screens`, `backend.routes`, `settings`, `visualization` | screen provider, route provider, observer | Planned | High | Routes/settings and 3D highway visualization observation. |
|
||||
| `song_preview` | `playback`, `audio-mix`, `ui.plugin-screens`, `backend.routes`, `settings` | preview provider, route provider, audio participant | Playback/audio-mix active; UI/routes planned | Medium | Preview screen/routes/settings and audio preview behavior. |
|
||||
| `splitscreen` | `ui.player-panels`, `ui.player-overlays`, `visualization`, `playback`, `keyboard-shortcuts`, `settings` | panel provider, observer, shortcut provider | Playback active; UI/visualization planned; shortcuts missing | High | Multi-highway panels, playback/screen wrappers, panel shortcuts/settings. |
|
||||
| `stem_mixer` | `stems`, `audio-mix`, `ui.plugin-screens`, `backend.routes`, `settings`, `jobs` | stem provider, mixer provider, route provider | Audio active; jobs planned | High | Stems mixer routes/settings and stem/audio mix ownership. |
|
||||
@@ -231,11 +231,11 @@ For active domains, command and operation names should follow [capability-domain
|
||||
|
||||
## Highway String Colors (data-plane API)
|
||||
|
||||
User-customizable per-string highway colors (the "Highway String Colors" setting in the 3D Highway plugin's panel) are **not** a capability domain. Consistent with `capability-domains.md` keeping highway-rendering and `visualization` surfaces off the capability graph until a dedicated render-facade slice lands, they are exposed as a synchronous **data-plane** API on `window.feedBack.highwayColors` plus a change event. Visualization/overlay plugins (custom highways, minigames, fretboard widgets) should read colors from here so their gems/strings match the user's theme.
|
||||
User-customizable per-string highway colors (the "Highway String Colors" setting in the 3D Highway plugin's panel) are **not** a capability domain. Consistent with `capability-domains.md` keeping highway-rendering and `visualization` surfaces off the capability graph until a dedicated render-facade slice lands, they are exposed as a synchronous **data-plane** API on `window.slopsmith.highwayColors` plus a change event. Visualization/overlay plugins (custom highways, minigames, fretboard widgets) should read colors from here so their gems/strings match the user's theme.
|
||||
|
||||
Colors are keyed by **named string slot**, not raw index, so a string keeps its color across arrangements (Low E stays Low E's color on a 6-string guitar, 4-string bass, or 7/8-string, where the extra low strings use the `low7`/`low8` slots). Slots: `highE`, `B`, `G`, `D`, `A`, `lowE`, `low7` (7-string Low B), `low8` (8-string Low F#).
|
||||
|
||||
`window.feedBack.highwayColors` (`version: 1`):
|
||||
`window.slopsmith.highwayColors` (`version: 1`):
|
||||
|
||||
| Member | Returns | Purpose |
|
||||
|--------|---------|---------|
|
||||
@@ -250,7 +250,7 @@ Colors are keyed by **named string slot**, not raw index, so a string keeps its
|
||||
| `encodeShare(name, map)` / `decodeShare(code)` | `string` / `{name,colors}` | The `SLOPHWY2.` copy/paste share format. |
|
||||
| `onChange(fn)` / `offChange(fn)` | unsubscribe fn | `fn(resolvedMap)` fires on any color change (also on song load when the slot→index mapping shifts). |
|
||||
|
||||
The underlying change event is `window.feedBack.emit('highway:stringColors', …)`; `onChange` wraps it and hands back the resolved map. The raw `window.highway.getStringColors()` data-plane accessor (per-index) remains available for renderers that only need the current applied array. When a `visualization` capability slice eventually lands, this facade is the natural thing to fold into it.
|
||||
The underlying change event is `window.slopsmith.emit('highway:stringColors', …)`; `onChange` wraps it and hands back the resolved map. The raw `window.highway.getStringColors()` data-plane accessor (per-index) remains available for renderers that only need the current applied array. When a `visualization` capability slice eventually lands, this facade is the natural thing to fold into it.
|
||||
|
||||
## Validation Notes
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
```text
|
||||
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,234 @@
|
||||
# `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,
|
||||
"standards": ["capability-pipelines.v1", "plugin-runtime-idempotent.v1"],
|
||||
"screen": "screen.html",
|
||||
"script": "screen.js",
|
||||
"styles": "assets/plugin.css",
|
||||
"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"
|
||||
},
|
||||
"settings_schema": {
|
||||
"schema_version": "1",
|
||||
"packable_keys": ["enabled"]
|
||||
},
|
||||
"ui": {
|
||||
"settings": [{ "id": "my-plugin-settings", "region": "plugin-settings", "label": "My Plugin" }],
|
||||
"ui.plugin-screens": [{ "id": "my-plugin-screen", "region": "plugin.main", "label": "My Plugin" }]
|
||||
},
|
||||
"capabilities": {
|
||||
"library": {
|
||||
"roles": ["provider"],
|
||||
"operations": ["query-page", "query-artists", "query-stats"],
|
||||
"mode": "active",
|
||||
"compatibility": "none",
|
||||
"ownership": "multi-provider",
|
||||
"safety": "safe",
|
||||
"version": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
All fields except `id` and `name` are optional. Runtime files such as `screen`, `script`, `routes`, and `settings.html` should correspond to declared `capabilities`, `ui`, diagnostics, or settings metadata.
|
||||
|
||||
## 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.
|
||||
|
||||
### `standards` (string[], expected)
|
||||
|
||||
Versioned contracts the plugin participates in. Plugin manifests are expected to declare `"capability-pipelines.v1"` for Slopsmith-facing behavior and metadata. Omit it only for metadata-only or transitional manifests with no capability participation yet.
|
||||
|
||||
Declare `"plugin-runtime-idempotent.v1"` only when repeated script hydration cannot duplicate wrappers, listeners, timers, DOM roots, diagnostics contributors, jobs, media nodes, or capability participants.
|
||||
|
||||
### `capability_api` (object, optional)
|
||||
|
||||
Explicit capability API marker. Most plugins can use the compact `standards` form instead:
|
||||
|
||||
```json
|
||||
{ "capability_api": { "standard": "capability-pipelines.v1", "version": 1 } }
|
||||
```
|
||||
|
||||
### `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.
|
||||
|
||||
### `styles` (string, optional)
|
||||
|
||||
Path to a plugin-owned compiled stylesheet under `assets/`, for example `"assets/plugin.css"`. Use this when a plugin ships Tailwind utilities that core's prebuilt stylesheet cannot know about, especially arbitrary-value classes in runtime-installed plugins. The stylesheet must be built ahead of time with Tailwind `preflight: false`; Slopsmith injects one versioned `<link>` for the plugin. See [plugin-styles.md](plugin-styles.md).
|
||||
|
||||
### `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.
|
||||
|
||||
### `settings_schema` (object, optional)
|
||||
|
||||
Redaction-safe settings metadata for support tooling and capability diagnostics. Use this to describe schema/version and packable key names; do not store user settings values, paths, tokens, or plugin-private payloads here.
|
||||
|
||||
### `ui` / `ui_contributions` (object, optional)
|
||||
|
||||
Native UI contribution declarations keyed by UI domain or surface. Use these whenever a plugin contributes settings panels, plugin screens, player controls, overlays, tours, or other host-visible UI so Slopsmith can attribute UI to stable contribution records.
|
||||
|
||||
Example:
|
||||
|
||||
```json
|
||||
{
|
||||
"ui": {
|
||||
"settings": [{ "id": "my-plugin-settings", "region": "plugin-settings", "label": "My Plugin" }],
|
||||
"ui.player-overlays": [{ "id": "my-plugin-overlay", "region": "player.overlays.highway", "label": "My Overlay" }]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Contribution ids must be stable and unique per plugin. Keep metadata redaction-safe: no settings values, DOM handles, local paths, callbacks, or private payloads.
|
||||
|
||||
### `capabilities` (object, expected for app-facing behavior)
|
||||
|
||||
Native `capability-pipelines.v1` declarations keyed by capability domain. They describe what the plugin owns, provides, requests, observes, or emits before the runtime script hydrates, and are the default way plugin behavior is made visible to Slopsmith.
|
||||
|
||||
```json
|
||||
{
|
||||
"standards": ["capability-pipelines.v1"],
|
||||
"capabilities": {
|
||||
"library": {
|
||||
"roles": ["provider"],
|
||||
"operations": ["query-page", "query-artists", "query-stats", "tuning-names", "get-art", "sync-song"],
|
||||
"description": "Adds a browsable library source.",
|
||||
"mode": "active",
|
||||
"compatibility": "none",
|
||||
"ownership": "multi-provider",
|
||||
"safety": "safe",
|
||||
"version": 1
|
||||
},
|
||||
"playback": {
|
||||
"roles": ["observer"],
|
||||
"observes": ["loading", "ready", "stopped", "ended"],
|
||||
"description": "Observes playback lifecycle through playback capability events.",
|
||||
"mode": "active",
|
||||
"compatibility": "none",
|
||||
"ownership": "observer-only",
|
||||
"safety": "safe",
|
||||
"version": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Supported declaration fields include:
|
||||
|
||||
- `roles`: `owner`, `coordinator`, `provider`, `observer`, `requester`, `transformer`, `handler`, `validator`, `short-circuiter`, `contributor`
|
||||
- `commands`, `operations`, `requests`, `observes`, `emits`, `events`: string arrays naming public commands, provider operations, or events
|
||||
- `kind`: `command`, `provider-coordinator`, `event`, `diagnostic`, `privileged`
|
||||
- `mode`: `active`, `optional`, `legacy-shim`, `disabled`
|
||||
- `compatibility`: prefer `none` for new declarations
|
||||
- `ownership`: `exclusive-owner`, `multi-provider`, `observer-only`, `requester-only`, `privileged`, `diagnostic-only`
|
||||
- `safety`: `safe`, `privileged`, `sensitive`, `diagnostic-only`
|
||||
- `description` / `summary`: short redaction-safe text for local tooling
|
||||
- `version`: `1`
|
||||
|
||||
Invalid capability metadata is rejected by schema validation and ignored by runtime capability tooling. Fix invalid metadata rather than relying on undocumented runtime behavior.
|
||||
|
||||
### `license` (string, optional but recommended)
|
||||
|
||||
SPDX identifier. Contributions must use `AGPL-3.0-only`. 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).
|
||||
|
||||
Prefer native capability declarations and provider registration for Slopsmith-facing behavior. Backend plugins should not open ad hoc SQLite connections to core databases or treat database tables as a public integration surface. If a legacy or core-provided route path already needs metadata access through `context["meta_db"]`, use the shared `MetadataDB` instance and its synchronized methods only; do not bypass its `threading.Lock` protection with direct SQLite access.
|
||||
|
||||
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), including capability-pipelines metadata.
|
||||
|
||||
## 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
|
||||
@@ -1,64 +1,398 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://feedBack.local/contracts/plugin-manifest-capabilities.schema.json",
|
||||
"title": "FeedBack Plugin Manifest Capability Contract",
|
||||
"$id": "https://slopsmith.local/contracts/plugin-manifest-capabilities.schema.json",
|
||||
"title": "Slopsmith Plugin Manifest Capability Contract",
|
||||
"type": "object",
|
||||
"required": ["id", "name"],
|
||||
"required": [
|
||||
"id",
|
||||
"name"
|
||||
],
|
||||
"properties": {
|
||||
"id": { "type": "string", "minLength": 1, "pattern": "^[A-Za-z0-9_.-]+$" },
|
||||
"name": { "type": "string", "minLength": 1 },
|
||||
"version": { "type": ["string", "null"] },
|
||||
"private": { "type": "boolean" },
|
||||
"standards": { "type": "array", "items": { "type": "string", "minLength": 1 }, "uniqueItems": true },
|
||||
"id": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"pattern": "^[A-Za-z0-9_.-]+$"
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"version": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"private": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"standards": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"uniqueItems": true
|
||||
},
|
||||
"capability_api": {
|
||||
"type": "object",
|
||||
"properties": { "standard": { "const": "capability-pipelines.v1" }, "version": { "const": 1 } },
|
||||
"properties": {
|
||||
"standard": {
|
||||
"const": "capability-pipelines.v1"
|
||||
},
|
||||
"version": {
|
||||
"const": 1
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"capabilities": {
|
||||
"type": "object",
|
||||
"propertyNames": { "$ref": "#/$defs/domainName" },
|
||||
"additionalProperties": { "$ref": "#/$defs/capabilityDeclaration" }
|
||||
"propertyNames": {
|
||||
"$ref": "#/$defs/domainName"
|
||||
},
|
||||
"additionalProperties": {
|
||||
"$ref": "#/$defs/capabilityDeclaration"
|
||||
}
|
||||
},
|
||||
"ui_contributions": { "type": "object", "propertyNames": { "$ref": "#/$defs/domainName" }, "additionalProperties": { "$ref": "#/$defs/contributionList" } },
|
||||
"ui": { "type": "object", "propertyNames": { "$ref": "#/$defs/domainName" }, "additionalProperties": { "$ref": "#/$defs/contributionList" } },
|
||||
"runtime_domains": { "type": "object", "propertyNames": { "$ref": "#/$defs/domainName" }, "additionalProperties": { "$ref": "#/$defs/domainDeclaration" } },
|
||||
"domains": { "type": "object", "propertyNames": { "$ref": "#/$defs/domainName" }, "additionalProperties": { "$ref": "#/$defs/domainDeclaration" } },
|
||||
"settings_schema": { "type": "object" },
|
||||
"nav": {}, "screen": {}, "script": {}, "routes": {}, "settings": {}, "diagnostics": {}, "type": { "type": "string" }, "tour": {},
|
||||
"description": { "type": "string", "description": "Short one-sentence summary of the plugin, surfaced on the v3 Pedalboard Plugins page (clamped to ~2 lines). Optional and additive." },
|
||||
"category": { "type": "string", "description": "Which pedalboard the plugin sits on in the v3 Plugins page. Suggested values: 'audio', 'creation', 'practice', 'game', 'tools'. Free-form; unknown/absent values fall back to a curated default then 'other'. Optional and additive." },
|
||||
"icon": { "type": "string", "minLength": 1, "pattern": "^assets/(?!.*\\.\\.)[^\\\\?#]+$", "description": "Plugin-root-relative path under assets/ (e.g. 'assets/thumb.png') to a thumbnail (~square, ~256x256 PNG/SVG) shown as the pedal graphic on the v3 Plugins page. Same containment rule as `styles`. If omitted, the loader auto-detects assets/thumb.png; failing that the UI shows a default pedal graphic. Optional and additive." },
|
||||
"styles": { "type": "string", "minLength": 1, "pattern": "^assets/(?!.*\\.\\.)[^\\\\?#]+$", "description": "Plugin-root-relative path under assets/ (e.g. 'assets/plugin.css') to a compiled, preflight-off Tailwind stylesheet the frontend injects as a <link>. Must stay under assets/ with no '..', backslash, or query/fragment. See docs/plugin-styles.md." }
|
||||
"ui_contributions": {
|
||||
"type": "object",
|
||||
"propertyNames": {
|
||||
"$ref": "#/$defs/domainName"
|
||||
},
|
||||
"additionalProperties": {
|
||||
"$ref": "#/$defs/contributionList"
|
||||
}
|
||||
},
|
||||
"ui": {
|
||||
"type": "object",
|
||||
"propertyNames": {
|
||||
"$ref": "#/$defs/domainName"
|
||||
},
|
||||
"additionalProperties": {
|
||||
"$ref": "#/$defs/contributionList"
|
||||
}
|
||||
},
|
||||
"runtime_domains": {
|
||||
"type": "object",
|
||||
"propertyNames": {
|
||||
"$ref": "#/$defs/domainName"
|
||||
},
|
||||
"additionalProperties": {
|
||||
"$ref": "#/$defs/domainDeclaration"
|
||||
}
|
||||
},
|
||||
"domains": {
|
||||
"type": "object",
|
||||
"propertyNames": {
|
||||
"$ref": "#/$defs/domainName"
|
||||
},
|
||||
"additionalProperties": {
|
||||
"$ref": "#/$defs/domainDeclaration"
|
||||
}
|
||||
},
|
||||
"settings_schema": {
|
||||
"type": "object"
|
||||
},
|
||||
"nav": {},
|
||||
"screen": {
|
||||
"$ref": "#/$defs/pluginRelpath"
|
||||
},
|
||||
"script": {
|
||||
"$ref": "#/$defs/pluginRelpath"
|
||||
},
|
||||
"routes": {
|
||||
"$ref": "#/$defs/pluginRelpath"
|
||||
},
|
||||
"settings": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"html": {
|
||||
"$ref": "#/$defs/pluginRelpath"
|
||||
},
|
||||
"server_files": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"not": {
|
||||
"pattern": "^/|^[a-zA-Z]:|\\\\|(^|/)\\.\\.(/|$)|//|(^|/)\\.(/|$)|^\\."
|
||||
}
|
||||
},
|
||||
"uniqueItems": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"diagnostics": {},
|
||||
"type": {
|
||||
"type": "string"
|
||||
},
|
||||
"tour": {
|
||||
"$ref": "#/$defs/pluginRelpath"
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"description": "Short one-sentence summary of the plugin, surfaced on the v3 Pedalboard Plugins page (clamped to ~2 lines). Optional and additive."
|
||||
},
|
||||
"category": {
|
||||
"type": "string",
|
||||
"description": "Which pedalboard the plugin sits on in the v3 Plugins page. Suggested values: 'audio', 'creation', 'practice', 'game', 'tools'. Free-form; unknown/absent values fall back to a curated default then 'other'. Optional and additive."
|
||||
},
|
||||
"icon": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"pattern": "^assets/(?!.*\\.\\.)[^\\\\?#]+$",
|
||||
"description": "Plugin-root-relative path under assets/ (e.g. 'assets/thumb.png') to a thumbnail (~square, ~256x256 PNG/SVG) shown as the pedal graphic on the v3 Plugins page. Same containment rule as `styles`. If omitted, the loader auto-detects assets/thumb.png; failing that the UI shows a default pedal graphic. Optional and additive."
|
||||
},
|
||||
"styles": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"pattern": "^assets/(?!.*\\.\\.)[^\\\\?#]+$",
|
||||
"description": "Plugin-root-relative path under assets/ (e.g. 'assets/plugin.css') to a compiled, preflight-off Tailwind stylesheet the frontend injects as a <link>. Must stay under assets/ with no '..', backslash, query, or fragment. See docs/plugin-styles.md."
|
||||
}
|
||||
},
|
||||
"additionalProperties": true,
|
||||
"$defs": {
|
||||
"domainName": { "type": "string", "minLength": 1, "pattern": "^[A-Za-z0-9_.:-]+$" },
|
||||
"pluginRelpath": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"not": {
|
||||
"pattern": "^/|^[a-zA-Z]:|\\\\|(^|/)\\.\\.(/|$)|//|(^|/)\\.(/|$)|^\\.|[?#]"
|
||||
}
|
||||
},
|
||||
"domainName": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"pattern": "^[A-Za-z0-9_.:-]+$"
|
||||
},
|
||||
"capabilityDeclaration": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"roles": { "type": "array", "items": { "enum": ["owner", "provider", "observer", "requester", "transformer", "handler", "validator", "short-circuiter", "contributor"] }, "uniqueItems": true },
|
||||
"commands": { "type": "array", "items": { "type": "string", "minLength": 1 }, "uniqueItems": true },
|
||||
"operations": { "type": "array", "items": { "type": "string", "minLength": 1 }, "uniqueItems": true },
|
||||
"requests": { "type": "array", "items": { "type": "string", "minLength": 1 }, "uniqueItems": true },
|
||||
"observes": { "type": "array", "items": { "type": "string", "minLength": 1 }, "uniqueItems": true },
|
||||
"emits": { "type": "array", "items": { "type": "string", "minLength": 1 }, "uniqueItems": true },
|
||||
"events": { "type": "array", "items": { "type": "string", "minLength": 1 }, "uniqueItems": true },
|
||||
"kind": { "enum": ["command", "provider-coordinator", "event", "diagnostic", "privileged"] },
|
||||
"mode": { "enum": ["active", "optional", "legacy-shim", "disabled"] },
|
||||
"compatibility": { "enum": ["none", "shim-allowed", "degrade-noop", "required", "legacy-window-shim"] },
|
||||
"ownership": { "enum": ["exclusive-owner", "multi-provider", "observer-only", "requester-only", "privileged", "diagnostic-only"] },
|
||||
"safety": { "enum": ["safe", "privileged", "sensitive", "diagnostic-only"] },
|
||||
"order": { "type": "object", "properties": { "fixed": { "type": "boolean" }, "before": { "type": "array", "items": { "type": "string", "minLength": 1 }, "uniqueItems": true }, "after": { "type": "array", "items": { "type": "string", "minLength": 1 }, "uniqueItems": true } }, "additionalProperties": false },
|
||||
"provider_policy": { "type": "object" },
|
||||
"settings": { "type": "array", "description": "Declarative per-instance control descriptors (toggle / range / select) a participant exposes for a consuming host to render generically. Domain-agnostic in shape; how a host applies a chosen value is defined by each capability domain's contract (the visualization domain requires an applySetting(key, value) method on the renderer instance).", "items": { "type": "object", "required": ["key", "type"], "properties": { "key": { "type": "string", "minLength": 1 }, "label": { "type": "string" }, "type": { "enum": ["toggle", "range", "select"] }, "default": {}, "min": { "type": "number" }, "max": { "type": "number" }, "step": { "type": "number" }, "options": { "type": "array", "items": { "type": "object", "required": ["id"], "properties": { "id": { "type": "string", "minLength": 1 }, "label": { "type": "string" } }, "additionalProperties": false } } }, "additionalProperties": false } },
|
||||
"description": { "type": "string" },
|
||||
"summary": { "type": "string" },
|
||||
"version": { "const": 1 }
|
||||
"roles": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"enum": [
|
||||
"owner",
|
||||
"coordinator",
|
||||
"provider",
|
||||
"observer",
|
||||
"requester",
|
||||
"transformer",
|
||||
"handler",
|
||||
"validator",
|
||||
"short-circuiter",
|
||||
"contributor"
|
||||
]
|
||||
},
|
||||
"uniqueItems": true
|
||||
},
|
||||
"commands": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"uniqueItems": true
|
||||
},
|
||||
"operations": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"uniqueItems": true
|
||||
},
|
||||
"requests": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"uniqueItems": true
|
||||
},
|
||||
"observes": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"uniqueItems": true
|
||||
},
|
||||
"emits": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"uniqueItems": true
|
||||
},
|
||||
"events": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"uniqueItems": true
|
||||
},
|
||||
"kind": {
|
||||
"enum": [
|
||||
"command",
|
||||
"provider-coordinator",
|
||||
"event",
|
||||
"diagnostic",
|
||||
"privileged"
|
||||
]
|
||||
},
|
||||
"mode": {
|
||||
"enum": [
|
||||
"active",
|
||||
"optional",
|
||||
"legacy-shim",
|
||||
"disabled"
|
||||
]
|
||||
},
|
||||
"compatibility": {
|
||||
"enum": [
|
||||
"none",
|
||||
"shim-allowed",
|
||||
"degrade-noop",
|
||||
"required",
|
||||
"legacy-window-shim"
|
||||
]
|
||||
},
|
||||
"ownership": {
|
||||
"enum": [
|
||||
"exclusive-owner",
|
||||
"multi-provider",
|
||||
"observer-only",
|
||||
"requester-only",
|
||||
"privileged",
|
||||
"diagnostic-only"
|
||||
]
|
||||
},
|
||||
"safety": {
|
||||
"enum": [
|
||||
"safe",
|
||||
"privileged",
|
||||
"sensitive",
|
||||
"diagnostic-only"
|
||||
]
|
||||
},
|
||||
"order": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"fixed": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"before": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"uniqueItems": true
|
||||
},
|
||||
"after": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"uniqueItems": true
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"provider_policy": {
|
||||
"type": "object"
|
||||
},
|
||||
"description": {
|
||||
"type": "string"
|
||||
},
|
||||
"summary": {
|
||||
"type": "string"
|
||||
},
|
||||
"version": {
|
||||
"const": 1
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"domainDeclaration": { "oneOf": [{ "type": "object", "properties": { "role": { "type": "string", "minLength": 1 }, "roles": { "type": "array", "items": { "type": "string", "minLength": 1 } }, "ownership": { "enum": ["exclusive-owner", "multi-provider", "observer-only", "requester-only", "privileged", "diagnostic-only"] }, "safety": { "enum": ["safe", "privileged", "sensitive", "diagnostic-only"] }, "legacy_source": { "type": "string", "minLength": 1 } }, "additionalProperties": true }, { "type": "array" }] },
|
||||
"contributionList": { "type": "array", "items": { "type": "object", "required": ["id"], "properties": { "id": { "type": "string", "minLength": 1 }, "region": { "type": "string", "minLength": 1 }, "label": { "type": "string" }, "order": { "type": ["number", "integer", "string"] } }, "additionalProperties": true } }
|
||||
"domainDeclaration": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"role": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"roles": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
}
|
||||
},
|
||||
"ownership": {
|
||||
"enum": [
|
||||
"exclusive-owner",
|
||||
"multi-provider",
|
||||
"observer-only",
|
||||
"requester-only",
|
||||
"privileged",
|
||||
"diagnostic-only"
|
||||
]
|
||||
},
|
||||
"safety": {
|
||||
"enum": [
|
||||
"safe",
|
||||
"privileged",
|
||||
"sensitive",
|
||||
"diagnostic-only"
|
||||
]
|
||||
},
|
||||
"legacy_source": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
}
|
||||
},
|
||||
"additionalProperties": true
|
||||
},
|
||||
{
|
||||
"type": "array"
|
||||
}
|
||||
]
|
||||
},
|
||||
"contributionList": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"id"
|
||||
],
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"region": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"label": {
|
||||
"type": "string"
|
||||
},
|
||||
"order": {
|
||||
"type": [
|
||||
"number",
|
||||
"integer",
|
||||
"string"
|
||||
]
|
||||
}
|
||||
},
|
||||
"additionalProperties": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,103 +0,0 @@
|
||||
# Plugin ES-module migration playbook
|
||||
|
||||
How to move a plugin off a single global-scope `screen.js` IIFE onto a native
|
||||
ES-module graph — **no build step, no framework, no bundler**. This is the
|
||||
mechanism the monolith-killing refactor uses; the host rails for it shipped in
|
||||
R0 (see `.specify/memory/constitution.md` Principle II + the "Module load
|
||||
contract" in Operating Constraints).
|
||||
|
||||
## The shape
|
||||
|
||||
```
|
||||
my-plugin/
|
||||
plugin.json + "scriptType": "module" ← opt in
|
||||
screen.js import './src/main.js'; ← the entire file
|
||||
src/
|
||||
state.js (0) module state + accessors
|
||||
util/… (1) pure helpers — real-import testable
|
||||
…/… (2..4) model → render/audio/io → input
|
||||
globals.js (5) THE ONLY file that writes window.*
|
||||
main.js (5) boot: wire modules, register screen:changed
|
||||
assets/… worklets / WASM / images (unchanged, served as today)
|
||||
```
|
||||
|
||||
`screen.js` becomes a one-line static `import`. The host injects it as
|
||||
`<script type="module">`, whose load event fires **only after the whole
|
||||
static-import graph fetches and evaluates** — so the loader's
|
||||
completion-by-`onload` + `_loadingPluginId` window + `playSong` wrapper-chain
|
||||
order are all preserved. (A classic IIFE that fired a fire-and-forget
|
||||
`import()` would break that contract — don't do that; use `scriptType:"module"`.)
|
||||
|
||||
## Non-negotiable rules
|
||||
|
||||
1. **Source-served, no build.** Modules are plain source files fetched from
|
||||
`/api/plugins/<id>/src/<path>`. No bundler, transpiler, or TypeScript.
|
||||
2. **Layering points downward** — `state → util → commands/model →
|
||||
render/audio/io → input → globals/main`. A lint check (`import-x/no-cycle`)
|
||||
enforces acyclicity; extract bottom-up so each move only imports
|
||||
already-extracted layers.
|
||||
3. **`globals.js` is the only writer of `window.*`.** The deliberate global
|
||||
surface shrinks to one auditable file; everything else is module-scoped.
|
||||
4. **Import-time purity.** `node --test` runs a module's top-level code on
|
||||
import, so a module you want to unit-test must be side-effect-free at import:
|
||||
no `document` / `window` / `localStorage` at module top level — lift init
|
||||
into an exported `init()` called by `main.js`. (Constitution Principle V's
|
||||
"no implicit IO at import time", applied to the frontend.) Tests are `.mjs`
|
||||
and use real `import`, retiring the regex/`extractFunction` harness.
|
||||
5. **Assets resolve via `import.meta.url`.** `document.currentScript` is `null`
|
||||
inside a module. `assets/` lives at the plugin root, so a `src/` module must
|
||||
climb out of `src/`: from `src/main.js`, `new URL('../assets/x.js',
|
||||
import.meta.url)` (deeper modules need more `../`). Simpler and
|
||||
depth-independent: the absolute route `/api/plugins/<id>/assets/x.js`.
|
||||
Worklets run in a *separate* module graph (`AudioWorkletGlobalScope`) and
|
||||
cannot share modules with `src/`.
|
||||
6. **Re-init comes from `screen:changed`, not re-execution.** The host loads
|
||||
`screen.js` once per version and `showScreen` re-injects nothing, so module
|
||||
top-level code does **not** re-run when the user re-enters the screen at the
|
||||
same version. Keep per-visit setup/teardown in a `window.feedBack.on(
|
||||
'screen:changed', …)` handler — exactly as classic plugins (tuner,
|
||||
minigames) already do. Do not rely on the IIFE re-running.
|
||||
7. **Inline `onclick=` keeps working** during migration via `globals.js` (which
|
||||
keeps every referenced symbol on `window`); retire inline handlers to
|
||||
module-side `addEventListener` opportunistically, never as a blocking step.
|
||||
|
||||
## The live-edit loop
|
||||
|
||||
The host serves `screen.js`, `src/**`, and `assets/**` with
|
||||
`Cache-Control: no-cache` + a weak `ETag` and honors `If-None-Match` → `304`.
|
||||
So: edit a `src/` file → **refresh the browser** → the edited module returns
|
||||
`200` and reloads while every unchanged module `304`s. There is no hot-reload;
|
||||
the loop is edit → refresh → see change, exactly as before. The `?v=<version>`
|
||||
query on `screen.js` is the legacy version buster; it does **not** propagate
|
||||
into the `src/` graph and does not need to — ETag/mtime is the correctness
|
||||
authority for the whole graph.
|
||||
|
||||
## Host-version floor (`minHost`)
|
||||
|
||||
A migrated plugin *requires* a host new enough to serve `src/` and inject
|
||||
`type=module`. Declare the floor with `"minHost": "X.Y.Z"` in `plugin.json`.
|
||||
(R0 plumbs the field through `/api/plugins`; enforcement — refuse-with-message
|
||||
on an older host — is deferred, so bundled plugins are unaffected. Community
|
||||
plugins should state the floor and not migrate below it.)
|
||||
|
||||
## Migration mechanics
|
||||
|
||||
- **Move-only PRs.** One slice extracts one module: cut code, add
|
||||
imports/exports, update `globals.js` — zero behavior change. Behavior fixes
|
||||
are separate PRs. (Init-lifts for import purity are the one non-pure move —
|
||||
budget them.)
|
||||
- **Bottom-up, layer by layer.** Within a layer, independent modules are
|
||||
independent PRs (a DAG, not a chain); use a git worktree per branch.
|
||||
- Tests move with their subject and convert to real `.mjs` imports in the same
|
||||
PR (assertions unchanged).
|
||||
- Size norm: no source file over **1,500 lines**; legitimate exceptions
|
||||
(hot renderers, etc.) go in the signed register at `docs/size-exemptions.md`.
|
||||
|
||||
## Verifying a migration
|
||||
|
||||
`node --test <plugin>/tests/*.mjs`; load the plugin on the `:8000` testbed and
|
||||
confirm it boots (`<script type=module>` in DevTools, the `src/` graph in
|
||||
Network); edit a `src/` file → refresh → change visible (`200` on the edited
|
||||
file, `304` on the rest); leave and re-enter the screen at the same version →
|
||||
it re-inits via `screen:changed`. The R1 pilots (stems, then studio) certify
|
||||
this end-to-end before the flagship repos migrate.
|
||||
@@ -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")
|
||||
ArchiveReader = extractor.ArchiveReader
|
||||
# …
|
||||
```
|
||||
|
||||
## 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
|
||||
@@ -1,14 +1,14 @@
|
||||
# Plugin styling — the `styles` capability
|
||||
|
||||
> Building for the redesigned **v3 UI** (`FEEDBACK_UI=v3` / `/v3`)? v3 uses `fb-*`
|
||||
> Building for the redesigned **v3 UI** (`SLOPSMITH_UI=v3` / `/v3`)? v3 uses `fb-*`
|
||||
> design tokens and a restructured player chrome with a dedicated plugin-control
|
||||
> slot. See **[plugin-v3-ui.md](plugin-v3-ui.md)** for the player-chrome contract
|
||||
> plugins must follow in v3.
|
||||
|
||||
FeedBack serves Tailwind as a **prebuilt** stylesheet
|
||||
Slopsmith serves Tailwind as a **prebuilt** stylesheet
|
||||
(`static/tailwind.min.css`), never the runtime Play CDN. The CDN's on-the-fly
|
||||
JIT rescanned the DOM on the main thread and dropped ~26% of frames with the 3D
|
||||
highway running (feedBack-desktop#110). See **constitution Principle II**.
|
||||
highway running (slopsmith-desktop#110). See **constitution Principle II**.
|
||||
|
||||
A prebuilt stylesheet only contains the classes the build scanner saw in **core
|
||||
source at core build time**. That has a consequence for plugins:
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
# Building plugins for the v3 UI (fee[dB]ack v0.3.0)
|
||||
|
||||
v0.3.0 ("fee[dB]ack") ships a redesigned UI **behind a flag** — `FEEDBACK_UI=v3`
|
||||
v0.3.0 ("fee[dB]ack") ships a redesigned UI **behind a flag** — `SLOPSMITH_UI=v3`
|
||||
or the `/v3` route. The classic UI (v2) remains the default until 0.3.0 ships, so
|
||||
plugins must work in **both**.
|
||||
|
||||
The good news: v3 **reuses the same engine** as v2 — same `server.py`, `app.js`,
|
||||
`highway.js`, `playSong`, `showScreen`, capability registry, library providers,
|
||||
and the `window.feedBackViz_<id>` / `setRenderer` visualization contract. So your
|
||||
and the `window.slopsmithViz_<id>` / `setRenderer` visualization contract. So your
|
||||
plugin's **backend, capabilities, library providers, `nav`/`screen`, visualization
|
||||
renderers, diagnostics, and settings export all work unchanged in v3.** v3 surfaces
|
||||
your `nav` entry in the new sidebar (via `shell.js` `renderPluginNav`) and your
|
||||
@@ -34,8 +34,8 @@ So the legacy way of injecting a control breaks in v3 two ways:
|
||||
|
||||
The host exposes:
|
||||
|
||||
- `window.feedBack.uiVersion === 'v3'` — detect v3 (absent / not `'v3'` in v2).
|
||||
- `window.feedBack.ui.playerControlSlot()` — returns a **stable, always-reachable
|
||||
- `window.slopsmith.uiVersion === 'v3'` — detect v3 (absent / not `'v3'` in v2).
|
||||
- `window.slopsmith.ui.playerControlSlot()` — returns a **stable, always-reachable
|
||||
container** (the "Plugins" rail popover). In v3, append your control(s) here
|
||||
instead of `#player-controls`.
|
||||
|
||||
@@ -43,9 +43,9 @@ Canonical pattern for any control you inject into the player:
|
||||
|
||||
```js
|
||||
function playerSlot() {
|
||||
return (window.feedBack && window.feedBack.uiVersion === 'v3'
|
||||
&& window.feedBack.ui && typeof window.feedBack.ui.playerControlSlot === 'function')
|
||||
? window.feedBack.ui.playerControlSlot() : null;
|
||||
return (window.slopsmith && window.slopsmith.uiVersion === 'v3'
|
||||
&& window.slopsmith.ui && typeof window.slopsmith.ui.playerControlSlot === 'function')
|
||||
? window.slopsmith.ui.playerControlSlot() : null;
|
||||
}
|
||||
|
||||
function injectMyButton() {
|
||||
@@ -183,7 +183,7 @@ out of the capability graph.
|
||||
- [ ] Backend / capabilities / library provider / `nav` + `screen` /
|
||||
visualization renderer — **no change needed** (they work in v3 as-is).
|
||||
- [ ] If you inject a control into the player: detect v3 and mount into
|
||||
`window.feedBack.ui.playerControlSlot()`; drop the dead separator /
|
||||
`window.slopsmith.ui.playerControlSlot()`; drop the dead separator /
|
||||
`button:last-child` anchor; guard `contains()` against the actual container.
|
||||
- [ ] Dropdowns positioned via `getBoundingClientRect()`, not `#player-controls`.
|
||||
- [ ] `#player` overlays keep `z-index` ≤ the chrome layers (transport/HUD 20,
|
||||
|
||||
@@ -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
|
||||
@@ -1,66 +0,0 @@
|
||||
# Size-exemption register
|
||||
|
||||
The working norm (constitution Principle II; enforced by the `max-lines` lint
|
||||
gate) is **no source file over 1,500 lines**. A few files are allowed to exceed
|
||||
it because splitting them would do more harm than good — hot per-frame
|
||||
renderers, C++, offline generators, cohesive registries. This register is the
|
||||
list of those exceptions: each row is a **deliberate, signed** decision with a
|
||||
ceiling, a rationale, and a review trigger. Without it, "no file over 1,500
|
||||
without a *signed* exemption" is unenforceable.
|
||||
|
||||
**Rules**
|
||||
- One row per file: a ceiling, a rationale, a signer, a review trigger.
|
||||
- The `max-lines` per-file ceilings in `eslint.config.js` mirror this table —
|
||||
keep them in sync (this register is canonical).
|
||||
- Files with a scheduled split **plan** are *not* exempt — they live in
|
||||
"Planned, not exempt" at the bottom so nothing falls between the two states.
|
||||
- **Signers** (decided 2026-07-08): **Byron** signs core + bundled rows;
|
||||
**Christian** signs the authored-plugin row (virtuoso, its own repo/track).
|
||||
|
||||
## Permanent exemptions (structural rationale)
|
||||
|
||||
| Repo / file | Lines (7-07) | Ceiling | Rationale | Signer | Review |
|
||||
|---|---|---|---|---|---|
|
||||
| core `static/highway.js` → residual `renderer-2d.js` (post-split) | ~2,400–2,900 est. | **3,000** | 60 fps hot path; no module boundary inside the per-frame loop | Byron | after the highway.js split |
|
||||
| core `plugins/highway_3d/` → residual renderer | sized at split; likely **>3,000** | set at split, flagged now | same hot-path rule; the draw core can't be cut without behavior risk | Byron | after the highway_3d split |
|
||||
| core `static/capabilities.js` | 1,538 | 1,600 | cohesive registry + `window.feedBack` bus, 38 lines over; a split spends credibility for nothing | Byron | R4 |
|
||||
| tutorials `builtin/reading-the-highway/generate.py` | 1,818 | 2,000 | offline content generator, never imported at runtime, deps not in runtime requirements | Byron | if a 3rd builtin pack appears |
|
||||
| desktop `src/audio/NodeAddon.cpp` | 3,542 | as-is | C++, outside the ESM/routes playbooks; under active use-after-free crash work — do not churn | Byron | after crash-class work settles |
|
||||
| desktop `src/audio/AudioEngine.cpp` | 2,977 | as-is | same | Byron | same |
|
||||
| desktop `src/vst-host/main.cpp` | 1,928 | as-is | same | Byron | same |
|
||||
| virtuoso `screen.js` (authored, own track) | 25,741 | as-is until its own split | authored plugin on a separate roadmap; migrates on its own schedule | Christian | virtuoso split kickoff |
|
||||
|
||||
## Split-when-touched (no scheduled train; row retires when split)
|
||||
|
||||
| Repo / file | Lines | Ceiling | Rationale | Signer | Review |
|
||||
|---|---|---|---|---|---|
|
||||
| core `lib/gp2rs_gpx.py` | 2,540 | as-is | import converter, off the serve-path hot loop | Byron | when next touched |
|
||||
| core `lib/gp2rs.py` | 2,055 | as-is | same | Byron | when next touched |
|
||||
| core `lib/song.py` | 1,689 | as-is | data models + wire format; cohesive | Byron | when next touched |
|
||||
| core `lib/gp_autosync.py` | 1,572 | as-is | under active dev (#787/#791) — don't collide | Byron | after in-flight work lands |
|
||||
| core `plugins/capability_inspector/screen.js` | 1,752 | as-is | bundled diagnostics plugin, low churn | Byron | when next touched |
|
||||
| core `plugins/folder_library/screen.js` | 1,672 | as-is | bundled plugin, low churn | Byron | when next touched |
|
||||
|
||||
## Temporary rows (cleared by a scheduled PR)
|
||||
|
||||
| Repo / file | Lines | Cleared by |
|
||||
|---|---|---|
|
||||
| core `plugins/__init__.py` | ~2,470 (grew under R0) | the `plugins/_routes.py` + `plugins/_registry.py` split (rides the server.py router work) |
|
||||
|
||||
## Watch list (under the norm — no row needed, re-census each phase)
|
||||
|
||||
`musicxml-import/mxml2notation.py` (1,456) · core `static/capabilities/audio-effects.js`
|
||||
(1,436) · `studio routes.py` (1,399) · `update-manager screen.js` (1,492 — zero headroom).
|
||||
|
||||
## 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`
|
||||
(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
|
||||
by policy — the norm governs source files.
|
||||
@@ -1,12 +1,12 @@
|
||||
# Debugging Keyboard Shortcuts
|
||||
|
||||
This skill helps you debug keyboard shortcut issues in FeedBack.
|
||||
This skill helps you debug keyboard shortcut issues in Slopsmith.
|
||||
|
||||
## Quick Start
|
||||
|
||||
1. **Start FeedBack:**
|
||||
1. **Start Slopsmith:**
|
||||
```bash
|
||||
cd ~/path/to/feedBack
|
||||
cd ~/path/to/slopsmith
|
||||
LIBRARY_PATH=/path/to/your/library docker compose up -d
|
||||
```
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ A `.sloppak` is just a zip of plain files: some YAML, some JSON, some OGG audio,
|
||||
|
||||
This guide walks through the most common edits, aimed at musicians who are comfortable with a text editor and Audacity but don't live on the command line.
|
||||
|
||||
> For the format **schema** (what every field means, how the wire format works, how to extend the format with new data types), see the authoritative [feedpak spec](https://github.com/got-feedback/feedpak-spec/blob/main/spec/feedpak-v1.md) (the local [sloppak-spec.md](sloppak-spec.md) is now a pointer to it). This document is the **how-do-I-actually-edit-mine** companion.
|
||||
> For the format **schema** (what every field means, how the wire format works, how to extend the format with new data types), see [sloppak-spec.md](sloppak-spec.md). This document is the **how-do-I-actually-edit-mine** companion.
|
||||
|
||||
---
|
||||
|
||||
@@ -17,27 +17,27 @@ A sloppak exists in two interchangeable forms:
|
||||
| **Directory** | A folder named `something.sloppak/` with the files loose inside | **Authoring** — easy to edit, no zip/unzip cycle |
|
||||
| **Zip** | A `something.sloppak` file (zip with the same files inside) | **Distributing** — single file to share |
|
||||
|
||||
FeedBack reads both. You can drop either one straight into your DLC folder and it'll show up in the library.
|
||||
Slopsmith reads both. You can drop either one straight into your DLC folder and it'll show up in the library.
|
||||
|
||||
### Unzipping for editing
|
||||
|
||||
FeedBack's converter ships sloppaks in zip form. To edit one, unzip it:
|
||||
Slopsmith's converter ships sloppaks in zip form. To edit one, unzip it:
|
||||
|
||||
- **Windows:** rename `mysong.sloppak` → `mysong.zip`, right-click → Extract All. Then rename the resulting folder back to `mysong.sloppak/` (with the trailing slash / folder form). Or use [7-Zip](https://www.7-zip.org/) and unzip without renaming.
|
||||
- **macOS:** rename `.sloppak` → `.zip`, double-click. Or use The Unarchiver.
|
||||
- **Linux:** `unzip mysong.sloppak -d mysong.sloppak/`.
|
||||
|
||||
Once you have the directory form, you can edit any file inside and FeedBack will pick it up — no re-zipping required for your own use.
|
||||
Once you have the directory form, you can edit any file inside and Slopsmith will pick it up — no re-zipping required for your own use.
|
||||
|
||||
### Cache: when changes don't appear
|
||||
|
||||
The first time FeedBack opens a zip-form sloppak, it extracts a working copy into its config directory's cache: `${CONFIG_DIR}/sloppak_cache/<safe-id>` (in the standard Docker setup that's inside the `feedBack-config` volume, mounted at `/config` in the container). The `<safe-id>` is the sloppak filename with each path separator (`/` or `\`) replaced by `__` and each space replaced by `_`. So `My-Song.sloppak` stays `My-Song.sloppak`, and `Artist/My Song.sloppak` becomes `Artist__My_Song.sloppak`.
|
||||
The first time Slopsmith opens a zip-form sloppak, it extracts a working copy into its config directory's cache: `${CONFIG_DIR}/sloppak_cache/<safe-id>` (in the standard Docker setup that's inside the `slopsmith-config` volume, mounted at `/config` in the container). The `<safe-id>` is the sloppak filename with each path separator (`/` or `\`) replaced by `__` and each space replaced by `_`. So `My-Song.sloppak` stays `My-Song.sloppak`, and `Artist/My Song.sloppak` becomes `Artist__My_Song.sloppak`.
|
||||
|
||||
You almost never need to touch this cache directly. If you edit the **original zip** in your DLC folder, FeedBack re-extracts automatically when the zip's modification time or size changes — just save your edits and reload.
|
||||
You almost never need to touch this cache directly. If you edit the **original zip** in your DLC folder, Slopsmith re-extracts automatically when the zip's modification time or size changes — just save your edits and reload.
|
||||
|
||||
If a change still isn't appearing, the simplest reset is to remove the matching cache folder so FeedBack rebuilds it on the next song load. In a default Docker install that's `docker exec <container> rm -rf /config/sloppak_cache/<safe-id>` (or the equivalent for your setup).
|
||||
If a change still isn't appearing, the simplest reset is to remove the matching cache folder so Slopsmith rebuilds it on the next song load. In a default Docker install that's `docker exec <container> rm -rf /config/sloppak_cache/<safe-id>` (or the equivalent for your setup).
|
||||
|
||||
If you'd rather skip the cache layer entirely, **drop the directory form straight into your DLC folder** — FeedBack uses it in place and there's nothing to invalidate.
|
||||
If you'd rather skip the cache layer entirely, **drop the directory form straight into your DLC folder** — Slopsmith uses it in place and there's nothing to invalidate.
|
||||
|
||||
---
|
||||
|
||||
@@ -72,7 +72,7 @@ The use case: the converted rhythm guitar sounds muddy (Demucs has a tough time
|
||||
1. Copy `rhythm_custom.ogg` into the sloppak's `stems/` folder.
|
||||
2. Open `manifest.yaml` in any text editor (Notepad++, VS Code, BBEdit, gedit — all fine; just **don't use Word**).
|
||||
3. Find the `stems:` block. Two things matter here:
|
||||
- **Order:** FeedBack's base `<audio>` element always plays the **first** stem listed in `stems[]`, regardless of `default:` flags. So if you want your custom stem to be what the player plays out-of-the-box (and what users without the Stems plugin will hear), put it **first**.
|
||||
- **Order:** Slopsmith's base `<audio>` element always plays the **first** stem listed in `stems[]`, regardless of `default:` flags. So if you want your custom stem to be what the player plays out-of-the-box (and what users without the Stems plugin will hear), put it **first**.
|
||||
- **`default:` flags:** consulted by the [Stems plugin](https://github.com/topkoa/slopsmith-plugin-stems) to decide which faders start un-muted. They do **not** affect what the base `<audio>` element plays — that's purely the first-stem rule above.
|
||||
|
||||
Example for a Demucs-split sloppak where you re-recorded the rhythm guitar:
|
||||
@@ -110,14 +110,14 @@ The use case: the converted rhythm guitar sounds muddy (Demucs has a tough time
|
||||
|
||||
### Step 5 — Reload and verify
|
||||
|
||||
Reload the song in FeedBack. The [Stems plugin](https://github.com/topkoa/slopsmith-plugin-stems) will show a fader for `rhythm_custom` next to the others. If you don't see it, check the cache notes in §1.
|
||||
Reload the song in Slopsmith. The [Stems plugin](https://github.com/topkoa/slopsmith-plugin-stems) will show a fader for `rhythm_custom` next to the others. If you don't see it, check the cache notes in §1.
|
||||
|
||||
### Common gotchas
|
||||
|
||||
- **Sample-rate mismatch** → choppy/pitched-wrong playback. Re-export from Audacity at exactly the rate the other stems use.
|
||||
- **Mono vs stereo mismatch** is fine for playback but levels can feel different — match what the other stems use if you want consistent behavior in the mixer.
|
||||
- **Silence padding at the start** of your recording → your stem will play late. Trim it tight in Audacity before exporting.
|
||||
- **Tabs in `manifest.yaml`** → FeedBack will refuse to load the song. Use two spaces.
|
||||
- **Tabs in `manifest.yaml`** → Slopsmith will refuse to load the song. Use two spaces.
|
||||
|
||||
---
|
||||
|
||||
@@ -242,7 +242,7 @@ For 4-string bass, only indices 0–3 are meaningful; leave 4 and 5 at `0`.
|
||||
|
||||
### What *not* to put in `manifest.yaml`
|
||||
|
||||
Don't add per-machine settings (audio device picks, MIDI port IDs), UI state, or your own play counts. The sloppak holds the song's authored data — anything that varies by user or machine lives in FeedBack's config dir or the metadata DB. See [feedpak spec §9.5](https://github.com/got-feedback/feedpak-spec/blob/main/spec/feedpak-v1.md#95-what-does-not-belong-in-a-feedpak) for the full list.
|
||||
Don't add per-machine settings (audio device picks, MIDI port IDs), UI state, or your own play counts. The sloppak holds the song's authored data — anything that varies by user or machine lives in Slopsmith's config dir or the metadata DB. See [sloppak-spec.md §5.7](sloppak-spec.md#57-dont-break-the-manifest-contract) for the full list.
|
||||
|
||||
---
|
||||
|
||||
@@ -252,15 +252,15 @@ If you want to share your modified sloppak with someone else, re-zip it:
|
||||
|
||||
1. Open the `mysong.sloppak/` directory.
|
||||
2. Select **everything inside** — `manifest.yaml`, `arrangements/`, `stems/`, `lyrics.json`, `cover.jpg`.
|
||||
3. Zip the **contents**, not the parent folder. (If you zip the folder, the zip will have a top-level `mysong.sloppak/` directory inside, which FeedBack won't parse — the manifest must be at the zip root.)
|
||||
3. Zip the **contents**, not the parent folder. (If you zip the folder, the zip will have a top-level `mysong.sloppak/` directory inside, which Slopsmith won't parse — the manifest must be at the zip root.)
|
||||
4. Rename `mysong.zip` → `mysong.sloppak`.
|
||||
|
||||
For your own use, you can skip this entirely — FeedBack reads the directory form straight from your DLC folder.
|
||||
For your own use, you can skip this entirely — Slopsmith reads the directory form straight from your DLC folder.
|
||||
|
||||
---
|
||||
|
||||
## Out of scope (for now)
|
||||
|
||||
- **Authoring a sloppak from scratch** (no Guitar Pro / MusicXML source file) — that's a developer task. Start at [feedpak spec §8 (Reading and writing)](https://github.com/got-feedback/feedpak-spec/blob/main/spec/feedpak-v1.md#8-reading-and-writing).
|
||||
- **Editing notes / chords in `arrangements/*.json`** — technically possible but extremely tedious by hand: hundreds of objects with short field names per song. The fields are documented in [feedpak spec §6 (Arrangement JSON)](https://github.com/got-feedback/feedpak-spec/blob/main/spec/feedpak-v1.md#6-arrangement-json), but for any real chart edit you want the [Arrangement Editor plugin](https://github.com/got-feedback/feedBack-plugin-editor).
|
||||
- **Authoring a sloppak from scratch** (no Guitar Pro / MusicXML source file) — that's a developer task. Start at [sloppak-spec.md §4.2](sloppak-spec.md#42-writing-python-server-side).
|
||||
- **Editing notes / chords in `arrangements/*.json`** — technically possible but extremely tedious by hand: hundreds of objects with short field names per song. The fields are documented in [sloppak-spec.md §3](sloppak-spec.md#3-arrangement-json--the-wire-format), but for any real chart edit you want the [Arrangement Editor plugin](https://github.com/got-feedback/feedback-plugin-editor).
|
||||
- **Loudness normalization / advanced stem processing** — out of scope here; standard Audacity or ffmpeg workflows apply to any OGG file before you drop it into `stems/`.
|
||||
|
||||
+921
-31
@@ -1,49 +1,939 @@
|
||||
# Sloppak / feedpak Format — moved
|
||||
# Sloppak Format — Developer Guide
|
||||
|
||||
The full format specification that used to live here has moved to its own repository and is now
|
||||
the **authoritative, versioned reference**:
|
||||
Sloppak is Slopsmith's open, hand-editable song format. This guide is for developers who want to **read**, **write**, or **extend** the format — including adding new data types like drum tabs, vocal pitches, lighting cues, key/scale annotations, or anything else a future visualization plugin might need.
|
||||
|
||||
> **📖 https://github.com/got-feedback/feedpak-spec**
|
||||
> — normative spec ([`spec/feedpak-v1.md`](https://github.com/got-feedback/feedpak-spec/blob/main/spec/feedpak-v1.md)),
|
||||
> JSON Schemas, examples, and a reference validator.
|
||||
> If you're a **user** wanting to modify an existing sloppak — record your own rhythm stem, fix metadata, swap cover art, replace a Demucs split — see [sloppak-hand-editing.md](sloppak-hand-editing.md). That guide is the practical, step-by-step companion to this developer reference.
|
||||
|
||||
Update bookmarks to point there. This page is a thin pointer kept at the original path so existing
|
||||
links keep resolving.
|
||||
The authoritative format reference lives in code (`lib/sloppak.py`, `lib/song.py`); this doc explains the why, the how, and the conventions you should follow when adding to it.
|
||||
|
||||
## Naming: `sloppak` here, `feedpak` in the spec
|
||||
---
|
||||
|
||||
The published format is named **feedpak** (extension `.feedpak`, manifest key `feedpak_version`).
|
||||
This codebase still uses the legacy **sloppak** name internally — `lib/sloppak.py`, the
|
||||
`.sloppak` extension, `FEEDBACK_*` env vars, etc. **They describe the same on-disk format.** The
|
||||
rename is repo/public-facing only for now (see the top-level workspace `CLAUDE.md`), so when the
|
||||
spec says `feedpak` / `feedpak_version`, the packs this server reads and writes today are the same
|
||||
structure under the `.sloppak` name. The internal rename is a separate, later effort.
|
||||
## 1. Format at a glance
|
||||
|
||||
## Hand-editing a pack
|
||||
A sloppak exists in **two interchangeable forms**:
|
||||
|
||||
For the practical "how do I edit my own pack" walkthrough (record your own stem, fix metadata,
|
||||
swap cover art, replace a stem split), see the companion guide that stays in this repo:
|
||||
[sloppak-hand-editing.md](sloppak-hand-editing.md).
|
||||
| Form | What it is | Used for |
|
||||
|---|---|---|
|
||||
| **Directory** | A folder named `*.sloppak/` containing the files below | Authoring, hand editing, plugin development |
|
||||
| **Zip archive** | A `.sloppak` file (zip with the same files inside) | Distribution |
|
||||
|
||||
## Where the format maps to code (this repo)
|
||||
Both forms hold identical contents. Slopsmith resolves either transparently — zip files are unpacked to a cache the first time they're opened (see `resolve_source_dir()` in [lib/sloppak.py](../lib/sloppak.py)).
|
||||
|
||||
The spec is implementation-independent; this table is the feedback-specific bridge from format
|
||||
concepts to the code that reads and writes them. It is **not** part of the format.
|
||||
### Directory layout
|
||||
|
||||
```
|
||||
my-song.sloppak/
|
||||
├── manifest.yaml # Required — all metadata + file index
|
||||
├── arrangements/
|
||||
│ ├── lead.json # One JSON per playable arrangement
|
||||
│ ├── rhythm.json
|
||||
│ └── bass.json
|
||||
├── stems/
|
||||
│ ├── full.ogg # Mixed audio (initial single-stem output; may be absent after stem splitting)
|
||||
│ ├── guitar.ogg # Optional individual stems
|
||||
│ ├── bass.ogg
|
||||
│ ├── drums.ogg
|
||||
│ ├── vocals.ogg
|
||||
│ └── other.ogg
|
||||
├── lyrics.json # Optional — syllable-level lyrics
|
||||
└── cover.jpg # Optional — album art
|
||||
```
|
||||
|
||||
Three rules to remember:
|
||||
|
||||
1. **`manifest.yaml` is the index.** Nothing inside the sloppak is auto-discovered — every file path is listed in the manifest. This makes the format predictable: no scanning, no guessing. (One historical exception: the cover-art handler in `server.py` falls back to `cover.jpg` when `manifest.cover` is missing. New code should not add similar filename fallbacks.)
|
||||
2. **Filenames in `manifest.yaml` are POSIX paths**, relative to the sloppak root (forward slashes, no leading `/`).
|
||||
3. **YAML for the manifest, JSON for everything else.** YAML is hand-editable for users; JSON is fast-parsed and easy to round-trip in code.
|
||||
|
||||
---
|
||||
|
||||
## 2. `manifest.yaml` reference
|
||||
|
||||
Minimal valid manifest:
|
||||
|
||||
```yaml
|
||||
title: "Black Hole Sun"
|
||||
artist: "Soundgarden"
|
||||
duration: 320.5
|
||||
arrangements:
|
||||
- id: lead
|
||||
name: Lead
|
||||
file: arrangements/lead.json
|
||||
tuning: [0, 0, 0, 0, 0, 0]
|
||||
capo: 0
|
||||
stems:
|
||||
- id: full
|
||||
file: stems/full.ogg
|
||||
default: true
|
||||
```
|
||||
|
||||
Full set of currently-recognized top-level keys:
|
||||
|
||||
| Key | Type | Required | Description |
|
||||
|---|---|---|---|
|
||||
| `title` | string | yes | Song title |
|
||||
| `artist` | string | yes | Artist name |
|
||||
| `album` | string | no | Album |
|
||||
| `year` | int | no | Release year |
|
||||
| `duration` | float | yes | Song length in seconds |
|
||||
| `arrangements` | list | yes | Playable arrangements (see §2.1) |
|
||||
| `stems` | list | yes | Audio stems (see §2.2) |
|
||||
| `stem_separation` | object | no | Structured metadata when stems were produced by an automated separation engine (currently `demucs`). Shape: `{engine, model, version}`. See §2.2 for fields + semver semantics per [slopsmith#357](https://github.com/got-feedback/feedback/issues/357). Omitted for single-stem sloppaks (`stems: [{id: full, ...}]`) and for hand-edited / user-recorded stems |
|
||||
| `lyrics` | string | no | Path to lyrics JSON |
|
||||
| `lyrics_source` | string | no | Where the lyrics came from: `xml` (vocals XML from the chart source), `whisperx` (auto-transcribed), or `user` (hand-edited). Absent on legacy sloppaks — readers should treat missing as `xml` |
|
||||
| `lyric_transcription` | object | no | Structured metadata when lyrics came from an automated engine (currently `whisperx`). Same shape as the parent `stem_separation` block defined by [slopsmith#357](https://github.com/got-feedback/feedback/issues/357) — see §2.3 for fields and semver semantics. Omitted for authored lyrics (`xml`/`user`) |
|
||||
| `vocal_pitch` | string | no | Path to per-syllable pitch JSON (`{"version": 1, "notes": [{t, d, midi}, ...]}`). Consumed by [slopsmith-plugin-lyrics-karaoke](https://github.com/got-feedback/feedback-plugin-lyrics-karaoke) to render karaoke note bars. See §2.4 |
|
||||
| `pitch_extraction` | object | no | Structured metadata when pitch was extracted by an automated engine (currently `crepe` via the demucs server's `/pitch` endpoint). Same shape as `stem_separation` / `lyric_transcription`. Omitted for hand-edited pitch tracks |
|
||||
| `cover` | string | no | Path to cover image |
|
||||
| `preview` | string | no | Path to a short preview audio clip (OGG) at the sloppak root. Populated when the source carries a separate short browser-preview clip (decoded to `preview.ogg`); absent otherwise. Consumed by [`slopsmith-plugin-song-preview`](https://github.com/got-feedback/feedback-plugin-song-preview) for hover-to-listen previews in the library |
|
||||
| `song_timeline` | string | no | Path to a `song_timeline.json` file carrying song-wide beats and sections (see §5.3). When present, its data takes priority over any beats/sections embedded in arrangement JSONs. Older readers ignore the key and fall back to reading beats/sections from the first arrangement JSON as before |
|
||||
| `drum_tab` | string | no | Path to `drum_tab.json` — per-piece drum hits (see §5.3). Implemented end-to-end as of slopsmith#344 |
|
||||
|
||||
Unknown keys are **silently ignored** by the loader. This is deliberate — it's the extensibility hook (see §5).
|
||||
|
||||
### 2.1. `arrangements[]`
|
||||
|
||||
Each entry describes one playable arrangement and points at its JSON file:
|
||||
|
||||
```yaml
|
||||
arrangements:
|
||||
- id: lead # filesystem-safe stable ID, used for filenames
|
||||
name: Lead # display name (Lead/Rhythm/Bass/Combo are sorted first)
|
||||
file: arrangements/lead.json
|
||||
tuning: [0, 0, 0, 0, 0, 0] # six semitone offsets from E A D G B E
|
||||
capo: 0
|
||||
centOffset: 0.0 # optional float, cents; default 0.0
|
||||
```
|
||||
|
||||
- `tuning` is a list of semitone offsets from standard `E2 A2 D2 G3 B3 E4`. **Six elements is the standard six-string convention** and the only length `lib/tunings.py` produces friendly names for; 5- and 7-string content is accepted by the loader and falls through to a numeric label. For bass, the four bass strings are at indices 0–3; the other two slots are `0`. Consumers should not hard-code `len(tuning) == 6`.
|
||||
- `name` controls the sort order in the UI: `Lead > Combo > Rhythm > Bass > everything else`.
|
||||
- `centOffset` is a pitch-shift value in cents. Commonly `-1200.0` for extended-range bass arrangements tuned one octave down; small non-zero values for songs mastered at a non-A440 reference pitch (e.g. A443 ≈ +11.8 cents). Absent / `0.0` means no shift. Exposed to plugins via `getSongInfo().centOffset`.
|
||||
- Manifest-level `tuning`, `capo`, and `centOffset` **override** anything embedded in the arrangement JSON. The arrangement JSON's own values are fallbacks.
|
||||
- `notation` (optional string) — path to a `notation_<id>.json` file carrying standard musical notation data for this arrangement (see §5.3). When present, the loader surfaces it on `LoadedSloppak.notation_by_id[id]` and the highway WS streams `notation_info` + `notation_measures` messages. The `file:` key may be omitted when `notation:` is present — the loader creates a stub arrangement so the notation file can be the sole data source.
|
||||
|
||||
### 2.2. `stems[]`
|
||||
|
||||
```yaml
|
||||
stems:
|
||||
- id: full
|
||||
file: stems/full.ogg
|
||||
default: true # plays by default when the song opens
|
||||
- id: guitar
|
||||
file: stems/guitar.ogg
|
||||
default: true
|
||||
- id: drums
|
||||
file: stems/drums.ogg
|
||||
default: false
|
||||
```
|
||||
|
||||
- `id` is referenced by the Stems plugin and any other consumer; keep it stable.
|
||||
- `default` accepts `true`/`false`, or strings (`"on"`/`"off"`/`"true"`/etc.) for hand-edited manifests.
|
||||
- A freshly converted sloppak from `lib/sloppak_convert.py` starts with a single `{id: full, file: stems/full.ogg, ...}` entry. After stem-splitting (Demucs), `full.ogg` is removed and the manifest is rewritten with per-instrument entries (`guitar`, `bass`, `drums`, `vocals`, `other`). The format requires only that `stems` is non-empty — there's no specific filename or id that must always be present.
|
||||
|
||||
When stems were produced by an automated separation engine (Demucs), an optional `stem_separation` block records which engine + model produced them. Per [slopsmith#357](https://github.com/got-feedback/feedback/issues/357):
|
||||
|
||||
```yaml
|
||||
stem_separation:
|
||||
engine: demucs # stable engine id; only `demucs` today
|
||||
model: htdemucs_6s # specific model name (htdemucs_6s / htdemucs_ft / htdemucs / mdx_extra / ...)
|
||||
version: 1.0.0 # semver for slopsmith's stem-artifact contract
|
||||
```
|
||||
|
||||
Fields:
|
||||
- `engine` — stable identifier for the separation engine. Currently always `demucs`. New engines (e.g. a hypothetical `spleeter`) would get their own stable id.
|
||||
- `model` — the engine-specific model id used for this split. For Demucs this is the `-n` flag value.
|
||||
- `version` — semver for Slopsmith's stem-artifact contract (independent of upstream Demucs / model versions). Bump per the same semantics #357 defines: patch = metadata-only fixes, minor = backward-compatible additions, major = stem set / packing / post-processing changed and existing splits should be regenerated.
|
||||
|
||||
Omitted for single-stem sloppaks (`stems: [{id: full, ...}]` — no automated separation ran) and for hand-edited / user-recorded stems. The RFC reserves a separate `stem_authoring` sibling block for the hand-edit case; that's deferred to a follow-up.
|
||||
|
||||
A remote Demucs server can use this block as part of a cache key so that changing the model or major version naturally produces a cache miss. Local plugin jobs should preserve this metadata in job state and in any copied/downloaded manifests.
|
||||
|
||||
### 2.3. `lyrics`
|
||||
|
||||
If present, points at a JSON file containing a flat list of syllable objects:
|
||||
|
||||
```json
|
||||
[
|
||||
{"t": 12.34, "d": 0.18, "w": "Hel"},
|
||||
{"t": 12.52, "d": 0.22, "w": "lo-"},
|
||||
{"t": 13.10, "d": 0.30, "w": "world"}
|
||||
]
|
||||
```
|
||||
|
||||
| Field | Meaning |
|
||||
|---|---|
|
||||
| `t` | Time in seconds |
|
||||
| `d` | Duration in seconds |
|
||||
| `w` | Syllable text. Trailing `-` joins to the next syllable as one word; trailing `+` marks the last syllable of a line (renderer wraps after it). Both are suffixes on a real syllable — not standalone entries. See `static/highway.js` for the rendering: `raw.endsWith('+')` flags end-of-line, and `sylText` strips the trailing marker before drawing |
|
||||
|
||||
When lyrics are present, the optional top-level `lyrics_source` key records where they came from. The assembler sets it to `xml` when the lyrics were parsed from the source chart's vocals XML; the WhisperX auto-transcription fallback (`scripts/transcribe_lyrics.py`, or `--auto-lyrics` on the split scripts) sets it to `whisperx`. Hand-edited lyrics should bump it to `user` so UI consumers can render a different badge (or no badge) than for machine-generated lyrics. The key is absent on sloppaks produced before this field existed — readers should treat missing as `xml` for backward compatibility.
|
||||
|
||||
When `lyrics_source` is `whisperx` (or any future automated engine), an optional `lyric_transcription` block records which engine + model produced the file. Shape mirrors the parent `stem_separation` RFC ([slopsmith#357](https://github.com/got-feedback/feedback/issues/357)):
|
||||
|
||||
```yaml
|
||||
lyric_transcription:
|
||||
engine: whisperx # stable engine id
|
||||
model: medium # the WhisperX model size that ran (tiny/base/small/medium/large-v2/large-v3)
|
||||
version: 1.0.0 # semver for slopsmith's lyric-transcription artifact contract
|
||||
```
|
||||
|
||||
Fields:
|
||||
- `engine` — stable identifier for the transcription engine; currently always `whisperx`.
|
||||
- `model` — the engine-specific model id used for this transcription.
|
||||
- `version` — semver for Slopsmith's lyric-transcription artifact contract (independent of upstream Whisper / WhisperX versions). Bump per the same semantics #357 defines for stems: patch = metadata-only fixes, minor = backward-compatible additions, major = output shape changed and existing transcriptions should be regenerated.
|
||||
|
||||
Omitted for authored lyrics (`xml` / `user`). A remote WhisperX server can use this block as part of a cache key the same way #357 envisions for stems — caches should miss whenever any of the three fields change, ensuring stale transcriptions don't get returned after a model bump.
|
||||
|
||||
### 2.4. `vocal_pitch`
|
||||
|
||||
If present, points at a JSON file holding per-syllable pitch data — the karaoke companion to `lyrics`. Consumed by [slopsmith-plugin-lyrics-karaoke](https://github.com/got-feedback/feedback-plugin-lyrics-karaoke) to render karaoke-style note bars over the lyric text. Shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"notes": [
|
||||
{"t": 12.34, "d": 0.40, "midi": 64},
|
||||
{"t": 12.78, "d": 0.55, "midi": 67}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Meaning |
|
||||
|---|---|
|
||||
| `version` | Schema version of this `vocal_pitch.json` file (currently the integer `1`). Bump on a breaking change to the `notes` entry shape. This is *not* the same as the top-level `pitch_extraction.version` block below, which is a semver string used as a cache-key for the extractor engine |
|
||||
| `notes` | List of pitch entries, one per syllable that the extractor could lock onto. `t` + `d` mirror the matching `lyrics.json` entry; `midi` is the MIDI note number (60 = middle C). Syllables the extractor couldn't pitch (silent / sub-confidence) are omitted from this list — it may be shorter than `lyrics.json` |
|
||||
|
||||
When pitch came from an automated engine (the demucs server's `/pitch` endpoint, which runs CREPE), the optional top-level `pitch_extraction` block records which engine + model produced the file. Same shape and semver-string semantics as `stem_separation` / `lyric_transcription` — distinct from the in-file integer `version` field above:
|
||||
|
||||
```yaml
|
||||
pitch_extraction:
|
||||
engine: crepe
|
||||
model: v1
|
||||
version: 1.0.0
|
||||
```
|
||||
|
||||
Omitted for hand-edited pitch tracks. As with the other two automated-artifact blocks, a remote pitch server can use this for cache-key invalidation.
|
||||
|
||||
The sloppak assembler runs pitch extraction automatically when `pitch_extraction.enabled` is set in its config AND a server URL is configured (either `pitch_extraction.server_url` or the shared `demucs_server_url`) AND the sloppak has lyrics + a `stems/vocals.ogg` after the split pass — either because `_maybe_transcribe_lyrics` just produced them via WhisperX OR because they were already on disk (from the source chart's vocals XML, hand-authoring, or an earlier build). Pitch is *not* coupled to `whisperx.enabled` — setting `pitch_extraction.enabled=true` alone (with WhisperX off) is enough to retro-generate pitch over any existing on-disk lyrics. Sloppaks built before this field existed simply don't carry it — readers should treat missing `vocal_pitch` as "no pitch data, fall back to whatever the karaoke plugin's local-extraction path produces (if any)".
|
||||
|
||||
---
|
||||
|
||||
## 3. Arrangement JSON — the wire format
|
||||
|
||||
Arrangement JSON files use the **wire format** produced by `arrangement_to_wire()` — the on-disk representation of a complete arrangement. Slopsmith's `/ws/highway/{filename}` endpoint transports similar data as a sequence of typed messages (`notes`, `chords`, `anchors`, `chord_templates`, `phrases`, …) rather than as one identical top-level JSON object. In practice, the WebSocket stream reuses the same per-object field names where applicable, but it should not be treated as a byte-for-byte match for `arrangements/*.json`.
|
||||
|
||||
The authoritative serializer/deserializer is in [lib/song.py](../lib/song.py):
|
||||
|
||||
- `arrangement_to_wire(arr) → dict` — write
|
||||
- `arrangement_from_wire(dict) → Arrangement` — read
|
||||
|
||||
### 3.1. Top-level shape
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "Lead",
|
||||
"tuning": [0, 0, 0, 0, 0, 0],
|
||||
"capo": 0,
|
||||
"centOffset": 0.0, /* optional, float cents, default 0.0 */
|
||||
"notes": [ /* see 3.2 */ ],
|
||||
"chords": [ /* see 3.3 */ ],
|
||||
"anchors": [ /* see 3.4 */ ],
|
||||
"handshapes": [ /* see 3.5 */ ],
|
||||
"templates": [ /* see 3.6 */ ],
|
||||
"phrases": [ /* optional, see 3.7 */ ],
|
||||
"tones": { /* optional, see 3.9 */ },
|
||||
"beats": [ /* see 3.8, only on first arrangement */ ],
|
||||
"sections": [ /* see 3.8, only on first arrangement */ ]
|
||||
}
|
||||
```
|
||||
|
||||
`beats` and `sections` are **song-level** but live on the first arrangement's JSON for legacy reasons — `lib/sloppak.py` hoists them to the `Song` object on load. If you author multiple arrangements, only put them in one file. **New sloppaks should use `song_timeline.json` instead** (see §2 and §5.3) — when the manifest carries a `song_timeline:` key pointing at a schema-valid file, its beats/sections **replace** whatever the arrangement JSONs loaded (the override is applied after arrangement loading, so a valid `song_timeline.json` always wins). Arrangement-JSON beats/sections remain supported for backward compatibility with all existing sloppaks and are the fallback when the file is absent or invalid.
|
||||
|
||||
### 3.2. Notes
|
||||
|
||||
Field names are short on purpose — these get streamed thousands of times per song. Don't expand them.
|
||||
|
||||
```json
|
||||
{
|
||||
"t": 12.345, // time (s)
|
||||
"s": 2, // string (0 = lowest)
|
||||
"f": 7, // fret (0 = open, 24 = max)
|
||||
"sus": 0.5, // sustain (s, 0 = none)
|
||||
"sl": 9, // pitched slide-to fret (-1 = no slide)
|
||||
"slu": -1, // unpitched slide-to fret (-1 = no slide)
|
||||
"bn": 1.0, // bend amount in semitones
|
||||
"ho": false, // hammer-on
|
||||
"po": false, // pull-off
|
||||
"hm": false, // natural harmonic
|
||||
"hp": false, // pinch harmonic
|
||||
"pm": false, // palm mute
|
||||
"mt": false, // string mute
|
||||
"vb": false, // vibrato
|
||||
"tr": false, // tremolo
|
||||
"ac": false, // accent
|
||||
"tp": false, // tap
|
||||
"ln": false, // link-next (chord linking metadata; renderers may ignore — runtime linking is derived from proximity)
|
||||
"fhm": false, // fret-hand mute
|
||||
"plk": false, // pluck (pop, bass)
|
||||
"slp": false, // slap (bass)
|
||||
"rh": -1, // right-hand fingering (-1 = unset)
|
||||
"pkd": -1, // pick direction (-1 = unset, 0 = down, 1 = up)
|
||||
"ig": false // ignore (chart-author flag — note is rendered but not scored / sequenced)
|
||||
}
|
||||
```
|
||||
|
||||
Default values: numbers → `0` or `-1` (slides / `rh` / `pkd`), bools → `false`. Omit fields equal to their default if you're authoring by hand — the parser fills them in. **Encoders should default-omit the newer technique keys** (`ln`, `fhm`, `plk`, `slp`, `rh`, `pkd`, `ig`) — the highway streams notes thousands of times per song, so trimming the common case keeps the WebSocket payload tight. The pre-existing keys are still emitted unconditionally to preserve the legacy wire contract.
|
||||
|
||||
### 3.3. Chords
|
||||
|
||||
A chord groups note-shaped objects under a single time:
|
||||
|
||||
```json
|
||||
{
|
||||
"t": 30.0,
|
||||
"id": 12, // index into templates[]
|
||||
"hd": false, // high-density flag
|
||||
"notes": [
|
||||
{"s": 0, "f": 3, "sus": 0.0, ...},
|
||||
{"s": 1, "f": 5, "sus": 0.0, ...}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Chord notes use the same field set as standalone notes, **except `t` is omitted** (the chord carries the time). The fingering / shape lookup is `chord.id → templates[id]`.
|
||||
|
||||
### 3.4. Anchors
|
||||
|
||||
Where the fretting hand sits. Drives the highway zoom box.
|
||||
|
||||
```json
|
||||
{"time": 12.0, "fret": 5, "width": 4}
|
||||
```
|
||||
|
||||
### 3.5. Hand shapes
|
||||
|
||||
Spans during which a chord shape is held:
|
||||
|
||||
```json
|
||||
{"chord_id": 12, "start_time": 30.0, "end_time": 31.5, "arp": false}
|
||||
```
|
||||
|
||||
- `chord_id` (`int`, default `0`) — index into `templates[]`; identifies which chord template the span is holding.
|
||||
- `start_time` (`float`, default `0.0`) — start of the span in seconds.
|
||||
- `end_time` (`float`, default `0.0`) — end of the span in seconds.
|
||||
- `arp` (`bool`, default `false`, allowed values `true`/`false`) — whether this hand shape should be treated as an arpeggio span rather than a fully-strummed chord hold.
|
||||
|
||||
### 3.6. Chord templates
|
||||
|
||||
Named shapes referenced by `chord.id` and `handshape.chord_id`:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "Em7",
|
||||
"displayName": "Em7",
|
||||
"arp": false,
|
||||
"fingers": [-1, 2, 1, -1, -1, -1],
|
||||
"frets": [ 0, 2, 2, 0, 0, 0]
|
||||
}
|
||||
```
|
||||
|
||||
- `name` (`string`, default `""`) — canonical template name used by the parser / authoring data.
|
||||
- `displayName` (`string`, default `name`) — label shown in the UI; source XML may use this for display-specific variants such as `-arp`.
|
||||
- `arp` (`bool`, default `false`, allowed values `true`/`false`) — whether the template is flagged as arpeggiated. Parsed from explicit XML attributes (`arpeggio` / `arp`, any common casing) or inferred from `displayName` markers such as `-arp`.
|
||||
- `fingers` (`int[6]`, default `[-1, -1, -1, -1, -1, -1]`) — fretting-hand finger numbers, lowest string first. `-1` = unused string, `0` = open string / no fretting finger, `1..4` = index/middle/ring/pinky.
|
||||
- `frets` (`int[6]`, default `[-1, -1, -1, -1, -1, -1]`) — fret numbers, lowest string first. `-1` = unused string, `0` = open string, positive values = fretted note.
|
||||
|
||||
### 3.7. Phrases (optional, multi-difficulty data)
|
||||
|
||||
Sources that carry per-phrase difficulty ladders (phrase-aware arrangement XML) include this. GP imports and legacy sloppaks omit it:
|
||||
|
||||
```json
|
||||
"phrases": [
|
||||
{
|
||||
"start_time": 0.0,
|
||||
"end_time": 12.5,
|
||||
"max_difficulty": 4,
|
||||
"levels": [
|
||||
{ "difficulty": 0, "notes": [...], "chords": [...], "anchors": [...], "handshapes": [...] },
|
||||
{ "difficulty": 1, "notes": [...], "chords": [...], "anchors": [...], "handshapes": [...] },
|
||||
...
|
||||
]
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
If you're writing a converter that doesn't have multi-difficulty data, **omit the `phrases` key entirely** (don't emit `"phrases": []`). A missing key signals "no ladder, disable the master-difficulty slider"; an empty list is the same in current code but reads ambiguously.
|
||||
|
||||
### 3.8. Beats and sections
|
||||
|
||||
```json
|
||||
"beats": [{"time": 0.5, "measure": 1}, {"time": 1.0, "measure": -1}, ...],
|
||||
"sections": [{"name": "verse", "number": 1, "time": 12.5}, ...]
|
||||
```
|
||||
|
||||
`measure: -1` = sub-beat (not a downbeat). Section `name` follows the usual song-structure conventions (`intro`, `verse`, `chorus`, `bridge`, `solo`, `outro`, …).
|
||||
|
||||
### 3.9. Tones (optional)
|
||||
|
||||
`tones` carries the arrangement's guitar tones — the amp/pedal/cabinet gear and the in-song tone switches. It's populated when the source chart carries tone data (`lib/tones.py`); a sloppak authored from scratch may omit it entirely.
|
||||
|
||||
```json
|
||||
"tones": {
|
||||
"base": "Clean Rhythm",
|
||||
"changes": [
|
||||
{"t": 12.5, "name": "Lead Drive"},
|
||||
{"t": 48.0, "name": "Clean Rhythm"}
|
||||
],
|
||||
"definitions": [
|
||||
{
|
||||
"Name": "Clean Rhythm",
|
||||
"Key": "Tone_A",
|
||||
"GearList": { /* raw gear blocks: Amp, PrePedal1-4, … */ }
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
- `base` (string) — the tone in effect before the first change.
|
||||
- `changes` (list, time-sorted) — `{"t": seconds, "name": str}` tone switches. The highway draws a marker at each. Omit when the arrangement never switches tone.
|
||||
- `definitions` (list) — the **raw tone objects** (`Name`, `Key`, `GearList`), copied verbatim from the source chart's tone manifest. The Tones plugin parses these into the rendered signal chain (it owns the gear-name/image map, so the data is stored unparsed here).
|
||||
|
||||
All three sub-keys are individually optional; an arrangement with none of them simply omits `tones`. Readers that don't know about tones ignore the key (the loader preserves it verbatim).
|
||||
|
||||
---
|
||||
|
||||
## 4. Reading and writing sloppaks programmatically
|
||||
|
||||
### 4.1. Reading (Python, server-side)
|
||||
|
||||
```python
|
||||
from pathlib import Path
|
||||
from sloppak import load_song, load_manifest
|
||||
|
||||
# Quick metadata only (parses manifest, skips arrangement JSONs)
|
||||
manifest = load_manifest(Path("song.sloppak"))
|
||||
|
||||
# Full song load (manifest + all arrangements + lyrics)
|
||||
loaded = load_song("song.sloppak", dlc_root=Path("/dlc"), unpack_cache_root=Path("/cache"))
|
||||
print(loaded.song.title, len(loaded.song.arrangements))
|
||||
print(loaded.stems) # [{"id": "full", "file": "stems/full.ogg", "default": True}]
|
||||
print(loaded.manifest) # raw dict — read your custom keys here
|
||||
```
|
||||
|
||||
### 4.2. Writing (Python, server-side)
|
||||
|
||||
There's no general-purpose writer in `lib/` yet. The current writer lives in [lib/sloppak_convert.py](../lib/sloppak_convert.py) inside the sloppak assembly function — it's the single source of truth for "how a sloppak gets built." If you need to write sloppaks from a new source, copy the structure of that function:
|
||||
|
||||
1. Build a `work_dir/` in temp.
|
||||
2. Write `arrangements/{id}.json` per arrangement using `arrangement_to_wire()`.
|
||||
3. Encode audio to OGG into `stems/`.
|
||||
4. Optionally write `lyrics.json`, `cover.jpg`.
|
||||
5. Compose the `manifest` dict and dump as YAML with `yaml.safe_dump(manifest, sort_keys=False, allow_unicode=True)`.
|
||||
6. Either `shutil.copytree(work_dir, out)` for directory form, or `_zip_dir(work_dir, out)` for zip form.
|
||||
|
||||
Always use `yaml.safe_dump` (not `yaml.dump`) and pass `sort_keys=False` so the human-readable order is preserved.
|
||||
|
||||
### 4.3. Reading (JavaScript, plugin-side)
|
||||
|
||||
Plugins typically don't read the sloppak file directly — they consume the `/ws/highway/{filename}` WebSocket stream (see [`websocket-protocol.md`](websocket-protocol.md) for the message protocol), which produces the same shapes. If you specifically need raw manifest access from the browser, expose it through a custom backend route in your plugin's `routes.py` and fetch it.
|
||||
|
||||
---
|
||||
|
||||
## 5. Extending the format — adding new data
|
||||
|
||||
Sloppak is designed to be extended without breaking older readers. The conventions below come from how `lyrics`, `stems`, and the optional `phrases` ladder were each added.
|
||||
|
||||
### 5.1. The golden rule: **manifest opt-in, file off to the side**
|
||||
|
||||
New data types should follow this pattern:
|
||||
|
||||
1. **Drop a new file** alongside the standard ones (e.g., `drums.json`, `keys.json`, `lighting.json`).
|
||||
2. **Add a manifest key** that *points at* that file (e.g., `drum_tab: drums.json`).
|
||||
3. **Make consumers gate on the manifest key**: if the key is absent, do nothing. Never auto-discover by filename — that breaks the "manifest is the index" rule.
|
||||
|
||||
So a sloppak with drum tabs would look like:
|
||||
|
||||
```yaml
|
||||
# manifest.yaml
|
||||
title: "Song"
|
||||
artist: "Band"
|
||||
duration: 240.0
|
||||
arrangements: [...]
|
||||
stems: [...]
|
||||
drum_tab: drum_tab.json # ← new key
|
||||
```
|
||||
|
||||
```
|
||||
my-song.sloppak/
|
||||
├── manifest.yaml
|
||||
├── arrangements/...
|
||||
├── stems/...
|
||||
└── drum_tab.json # ← new file
|
||||
```
|
||||
|
||||
Older Slopsmith readers ignore the unknown `drum_tab` key (the loader uses `manifest.get("drum_tab")` / unknown keys pass through). Your plugin checks for it and renders accordingly. **Zero coordination needed with core.**
|
||||
|
||||
### 5.2. Naming conventions for new keys and files
|
||||
|
||||
- **Manifest keys**: `snake_case`, descriptive, singular when the value is one thing (`lyrics`, `cover`, `drum_tab`), plural when it's a list (`stems`, `arrangements`).
|
||||
- **File names**: lowercase, hyphenated or underscored, JSON for structured data, OGG for audio, JPG/PNG for images.
|
||||
- **Inside JSON**: short field names for hot-path data that gets streamed thousands of times (`t`, `s`, `f` — see §3.2). Long names are fine for one-off metadata.
|
||||
- **Time fields**: always `t` or `time` (not `start`, not `timestamp`) — and always **seconds as floats**, not ms or ticks. Be consistent with the existing wire format.
|
||||
- **Indexes / IDs**: stable, filesystem-safe, lowercase. Don't reuse a source format's internal numeric IDs unless you have to.
|
||||
|
||||
### 5.3. Worked examples for the kinds of additions you mentioned
|
||||
|
||||
#### Drum tab
|
||||
|
||||
`drum_tab.json` carries per-piece hits authored on top of the song's audio.
|
||||
Implemented end-to-end as of slopsmith#344 (drums-from-scratch): the loader
|
||||
in `lib/sloppak.py` parses it, `lib/drums.py` defines the canonical piece-id
|
||||
vocabulary, and `/ws/highway/{filename}` streams it as `drum_tab` + chunked
|
||||
`drum_hits` messages.
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"name": "Drums",
|
||||
"kit": [
|
||||
{"id": "kick", "name": "Kick"},
|
||||
{"id": "snare", "name": "Snare"},
|
||||
{"id": "hh_closed", "name": "Hi-hat (closed)"},
|
||||
{"id": "hh_open", "name": "Hi-hat (open)"},
|
||||
{"id": "crash_r", "name": "Crash (right)"},
|
||||
{"id": "ride", "name": "Ride"}
|
||||
],
|
||||
"hits": [
|
||||
{"t": 0.500, "p": "kick", "v": 110},
|
||||
{"t": 0.750, "p": "snare", "v": 92},
|
||||
{"t": 0.750, "p": "hh_closed", "v": 70},
|
||||
{"t": 1.000, "p": "snare", "v": 60, "g": true},
|
||||
{"t": 1.250, "p": "snare", "v": 105, "f": true},
|
||||
{"t": 4.000, "p": "crash_r", "v": 120, "k": 0.080}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Manifest:
|
||||
|
||||
```yaml
|
||||
drum_tab: drum_tab.json
|
||||
```
|
||||
|
||||
##### Hit fields
|
||||
|
||||
| key | type | meaning |
|
||||
| --- | --- | --- |
|
||||
| `t` | float seconds | hit time, required, monotonic in `hits[]` |
|
||||
| `p` | string | piece-id from the closed list below; required |
|
||||
| `v` | int 1-127 | velocity (default 100) |
|
||||
| `g` | bool | ghost note (renders smaller / outline-only) |
|
||||
| `f` | bool | flam (renders a small leading ghost glyph 30 ms early) |
|
||||
| `k` | float seconds | cymbal-choke tail duration (renders a fade-out) |
|
||||
|
||||
##### Canonical piece-id vocabulary
|
||||
|
||||
A closed list lives in `lib/drums.py::PIECES`. Open/closed hi-hat are
|
||||
**distinct piece-ids**, not articulation flags — hit detection must reject
|
||||
a closed-hat strike on an open-hat note, which it can only do if the
|
||||
articulation is part of the piece-id.
|
||||
|
||||
| piece-id | category | default GM MIDI | default shape |
|
||||
| --- | --- | --- | --- |
|
||||
| `kick` | kick | 35, 36 | bar (full-width across all non-kick lanes) |
|
||||
| `snare` | drum | 38, 40 | rectangle |
|
||||
| `snare_xstick` | drum | 37 | hatched rectangle |
|
||||
| `tom_hi` | drum | 50, 48 | rectangle |
|
||||
| `tom_mid` | drum | 47, 45 | rectangle |
|
||||
| `tom_low` | drum | 43 | rectangle |
|
||||
| `tom_floor` | drum | 41 | rectangle |
|
||||
| `hh_closed` | cymbal | 42 | filled circle |
|
||||
| `hh_open` | cymbal | 46 | ring (outline) circle |
|
||||
| `hh_pedal` | cymbal | 44 | small circle with × |
|
||||
| `stack` | cymbal | 30 | jagged circle (no GM standard — reuses 30 from extended-percussion range) |
|
||||
| `crash_l` | cymbal | 49 | circle |
|
||||
| `crash_r` | cymbal | 57 | circle |
|
||||
| `splash` | cymbal | 55 | small circle |
|
||||
| `china` | cymbal | 52 | jagged circle |
|
||||
| `ride` | cymbal | 51, 59 | circle |
|
||||
| `ride_bell` | cymbal | 53 | circle with centre dot |
|
||||
| `bell` | cymbal | 80 | circle with centre dot (no GM standard — reuses "Mute Triangle") |
|
||||
|
||||
Unknown piece-ids round-trip through the loader (forward-compat); the
|
||||
client just renders them as a default rectangle.
|
||||
|
||||
##### Wire format
|
||||
|
||||
Streamed as two highway-WS message types:
|
||||
|
||||
```json
|
||||
{ "type": "drum_tab", "version": 1, "name": "Drums",
|
||||
"kit": [{"id": "kick", "name": "Kick"}, ...], "total": 1234 }
|
||||
```
|
||||
|
||||
…followed by one or more chunks of 500 hits:
|
||||
|
||||
```json
|
||||
{ "type": "drum_hits", "data": [{"t": 0.5, "p": "kick", "v": 110}, ...],
|
||||
"total": 1234 }
|
||||
```
|
||||
|
||||
##### Design notes
|
||||
|
||||
- `kit[]` is the legend — fixed metadata, separated from hot-path data.
|
||||
- `hits[]` uses short field names because this list can be thousands long.
|
||||
- `v` defaults to 100; ghost / flam / choke flags are all optional.
|
||||
- Older sloppaks whose drums are encoded as guitar notes (`midi = string*24 + fret`) still play — the drums plugin keeps a legacy decoder that reads the standard `notes` stream and synthesises `drum_hits` from it.
|
||||
|
||||
#### Song timeline (beats and sections as a top-level file)
|
||||
|
||||
`song_timeline.json` moves song-wide beats and sections out of the first
|
||||
arrangement JSON and into a dedicated file. Implemented in `lib/sloppak.py`
|
||||
alongside the notation format: the loader reads the manifest's optional
|
||||
`song_timeline:` key, validates the file, and populates `Song.beats` /
|
||||
`Song.sections` from it, taking priority over any beats/sections embedded
|
||||
in arrangement JSONs.
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"beats": [
|
||||
{"time": 0.500, "measure": 1},
|
||||
{"time": 1.000, "measure": -1},
|
||||
{"time": 1.500, "measure": -1},
|
||||
{"time": 2.000, "measure": 2}
|
||||
],
|
||||
"sections": [
|
||||
{"name": "intro", "number": 1, "time": 0.0},
|
||||
{"name": "verse", "number": 1, "time": 16.0},
|
||||
{"name": "chorus", "number": 1, "time": 32.0}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Manifest:
|
||||
|
||||
```yaml
|
||||
song_timeline: song_timeline.json
|
||||
```
|
||||
|
||||
| Field in `beats[]` | Type | Notes |
|
||||
|---|---|---|
|
||||
| `time` | float seconds | Beat timestamp. Matches the existing arrangement-JSON wire convention |
|
||||
| `measure` | int | 1-based downbeat number. `-1` = sub-beat (not a downbeat) |
|
||||
|
||||
| Field in `sections[]` | Type | Notes |
|
||||
|---|---|---|
|
||||
| `name` | string | song-structure convention: `intro`, `verse`, `chorus`, `bridge`, `solo`, `outro`, … |
|
||||
| `number` | int | Section repeat number |
|
||||
| `time` | float seconds | Section start |
|
||||
|
||||
**Backward compatibility.** Sloppaks without `song_timeline:` continue to
|
||||
work — the loader falls through to reading beats/sections from the first
|
||||
arrangement JSON exactly as before. No migration is needed.
|
||||
|
||||
**New sloppaks** should put beats/sections here and leave arrangement JSONs
|
||||
free of timeline data. This is especially important for notation-only
|
||||
arrangements (see below) where there may be no arrangement JSON at all.
|
||||
|
||||
---
|
||||
|
||||
#### Notation format (standard musical notation per arrangement)
|
||||
|
||||
The notation format promotes keys, piano, violin, and any other
|
||||
staff-notation instrument to first-class status with their own data
|
||||
structure, separate from the guitar wire format. Implemented in
|
||||
`lib/sloppak.py` and `lib/notation.py`; the highway WS streams
|
||||
`notation_info` + `notation_measures` messages when notation data is
|
||||
present for the active arrangement.
|
||||
|
||||
**Architecture: per-arrangement, not song-wide.** Unlike `drum_tab`
|
||||
(one drum track per song, top-level manifest key), notation is
|
||||
per-instrument. A song could carry both `notation_keys.json` and
|
||||
`notation_violin.json`. The manifest key lives on the **arrangement
|
||||
entry**, not at the top level.
|
||||
|
||||
```yaml
|
||||
arrangements:
|
||||
- id: keys
|
||||
name: Keys
|
||||
type: piano
|
||||
notation: notation_keys.json # per-arrangement sub-key
|
||||
# file: is optional when notation: is present
|
||||
```
|
||||
|
||||
```text
|
||||
my-song.sloppak/
|
||||
├── manifest.yaml
|
||||
├── song_timeline.json
|
||||
├── notation_keys.json
|
||||
└── stems/
|
||||
└── full.ogg
|
||||
```
|
||||
|
||||
**`notation_<id>.json` — file schema:**
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"instrument": "piano",
|
||||
"staves": [
|
||||
{"id": "rh", "clef": "G2", "label": "Right Hand"},
|
||||
{"id": "lh", "clef": "F4", "label": "Left Hand"}
|
||||
],
|
||||
"measures": [
|
||||
{
|
||||
"idx": 1,
|
||||
"t": 0.0,
|
||||
"ts": [4, 4],
|
||||
"ks": 0,
|
||||
"tempo": 120.0,
|
||||
"staves": {
|
||||
"rh": {
|
||||
"voices": [{"v": 1, "beats": [
|
||||
{"t": 0.000, "dur": 4, "notes": [{"midi": 64}]},
|
||||
{"t": 0.500, "dur": 4, "notes": [{"midi": 67}]}
|
||||
]}]
|
||||
},
|
||||
"lh": {
|
||||
"voices": [{"v": 1, "beats": [
|
||||
{"t": 0.000, "dur": 1, "notes": [{"midi": 52}, {"midi": 60}]}
|
||||
]}]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Top-level fields:**
|
||||
|
||||
| Field | Type | Notes |
|
||||
|---|---|---|
|
||||
| `version` | int | Always `1`. Bump on breaking schema change |
|
||||
| `instrument` | string | Mirrors arrangement `type`: `piano`, `violin`, `guitar`, etc. Makes the file self-describing |
|
||||
| `rights` | string | Optional copyright / rights text (MusicXML `<rights>`). Omit when absent |
|
||||
| `lyricist` | string | Optional lyricist credit (MusicXML `<creator type="lyricist">`). Omit when absent |
|
||||
| `arranger` | string | Optional arranger credit (MusicXML `<creator type="arranger">`). Omit when absent |
|
||||
| `staves` | list | Static staff definitions. Each has `id` (stable, referenced by `measures[].staves` keys), `clef` (see below), and optional `label` |
|
||||
| `measures` | list | Ordered measure data — the hot path |
|
||||
|
||||
**Clef vocabulary** (defined in `lib/notation.py::CLEFS`):
|
||||
|
||||
| Value | Meaning |
|
||||
|---|---|
|
||||
| `G2` | Treble clef — guitar, violin, flute, piano RH |
|
||||
| `F4` | Bass clef — bass guitar, cello, piano LH |
|
||||
| `C3` | Alto clef — viola |
|
||||
| `C4` | Tenor clef — cello upper register, trombone |
|
||||
| `neutral` | Unpitched / percussion staff |
|
||||
|
||||
**Measure fields:**
|
||||
|
||||
| Field | Type | Notes |
|
||||
|---|---|---|
|
||||
| `idx` | int | 1-based measure number |
|
||||
| `t` | float | Time in seconds at measure downbeat |
|
||||
| `ts` | int[2] | Time signature `[numerator, denominator]`. Omit if unchanged |
|
||||
| `beat_groups` | int[] | Beat grouping for compound and irregular meters, as a list of integers. Each integer is the count of time-signature denominator units in that primary beat group. The sum must equal the time-signature numerator. E.g. 6/8 → `[3, 3]`; 9/8 → `[3, 3, 3]`; 7/8 → `[2, 2, 3]`; 5/8 → `[2, 3]` or `[3, 2]`. Omit for simple meters (2/4, 3/4, 4/4) where grouping is unambiguous. Renderers translate this to their own beam-grouping API at render time — this field is renderer-agnostic. |
|
||||
| `ks` | int | Key signature: semitones from C, −7 to +7 (negative = flats, positive = sharps). Omit if unchanged |
|
||||
| `tempo` | float | BPM. Omit if unchanged |
|
||||
| `pickup` | bool | `true` when this measure is an anacrusis (pickup / upbeat) shorter than the time signature implies (MusicXML `implicit="yes"`). Renderers suppress the measure number and start counting from the next full measure. Omit when false |
|
||||
| `staves` | object | Keyed by staff `id`. Each staff has optional `clef` (omit if unchanged) and `voices` |
|
||||
|
||||
**Beat fields** (inside `staves → voices → beats`):
|
||||
|
||||
| Field | Default | Notes |
|
||||
|---|---|---|
|
||||
| `t` | required | Time in seconds |
|
||||
| `dur` | required | Duration denominator: `1`=whole, `2`=half, `4`=quarter, `8`=eighth, `16`=sixteenth, `32`=thirty-second |
|
||||
| `dot` | omit | Augmentation dots: `1`=dotted, `2`=double-dotted |
|
||||
| `rest` | omit | `true` if this beat is a rest; `notes` is omitted |
|
||||
| `tu` | omit | Tuplet: `[numerator, denominator]`, e.g. `[3, 2]` for triplet |
|
||||
| `beat_pos` | omit | Exact position within the measure as a rational `[numerator, denominator]` pair, where the denominator is the time-signature denominator. E.g. beat 2 in 6/8 (the second dotted quarter) = `[3, 8]`. Avoids floating-point imprecision when deriving beat position from tempo and absolute time. Omit if not set by the importer. Renderers that do not recognise this field derive position from `t` and the tempo map as before. |
|
||||
| `notes` | omit | List of note objects (omit for rests) |
|
||||
| `dyn` | omit | Dynamic: `ppp`, `pp`, `p`, `mp`, `mf`, `f`, `ff`, `fff` |
|
||||
| `slr` | omit | Slur start |
|
||||
| `slre` | omit | Slur end |
|
||||
| `grace` | omit | Grace-note beat, typed: `"a"` = acciaccatura (slashed, steals time from the previous note; MusicXML `<grace slash="yes">`), `"p"` = appoggiatura (unslashed, steals time from the following note; `<grace>`). The beat's `dur` is the grace note's written duration. Vocabulary in `lib/notation.py::GRACE_TYPES` |
|
||||
| `arp` | omit | `true` when the beat's chord is arpeggiated (rolled; MusicXML `<arpeggiate>`) |
|
||||
| `ferm` | omit | `true` when the beat carries a fermata (MusicXML `<fermata>`) |
|
||||
| `spd` / `sph` / `spu` | omit | Sustain pedal: pedal **d**own / **h**old-through-this-beat / **u**p. This is the only pedal encoding — there is deliberately no separate `ped` field. MusicXML mapping: `<pedal type="start">` → `spd`, `<pedal type="change">` → `spu` + `spd` on the same beat (re-pedal), `<pedal type="stop">` → `spu`; beats inside an active pedal span carry `sph` |
|
||||
| Additional beat effects | omit | `cre`, `dec`, `vib`, `vibw`, `fade`, `pm`, `lr`, `slap`, `pop`, `tap`, `su`, `sd`, `rasg`, `golpe`, `wah`, `txt`, `chrd` — all optional, omit when absent |
|
||||
|
||||
**Note fields** (inside `beats → notes`):
|
||||
|
||||
| Field | Default | Notes |
|
||||
|---|---|---|
|
||||
| `midi` | required | MIDI pitch 0–127. Unambiguous — no string/fret/tuning indirection |
|
||||
| `tied` | omit | Tied from the previous beat |
|
||||
| `acc` | omit | Accidental override: `null`/omit = derive from key sig; `0` = force natural (♮); `−2`/`−1`/`1`/`2` = double-flat/flat/sharp/double-sharp |
|
||||
| `stem` | omit | Force stem direction: `"up"` or `"down"` (MusicXML `<stem>`). Omit to let the renderer decide. Vocabulary in `lib/notation.py::STEM_DIRECTIONS` |
|
||||
| Additional note effects | omit | `stc`, `ten`, `ac`, `hac`, `vib`, `vibw`, `dead`, `ghost`, `fng`, `rfng`, `str`, `harm`, `bend`, `slide`, `trill`, `ho`, `po`, `tp`, `barre` — all optional |
|
||||
|
||||
**Wire format.** `song_info` carries `has_notation: bool`. Notation data
|
||||
is streamed as two highway-WS message types after `sections`, before `anchors`:
|
||||
|
||||
```json
|
||||
{"type": "notation_info", "version": 1, "instrument": "piano",
|
||||
"staves": [...], "total": 64}
|
||||
```
|
||||
|
||||
…followed by one or more chunks of 32 measures:
|
||||
|
||||
```json
|
||||
{"type": "notation_measures", "data": [...], "total": 64}
|
||||
```
|
||||
|
||||
`total` is the measure count across **all** chunks. Clients accumulate `data` arrays until the accumulated measure count reaches `total` (an individual chunk's `data.length` says nothing — every full chunk of a multi-chunk stream is shorter than `total`). The `anchors` frame that follows the notation block is a secondary end-of-block signal.
|
||||
|
||||
**`lib/notation.py`** is the vocabulary library: `SCHEMA_VERSION`, `CLEFS`, `DURATIONS`, `validate_notation()`, `measure_to_wire()`, `measures_to_wire()`.
|
||||
|
||||
**Legacy fallback.** Sloppaks that carry keys as guitar wire format (Clone Hero converted content) continue to work — the notation plugin checks for the `notation` key on the arrangement entry. When absent, it falls back to decoding guitar wire format notes via `midi = s * 24 + f`.
|
||||
|
||||
**v1 non-features (accepted limitations).** The following are deliberately
|
||||
out of schema v1; they ship, if ever, as **additive v1.x patches** (new
|
||||
optional fields old consumers ignore — the permissive validator passes
|
||||
unknown fields through by design):
|
||||
|
||||
- Microtonal pitch (anything finer than the ±2 semitone `acc` vocabulary).
|
||||
- Figured bass.
|
||||
- Mid-measure key-signature, time-signature, or clef changes (all three are
|
||||
measure-granular in v1).
|
||||
- Ottava lines (`ott`), repeat/volta barline semantics (`barline`),
|
||||
ornaments beyond trills (mordents, turns), tremolo (`trem`), and notated
|
||||
glissando lines (`glis`).
|
||||
|
||||
Importers MUST drop these source features with a logged warning rather than
|
||||
approximate them into wrong notation; renderers MUST NOT invent semantics
|
||||
for field names from this list before a v1.x patch specifies them.
|
||||
|
||||
---
|
||||
|
||||
#### Key / scale annotations (for theory-aware visualizations)
|
||||
|
||||
`keys.json` mirroring the `sections[]` shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"events": [
|
||||
{"t": 0.0, "key": "Em", "scale": "natural_minor"},
|
||||
{"t": 64.5, "key": "G", "scale": "major"},
|
||||
{"t": 142.0, "key": "Em", "scale": "natural_minor"}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Manifest:
|
||||
|
||||
```yaml
|
||||
keys: keys.json
|
||||
```
|
||||
|
||||
Each entry implicitly applies until the next event. Same model as `sections[]`.
|
||||
|
||||
#### Vocal pitch contour (a different shape, a different key)
|
||||
|
||||
The canonical `vocal_pitch` key + file (defined in §2.4) is the
|
||||
per-syllable note format consumed by the karaoke plugin —
|
||||
`{version: 1, notes: [{t, d, midi}]}`. If you want to ship a finer-
|
||||
grained pitch *contour* (one sample every 20 ms, Hz instead of MIDI),
|
||||
that's a different shape and should ride on its own manifest key so
|
||||
the two don't collide:
|
||||
|
||||
```yaml
|
||||
vocal_pitch_contour: vocal_pitch_contour.json
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"samples": [
|
||||
{"t": 0.000, "hz": 220.5},
|
||||
{"t": 0.020, "hz": 222.1}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Per §5.1, manifest keys are cheap — reach for a new one when the
|
||||
schema diverges, don't overload an existing key with a second shape.
|
||||
|
||||
### 5.4. `version` field — always include it
|
||||
|
||||
Every new file should have `"version": 1` at the top. It's free insurance: when you change the schema later, `version: 2` consumers can branch on it. Old consumers without that branch ignore the file (or fall back gracefully).
|
||||
|
||||
### 5.5. Stay backward-compatible
|
||||
|
||||
If you change a field that already shipped:
|
||||
|
||||
- **Adding fields** is always safe (older readers ignore them).
|
||||
- **Removing fields** breaks older readers. Don't.
|
||||
- **Repurposing fields** (changing meaning or units) is the worst — bump `version` and branch.
|
||||
|
||||
If you're tempted to remove or repurpose: leave the old field, add a new one, and sunset the old one over a release or two.
|
||||
|
||||
### 5.6. When to put data inside an arrangement vs. its own file
|
||||
|
||||
- **Inside arrangement JSON** (`arrangements/lead.json`):
|
||||
- Data that is *per-arrangement* and *per-instrument* (notes, chords, anchors, hand-shapes — guitar specifics).
|
||||
- Data that meaningfully differs between Lead and Rhythm versions of the same song.
|
||||
- **Its own file** (and pointed-at via manifest key):
|
||||
- Data that is *song-wide* (lyrics, beats, sections, tempo map, drum tab, lighting, key/scale changes).
|
||||
- Data that may be authored or generated independently of the playable arrangement (a stem split, an AI-generated drum tab).
|
||||
|
||||
Beats and sections historically lived inside the first arrangement JSON (early arrangement XML put them there). The `song_timeline.json` file (see §5.3) is the correct home for new sloppaks — the loader reads it first and it takes priority. New song-wide data should always be its own file.
|
||||
|
||||
### 5.7. Don't break the manifest contract
|
||||
|
||||
A few things that should *not* end up in `manifest.yaml`:
|
||||
|
||||
- **Per-machine settings** (DMX universes, IPs, output device picks) — those go in `${CONFIG_DIR}/...json`, not the sloppak.
|
||||
- **UI state** (last zoom level, panel sizes) — `localStorage` only.
|
||||
- **User progress / play counts** — Slopsmith stores these in its metadata DB, not in the sloppak.
|
||||
|
||||
The sloppak holds **the song's authored data**. Anything that varies by user or by machine is out.
|
||||
|
||||
---
|
||||
|
||||
## 6. Quick reference — file types you'll touch
|
||||
|
||||
| File | Format | Schema lives in | Authority |
|
||||
|---|---|---|---|
|
||||
| `manifest.yaml` | YAML | `lib/sloppak.py` (`load_manifest`, `extract_meta`) | This doc + the loader |
|
||||
| `arrangements/*.json` | JSON | `lib/song.py` (`arrangement_to_wire`, `arrangement_from_wire`) | The wire-format functions |
|
||||
| `lyrics.json` | JSON (flat list) | `lib/sloppak.py` (passed through to `Song.lyrics`) | This doc §2.3 |
|
||||
| `song_timeline.json` | JSON | `lib/sloppak.py` (loader) | This doc §5.3 |
|
||||
| `notation_<id>.json` | JSON | `lib/notation.py` (`validate_notation`, `measures_to_wire`) | This doc §5.3 |
|
||||
| `stems/*.ogg` | OGG Vorbis | — | Convention: `q:a 5` for size/quality balance |
|
||||
| `cover.jpg` | JPEG | — | Convention: square, 500–1500 px on a side |
|
||||
| Your new file | JSON (preferred) | Your plugin's spec doc | You |
|
||||
|
||||
---
|
||||
|
||||
## 7. Testing your extension
|
||||
|
||||
If you add a new file type or manifest key:
|
||||
|
||||
1. **Round-trip test**: write a sample, load it, write it back, compare. Add to `tests/test_sloppak.py`.
|
||||
2. **Backward-compat test**: load a sloppak that *doesn't* have your new key — your code must not crash, and the song must still play.
|
||||
3. **Hand-edit test**: open the directory form in a text editor, change a field by hand, reload Slopsmith. The format is meant to be hand-editable; your additions should preserve that.
|
||||
4. **Both forms**: test with both the directory form and the zipped form. The unpack cache is invalidated based on mtime and size, so you can repackage and reload without restarting the server.
|
||||
|
||||
The full pytest suite (`pytest`) must stay green before any PR.
|
||||
|
||||
---
|
||||
|
||||
## 8. Where to look in the code
|
||||
|
||||
| For… | Read |
|
||||
|---|---|
|
||||
| Format detection, source resolution, zip unpacking | [lib/sloppak.py](../lib/sloppak.py) |
|
||||
| Data classes (`Note`, `Chord`, `Arrangement`, `Song`, `Phrase`) | [lib/song.py](../lib/song.py) |
|
||||
| Wire-format helpers (`*_to_wire` / `*_from_wire`) | [lib/song.py](../lib/song.py) |
|
||||
| The reference pack writer (assembly pipeline) | [lib/sloppak_convert.py](../lib/sloppak_convert.py) |
|
||||
| Drum-tab vocabulary and wire helpers | [lib/drums.py](../lib/drums.py) |
|
||||
| The reference sloppak writer | [lib/sloppak_convert.py](../lib/sloppak_convert.py) |
|
||||
| Drum tab vocabulary and wire helpers | [lib/drums.py](../lib/drums.py) |
|
||||
| Notation vocabulary and wire helpers | [lib/notation.py](../lib/notation.py) |
|
||||
| Live streaming over WebSocket (consumes the same shapes) | `server.py` (`/ws/highway/{filename}`) |
|
||||
| The plugin system (where new visualization consumers go) | [CLAUDE.md](../CLAUDE.md) |
|
||||
| The plugin system (where new viz consumers go) | [docs/PLUGIN_AUTHORING.md](PLUGIN_AUTHORING.md) + [docs/plugin-visualization-contracts.md](plugin-visualization-contracts.md) |
|
||||
| Tests | [tests/test_sloppak.py](../tests/test_sloppak.py), [tests/test_sloppak_convert.py](../tests/test_sloppak_convert.py) |
|
||||
|
||||
> **Note on older section references.** Some inline code comments in this repo cite section
|
||||
> numbers from the previous version of this document (e.g. "sloppak-spec §5.3"). The external spec
|
||||
> renumbered its sections, so those citations are approximate — find the topic by name in the
|
||||
> [feedpak spec](https://github.com/got-feedback/feedpak-spec/blob/main/spec/feedpak-v1.md)
|
||||
> rather than by the old number.
|
||||
|
||||
@@ -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
|
||||
|
||||
```text
|
||||
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 whose length depends on the source arrangement (typically 6 for guitar, 4 for bass, but extended-range GP imports can be 7/8 for guitar or 5/6 for bass — use `highway.getStringCount()` for the authoritative count). |
|
||||
| `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 (legacy archive / 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
|
||||
@@ -1,53 +0,0 @@
|
||||
# Working-tuning — on-device test checklist
|
||||
|
||||
The working-tuning series (PRs 1–9) ships with headless unit tests for every state
|
||||
machine (`tests/js/working_tuning*.test.js`, `tests/js/tuner_auto_open.test.js`). The
|
||||
items below are the parts that **cannot** be covered headlessly — they need a real mic,
|
||||
a real instrument, and (for the ASIO item) a specific audio backend. Run these on a
|
||||
build before shipping the feature to users.
|
||||
|
||||
Prereq: enable the opt-in in **Tuner → settings → "Auto-open on tuning change"** (it's
|
||||
off by default). Have a guitar (and a bass, for the per-instrument checks) on hand.
|
||||
|
||||
## 1. Auto-open + gate ("tune before you play")
|
||||
- [ ] Load a song whose tuning differs from your instrument's current tuning → the tuner
|
||||
**auto-opens** and playback **waits** (does not start underneath it).
|
||||
- [ ] Load a song already covered by your tuning → **no** auto-open, playback starts.
|
||||
- [ ] **Skip** ("I've tuned") → playback starts, and the tuner badge stops flagging this
|
||||
song's tuning (a working tuning was recorded).
|
||||
- [ ] **Back to library** / **Esc** → leaves the song, records **nothing** (re-enter the
|
||||
same song → it still prompts).
|
||||
- [ ] Take **longer than 12 s** to tune with the panel open → playback does **not** start
|
||||
underneath you (the fail-open backstop was settled once the panel opened).
|
||||
- [ ] Hit **Play** manually while the panel is open → Play wins; no double-start.
|
||||
|
||||
## 2. Both-directions retune prompt
|
||||
- [ ] From standard, load a Drop-C# song → prompted **down** (E→C#). Tune down, Skip.
|
||||
- [ ] Now load a standard song → prompted **back up** (C#→E). (Pre-series, this direction
|
||||
was silent.)
|
||||
- [ ] Switch guitar↔bass in the instrument card → each instrument remembers its **own**
|
||||
working tuning; the card label follows the selection (dim = home, amber = retuned).
|
||||
|
||||
## 3. Mic-verify (assumed → verified)
|
||||
- [ ] With a selected (non-free) tuning, tap **Verify tuning** and play each string in tune.
|
||||
Each string needs ~8 stable in-tune frames (±6 ¢); the per-string progress advances.
|
||||
- [ ] Play a string **out of tune** → it never completes; drifting out mid-streak resets it.
|
||||
- [ ] Complete all strings → the instrument card's provenance glyph flips to the **filled**
|
||||
(verified) diamond, and the recorded working tuning carries the tuning you verified
|
||||
(not a stale one).
|
||||
- [ ] Load the **next** song → the verified state **decays to assumed** (per-session only).
|
||||
- [ ] Verify against a **manually-selected** tuning (tuner opened off a song) → the stamped
|
||||
offsets match that tuning, not the last song's.
|
||||
|
||||
## 4. Mic contention with note-detection (the ASIO / exclusive-mode risk)
|
||||
This is the item flagged in the design charrette: the tuner's mic capture must not starve
|
||||
note_detect's scoring input.
|
||||
- [ ] Desktop, **ASIO / WASAPI-exclusive** device: auto-open the tuner mid-song, tune, Skip
|
||||
→ scoring resumes cleanly; no dropped input, no device-in-use error, no crash.
|
||||
- [ ] Shared/`auto` device: same flow → both the tuner and scoring read the mic without a
|
||||
stall.
|
||||
- [ ] Leave the tuner's background badge audio running + start a scored song → note_detect
|
||||
still scores (the badge auto-start doesn't hold the device exclusively).
|
||||
|
||||
Log the build hash and OS/audio backend with results; file any failure against the
|
||||
working-tuning series.
|
||||
@@ -1,66 +0,0 @@
|
||||
// Flat ESLint config — MAINTAINER / CI ONLY. Never runs on the serve or Docker
|
||||
// path (constitution Principle I: dev-only tooling is exempt, same category as
|
||||
// scripts/build-tailwind.sh). It enforces the module-migration guardrails:
|
||||
//
|
||||
// * max-lines — the 1,500-line size norm, as a WARNING ratchet. Legacy
|
||||
// monoliths warn (the "this is over the norm, split it" signal) and shrink
|
||||
// as the refactor lands; warnings do not fail CI. Genuinely-large files are
|
||||
// exempted below, mirroring the signed register in docs/size-exemptions.md.
|
||||
// * import-x/no-unresolved + no-cycle — module hygiene, scoped to the real
|
||||
// ES-module graphs the refactor produces (a plugin's src/ tree, .mjs
|
||||
// tests). no-unresolved (a HARD error) catches broken import paths;
|
||||
// no-cycle enforces the downward-only layering rule. Core's classic scripts
|
||||
// have no import graph, so both are dormant today and become live gates the
|
||||
// moment module code appears — validated against the first real module
|
||||
// plugin (R1 pilot).
|
||||
|
||||
const importX = require('eslint-plugin-import-x');
|
||||
|
||||
// Per-file size ceilings — a mirror of docs/size-exemptions.md (canonical).
|
||||
// Keep in sync; each entry corresponds to a signed row in the register.
|
||||
const SIZE_EXEMPTIONS = [
|
||||
{ files: ['**/static/capabilities.js'], max: 1600 },
|
||||
{ files: ['**/plugins/capability_inspector/screen.js'], max: 100000 },
|
||||
{ files: ['**/plugins/folder_library/screen.js'], max: 100000 },
|
||||
];
|
||||
|
||||
const sizeRule = (max) => ['warn', { max, skipBlankLines: false, skipComments: false }];
|
||||
|
||||
module.exports = [
|
||||
{
|
||||
ignores: [
|
||||
'node_modules/**',
|
||||
'static/vendor/**',
|
||||
'plugins/**/assets/vendor/**',
|
||||
'**/*.min.js',
|
||||
'static/tailwind.min.css',
|
||||
],
|
||||
},
|
||||
// Size norm across all first-party JS. Classic scripts are parsed as
|
||||
// scripts (no import/export); module files get their own block below.
|
||||
{
|
||||
files: ['**/*.js', '**/*.cjs'],
|
||||
languageOptions: { ecmaVersion: 'latest', sourceType: 'script' },
|
||||
rules: { 'max-lines': sizeRule(1500) },
|
||||
},
|
||||
// ES-module graphs (a plugin's src/ tree, .mjs tests): module parsing + the
|
||||
// acyclic-imports hard gate + the size norm. A migrated bundled plugin's
|
||||
// entry `import './src/main.js'` screen.js must parse as a module — add its
|
||||
// glob here in that plugin's migration PR (classic screen.js stays a script).
|
||||
{
|
||||
files: ['**/src/**/*.js', '**/*.mjs'],
|
||||
languageOptions: { ecmaVersion: 'latest', sourceType: 'module' },
|
||||
plugins: { 'import-x': importX },
|
||||
// v4 flat-config resolver (resolver-next + createNodeResolver). Without
|
||||
// it the import rules silently skip imports they can't resolve.
|
||||
settings: { 'import-x/resolver-next': [importX.createNodeResolver()] },
|
||||
rules: {
|
||||
'max-lines': sizeRule(1500),
|
||||
'import-x/no-unresolved': 'error',
|
||||
'import-x/no-cycle': 'error',
|
||||
},
|
||||
},
|
||||
// Signed size exemptions (docs/size-exemptions.md) — raise the ceiling so
|
||||
// registered files don't warn below it.
|
||||
...SIZE_EXEMPTIONS.map(({ files, max }) => ({ files, rules: { 'max-lines': sizeRule(max) } })),
|
||||
];
|
||||
@@ -1,152 +0,0 @@
|
||||
"""AcoustID audio-fingerprint identification for MusicBrainz enrichment.
|
||||
|
||||
A flat MusicBrainz *text* search ties every take of a song at the same score —
|
||||
studio, a dozen live bootlegs, and every compilation — so "AC/DC — Highway to
|
||||
Hell" returns junk (see lib/mb_match.py's canonical re-ranking, which mitigates
|
||||
it). The definitive fix is content-based: fingerprint the actual audio with
|
||||
Chromaprint (`fpcalc`) and look it up on AcoustID, which maps the fingerprint
|
||||
straight to the *exact* MusicBrainz recording — the same approach Lidarr uses.
|
||||
|
||||
This module is the PURE half (no network, no subprocess): response parsing +
|
||||
config gating, so it is unit-testable in isolation. server.py owns the `fpcalc`
|
||||
subprocess and the throttled HTTP GET to api.acoustid.org.
|
||||
|
||||
Operational requirements (both optional — absent ⇒ this path is a graceful
|
||||
no-op and the text matcher still runs):
|
||||
* `fpcalc` (Chromaprint) on PATH or at $FPCALC — generates the fingerprint.
|
||||
* an AcoustID application API key in $ACOUSTID_API_KEY — free from
|
||||
https://acoustid.org/new-application ; AcoustID etiquette limits to ~3 req/s.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
ACOUSTID_API_ROOT = "https://api.acoustid.org/v2"
|
||||
|
||||
# The `meta` fields we ask AcoustID to return so a hit resolves to displayable
|
||||
# metadata without a second MusicBrainz round-trip. SPACE-separated, not
|
||||
# `+`-joined: a literal `+` in the value gets percent-encoded to %2B, which
|
||||
# AcoustID does NOT split into flags — it then attaches no recording metadata
|
||||
# and every hit comes back empty (verified: `+` → 0 recordings, space → 28).
|
||||
# `releases` is what carries the per-release DATE (nested under each
|
||||
# releasegroup), which we need to pick the earliest original album + fill year.
|
||||
LOOKUP_META = "recordings releasegroups releases compress"
|
||||
|
||||
# Mirror mb_match._SECONDARY_SKIP: release-group secondary types that mark a
|
||||
# non-canonical (live/comp/remix) release, so we can flag the studio take.
|
||||
_SECONDARY_SKIP = {
|
||||
"live", "compilation", "remix", "dj-mix", "mixtape/street",
|
||||
"demo", "interview", "audiobook", "spokenword",
|
||||
}
|
||||
|
||||
|
||||
def api_key(explicit: str | None = None) -> str:
|
||||
"""The AcoustID application API key: an explicit value (e.g. a host setting)
|
||||
wins, else $ACOUSTID_API_KEY, else "" (⇒ fingerprinting disabled)."""
|
||||
return (explicit or os.environ.get("ACOUSTID_API_KEY") or "").strip()
|
||||
|
||||
|
||||
def is_configured(explicit_key: str | None = None) -> bool:
|
||||
"""True when an API key is available. `fpcalc` presence is checked by
|
||||
server.py (it owns the binary lookup); both are required to actually run."""
|
||||
return bool(api_key(explicit_key))
|
||||
|
||||
|
||||
def _rg_is_studio(rg: dict) -> bool:
|
||||
if str(rg.get("type", "")).lower() != "album":
|
||||
return False
|
||||
secs = {str(s).lower() for s in (rg.get("secondarytypes") or [])}
|
||||
return not (secs & _SECONDARY_SKIP)
|
||||
|
||||
|
||||
def _rg_earliest_year(rg: dict) -> "int | None":
|
||||
"""Earliest release YEAR in a release-group (min over its nested releases'
|
||||
dates). None when no release carries a date. This is what separates the
|
||||
original pressing from later reissues/comps sharing the same group."""
|
||||
years = []
|
||||
for rel in (rg.get("releases") or []):
|
||||
d = (rel or {}).get("date")
|
||||
if isinstance(d, dict) and d.get("year"):
|
||||
try:
|
||||
years.append(int(d["year"]))
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
return min(years) if years else None
|
||||
|
||||
|
||||
def _best_group(recording: dict) -> dict:
|
||||
"""Pick the display album: a clean studio Album first, and among those the
|
||||
EARLIEST-released one — the original, not a later reissue or a compilation
|
||||
that happens to be typed 'Album' (e.g. a soundtrack). This is what pulls
|
||||
"Machine Head" ahead of a later comp for "Smoke on the Water". Falls back to
|
||||
the first group when nothing is a studio album or nothing carries a date."""
|
||||
groups = [g for g in (recording.get("releasegroups") or []) if isinstance(g, dict)]
|
||||
if not groups:
|
||||
return {}
|
||||
|
||||
def sort_key(g):
|
||||
yr = _rg_earliest_year(g)
|
||||
# studio (0) before non-studio (1); then earliest year (undated last).
|
||||
return (0 if _rg_is_studio(g) else 1, yr if yr is not None else 9999)
|
||||
|
||||
return sorted(groups, key=sort_key)[0]
|
||||
|
||||
|
||||
def _first_artist(recording: dict) -> str:
|
||||
for a in (recording.get("artists") or []):
|
||||
if isinstance(a, dict) and a.get("name"):
|
||||
return str(a["name"])
|
||||
return ""
|
||||
|
||||
|
||||
def parse_lookup_response(body: dict) -> list[dict]:
|
||||
"""Normalize an AcoustID /v2/lookup response into the same flat candidate
|
||||
shape as mb_match (recording_id / title / artist / album / year / duration /
|
||||
studio / mb_score / score), so the review UI and the editor's Match popup
|
||||
render fingerprint hits and text hits identically. `mb_score` carries the
|
||||
AcoustID confidence (0-100) — a fingerprint hit is high-signal by nature."""
|
||||
if not isinstance(body, dict) or body.get("status") != "ok":
|
||||
return []
|
||||
out: list[dict] = []
|
||||
seen: set[str] = set()
|
||||
for result in (body.get("results") or []):
|
||||
if not isinstance(result, dict):
|
||||
continue
|
||||
try:
|
||||
score = float(result.get("score") or 0.0)
|
||||
except (TypeError, ValueError):
|
||||
score = 0.0
|
||||
for rec in (result.get("recordings") or []):
|
||||
if not isinstance(rec, dict) or not rec.get("id"):
|
||||
continue
|
||||
rid = str(rec["id"])
|
||||
if rid in seen:
|
||||
continue
|
||||
seen.add(rid)
|
||||
rg = _best_group(rec)
|
||||
_yr = _rg_earliest_year(rg)
|
||||
year = str(_yr) if _yr else ""
|
||||
dur = rec.get("duration")
|
||||
try:
|
||||
duration = int(round(float(dur))) if dur else None
|
||||
except (TypeError, ValueError):
|
||||
duration = None
|
||||
out.append({
|
||||
"recording_id": rid,
|
||||
"title": str(rec.get("title", "") or ""),
|
||||
"artist": _first_artist(rec),
|
||||
"album": str(rg.get("title", "") or ""),
|
||||
"year": year,
|
||||
"duration": duration,
|
||||
"isrc": "",
|
||||
"genres": [],
|
||||
"studio": _rg_is_studio(rg),
|
||||
"acoustid_score": round(score, 4),
|
||||
# Fingerprint hits are content-verified, not text-guessed — carry
|
||||
# the AcoustID confidence as the display score band.
|
||||
"mb_score": int(round(score * 100)),
|
||||
"score": round(score, 4),
|
||||
"source": "acoustid",
|
||||
})
|
||||
# Best AcoustID confidence first; studio take breaks ties.
|
||||
out.sort(key=lambda c: (c["acoustid_score"], 1 if c["studio"] else 0), reverse=True)
|
||||
return out
|
||||
@@ -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)
|
||||
+3
-3
@@ -7,7 +7,7 @@ import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
log = logging.getLogger("feedBack.lib.audio")
|
||||
log = logging.getLogger("slopsmith.lib.audio")
|
||||
|
||||
# Maximum length of any single decoder-error fragment that we surface to
|
||||
# the client. ffmpeg can emit multi-kB build-configuration / version
|
||||
@@ -123,7 +123,7 @@ def _scrub_quoted_match(match: re.Match) -> str:
|
||||
|
||||
def _bundled_bin_dir() -> Path | None:
|
||||
"""Resolve the desktop bundle's resources/bin/ directory if we're
|
||||
running inside one. Layout: resources/feedBack/lib/audio.py →
|
||||
running inside one. Layout: resources/slopsmith/lib/audio.py →
|
||||
resources/bin/. Gate on vgmstream-cli's presence so we don't
|
||||
misidentify random parent dirs (e.g. Docker's `/bin`, dev
|
||||
layouts where parents[2] resolves to the repo root) — vgmstream-cli
|
||||
@@ -284,7 +284,7 @@ def _scrub_paths(text: str, *paths: str) -> str:
|
||||
"""Replace absolute filesystem paths in `text` with their basenames.
|
||||
|
||||
Decoder error strings get joined into the RuntimeError that
|
||||
`convert_wem` raises, and feedBack surfaces that text in the
|
||||
`convert_wem` raises, and slopsmith surfaces that text in the
|
||||
browser as `audio_error`. Leaking install / user / DLC paths to the
|
||||
client is a needless info disclosure, so before any decoder error
|
||||
leaves this module we strip absolute paths down to their final
|
||||
|
||||
@@ -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
|
||||
+24
-24
@@ -130,7 +130,7 @@ ENV_ALLOWLIST = (
|
||||
"LOG_LEVEL",
|
||||
"LOG_FORMAT",
|
||||
"LOG_FILE",
|
||||
"FEEDBACK_RUNTIME",
|
||||
"SLOPSMITH_RUNTIME",
|
||||
"PORT",
|
||||
"HOST",
|
||||
"TZ",
|
||||
@@ -154,13 +154,13 @@ def _safe_json_dumps(obj) -> str:
|
||||
return json.dumps({"error": "unserializable payload"}, indent=2)
|
||||
|
||||
|
||||
def _system_version(feedBack_version: str, redactor=None) -> dict:
|
||||
def _system_version(slopsmith_version: str, redactor=None) -> dict:
|
||||
executable = sys.executable
|
||||
if redactor is not None:
|
||||
executable = redactor.redact_text(executable)
|
||||
return {
|
||||
"schema": "system.version.v1",
|
||||
"feedBack_version": feedBack_version,
|
||||
"slopsmith_version": slopsmith_version,
|
||||
"python": {
|
||||
"version": platform.python_version(),
|
||||
"implementation": platform.python_implementation(),
|
||||
@@ -233,7 +233,7 @@ def _summarize_payload(path: str, parsed) -> dict | None:
|
||||
py = parsed.get("python") or {}
|
||||
os_ = parsed.get("os") or {}
|
||||
return {
|
||||
"feedBack": parsed.get("feedBack_version"),
|
||||
"slopsmith": parsed.get("slopsmith_version"),
|
||||
"python": py.get("version"),
|
||||
"os": os_.get("system"),
|
||||
}
|
||||
@@ -338,7 +338,7 @@ def _git_info(plugin_dir: Path) -> dict | None:
|
||||
"""Return git short SHA + remote URL for a plugin checkout.
|
||||
|
||||
Pure-Python — reads `.git/HEAD` and `.git/config` directly so this
|
||||
works in containers without the `git` binary installed (feedBack's
|
||||
works in containers without the `git` binary installed (slopsmith's
|
||||
runtime image is minimal). Plugins are gitlinks (see CLAUDE.md);
|
||||
the SHA is the most reliable "what build is this" identifier.
|
||||
|
||||
@@ -393,7 +393,7 @@ def _system_plugins(loaded_plugins: list[dict], plugins_root: "Path | list[Path]
|
||||
show up in the bundle.
|
||||
|
||||
*plugins_root* accepts a single Path, a list of Paths (to cover both
|
||||
the built-in ``plugins/`` directory and ``FEEDBACK_PLUGINS_DIR``), or
|
||||
the built-in ``plugins/`` directory and ``SLOPSMITH_PLUGINS_DIR``), or
|
||||
None to skip orphan detection entirely.
|
||||
|
||||
Plugin directories not in ``LOADED_PLUGINS`` appear in ``orphans``.
|
||||
@@ -484,7 +484,7 @@ def _system_plugins(loaded_plugins: list[dict], plugins_root: "Path | list[Path]
|
||||
# plugin failed to load — common when requirements.txt installs
|
||||
# fail in a read-only container). Accepts a single Path, a list of
|
||||
# Paths (to cover both the built-in plugins/ dir and
|
||||
# FEEDBACK_PLUGINS_DIR), or None.
|
||||
# SLOPSMITH_PLUGINS_DIR), or None.
|
||||
orphans: list[dict] = []
|
||||
if plugins_root is not None:
|
||||
roots: list[Path] = plugins_root if isinstance(plugins_root, list) else [plugins_root]
|
||||
@@ -840,11 +840,11 @@ def _redact_value(value: object, redactor: "Redactor") -> object:
|
||||
|
||||
|
||||
README_TEMPLATE = """\
|
||||
FeedBack Diagnostics Bundle
|
||||
Slopsmith Diagnostics Bundle
|
||||
============================
|
||||
|
||||
Generated: {exported_at}
|
||||
FeedBack: {feedBack_version}
|
||||
Slopsmith: {slopsmith_version}
|
||||
Runtime: {runtime_kind}
|
||||
Redacted: {redacted}
|
||||
|
||||
@@ -1005,7 +1005,7 @@ def _build_files_meta(files: dict[str, bytes]) -> list[dict]:
|
||||
|
||||
def _assemble_files_and_notes(
|
||||
*,
|
||||
feedBack_version: str,
|
||||
slopsmith_version: str,
|
||||
config_dir: Path,
|
||||
dlc_dir: Path | None,
|
||||
log_file: Path | None,
|
||||
@@ -1038,7 +1038,7 @@ def _assemble_files_and_notes(
|
||||
if include.get("system", True):
|
||||
# Pass the redactor so python.executable is redacted when paths
|
||||
# should be hidden (it often lives under $HOME or a per-user venv).
|
||||
ver_payload = _safe_json_dumps(_system_version(feedBack_version, redactor=redactor)).encode("utf-8")
|
||||
ver_payload = _safe_json_dumps(_system_version(slopsmith_version, redactor=redactor)).encode("utf-8")
|
||||
files["system/version.json"] = ver_payload
|
||||
env_payload = _safe_json_dumps(_system_env(redactor=redactor)).encode("utf-8")
|
||||
files["system/env.json"] = env_payload
|
||||
@@ -1125,7 +1125,7 @@ def _assemble_files_and_notes(
|
||||
files.update(plugin_files)
|
||||
|
||||
# Per-plugin client-side contributions from
|
||||
# window.feedBack.diagnostics.contribute(plugin_id, payload).
|
||||
# window.slopsmith.diagnostics.contribute(plugin_id, payload).
|
||||
# Gated on the same "plugins" toggle as backend plugin diagnostics.
|
||||
if include.get("plugins", True) and client_contributions and isinstance(client_contributions, dict):
|
||||
# Build the set of actually-loaded plugin IDs so we only accept
|
||||
@@ -1160,7 +1160,7 @@ def _assemble_files_and_notes(
|
||||
|
||||
def _make_manifest(
|
||||
*,
|
||||
feedBack_version: str,
|
||||
slopsmith_version: str,
|
||||
runtime_kind: str,
|
||||
redact: bool,
|
||||
files: dict[str, bytes],
|
||||
@@ -1170,7 +1170,7 @@ def _make_manifest(
|
||||
return {
|
||||
"schema": BUNDLE_SCHEMA,
|
||||
"exported_at": _now_iso(),
|
||||
"feedBack_version": feedBack_version,
|
||||
"slopsmith_version": slopsmith_version,
|
||||
"runtime": runtime_kind,
|
||||
"redacted": redact,
|
||||
"files": _build_files_meta(files),
|
||||
@@ -1181,7 +1181,7 @@ def _make_manifest(
|
||||
|
||||
def build_bundle(
|
||||
*,
|
||||
feedBack_version: str,
|
||||
slopsmith_version: str,
|
||||
config_dir: Path,
|
||||
dlc_dir: Path | None,
|
||||
log_file: Path | None,
|
||||
@@ -1198,7 +1198,7 @@ def build_bundle(
|
||||
) -> tuple[bytes, str, dict]:
|
||||
"""Returns (zip_bytes, filename, manifest_dict)."""
|
||||
files, notes, runtime_kind, redactor = _assemble_files_and_notes(
|
||||
feedBack_version=feedBack_version,
|
||||
slopsmith_version=slopsmith_version,
|
||||
config_dir=config_dir,
|
||||
dlc_dir=dlc_dir,
|
||||
log_file=log_file,
|
||||
@@ -1215,7 +1215,7 @@ def build_bundle(
|
||||
)
|
||||
|
||||
manifest = _make_manifest(
|
||||
feedBack_version=feedBack_version,
|
||||
slopsmith_version=slopsmith_version,
|
||||
runtime_kind=runtime_kind,
|
||||
redact=redact,
|
||||
files=files,
|
||||
@@ -1225,7 +1225,7 @@ def build_bundle(
|
||||
|
||||
readme = README_TEMPLATE.format(
|
||||
exported_at=manifest["exported_at"],
|
||||
feedBack_version=feedBack_version,
|
||||
slopsmith_version=slopsmith_version,
|
||||
runtime_kind=runtime_kind,
|
||||
redacted=redact,
|
||||
)
|
||||
@@ -1259,13 +1259,13 @@ def build_bundle(
|
||||
for path, payload in sorted(files.items()):
|
||||
zf.writestr(path, payload)
|
||||
|
||||
filename = f"feedBack-diag-{feedBack_version}-{_now_filename_slug()}.zip"
|
||||
filename = f"slopsmith-diag-{slopsmith_version}-{_now_filename_slug()}.zip"
|
||||
return buf.getvalue(), filename, manifest
|
||||
|
||||
|
||||
def preview_bundle(
|
||||
*,
|
||||
feedBack_version: str,
|
||||
slopsmith_version: str,
|
||||
config_dir: Path,
|
||||
dlc_dir: Path | None,
|
||||
log_file: Path | None,
|
||||
@@ -1303,7 +1303,7 @@ def preview_bundle(
|
||||
for p in loaded_plugins
|
||||
]
|
||||
files, notes, runtime_kind, redactor = _assemble_files_and_notes(
|
||||
feedBack_version=feedBack_version,
|
||||
slopsmith_version=slopsmith_version,
|
||||
config_dir=config_dir,
|
||||
dlc_dir=dlc_dir,
|
||||
log_file=log_file,
|
||||
@@ -1336,7 +1336,7 @@ def preview_bundle(
|
||||
if key not in files:
|
||||
files[key] = _CALLABLE_PREVIEW_PLACEHOLDER
|
||||
# Frontend plugins (those with a screen or script) may call
|
||||
# window.feedBack.diagnostics.contribute() and produce a
|
||||
# window.slopsmith.diagnostics.contribute() and produce a
|
||||
# plugins/<id>/client.json in the real export. Advertise a
|
||||
# placeholder so the preview file tree is accurate.
|
||||
if p.get("has_screen") or p.get("has_script"):
|
||||
@@ -1377,14 +1377,14 @@ def preview_bundle(
|
||||
}).encode("utf-8")
|
||||
|
||||
manifest = _make_manifest(
|
||||
feedBack_version=feedBack_version,
|
||||
slopsmith_version=slopsmith_version,
|
||||
runtime_kind=runtime_kind,
|
||||
redact=redact,
|
||||
files=files,
|
||||
notes=notes,
|
||||
redactor=redactor,
|
||||
)
|
||||
filename = f"feedBack-diag-{feedBack_version}-{_now_filename_slug()}.zip"
|
||||
filename = f"slopsmith-diag-{slopsmith_version}-{_now_filename_slug()}.zip"
|
||||
return {
|
||||
"filename": filename,
|
||||
"manifest": manifest,
|
||||
|
||||
@@ -16,8 +16,6 @@ import platform
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from env_compat import getenv_compat
|
||||
|
||||
SCHEMA = "system.hardware.v1"
|
||||
|
||||
|
||||
@@ -43,7 +41,7 @@ def detect_runtime() -> dict:
|
||||
nvidia-smi / psutil CPU probes.
|
||||
"""
|
||||
out: dict = {"kind": "bare", "in_docker": False, "in_kubernetes": False}
|
||||
env_runtime = (getenv_compat("FEEDBACK_RUNTIME", "") or "").strip().lower()
|
||||
env_runtime = os.environ.get("SLOPSMITH_RUNTIME", "").strip().lower()
|
||||
if env_runtime in ("electron", "docker", "bare"):
|
||||
out["kind"] = env_runtime
|
||||
if Path("/.dockerenv").exists():
|
||||
@@ -67,7 +65,7 @@ def detect_runtime() -> dict:
|
||||
import psutil # type: ignore
|
||||
|
||||
parent = psutil.Process(os.getppid()).name().lower()
|
||||
if "electron" in parent or "feedBack" in parent:
|
||||
if "electron" in parent or "slopsmith" in parent:
|
||||
out["kind"] = "electron"
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -8,7 +8,7 @@ different salts so tokens cannot be cross-correlated between exports.
|
||||
Stable token grammar (see docs/diagnostics-bundle-spec.md):
|
||||
<DLC_DIR> — DLC root path
|
||||
<HOME> — user's home directory
|
||||
<CONFIG_DIR> — feedBack config dir
|
||||
<CONFIG_DIR> — slopsmith config dir
|
||||
<song:hash8> — song filename / basename (8 hex chars)
|
||||
<ip:hash6> — IPv4 / IPv6 address (6 hex chars)
|
||||
<redacted> — bearer tokens, key=/token= query strings
|
||||
@@ -34,7 +34,7 @@ _QSTRING_SECRET_RE = re.compile(
|
||||
r"(?i)\b(api[_-]?key|key|token|secret|password|pwd|auth)=([^\s&\"']+)"
|
||||
)
|
||||
_SONG_FILENAME_RE = re.compile(
|
||||
r"\b[\w()'\-+&,.!?\[\]]+\.(?:psarc|sloppak|feedpak|wem|ogg|mp3|wav)\b",
|
||||
r"\b[\w()'\-+&,.!?\[\]]+\.(?:psarc|sloppak|wem|ogg|mp3|wav)\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
+1
-1
@@ -21,7 +21,7 @@ from __future__ import annotations
|
||||
import logging
|
||||
import math
|
||||
|
||||
log = logging.getLogger("feedBack.lib.drums")
|
||||
log = logging.getLogger("slopsmith.lib.drums")
|
||||
|
||||
|
||||
# ── Piece vocabulary ──────────────────────────────────────────────────────────
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
"""Backward-compatible environment lookup for the slopsmith -> feedBack rename.
|
||||
|
||||
Canonical configuration variables are now ``FEEDBACK_*``. Deployments that
|
||||
predate the rename may still set the old ``SLOPSMITH_*`` names (docker-compose
|
||||
overrides, shell profiles, CI), so we honour those as a fallback. New code
|
||||
should always read the canonical ``FEEDBACK_*`` name and let this shim resolve
|
||||
the legacy alias.
|
||||
|
||||
Flat-importable, no import-time IO or global state (constitution P-V).
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
_CANON_PREFIX = "FEEDBACK_"
|
||||
_LEGACY_PREFIX = "SLOPSMITH_"
|
||||
_TRUE_VALUES = {"1", "true", "yes", "on"}
|
||||
|
||||
|
||||
def getenv_compat(name, default=None):
|
||||
"""``os.environ.get`` with a legacy ``SLOPSMITH_*`` fallback.
|
||||
|
||||
For a canonical ``FEEDBACK_<X>`` name, returns the value of ``FEEDBACK_<X>``
|
||||
if set, else ``SLOPSMITH_<X>`` if set, else ``default``. Names that do not
|
||||
start with ``FEEDBACK_`` behave exactly like ``os.environ.get``.
|
||||
"""
|
||||
value = os.environ.get(name)
|
||||
if value is not None:
|
||||
return value
|
||||
if name.startswith(_CANON_PREFIX):
|
||||
legacy = os.environ.get(_LEGACY_PREFIX + name[len(_CANON_PREFIX):])
|
||||
if legacy is not None:
|
||||
return legacy
|
||||
return default
|
||||
|
||||
|
||||
def env_flag_compat(name):
|
||||
"""Parse a conventional boolean env flag, honouring the legacy alias."""
|
||||
return (getenv_compat(name, "") or "").strip().lower() in _TRUE_VALUES
|
||||
+10
-12
@@ -8,9 +8,7 @@ import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from env_compat import getenv_compat
|
||||
|
||||
log = logging.getLogger("feedBack.lib.gp2midi")
|
||||
log = logging.getLogger("slopsmith.lib.gp2midi")
|
||||
|
||||
import guitarpro
|
||||
from midiutil import MIDIFile
|
||||
@@ -154,15 +152,15 @@ def _find_soundfont() -> str | None:
|
||||
"""Locate a .sf2 soundfont for MIDI rendering.
|
||||
|
||||
Precedence:
|
||||
1. ``FEEDBACK_SOUNDFONT`` env var (user override / desktop-app-supplied)
|
||||
1. ``SLOPSMITH_SOUNDFONT`` env var (user override / desktop-app-supplied)
|
||||
2. Bundled ``<RESOURCESPATH>/soundfonts/*.sf2`` (Electron desktop builds)
|
||||
3. Common system locations per OS.
|
||||
"""
|
||||
override = getenv_compat("FEEDBACK_SOUNDFONT")
|
||||
override = os.environ.get("SLOPSMITH_SOUNDFONT")
|
||||
if override:
|
||||
if os.path.isfile(override):
|
||||
return override
|
||||
log.warning("FEEDBACK_SOUNDFONT is set to %r but that file does not exist; falling back to other sources", override)
|
||||
log.warning("SLOPSMITH_SOUNDFONT is set to %r but that file does not exist; falling back to other sources", override)
|
||||
|
||||
resources = os.environ.get("RESOURCESPATH")
|
||||
if resources:
|
||||
@@ -189,10 +187,10 @@ def _find_soundfont() -> str | None:
|
||||
elif sys.platform == "win32":
|
||||
appdata = os.environ.get("APPDATA")
|
||||
if appdata:
|
||||
# "FeedBack" matches feedBack-desktop's Electron productName
|
||||
# (app.getPath('userData') resolves to %APPDATA%\FeedBack on Windows).
|
||||
# "Slopsmith" matches slopsmith-desktop's Electron productName
|
||||
# (app.getPath('userData') resolves to %APPDATA%\Slopsmith on Windows).
|
||||
for pattern in (
|
||||
os.path.join(appdata, "FeedBack", "soundfonts", "*.sf2"),
|
||||
os.path.join(appdata, "Slopsmith", "soundfonts", "*.sf2"),
|
||||
os.path.join(appdata, "SoundFonts", "*.sf2"),
|
||||
):
|
||||
candidates += sorted(glob.glob(pattern))
|
||||
@@ -220,16 +218,16 @@ def _soundfont_install_hint() -> str:
|
||||
"or FluidR3_GM from musical-artifacts.com) and either place the .sf2 "
|
||||
"file in /usr/local/share/sounds/sf2/ (Intel) or "
|
||||
"/opt/homebrew/share/sounds/sf2/ (Apple Silicon), or set the "
|
||||
"FEEDBACK_SOUNDFONT environment variable to its full path."
|
||||
"SLOPSMITH_SOUNDFONT environment variable to its full path."
|
||||
)
|
||||
if sys.platform == "win32":
|
||||
return (
|
||||
"Download a soundfont (e.g. GeneralUser GS from schristiancollins.com or "
|
||||
"FluidR3_GM from musical-artifacts.com) and either place the .sf2 file in "
|
||||
"%APPDATA%\\FeedBack\\soundfonts\\ or set the FEEDBACK_SOUNDFONT "
|
||||
"%APPDATA%\\Slopsmith\\soundfonts\\ or set the SLOPSMITH_SOUNDFONT "
|
||||
"environment variable to its full path."
|
||||
)
|
||||
return "Set FEEDBACK_SOUNDFONT to the full path of a .sf2 file."
|
||||
return "Set SLOPSMITH_SOUNDFONT to the full path of a .sf2 file."
|
||||
|
||||
|
||||
def _fluidsynth_install_hint() -> str:
|
||||
|
||||
+3
-7
@@ -19,8 +19,8 @@ bar-indexed tempo map, per-beat rhythm durations (dots + tuplets; see
|
||||
``_beat_secs`` for the one deliberate double-dot divergence), and
|
||||
``_note_midi`` — so the
|
||||
notation beats line up with the RS-XML notes the highway plays (see
|
||||
feedBack#618 for the longer-term goal of sharing the note-building walk
|
||||
itself, and feedBack#261 for the time-signature-denominator pitfalls the
|
||||
slopsmith#618 for the longer-term goal of sharing the note-building walk
|
||||
itself, and slopsmith#261 for the time-signature-denominator pitfalls the
|
||||
``beat_groups`` emission here exists to avoid re-introducing).
|
||||
|
||||
Where this plugs in: ``gp2rs_gpx.convert_file`` calls
|
||||
@@ -43,7 +43,7 @@ from pathlib import Path
|
||||
|
||||
import notation as notation_mod
|
||||
|
||||
log = logging.getLogger("feedBack.lib.gp2notation")
|
||||
log = logging.getLogger("slopsmith.lib.gp2notation")
|
||||
|
||||
|
||||
# GPX NoteValue string → notation duration denominator (sloppak-spec §5.3:
|
||||
@@ -522,10 +522,6 @@ def attach_notation_to_sloppak(sloppak_dir: str | Path, arr_id: str, payload: di
|
||||
json.dumps(payload, separators=(",", ":")), encoding="utf-8"
|
||||
)
|
||||
entry["notation"] = filename
|
||||
# Stamp the format version while we're rewriting the manifest (spec §4),
|
||||
# without downgrading an existing (possibly higher) declared version.
|
||||
from sloppak import FEEDPAK_VERSION
|
||||
manifest.setdefault("feedpak_version", FEEDPAK_VERSION)
|
||||
manifest_path.write_text(
|
||||
yaml.safe_dump(manifest, sort_keys=False, allow_unicode=True),
|
||||
encoding="utf-8",
|
||||
|
||||
+44
-265
@@ -1,6 +1,5 @@
|
||||
"""Convert Guitar Pro files (.gp5/.gp4/.gp3) to arrangement XML."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import xml.etree.ElementTree as ET
|
||||
@@ -10,7 +9,7 @@ from pathlib import Path
|
||||
|
||||
import guitarpro
|
||||
|
||||
log = logging.getLogger("feedBack.lib.gp2rs")
|
||||
log = logging.getLogger("slopsmith.lib.gp2rs")
|
||||
|
||||
_YEAR_RE = re.compile(r"\b(1[89]\d{2}|20\d{2})\b")
|
||||
|
||||
@@ -57,8 +56,6 @@ class RsNote:
|
||||
fret: int
|
||||
sustain: float = 0.0
|
||||
bend: float = 0.0
|
||||
bend_intent: int = 0
|
||||
bend_values: list | None = None
|
||||
slide_to: int = -1
|
||||
slide_unpitch_to: int = -1
|
||||
hammer_on: bool = False
|
||||
@@ -72,9 +69,6 @@ class RsNote:
|
||||
tremolo: bool = False
|
||||
tap: bool = False
|
||||
link_next: bool = False
|
||||
# Teaching mark (§6.2.2): fret-hand finger (-1 unset, 0 thumb..4 pinky).
|
||||
# Display only — never used for grading.
|
||||
fret_finger: int = -1
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -197,77 +191,6 @@ def _duration_to_seconds(duration: guitarpro.Duration, tempo: float) -> float:
|
||||
return beats * (60.0 / tempo)
|
||||
|
||||
|
||||
# pyguitarpro models bend-point x-positions on 0..BendEffect.maxPosition (12)
|
||||
# across the note's duration; y-values are half-quarter-tone units where 12 = 6
|
||||
# semitones, so semitones = value / 2.0 (matches the scalar `bend` derivation).
|
||||
_GP_BEND_MAX_POSITION = 12
|
||||
|
||||
|
||||
def _bend_intent_from_values(values: list[float]) -> int:
|
||||
"""Classify a bend gesture (§6.2.1) from its time-ordered semitone values:
|
||||
0 up, 1 release, 2 pre-bend, 3 pre-bend-and-release, 4 round-trip."""
|
||||
if not values:
|
||||
return 0
|
||||
eps = 0.05
|
||||
first, last, peak = values[0], values[-1], max(values)
|
||||
if first > eps:
|
||||
if last <= eps:
|
||||
return 3 # pre-bent, then released to pitch
|
||||
if last < first - eps:
|
||||
return 1 # held bend let down
|
||||
return 2 # pre-bend held
|
||||
if peak > eps and last <= eps:
|
||||
return 4 # bend up and back down
|
||||
return 0 # plain bend up
|
||||
|
||||
|
||||
def _gp_bend_shape(bend, duration_secs: float):
|
||||
"""From a pyguitarpro ``BendEffect``, return ``(peak, intent, curve)``.
|
||||
|
||||
``peak`` is the bend's peak in semitones (the scalar ``bn``); ``intent`` is
|
||||
the §6.2.1 ``bt`` code; ``curve`` is the time-stamped ``bnv`` list
|
||||
(``[{t: seconds-from-onset, v: semitones}]``) or ``None`` when there's no
|
||||
usable shape (no points, or a zero-length note collapsing every point to
|
||||
``t=0``)."""
|
||||
pts = sorted(bend.points or [], key=lambda p: p.position)
|
||||
if not pts:
|
||||
return 0.0, 0, None
|
||||
values = [round(p.value / 2.0, 1) for p in pts]
|
||||
peak = round(max(values), 1)
|
||||
intent = _bend_intent_from_values(values)
|
||||
curve = None
|
||||
if duration_secs > 0 and len(pts) >= 2:
|
||||
curve = [
|
||||
{"t": round(duration_secs * (p.position / _GP_BEND_MAX_POSITION), 3),
|
||||
"v": v}
|
||||
for p, v in zip(pts, values)
|
||||
]
|
||||
return peak, intent, curve
|
||||
|
||||
|
||||
def _bend_shape_xml_attrs(n: "RsNote") -> dict:
|
||||
"""Optional bend-shape XML attributes for a <note>/<chordNote>, default-
|
||||
omitted: `bendIntent` only when non-zero, `bendValues` (a JSON-encoded
|
||||
[{t,v}] curve) only when present. `_parse_note` (lib/song.py) reads these
|
||||
back so a GP-imported bend curve survives import → wire → highway."""
|
||||
attrs: dict = {}
|
||||
if n.bend_intent:
|
||||
attrs["bendIntent"] = str(int(n.bend_intent))
|
||||
if n.bend_values:
|
||||
attrs["bendValues"] = json.dumps(n.bend_values, separators=(",", ":"))
|
||||
return attrs
|
||||
|
||||
|
||||
def _finger_xml_attrs(n: "RsNote") -> dict:
|
||||
"""Optional teaching-mark XML attribute for a <note>/<chordNote>: `fretFinger`
|
||||
only when set (!= -1). `_parse_note` (lib/song.py) reads it back so a
|
||||
GP-imported fret-hand finger survives import → wire → highway. Display only;
|
||||
never used for grading (§6.2.2)."""
|
||||
if getattr(n, "fret_finger", -1) != -1:
|
||||
return {"fretFinger": str(int(n.fret_finger))}
|
||||
return {}
|
||||
|
||||
|
||||
def _tempo_at_tick(tick: int, tempo_map: list[TempoEvent]) -> float:
|
||||
"""Get the tempo at a given tick."""
|
||||
result = tempo_map[0].tempo
|
||||
@@ -537,69 +460,6 @@ def _gp_string_to_rs(gp_string: int, num_strings: int) -> int:
|
||||
return num_strings - gp_string
|
||||
|
||||
|
||||
def _gp_finger_to_rs(fingering) -> int:
|
||||
"""Coerce a pyguitarpro ``Fingering`` enum to an RS fret-hand finger int.
|
||||
|
||||
Fingering values are ``unknown=-2, open=-1, thumb=0, index=1, middle=2,
|
||||
annular=3, little=4`` — already the RS finger integers for 0..4. Anything
|
||||
open/unknown/out-of-range collapses to ``-1`` (unset), so we never invent a
|
||||
finger. Teaching mark only (§6.2.2); never used for grading."""
|
||||
val = getattr(fingering, "value", fingering)
|
||||
if not isinstance(val, int) or val < 0 or val > 4:
|
||||
return -1
|
||||
return val
|
||||
|
||||
|
||||
def _chord_fingers(chord, frets: list[int], num_strings: int) -> list[int]:
|
||||
"""Per-string fingering for a chord template, in RS string order.
|
||||
|
||||
pyguitarpro exposes the chord-diagram voicing on ``beat.effect.chord``:
|
||||
``chord.strings`` is a per-string fret list indexed 0 = highest string
|
||||
(GP string 1), -1 = unplayed; ``chord.fingerings`` is the parallel list
|
||||
of :class:`guitarpro.Fingering` enums (``open=-1, thumb=0, index=1,
|
||||
middle=2, annular=3, little=4`` — already the RS finger integers). The
|
||||
fingerings list may carry one trailing extra entry, so we only read the
|
||||
first ``len(strings)`` of it.
|
||||
|
||||
Returns a list the same width as ``frets`` (RS string index 0 = low).
|
||||
Only strings that are actually played in this template (``frets[rs] >= 0``)
|
||||
get a finger; everything else stays -1. A chord without a populated
|
||||
voicing yields all -1, so diagram-less charts are unchanged.
|
||||
"""
|
||||
fingers = [-1] * len(frets)
|
||||
strings = getattr(chord, "strings", None) or []
|
||||
fingerings = getattr(chord, "fingerings", None) or []
|
||||
for i, fret in enumerate(strings):
|
||||
if fret is None or fret < 0:
|
||||
continue # string not part of the voicing
|
||||
rs = _gp_string_to_rs(i + 1, num_strings)
|
||||
if not (0 <= rs < len(frets)) or frets[rs] < 0:
|
||||
continue
|
||||
if i < len(fingerings):
|
||||
val = getattr(fingerings[i], "value", fingerings[i])
|
||||
fingers[rs] = val if isinstance(val, int) else -1
|
||||
return fingers
|
||||
|
||||
|
||||
def _chord_diagram_frets(chord, num_strings: int, width: int) -> list[int]:
|
||||
"""RS-string-ordered absolute frets of the chord DIAGRAM voicing, padded to
|
||||
``width`` with -1.
|
||||
|
||||
Used to confirm the diagram describes the voicing actually played before
|
||||
enriching a template — mirrors the GP8 exact fret-pattern guard. pyguitarpro
|
||||
stores absolute frets in ``chord.strings`` (``firstFret`` is display-only),
|
||||
so the result compares directly against the played ``frets``."""
|
||||
out = [-1] * width
|
||||
strings = getattr(chord, "strings", None) or []
|
||||
for i, fret in enumerate(strings):
|
||||
if fret is None or fret < 0:
|
||||
continue
|
||||
rs = _gp_string_to_rs(i + 1, num_strings)
|
||||
if 0 <= rs < width:
|
||||
out[rs] = fret
|
||||
return out
|
||||
|
||||
|
||||
def _is_bass_track(track: guitarpro.Track) -> bool:
|
||||
"""Detect whether a GP track is a bass.
|
||||
|
||||
@@ -825,13 +685,12 @@ def convert_track(
|
||||
# Techniques
|
||||
eff = note.effect
|
||||
if eff.bend and eff.bend.points:
|
||||
# `bn` is the peak; `bnv`/`bt` describe the shape over
|
||||
# time (§6.2.1). semitones = value / 2 (maxValue 12 = 6
|
||||
# semitones); the old /100.0 made every bend round to 0.
|
||||
peak, intent, curve = _gp_bend_shape(eff.bend, dur)
|
||||
rn.bend = peak
|
||||
rn.bend_intent = intent
|
||||
rn.bend_values = curve
|
||||
# pyguitarpro bend point values are in quarter-tones
|
||||
# (maxValue 12 = 3 whole tones = 6 semitones), so
|
||||
# semitones = value / 2. The old /100.0 made every bend
|
||||
# round to 0 (a whole-tone bend is value 4 -> 0.04).
|
||||
max_bend = max(p.value for p in eff.bend.points)
|
||||
rn.bend = round(max_bend / 2.0, 1)
|
||||
|
||||
if eff.hammer:
|
||||
# HO vs PO from pitch direction off the prior note on the
|
||||
@@ -879,11 +738,6 @@ def convert_track(
|
||||
if eff.tremoloPicking:
|
||||
rn.tremolo = True
|
||||
|
||||
# Fret-hand fingering -> fg teaching mark (§6.2.2). Same
|
||||
# Fingering enum + value convention as the chord path.
|
||||
rn.fret_finger = _gp_finger_to_rs(
|
||||
getattr(eff, "leftHandFinger", None))
|
||||
|
||||
# Whammy / tremolo bar (beat-level dive/raise). RS has no
|
||||
# whammy attribute, so approximate the pitch movement as an
|
||||
# unpitched slide: a dive slides down, a raise slides up, by
|
||||
@@ -974,44 +828,17 @@ def convert_track(
|
||||
fret_key = tuple(frets)
|
||||
|
||||
if fret_key not in chord_template_map:
|
||||
# Try to get chord name from GP
|
||||
chord_name = ""
|
||||
if beat.effect and beat.effect.chord:
|
||||
chord_name = beat.effect.chord.name or ""
|
||||
idx = len(chord_templates)
|
||||
chord_templates.append(ChordTemplate(
|
||||
name="",
|
||||
name=chord_name,
|
||||
frets=list(frets),
|
||||
fingers=[-1] * width,
|
||||
))
|
||||
chord_template_map[fret_key] = idx
|
||||
else:
|
||||
idx = chord_template_map[fret_key]
|
||||
|
||||
# Enrich the template from the GP chord diagram attached to
|
||||
# this beat — but ONLY when the diagram describes the voicing
|
||||
# actually played (same width-normalized fret pattern). A
|
||||
# mismatched chord label/diagram would otherwise mis-name /
|
||||
# finger the played template, and the back-fill would spread
|
||||
# it to other strums of the same played pattern. Mirrors the
|
||||
# GP8 exact fret-pattern guard.
|
||||
#
|
||||
# Name and fingers back-fill INDEPENDENTLY: a name-only first
|
||||
# annotation must not block a later beat that carries fingers
|
||||
# (and vice versa). Back-fill any still-blank field so the
|
||||
# data attaches regardless of which strum carries it.
|
||||
if beat.effect and beat.effect.chord:
|
||||
gpc = beat.effect.chord
|
||||
# Compare over the FULL string span (played width vs the
|
||||
# track's string count) so a diagram that frets an
|
||||
# extended string the played voicing doesn't use counts
|
||||
# as a mismatch instead of being silently trimmed.
|
||||
_w = max(len(frets), num_strings)
|
||||
_played = frets + [-1] * (_w - len(frets))
|
||||
if _chord_diagram_frets(gpc, num_strings, _w) == _played:
|
||||
ct = chord_templates[idx]
|
||||
if not ct.name and gpc.name:
|
||||
ct.name = gpc.name
|
||||
if all(f < 0 for f in ct.fingers):
|
||||
fingers = _chord_fingers(gpc, frets, num_strings)
|
||||
if any(f >= 0 for f in fingers):
|
||||
ct.fingers = fingers
|
||||
|
||||
rs_chords.append(RsChord(
|
||||
time=t,
|
||||
@@ -1114,7 +941,7 @@ def _build_xml(
|
||||
ET.SubElement(root, "arrangement").text = arrangement
|
||||
ET.SubElement(root, "offset").text = f"{audio_offset:.3f}"
|
||||
ET.SubElement(root, "songLength").text = f"{song_length:.3f}"
|
||||
ET.SubElement(root, "startBeat").text = f"{beats[0].time:.6f}" if beats else "0.000000"
|
||||
ET.SubElement(root, "startBeat").text = f"{beats[0].time:.3f}" if beats else "0.000"
|
||||
ET.SubElement(root, "averageTempo").text = str(tempo)
|
||||
ET.SubElement(root, "artistName").text = artist
|
||||
ET.SubElement(root, "albumName").text = album
|
||||
@@ -1122,34 +949,17 @@ def _build_xml(
|
||||
|
||||
# Tuning. RS2014 schema names 6 string slots; we always emit those
|
||||
# for compatibility, and emit additional string6+ attributes (up to
|
||||
# `len(tuning)-1`) for 7+ string arrangements. FeedBack parses
|
||||
# `len(tuning)-1`) for 7+ string arrangements. Slopsmith parses
|
||||
# them; the format ignores them.
|
||||
#
|
||||
# `stringCount` records the AUTHORITATIVE string count (== len(tuning)),
|
||||
# because the 6-slot padding above erases the 4-vs-5-vs-6-string
|
||||
# distinction for standard tunings (a 4-string bass, 5-string bass and
|
||||
# 6-string guitar are otherwise byte-identical, all string0..5 = 0).
|
||||
# parse_arrangement trims `tuning` back to this on read so downstream
|
||||
# string-count derivation (song.arrangement_string_count, the editor's
|
||||
# _stringCountFor) sees the real width instead of guessing. RS2014 and
|
||||
# any other consumer simply ignore the unknown attribute.
|
||||
tuning_el = ET.SubElement(root, "tuning")
|
||||
tuning_el.set("stringCount", str(len(tuning)))
|
||||
for i in range(max(6, len(tuning))):
|
||||
tuning_el.set(f"string{i}", str(tuning[i] if i < len(tuning) else 0))
|
||||
ET.SubElement(root, "capo").text = "0"
|
||||
|
||||
# Ebeats — write beat times at MICROSECOND (6-decimal) precision, not
|
||||
# millisecond (3-decimal). The editor/timeline DERIVES per-bar BPM from beat
|
||||
# spans (bpm = beats·60/span), which amplifies any rounding: at 3 decimals a
|
||||
# constant-tempo GP (e.g. 140) shows a spurious ±0.05–0.7 BPM per-bar drift
|
||||
# (worse for fast/odd meters) because most bar lengths don't land on a ms
|
||||
# boundary. gp2rs computes these times exactly from the GP tempo map, so the
|
||||
# only loss is this format string — 6 decimals makes the derived tempo match
|
||||
# GP's authored value. (Everything else stays at :.3f; only beats drive tempo.)
|
||||
# Ebeats
|
||||
ebeats = ET.SubElement(root, "ebeats", count=str(len(beats)))
|
||||
for b in beats:
|
||||
ET.SubElement(ebeats, "ebeat", time=f"{b.time:.6f}", measure=str(b.measure))
|
||||
ET.SubElement(ebeats, "ebeat", time=f"{b.time:.3f}", measure=str(b.measure))
|
||||
|
||||
# Sections
|
||||
sections_el = ET.SubElement(root, "sections", count=str(len(sections)))
|
||||
@@ -1211,8 +1021,6 @@ def _build_xml(
|
||||
"tap": "1" if n.tap else "0",
|
||||
"ignore": "0",
|
||||
}
|
||||
attrs.update(_bend_shape_xml_attrs(n))
|
||||
attrs.update(_finger_xml_attrs(n))
|
||||
ET.SubElement(notes_el, "note", **attrs)
|
||||
|
||||
# Chords
|
||||
@@ -1223,30 +1031,25 @@ def _build_xml(
|
||||
chordId=str(ch.template_idx),
|
||||
highDensity="0", strum="down")
|
||||
for cn in ch.notes:
|
||||
cn_attrs = {
|
||||
"time": f"{cn.time:.3f}",
|
||||
"string": str(cn.string),
|
||||
"fret": str(cn.fret),
|
||||
"sustain": f"{cn.sustain:.3f}",
|
||||
"bend": f"{cn.bend:.1f}" if cn.bend else "0",
|
||||
"hammerOn": "1" if cn.hammer_on else "0",
|
||||
"pullOff": "1" if cn.pull_off else "0",
|
||||
"slideTo": str(cn.slide_to),
|
||||
"slideUnpitchTo": str(cn.slide_unpitch_to),
|
||||
"harmonic": "1" if cn.harmonic else "0",
|
||||
"harmonicPinch": "1" if cn.harmonic_pinch else "0",
|
||||
"palmMute": "1" if cn.palm_mute else "0",
|
||||
"mute": "1" if cn.mute else "0",
|
||||
"vibrato": "1" if cn.vibrato else "0",
|
||||
"tremolo": "1" if cn.tremolo else "0",
|
||||
"accent": "1" if cn.accent else "0",
|
||||
"linkNext": "1" if cn.link_next else "0",
|
||||
"tap": "1" if cn.tap else "0",
|
||||
"ignore": "0",
|
||||
}
|
||||
cn_attrs.update(_bend_shape_xml_attrs(cn))
|
||||
cn_attrs.update(_finger_xml_attrs(cn))
|
||||
ET.SubElement(chord_el, "chordNote", **cn_attrs)
|
||||
ET.SubElement(chord_el, "chordNote",
|
||||
time=f"{cn.time:.3f}",
|
||||
string=str(cn.string),
|
||||
fret=str(cn.fret),
|
||||
sustain=f"{cn.sustain:.3f}",
|
||||
bend=f"{cn.bend:.1f}" if cn.bend else "0",
|
||||
hammerOn="1" if cn.hammer_on else "0",
|
||||
pullOff="1" if cn.pull_off else "0",
|
||||
slideTo=str(cn.slide_to),
|
||||
slideUnpitchTo=str(cn.slide_unpitch_to),
|
||||
harmonic="1" if cn.harmonic else "0",
|
||||
harmonicPinch="1" if cn.harmonic_pinch else "0",
|
||||
palmMute="1" if cn.palm_mute else "0",
|
||||
mute="1" if cn.mute else "0",
|
||||
vibrato="1" if cn.vibrato else "0",
|
||||
tremolo="1" if cn.tremolo else "0",
|
||||
accent="1" if cn.accent else "0",
|
||||
linkNext="1" if cn.link_next else "0",
|
||||
tap="1" if cn.tap else "0", ignore="0")
|
||||
|
||||
# Anchors
|
||||
anchors_el = ET.SubElement(level, "anchors", count=str(len(anchors)))
|
||||
@@ -1843,10 +1646,9 @@ def convert_drum_track_to_drumtab(
|
||||
drum strings. Unknown percussion sounds (cowbell, tambourine etc.) are
|
||||
skipped — round-tripping them would require teaching `lib/drums.py` first.
|
||||
Callers can pass an empty dict as ``out_unmapped`` to receive a per-MIDI
|
||||
record of every skipped note (``{midi: {"count": int, "times": [...],
|
||||
"velocities": [...]}}``, times/velocities index-aligned and capped at
|
||||
100 samples per note — velocities carry the source notes' real dynamics)
|
||||
so they can surface a warning or offer a manual mapping UI.
|
||||
record of every skipped note (``{midi: {"count": int, "times": [...]}}``,
|
||||
times capped at 100 samples per note) so they can surface a warning or
|
||||
offer a manual mapping UI.
|
||||
|
||||
Honours GP repeat brackets and D.S./D.C./Coda/Fine jumps when
|
||||
``expand_repeats`` is true — same `_build_playback_schedule` machinery
|
||||
@@ -1902,29 +1704,18 @@ def convert_drum_track_to_drumtab(
|
||||
# NB: do NOT shadow the outer `entry` loop
|
||||
# variable from `for entry in schedule:`.
|
||||
unmapped_rec = out_unmapped.setdefault(
|
||||
int(midi_note),
|
||||
{"count": 0, "times": [], "velocities": []})
|
||||
int(midi_note), {"count": 0, "times": []})
|
||||
unmapped_rec["count"] += 1
|
||||
if len(unmapped_rec["times"]) < 100:
|
||||
unmapped_rec["times"].append(round(t, 3))
|
||||
# Index-aligned with times: the note's real
|
||||
# dynamics (same 1-127 gate as mapped hits,
|
||||
# falling back to the 100 import default) so
|
||||
# a hand-mapping UI doesn't flatten them.
|
||||
_uv = int(getattr(note, "velocity", 0) or 0)
|
||||
unmapped_rec["velocities"].append(
|
||||
_uv if 1 <= _uv <= 127 else 100)
|
||||
continue
|
||||
|
||||
hit: dict = {"t": round(t, 3), "p": piece}
|
||||
|
||||
# Velocity: GP stores 1-127 MIDI velocity directly. Note
|
||||
# this is GP's *authoring* default (95, Velocities.default)
|
||||
# — unrelated to the drumtab render default of 100
|
||||
# (DEFAULT_VELOCITY, lib/drums.py:179), which only applies
|
||||
# when `v` is omitted from a hit. Pass the GP value through
|
||||
# verbatim, clamping defensively so a corrupt file can't
|
||||
# poison the wire format.
|
||||
# Velocity: GP stores 1-127 MIDI velocity directly; default
|
||||
# is 95 (Velocities.default). Pass through verbatim,
|
||||
# clamping defensively so a corrupt file can't poison the
|
||||
# wire format.
|
||||
vel = int(getattr(note, "velocity", 0) or 0)
|
||||
if 1 <= vel <= 127:
|
||||
hit["v"] = vel
|
||||
@@ -1965,21 +1756,9 @@ def convert_drum_track_to_drumtab(
|
||||
# Times for unmapped notes were collected in beat-iteration order;
|
||||
# multi-voice measures can produce out-of-order beats, so sort each
|
||||
# entry's `times` list chronologically before returning to the caller.
|
||||
# Velocities are index-aligned with times, so they must sort in
|
||||
# LOCKSTEP — sorting times alone would silently reassign dynamics.
|
||||
if out_unmapped is not None:
|
||||
for _rec in out_unmapped.values():
|
||||
_vels = _rec.get("velocities")
|
||||
if _vels and len(_vels) == len(_rec["times"]):
|
||||
_pairs = sorted(zip(_rec["times"], _vels))
|
||||
_rec["times"] = [p[0] for p in _pairs]
|
||||
_rec["velocities"] = [p[1] for p in _pairs]
|
||||
else:
|
||||
# Belt-and-suspenders: times & velocities are always appended
|
||||
# together under the same `len(times) < 100` guard above, so
|
||||
# in practice the lengths can't diverge. Kept as a defensive
|
||||
# fallback, not a real divergence case.
|
||||
_rec["times"].sort()
|
||||
_rec["times"].sort()
|
||||
|
||||
return {
|
||||
"version": drums_mod.SCHEMA_VERSION,
|
||||
|
||||
+190
-589
@@ -1,7 +1,7 @@
|
||||
"""
|
||||
lib/gp2rs_gpx.py — Guitar Pro 6 (.gpx) support shim for gp2rs.
|
||||
|
||||
Drop this file into feedBack/lib/ alongside gp2rs.py.
|
||||
Drop this file into slopsmith/lib/ alongside gp2rs.py.
|
||||
No third-party dependencies — pure Python stdlib only.
|
||||
|
||||
Public API mirrors the two functions that the editor plugin calls:
|
||||
@@ -20,7 +20,7 @@ from pathlib import Path
|
||||
|
||||
from safepath import safe_join
|
||||
|
||||
_log = logging.getLogger("feedBack.lib.gp2rs_gpx")
|
||||
_log = logging.getLogger("slopsmith.lib.gp2rs_gpx")
|
||||
|
||||
|
||||
def _safe_filename_stem(name: str) -> str:
|
||||
@@ -121,18 +121,10 @@ def _parse_bcfs(bcfs: bytes) -> dict:
|
||||
while sc <= max_sectors:
|
||||
s = _gi(po + 4 * sc); sc += 1
|
||||
if s == 0: break
|
||||
start = HDR + s * SECTOR
|
||||
# Real .gpx files' final sector is a few bytes short of a full
|
||||
# 0x1000 block: the BCFZ-declared decompressed size isn't
|
||||
# sector-aligned, so the last (small) container file lands in a
|
||||
# partial trailing sector. Clamp the read to the buffer end —
|
||||
# the per-file size field (`fs`, applied below) trims any
|
||||
# padding — matching canonical GPX readers (alphaTab /
|
||||
# PyGuitarPro slice-and-clamp). Only a sector whose *start* is
|
||||
# past the end is genuinely malformed.
|
||||
if start < 0 or start >= len(data):
|
||||
so = s * SECTOR
|
||||
if HDR + so + SECTOR > len(data):
|
||||
raise ValueError("GPX BCFS sector pointer out of range (malformed file)")
|
||||
fb.extend(data[start: min(start + SECTOR, len(data))])
|
||||
fb.extend(data[HDR + so: HDR + so + SECTOR])
|
||||
else:
|
||||
raise ValueError("GPX BCFS sector chain too long (malformed file)")
|
||||
files[fn] = bytes(fb[:fs])
|
||||
@@ -237,47 +229,22 @@ def _build_tempo_map(root: ET.Element) -> list[tuple[int, float]]:
|
||||
return events
|
||||
|
||||
|
||||
def _parse_tuning(el: ET.Element) -> list[int]:
|
||||
"""Return the string-tuning MIDI pitches from the first ``Tuning`` Property
|
||||
at or below ``el`` (a Track or a single Staff), high string first. ``[]`` if
|
||||
there is no Tuning property or its Pitches text is unparseable."""
|
||||
for prop in el.findall('.//Property'):
|
||||
if prop.get('name') == 'Tuning':
|
||||
pe = prop.find('Pitches')
|
||||
if pe is not None and pe.text:
|
||||
try:
|
||||
return [int(p) for p in pe.text.split()]
|
||||
except ValueError:
|
||||
return []
|
||||
break
|
||||
return []
|
||||
|
||||
|
||||
def _gpif_tracks(root: ET.Element) -> list[dict]:
|
||||
"""Return a list of raw track dicts from the GPIF Tracks element."""
|
||||
# Lookups for per-track note counting. MasterBar/Bars lists one bar id per
|
||||
# *stave* (not per Track element) in document order. A multi-stave track
|
||||
# (e.g. GP8 piano with treble + bass) occupies N consecutive columns; the
|
||||
# bar_column counter below advances by num_staves per track so every track
|
||||
# gets the correct column regardless of neighbour stave counts.
|
||||
# track in raw Tracks order, so the enumerate index below (which counts
|
||||
# skipped pseudo-tracks) is the correct bar-lookup index — same mapping
|
||||
# convert_file uses via filtered_to_raw.
|
||||
_masterbars = list(root.find('MasterBars') or [])
|
||||
_bars_by_id = {b.get('id'): b for b in (root.find('Bars') or [])}
|
||||
_voices_by_id = {v.get('id'): v for v in (root.find('Voices') or [])}
|
||||
_beats_by_id = {b.get('id'): b for b in (root.find('Beats') or [])}
|
||||
_notes_by_id = {n.get('id'): n for n in (root.find('Notes') or [])}
|
||||
|
||||
def _note_count_for_raw(raw_idx: int) -> int:
|
||||
# Count of notes that ACTUALLY become RS notes for the track. This is
|
||||
# the single source of truth: list_tracks surfaces it as the 'notes'
|
||||
# field (the importer's per-track preview count) and _auto_select_gpx
|
||||
# uses (count == 0) to skip empty tracks — so the graph is walked once
|
||||
# here, not again in list_tracks.
|
||||
#
|
||||
# Tie-DESTINATION notes are excluded: a tied note is folded into the
|
||||
# previous note as extended sustain (see the `_note_is_tie` skips in
|
||||
# convert_file), so it never becomes a separate RS note. Counting them
|
||||
# made the preview overstate the result (e.g. 260 shown, 241 imported);
|
||||
# excluding them makes the preview match what the user actually gets.
|
||||
# Total note count for the track (sum of notes across all its beats).
|
||||
# This is the single source of truth: list_tracks surfaces it as the
|
||||
# 'notes' field, and _auto_select_gpx uses (count == 0) to skip empty
|
||||
# tracks — so the graph is walked once here, not again in list_tracks.
|
||||
n = 0
|
||||
for mb in _masterbars:
|
||||
bar_ids = mb.findtext('Bars', '').split()
|
||||
@@ -296,25 +263,16 @@ def _gpif_tracks(root: ET.Element) -> list[dict]:
|
||||
beat = _beats_by_id.get(bid)
|
||||
if beat is None:
|
||||
continue
|
||||
for nid in beat.findtext('Notes', '').split():
|
||||
note_el = _notes_by_id.get(nid)
|
||||
if note_el is not None and _note_is_tie(note_el):
|
||||
continue
|
||||
n += 1
|
||||
notes_text = beat.findtext('Notes', '').strip()
|
||||
if notes_text:
|
||||
n += len(notes_text.split())
|
||||
return n
|
||||
|
||||
result = []
|
||||
bar_column = 0
|
||||
for t in (root.find('Tracks') or []):
|
||||
# Count staves: each Staff occupies one column in MasterBar/Bars.
|
||||
# Default to 1 for tracks with no explicit <Staves> (GP3/4/5, old GPX).
|
||||
num_staves = max(1, len(list(t.findall('Staves/Staff'))))
|
||||
stave_columns = list(range(bar_column, bar_column + num_staves))
|
||||
|
||||
for raw_idx, t in enumerate(root.find('Tracks') or []):
|
||||
name = (t.findtext('Name') or '').strip()
|
||||
if name.startswith('@$') and name.endswith('$@'):
|
||||
bar_column += num_staves
|
||||
continue # GP internal pseudo-tracks (bar_column still advances)
|
||||
continue # GP internal pseudo-tracks (raw_idx still advances)
|
||||
|
||||
gm = t.find('GeneralMidi')
|
||||
midi_program = 0
|
||||
@@ -351,37 +309,27 @@ def _gpif_tracks(root: ET.Element) -> list[dict]:
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
# String tuning — one list per stave, in stave order. Reading all
|
||||
# `.//Property` descendants across every stave meant the last stave's
|
||||
# tuning overwrote the first; for a GP8 piano (treble 6-string +
|
||||
# bass 5-string) that caused stave-0 notes with String=5 to be
|
||||
# out-of-range against the 5-entry bass tuning and silently dropped.
|
||||
# A staff with no Tuning of its own falls back to the track-level
|
||||
# property (never to []) — an empty list silently drops every fretted
|
||||
# note on that stave in `_note_midi`. The list stays parallel to
|
||||
# `stave_columns` so a per-stave column always has a matching tuning.
|
||||
_track_tuning = _parse_tuning(t)
|
||||
_staff_els = list(t.findall('Staves/Staff'))
|
||||
if _staff_els:
|
||||
stave_pitches = [(_parse_tuning(s) or _track_tuning) for s in _staff_els]
|
||||
else:
|
||||
# No <Staves> (GP3/4/5 or old GPX): single track-level tuning.
|
||||
stave_pitches = [_track_tuning]
|
||||
# String tuning
|
||||
string_pitches: list[int] = []
|
||||
for prop in t.findall('.//Property'):
|
||||
if prop.get('name') == 'Tuning':
|
||||
pe = prop.find('Pitches')
|
||||
if pe is not None and pe.text:
|
||||
try:
|
||||
string_pitches = [int(p) for p in pe.text.split()]
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
result.append({
|
||||
'_el': t,
|
||||
'id': t.get('id', ''),
|
||||
'name': name,
|
||||
'string_pitches': stave_pitches[0], # primary stave (existing key)
|
||||
'num_staves': num_staves,
|
||||
'stave_columns': stave_columns,
|
||||
'stave_pitches': stave_pitches,
|
||||
'string_pitches': string_pitches,
|
||||
'is_drums': is_drums,
|
||||
'midi_program': midi_program,
|
||||
'midi_channel': midi_channel,
|
||||
'note_count': sum(_note_count_for_raw(c) for c in stave_columns),
|
||||
'note_count': _note_count_for_raw(raw_idx),
|
||||
})
|
||||
bar_column += num_staves
|
||||
return result
|
||||
|
||||
|
||||
@@ -409,121 +357,6 @@ def _beat_dur_secs(beat_el: ET.Element, rhythms_dict: dict, tempo_bpm: float) ->
|
||||
return dur_qn * (60.0 / tempo_bpm)
|
||||
|
||||
|
||||
def _collect_column_notes(
|
||||
col: int,
|
||||
string_pitches: list[int],
|
||||
*,
|
||||
masterbars: list,
|
||||
bars_by_id: dict,
|
||||
voices_dict: dict,
|
||||
beats_dict: dict,
|
||||
notes_dict: dict,
|
||||
rhythms_dict: dict,
|
||||
tempo_map: list,
|
||||
tempo_bpm: float,
|
||||
audio_offset: float,
|
||||
) -> list['RsNote']:
|
||||
"""Walk one ``MasterBar/Bars`` column (a single stave / hand) and return its
|
||||
notes as keys-encoded ``RsNote`` (``string = midi // 24``, ``fret = midi %
|
||||
24``). Tie destinations extend the matching prior note's sustain (keyed by
|
||||
pitch, so polyphonic parts are handled) rather than emitting a new note —
|
||||
mirroring the main ``convert_file`` builder, including its full-precision
|
||||
timing and the 0.2s sustain threshold.
|
||||
|
||||
Shared by the GPX LH/RH pair merge and the GP8 multi-stave (grand-staff)
|
||||
fold so the two code paths can never drift in tie / timing / dedup handling.
|
||||
"""
|
||||
from gp2rs import RsNote # lazy: gp2rs<->gpx circular import (see convert_file)
|
||||
|
||||
notes: list[RsNote] = []
|
||||
last_per_key: dict[int, RsNote] = {}
|
||||
tempo_iter = iter(tempo_map)
|
||||
next_bar, next_bpm = next(tempo_iter, (999999, tempo_bpm))
|
||||
cur_tempo = tempo_bpm
|
||||
t_cursor = 0.0
|
||||
|
||||
for mb_idx, mb in enumerate(masterbars):
|
||||
while mb_idx >= next_bar:
|
||||
cur_tempo = next_bpm
|
||||
next_bar, next_bpm = next(tempo_iter, (999999, cur_tempo))
|
||||
ts = mb.findtext('Time', '4/4')
|
||||
try:
|
||||
nb, db = [int(x) for x in ts.split('/')]
|
||||
except ValueError:
|
||||
nb, db = 4, 4
|
||||
bar_dur = nb * (4.0 / db) * (60.0 / cur_tempo)
|
||||
bar_ids = mb.findtext('Bars', '').split()
|
||||
bid = bar_ids[col] if col < len(bar_ids) else '-1'
|
||||
if bid != '-1' and bid:
|
||||
bar = bars_by_id.get(bid)
|
||||
if bar is not None:
|
||||
for vid in bar.findtext('Voices', '').split():
|
||||
if vid == '-1':
|
||||
continue
|
||||
voice = voices_dict.get(vid)
|
||||
if voice is None:
|
||||
continue
|
||||
vt = t_cursor
|
||||
for beat_id in voice.findtext('Beats', '').split():
|
||||
beat = beats_dict.get(beat_id)
|
||||
if beat is None:
|
||||
continue
|
||||
dur = _beat_dur_secs(beat, rhythms_dict, cur_tempo)
|
||||
for nid in beat.findtext('Notes', '').strip().split():
|
||||
note_el = notes_dict.get(nid)
|
||||
if note_el is None:
|
||||
continue
|
||||
if _note_is_tie(note_el):
|
||||
tie_midi = _note_midi(note_el, string_pitches)
|
||||
if tie_midi is not None:
|
||||
prev = last_per_key.get(tie_midi)
|
||||
tie_t = vt + audio_offset
|
||||
if prev is not None and prev.time < tie_t:
|
||||
prev.sustain = max(
|
||||
prev.sustain, (tie_t + dur) - prev.time)
|
||||
continue
|
||||
midi = _note_midi(note_el, string_pitches)
|
||||
if midi is None:
|
||||
continue
|
||||
rn = RsNote(
|
||||
time=vt + audio_offset,
|
||||
string=midi // 24,
|
||||
fret=midi % 24,
|
||||
sustain=dur if dur > 0.2 else 0.0,
|
||||
)
|
||||
notes.append(rn)
|
||||
last_per_key[midi] = rn
|
||||
vt += dur
|
||||
t_cursor += bar_dur
|
||||
return notes
|
||||
|
||||
|
||||
def _merge_lh_notes(rs_notes: list, rs_chords: list, lh_notes: list) -> None:
|
||||
"""Fold ``lh_notes`` (a second stave / left hand) into ``rs_notes`` in
|
||||
place, de-duplicating simultaneous same-pitch notes and keeping the LONGER
|
||||
sustain when both hands strike the same key at the same instant. Seeds the
|
||||
dedup set from chord notes too (polyphonic RH beats live in
|
||||
``rs_chords[*].notes``). No-op for an empty ``lh_notes``."""
|
||||
if not lh_notes:
|
||||
return
|
||||
seen: dict[tuple, RsNote] = {}
|
||||
for n in rs_notes:
|
||||
seen.setdefault((round(n.time, 3), n.string, n.fret), n)
|
||||
for c in rs_chords:
|
||||
for cn in c.notes:
|
||||
seen.setdefault((round(cn.time, 3), cn.string, cn.fret), cn)
|
||||
for rn in lh_notes:
|
||||
k = (round(rn.time, 3), rn.string, rn.fret)
|
||||
existing = seen.get(k)
|
||||
if existing is None:
|
||||
rs_notes.append(rn)
|
||||
seen[k] = rn
|
||||
elif rn.sustain > existing.sustain:
|
||||
# Mutating the RsNote also updates it in place inside any RH chord.
|
||||
existing.sustain = rn.sustain
|
||||
rs_notes.sort(key=lambda n: (n.time, n.string))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Drum encoding tables — ported from alphaTab PercussionMapper (MIT licensed)
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -612,118 +445,6 @@ def _gp6_element_variation_to_midi(element: int, variation: int) -> int | None:
|
||||
return _ART_TO_MIDI.get(art_id, art_id)
|
||||
|
||||
|
||||
# GPIF chord-diagram <Position finger="..."> names → RS finger integers,
|
||||
# matching the editor (E1) + gp2rs/pyguitarpro convention:
|
||||
# open/unused = -1, thumb = 0, index = 1, middle = 2, ring = 3, pinky = 4.
|
||||
_GPIF_FINGER_MAP = {
|
||||
'none': -1, 'open': -1, '': -1,
|
||||
'thumb': 0,
|
||||
'index': 1,
|
||||
'middle': 2,
|
||||
'ring': 3, 'annular': 3,
|
||||
'pinky': 4, 'little': 4,
|
||||
}
|
||||
|
||||
# Per-note <LeftFingering> teaching mark (§6.2.2). Unlike the chord-diagram
|
||||
# <Position finger=".."> path above, GPIF stores a single note's fret-hand
|
||||
# finger as a direct <Note> child element with the classical p-i-m-a-c letter
|
||||
# codes (verified against GP8 exports), mapped to the same RS finger integers
|
||||
# (open = -1, thumb = 0, index = 1, middle = 2, annular/ring = 3, little = 4).
|
||||
_GPIF_LEFT_FINGERING_MAP = {
|
||||
'open': -1, 'none': -1, '': -1,
|
||||
'p': 0, 'thumb': 0,
|
||||
'i': 1, 'index': 1,
|
||||
'm': 2, 'middle': 2,
|
||||
'a': 3, 'annular': 3, 'ring': 3,
|
||||
'c': 4, 'little': 4, 'pinky': 4,
|
||||
}
|
||||
|
||||
|
||||
def _gpif_left_fingering(note_el) -> int:
|
||||
"""Read a GPIF <Note>'s fret-hand finger (<LeftFingering>) -> RS finger int.
|
||||
|
||||
Returns -1 (unset) when absent or unrecognised — never fabricates a finger.
|
||||
Teaching mark only (§6.2.2); never used for grading."""
|
||||
raw = (note_el.findtext('LeftFingering') or '').strip().lower()
|
||||
if not raw:
|
||||
return -1
|
||||
return _GPIF_LEFT_FINGERING_MAP.get(raw, -1)
|
||||
|
||||
|
||||
def _rs_string_order(string_pitches: list[int]) -> dict[int, int]:
|
||||
"""Map each GPIF string index → RS string index (0 = lowest pitch).
|
||||
|
||||
Mirrors the per-note transform in ``convert_file`` (sort GPIF string
|
||||
indices by open pitch ascending, tiebreak on index, use the rank), so a
|
||||
chord diagram's string indices land on the same RS strings as the played
|
||||
notes regardless of format direction (GP6 .gpx high→low, GP8 .gp low→high).
|
||||
"""
|
||||
order = sorted(range(len(string_pitches)),
|
||||
key=lambda i: (string_pitches[i], i))
|
||||
return {gp: rs for rs, gp in enumerate(order)}
|
||||
|
||||
|
||||
def _parse_chord_diagrams(track_el, string_pitches: list[int]) -> dict:
|
||||
"""Map fret-pattern tuple → ``{'name', 'fingers'}`` from a track's diagrams.
|
||||
|
||||
GP7/GP8 GPIF stores authored chord diagrams per track under
|
||||
``Properties/Property[@name="DiagramCollection"]/Items/Item``. Each Item
|
||||
carries the chord name (its ``name`` attribute) and a ``<Diagram>`` with
|
||||
per-string ``<Fret string=.. fret=..>`` plus
|
||||
``<Fingering><Position finger=.. string=..></Fingering>``. Diagram string
|
||||
indices share the positional space of note ``String`` indices, so they go
|
||||
through the same pitch-rank transform; ``<Fret fret>`` is the absolute fret
|
||||
(``baseFret`` is display-only and not applied).
|
||||
|
||||
Keying by fret pattern (width-normalised to ≥6, exactly like the template
|
||||
build site) keeps the join key consistent with GP5 + the editor's
|
||||
preserve-by-fret-key (E0). Returns ``{}`` when there are no diagrams or no
|
||||
string tuning (orientation/width would be undefined).
|
||||
"""
|
||||
diagrams: dict[tuple, dict] = {}
|
||||
if track_el is None or not string_pitches:
|
||||
return diagrams
|
||||
gp_to_rs = _rs_string_order(string_pitches)
|
||||
for item in track_el.findall(
|
||||
'.//Property[@name="DiagramCollection"]/Items/Item'):
|
||||
diag = item.find('Diagram')
|
||||
if diag is None:
|
||||
continue
|
||||
rs_frets: dict[int, int] = {}
|
||||
for fr in diag.findall('Fret'):
|
||||
try:
|
||||
gp = int(fr.get('string'))
|
||||
fret = int(fr.get('fret'))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if fret < 0:
|
||||
continue
|
||||
rs = gp_to_rs.get(gp)
|
||||
if rs is not None:
|
||||
rs_frets[rs] = fret
|
||||
if not rs_frets:
|
||||
continue
|
||||
width = max(6, max(rs_frets) + 1)
|
||||
frets = [-1] * width
|
||||
fingers = [-1] * width
|
||||
for rs, fret in rs_frets.items():
|
||||
frets[rs] = fret
|
||||
for pos in diag.findall('Fingering/Position'):
|
||||
try:
|
||||
gp = int(pos.get('string'))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
rs = gp_to_rs.get(gp)
|
||||
if rs is None or not (0 <= rs < width) or frets[rs] < 0:
|
||||
continue
|
||||
fname = (pos.get('finger') or '').strip().lower()
|
||||
fingers[rs] = _GPIF_FINGER_MAP.get(fname, -1)
|
||||
# First diagram wins for a given voicing (stable, deterministic).
|
||||
diagrams.setdefault(tuple(frets),
|
||||
{'name': item.get('name', '') or '', 'fingers': fingers})
|
||||
return diagrams
|
||||
|
||||
|
||||
def _gpx_percussion_midis(track_el) -> list[int]:
|
||||
"""Flatten a drumKit ``InstrumentSet``'s articulations into a list of GM
|
||||
``OutputMidiNumber``s, positionally indexed to match a note's
|
||||
@@ -889,20 +610,6 @@ def _note_has_vibrato(note_el: ET.Element, prop_map: dict) -> bool:
|
||||
return 'Vibrato' in prop_map or note_el.find('Vibrato') is not None
|
||||
|
||||
|
||||
def _beat_has_tremolo(beat_el: ET.Element) -> bool:
|
||||
"""True if a GP7/GP8 beat carries tremolo picking.
|
||||
|
||||
GPIF encodes tremolo picking as a DIRECT beat-level
|
||||
``<Tremolo>1/8</Tremolo>`` child of ``<Beat>`` (the value is the rate). The
|
||||
RS note model has a single boolean tremolo flag with no rate, so the rate is
|
||||
intentionally ignored — any tremolo-picked beat maps to note tremolo across
|
||||
it. Matched as a direct child (not ``.//``) so it is never confused with the
|
||||
whammy-bar ``VibratoWTremBar`` Property, a separate beat-level effect
|
||||
handled elsewhere.
|
||||
"""
|
||||
return beat_el.find('Tremolo') is not None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# list_tracks — mirrors gp2rs.list_tracks interface
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -1353,59 +1060,6 @@ def _gpx_bend_scale(root: ET.Element) -> float:
|
||||
return 50.0 if peak <= 400 else 2500.0
|
||||
|
||||
|
||||
def _gpx_bend_float(tp: dict, name: str):
|
||||
"""Read a GPIF bend `<Property><Float>` value from the property map, or None."""
|
||||
el = tp.get(name)
|
||||
if el is None:
|
||||
return None
|
||||
try:
|
||||
return float(el.findtext('Float') or 0)
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
def _gpx_bend_shape(tp: dict, divisor: float, sustain: float):
|
||||
"""Build ``(peak, intent, curve)`` from a GPIF note's bend Properties (§6.2.1).
|
||||
|
||||
GPIF describes a bend as origin / middle / destination value+offset pairs;
|
||||
`value / divisor` is semitones (divisor auto-detected per file) and the
|
||||
`*Offset` Properties are 0..100 (percent of the note's duration). Produces a
|
||||
bnv curve of up to three points (mapping each offset to seconds-from-onset),
|
||||
or ``None`` when there's no usable shape (no points, flat-zero, or a
|
||||
zero-length note). When an offset Property is absent the stage falls back to
|
||||
an evenly-spaced default (origin 0%, middle 50%, destination 100%).
|
||||
|
||||
NOTE: offset Property names should be confirmed against a real GP8 export;
|
||||
the value path matches the existing scalar-bend extraction either way."""
|
||||
from gp2rs import _bend_intent_from_values # lazy: gp2rs<->gpx circular
|
||||
stages = (
|
||||
('BendOriginValue', 'BendOriginOffset', 0.0),
|
||||
('BendMiddleValue', 'BendMiddleOffset1', 50.0),
|
||||
('BendDestinationValue', 'BendDestinationOffset', 100.0),
|
||||
)
|
||||
pts = []
|
||||
for vkey, okey, default_off in stages:
|
||||
v = _gpx_bend_float(tp, vkey)
|
||||
if v is None:
|
||||
continue
|
||||
off = _gpx_bend_float(tp, okey)
|
||||
if off is None:
|
||||
off = default_off
|
||||
off = max(0.0, min(100.0, off))
|
||||
pts.append((off, round(v / divisor, 1)))
|
||||
if not pts:
|
||||
return 0.0, 0, None
|
||||
pts.sort(key=lambda p: p[0])
|
||||
values = [v for _, v in pts]
|
||||
peak = round(max(values), 1)
|
||||
intent = _bend_intent_from_values(values)
|
||||
curve = None
|
||||
if peak > 0 and sustain > 0 and len(pts) >= 2:
|
||||
curve = [{"t": round(sustain * (off / 100.0), 3), "v": v}
|
||||
for off, v in pts]
|
||||
return peak, intent, curve
|
||||
|
||||
|
||||
def _resolve_pending_slides(rs_notes, rs_chords, pending_slides):
|
||||
"""Resolve GP slide flags collected during the beat loop into RS slide
|
||||
fields, now that every note on each string is known.
|
||||
@@ -1506,10 +1160,6 @@ def convert_file(
|
||||
# Surface that to the caller rather than only the docstring: if the score
|
||||
# actually uses repeats, the produced bar count/timing will differ from the
|
||||
# equivalent .gp5. Warn once so plugin code/logs don't silently drift.
|
||||
# NB: lib/gp_autosync.gp_has_expandable_repeats() encodes this single-pass
|
||||
# behaviour (.gp/.gpx never expand). Implementing GPIF expansion here MUST
|
||||
# update that helper in the same change, or the editor's per-bar sync warp
|
||||
# would silently retime repeated sections onto the wrong bars.
|
||||
if expand_repeats and any(
|
||||
mb.find('Repeat') is not None or mb.find('AlternateEndings') is not None
|
||||
for mb in masterbars
|
||||
@@ -1527,84 +1177,25 @@ def convert_file(
|
||||
rhythms_dict = {r.get('id'): r for r in (root.find('Rhythms') or [])}
|
||||
_bend_divisor = _gpx_bend_scale(root) # GPIF bend value -> semitones
|
||||
|
||||
# Map filtered track index -> bar column (MasterBar/Bars position for
|
||||
# stave 0 of that track). `_gpif_tracks` already computed the per-stave
|
||||
# column layout (advancing by num_staves per track, pseudo-tracks skipped),
|
||||
# so reuse its `stave_columns[0]` rather than re-deriving the counting rule
|
||||
# here — divergence in stave counting *is* the bug class this fix closes.
|
||||
# NB: despite the historical name, the value is a bar *column*, not a raw
|
||||
# Track index — do not index `root.find('Tracks')` with it.
|
||||
filtered_to_raw: dict[int, int] = {
|
||||
i: t['stave_columns'][0] for i, t in enumerate(tracks)
|
||||
}
|
||||
# Map filtered track index -> raw track index (needed for bar lookup)
|
||||
raw_tracks = list(root.find('Tracks') or [])
|
||||
filtered_to_raw: dict[int, int] = {}
|
||||
filtered_pos = 0
|
||||
for raw_idx, t_el in enumerate(raw_tracks):
|
||||
name = (t_el.findtext('Name') or '').strip()
|
||||
if name.startswith('@$') and name.endswith('$@'):
|
||||
continue
|
||||
filtered_to_raw[filtered_pos] = raw_idx
|
||||
filtered_pos += 1
|
||||
|
||||
|
||||
# Detect and merge Piano LH+RH pairs into single full-keyboard arrangements
|
||||
track_indices, _piano_merge_map = _find_piano_pairs(track_indices, tracks, names)
|
||||
|
||||
output_files = []
|
||||
# All auto-assigned arrangement names handed out so far, so multiple
|
||||
# arrangements get distinct labels (Lead, Rhythm, Combo, Bass, Bass 2, …)
|
||||
# and the name-aware and positional guitar paths never collide.
|
||||
_used_arr_names: set[str] = set()
|
||||
|
||||
def _unique_arr_name(base: str) -> str:
|
||||
"""Return `base`, or `base 2`/`base 3`/… if it's already been used."""
|
||||
if base not in _used_arr_names:
|
||||
_used_arr_names.add(base)
|
||||
return base
|
||||
k = 2
|
||||
while f"{base} {k}" in _used_arr_names:
|
||||
k += 1
|
||||
name = f"{base} {k}"
|
||||
_used_arr_names.add(name)
|
||||
return name
|
||||
|
||||
_KEYS_PROGS = set(range(0, 8)) | set(range(16, 24)) | {80, 81, 82, 83}
|
||||
|
||||
def _auto_guitar_hint(track_idx: int):
|
||||
"""For a track that auto-resolves to a guitar arrangement, return its
|
||||
role hint: 'lead', 'rhythm', or '' (unhinted). None when the track is
|
||||
NOT an auto-named guitar (explicitly named, bass, drum, vocal, keys).
|
||||
Mirrors the per-track classification in the conversion loop below."""
|
||||
if track_idx >= len(tracks) or names.get(track_idx):
|
||||
return None
|
||||
t = tracks[track_idx]
|
||||
if t['is_drums'] or _is_vocal_track(t):
|
||||
return None
|
||||
low = t['name'].lower()
|
||||
sp = t['string_pitches']
|
||||
prog = t['midi_program']
|
||||
if (isinstance(prog, int) and 32 <= prog <= 39) or (bool(sp) and max(sp) <= 48) or 'bass' in low:
|
||||
return None # bass
|
||||
if (not sp and prog in _KEYS_PROGS) or any(kw in low for kw in ('piano', 'keys', 'keyboard', 'organ')):
|
||||
return None # keys
|
||||
if 'lead' in low and 'rhythm' not in low:
|
||||
return 'lead'
|
||||
if 'rhythm' in low and 'lead' not in low:
|
||||
return 'rhythm'
|
||||
return '' # guitar, no role hint
|
||||
|
||||
# Two-pass guitar role naming, resolved up front so the per-track loop just
|
||||
# looks names up. Reserve every name-hinted Lead/Rhythm first, THEN fill
|
||||
# unhinted guitars into the remaining canonical roles. A single pass would
|
||||
# let an unhinted guitar that appears BEFORE a hinted one steal its role,
|
||||
# pushing the real Lead/Rhythm to a non-canonical "Rhythm 2" that the
|
||||
# downstream name-based path classification doesn't recognise.
|
||||
_guitar_name_by_idx: dict[int, str] = {}
|
||||
_unhinted_guitars: list[int] = []
|
||||
for _ti in track_indices:
|
||||
hint = _auto_guitar_hint(_ti)
|
||||
if hint is None:
|
||||
continue
|
||||
if hint == 'lead':
|
||||
_guitar_name_by_idx[_ti] = _unique_arr_name('Lead')
|
||||
elif hint == 'rhythm':
|
||||
_guitar_name_by_idx[_ti] = _unique_arr_name('Rhythm')
|
||||
else:
|
||||
_unhinted_guitars.append(_ti)
|
||||
for _ti in _unhinted_guitars:
|
||||
base = next((r for r in ('Lead', 'Rhythm', 'Combo') if r not in _used_arr_names), 'Combo')
|
||||
_guitar_name_by_idx[_ti] = _unique_arr_name(base)
|
||||
# Counts of auto-named guitar/bass arrangements so far, so multiple guitars
|
||||
# get distinct RS roles (Lead, Rhythm, Combo, …) instead of all "Lead".
|
||||
_role_counts: dict[str, int] = {}
|
||||
|
||||
for track_idx in track_indices:
|
||||
if track_idx >= len(tracks):
|
||||
@@ -1628,16 +1219,7 @@ def convert_file(
|
||||
is_keys = (
|
||||
not is_drum and not is_vocal
|
||||
and (
|
||||
# A multi-stave track is a grand staff (treble + bass) — i.e. a
|
||||
# keyboard-family part. Treating it as keys end-to-end keeps the
|
||||
# stave-0 encoding and the folded stave-1+ encoding consistent
|
||||
# (both midi//24, midi%24) and makes the `note_count` preview
|
||||
# (which sums every stave column) match what actually imports,
|
||||
# even for instruments the name/program heuristics miss (harp,
|
||||
# celesta, marimba). GPIF writes guitars as a single Staff, so
|
||||
# this does not sweep in ordinary fretted tracks.
|
||||
track.get('num_staves', 1) > 1
|
||||
or any(kw in track['name'].lower() for kw in ('piano', 'keys', 'keyboard', 'organ'))
|
||||
any(kw in track['name'].lower() for kw in ('piano', 'keys', 'keyboard', 'organ'))
|
||||
or arr_name.lower().startswith('keys')
|
||||
or (
|
||||
not track['string_pitches']
|
||||
@@ -1661,12 +1243,16 @@ def convert_file(
|
||||
)
|
||||
low = track['name'].lower()
|
||||
if is_bass or 'bass' in low:
|
||||
arr_name = _unique_arr_name('Bass')
|
||||
_bc = _role_counts.get('bass', 0)
|
||||
_role_counts['bass'] = _bc + 1
|
||||
arr_name = 'Bass' if _bc == 0 else f'Bass {_bc + 1}'
|
||||
else:
|
||||
# Guitar role was resolved up front (two-pass, honoring
|
||||
# "lead"/"rhythm" in the GP track name so a Rhythm-before-Lead
|
||||
# file isn't swapped by positional assignment).
|
||||
arr_name = _guitar_name_by_idx.get(track_idx) or _unique_arr_name('Lead')
|
||||
# Distinct guitar roles by appearance order so two guitars
|
||||
# don't both become "Lead": Lead, Rhythm, Combo, then Combo N.
|
||||
_gc = _role_counts.get('guitar', 0)
|
||||
_role_counts['guitar'] = _gc + 1
|
||||
_roles = ('Lead', 'Rhythm', 'Combo')
|
||||
arr_name = _roles[_gc] if _gc < len(_roles) else f'Combo {_gc - 1}'
|
||||
|
||||
# Vocal tracks get their own converter — outputs vocals XML, not notes XML
|
||||
if is_vocal:
|
||||
@@ -1691,10 +1277,6 @@ def convert_file(
|
||||
rs_chords: list[RsChord] = []
|
||||
chord_templates: list[ChordTemplate] = []
|
||||
chord_template_map: dict[tuple, int] = {}
|
||||
# Authored chord diagrams (name + per-string fingering) for this track,
|
||||
# keyed by fret pattern so they enrich matching played voicings.
|
||||
chord_diagram_map = _parse_chord_diagrams(
|
||||
track.get('_el'), track['string_pitches'])
|
||||
beats_out: list[RsBeat] = []
|
||||
sections: list[RsSection] = []
|
||||
section_counts: dict[str, int] = {}
|
||||
@@ -1703,6 +1285,7 @@ def convert_file(
|
||||
pending_slides: list = [] # (RsNote, rs_string, gp_slide_flags) — resolved post-loop
|
||||
|
||||
current_time = 0.0
|
||||
num_raw_tracks = len(raw_tracks)
|
||||
|
||||
# Resolve current tempo per bar from the tempo map
|
||||
_tempo_iter = iter(tempo_map)
|
||||
@@ -1873,11 +1456,6 @@ def convert_file(
|
||||
rn.vibrato = True
|
||||
if 'LeftHandTapping' in _tp or 'Tapped' in _tp:
|
||||
rn.tap = True
|
||||
# Fret-hand fingering -> fg teaching mark
|
||||
# (§6.2.2). <LeftFingering> is a direct <Note>
|
||||
# child, not a <Property>, so read it off
|
||||
# note_el rather than the property map.
|
||||
rn.fret_finger = _gpif_left_fingering(note_el)
|
||||
if 'HarmonicType' in _tp:
|
||||
_ht = (_tp['HarmonicType'].findtext('HType')
|
||||
or '').strip().lower()
|
||||
@@ -1895,21 +1473,21 @@ def convert_file(
|
||||
rn.pull_off = True
|
||||
else:
|
||||
rn.hammer_on = True
|
||||
# Bend: `bn` is the peak; `bnv`/`bt` capture
|
||||
# the shape over time (§6.2.1). value/divisor
|
||||
# = semitones (scale auto-detected per file).
|
||||
# Bend: peak amount (GPIF bend value → semitones,
|
||||
# scale auto-detected per file in _bend_divisor).
|
||||
if 'Bended' in _tp:
|
||||
# Use the beat duration `dur`, not
|
||||
# `rn.sustain` (zeroed for notes <= 0.2s),
|
||||
# so short bends keep their bnv curve —
|
||||
# matching the GP5 path, which maps over
|
||||
# the raw note duration.
|
||||
_peak, _intent, _curve = _gpx_bend_shape(
|
||||
_tp, _bend_divisor, dur)
|
||||
if _peak > 0:
|
||||
rn.bend = _peak
|
||||
rn.bend_intent = _intent
|
||||
rn.bend_values = _curve
|
||||
_bv = 0.0
|
||||
for _bk in ('BendDestinationValue',
|
||||
'BendMiddleValue', 'BendOriginValue'):
|
||||
_be = _tp.get(_bk)
|
||||
if _be is not None:
|
||||
try:
|
||||
_bv = max(_bv, float(
|
||||
_be.findtext('Float') or 0))
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
if _bv > 0:
|
||||
rn.bend = round(_bv / _bend_divisor, 1)
|
||||
# Slide flags: 1/2 = pitched slide to the next
|
||||
# note; 4 = slide out down, 8 = out up. Resolved
|
||||
# post-loop (needs the next note on the string).
|
||||
@@ -1944,17 +1522,6 @@ def convert_file(
|
||||
for _bn in beat_rs_notes:
|
||||
_bn.vibrato = True
|
||||
|
||||
# Tremolo picking: GP7/GP8 encodes the rate as a
|
||||
# beat-level <Tremolo>1/8</Tremolo> child. The note
|
||||
# model has a single tremolo flag (no rate), so map
|
||||
# any tremolo-picked beat to note tremolo across it.
|
||||
# Independent of vibrato above — a note can carry
|
||||
# both. (Beat-level <Tremolo>, not the whammy
|
||||
# VibratoWTremBar Property, which is handled above.)
|
||||
if _beat_has_tremolo(beat_el):
|
||||
for _bn in beat_rs_notes:
|
||||
_bn.tremolo = True
|
||||
|
||||
if len(beat_rs_notes) == 1:
|
||||
rs_notes.append(beat_rs_notes[0])
|
||||
elif len(beat_rs_notes) > 1:
|
||||
@@ -1966,12 +1533,8 @@ def convert_file(
|
||||
fkey = tuple(frets_t)
|
||||
if fkey not in chord_template_map:
|
||||
chord_template_map[fkey] = len(chord_templates)
|
||||
_diag = chord_diagram_map.get(fkey)
|
||||
chord_templates.append(ChordTemplate(
|
||||
name=(_diag['name'] if _diag else ''),
|
||||
frets=list(frets_t),
|
||||
fingers=(list(_diag['fingers']) if _diag
|
||||
else [-1] * width),
|
||||
name='', frets=list(frets_t), fingers=[-1] * width,
|
||||
))
|
||||
rs_chords.append(RsChord(
|
||||
time=t,
|
||||
@@ -2034,20 +1597,112 @@ def convert_file(
|
||||
tuning = _gpx_tuning(track)
|
||||
|
||||
# Merge Piano LH notes into this (RH) arrangement if a pair was detected
|
||||
_walk_kwargs = dict(
|
||||
masterbars=masterbars, bars_by_id=bars_by_id,
|
||||
voices_dict=voices_dict, beats_dict=beats_dict,
|
||||
notes_dict=notes_dict, rhythms_dict=rhythms_dict,
|
||||
tempo_map=tempo_map, tempo_bpm=tempo_bpm, audio_offset=audio_offset,
|
||||
)
|
||||
if is_keys and track_idx in _piano_merge_map:
|
||||
# GPX LH/RH pair: the left hand is a *separate* Track element. Walk
|
||||
# its column and fold it into this (right-hand) arrangement.
|
||||
lh_idx = _piano_merge_map[track_idx]
|
||||
lh_track = tracks[lh_idx]
|
||||
lh_raw_idx = filtered_to_raw.get(lh_idx, lh_idx)
|
||||
_merge_lh_notes(rs_notes, rs_chords, _collect_column_notes(
|
||||
lh_raw_idx, lh_track['string_pitches'], **_walk_kwargs))
|
||||
|
||||
_lh_notes: list[RsNote] = []
|
||||
_lh_last_per_key: dict[int, RsNote] = {}
|
||||
_lh_tempo_iter = iter(tempo_map)
|
||||
_lh_next_bar, _lh_next_bpm = next(_lh_tempo_iter, (999999, tempo_bpm))
|
||||
_lh_cur_tempo = tempo_bpm
|
||||
_lh_time = 0.0
|
||||
|
||||
for _lh_mb_idx, _lh_mb in enumerate(masterbars):
|
||||
while _lh_mb_idx >= _lh_next_bar:
|
||||
_lh_cur_tempo = _lh_next_bpm
|
||||
_lh_next_bar, _lh_next_bpm = next(_lh_tempo_iter, (999999, _lh_cur_tempo))
|
||||
_lh_ts = _lh_mb.findtext('Time', '4/4')
|
||||
try:
|
||||
_lh_nb, _lh_db = [int(x) for x in _lh_ts.split('/')]
|
||||
except ValueError:
|
||||
_lh_nb, _lh_db = 4, 4
|
||||
_lh_bar_dur = _lh_nb * (4.0 / _lh_db) * (60.0 / _lh_cur_tempo)
|
||||
_lh_bar_ids = _lh_mb.findtext('Bars', '').split()
|
||||
_lh_bid = _lh_bar_ids[lh_raw_idx] if lh_raw_idx < len(_lh_bar_ids) else '-1'
|
||||
if _lh_bid != '-1' and _lh_bid:
|
||||
_lh_bar = bars_by_id.get(_lh_bid)
|
||||
if _lh_bar is not None:
|
||||
for _lh_vid in _lh_bar.findtext('Voices', '').split():
|
||||
if _lh_vid == '-1':
|
||||
continue
|
||||
_lh_voice = voices_dict.get(_lh_vid)
|
||||
if _lh_voice is None:
|
||||
continue
|
||||
_lh_vt = _lh_time
|
||||
for _lh_beat_id in _lh_voice.findtext('Beats', '').split():
|
||||
_lh_beat = beats_dict.get(_lh_beat_id)
|
||||
if _lh_beat is None:
|
||||
continue
|
||||
_lh_dur = _beat_dur_secs(_lh_beat, rhythms_dict, _lh_cur_tempo)
|
||||
for _lh_nid in _lh_beat.findtext('Notes', '').strip().split():
|
||||
_lh_note_el = notes_dict.get(_lh_nid)
|
||||
if _lh_note_el is None:
|
||||
continue
|
||||
if _note_is_tie(_lh_note_el):
|
||||
# Extend the matching prior note (same
|
||||
# pitch), mirroring the main builder's
|
||||
# last_note_per_key handling — blindly
|
||||
# extending the last-emitted note
|
||||
# mishandles polyphonic (chord) LH parts.
|
||||
_tie_midi = _note_midi(_lh_note_el, lh_track['string_pitches'])
|
||||
if _tie_midi is not None:
|
||||
_prev = _lh_last_per_key.get(_tie_midi)
|
||||
# Full-precision comparison (matching
|
||||
# the main builder); rounding only
|
||||
# happens at XML serialization. Rounding
|
||||
# here could make a short note appear to
|
||||
# start at the tie time and skip the
|
||||
# sustain extension.
|
||||
_tie_t = _lh_vt + audio_offset
|
||||
if _prev is not None and _prev.time < _tie_t:
|
||||
_prev.sustain = max(
|
||||
_prev.sustain,
|
||||
(_tie_t + _lh_dur) - _prev.time,
|
||||
)
|
||||
continue
|
||||
_lh_midi = _note_midi(_lh_note_el, lh_track['string_pitches'])
|
||||
if _lh_midi is None:
|
||||
continue
|
||||
# Keep full-precision time (like the main
|
||||
# convert_file() builder — rounding happens at
|
||||
# serialization); same 0.2s sustain threshold.
|
||||
_lh_rn = RsNote(
|
||||
time=_lh_vt + audio_offset,
|
||||
string=_lh_midi // 24,
|
||||
fret=_lh_midi % 24,
|
||||
sustain=_lh_dur if _lh_dur > 0.2 else 0.0,
|
||||
)
|
||||
_lh_notes.append(_lh_rn)
|
||||
_lh_last_per_key[_lh_midi] = _lh_rn
|
||||
_lh_vt += _lh_dur
|
||||
_lh_time += _lh_bar_dur
|
||||
|
||||
# Merge: combine and deduplicate simultaneous same-pitch notes, then
|
||||
# sort by time. Map each (time, string, fret) to its existing RsNote
|
||||
# so that when both hands hit the same key at the same instant we
|
||||
# keep the LONGER sustain instead of arbitrarily discarding the LH
|
||||
# one. Seed from both single notes and chord notes — polyphonic RH
|
||||
# beats live in rs_chords[*].notes, so seeding from rs_notes alone
|
||||
# would let an identical LH note slip in as a duplicate.
|
||||
_seen: dict[tuple, RsNote] = {}
|
||||
for _n in rs_notes:
|
||||
_seen.setdefault((round(_n.time, 3), _n.string, _n.fret), _n)
|
||||
for _c in rs_chords:
|
||||
for _cn in _c.notes:
|
||||
_seen.setdefault((round(_cn.time, 3), _cn.string, _cn.fret), _cn)
|
||||
for _lh_rn in _lh_notes:
|
||||
_k = (round(_lh_rn.time, 3), _lh_rn.string, _lh_rn.fret)
|
||||
_existing = _seen.get(_k)
|
||||
if _existing is None:
|
||||
rs_notes.append(_lh_rn)
|
||||
_seen[_k] = _lh_rn
|
||||
elif _lh_rn.sustain > _existing.sustain:
|
||||
# Same key both hands — preserve the longer sustain (mutating
|
||||
# the RsNote also updates it in place inside any RH chord).
|
||||
_existing.sustain = _lh_rn.sustain
|
||||
rs_notes.sort(key=lambda n: (n.time, n.string))
|
||||
|
||||
# Collapse "Keys 2" -> "Keys": the merged LH+RH is a single
|
||||
# keyboard arrangement. Keep the standard "Keys" name (not "Piano")
|
||||
@@ -2055,18 +1710,6 @@ def convert_file(
|
||||
# auto-select (which keys on arr_name.startswith("keys")) still work.
|
||||
arr_name = re.sub(r'\s*\d+$', '', arr_name).strip() or 'Keys'
|
||||
|
||||
elif track.get('num_staves', 1) > 1:
|
||||
# GP8 grand-staff keyboard: staves 1+ (bass clef, and any further
|
||||
# staves) are extra MasterBar/Bars columns for the SAME Track
|
||||
# element. Fold each one in, exactly like the GPX LH merge above.
|
||||
# (num_staves > 1 implies is_keys, set above.) Iterating every
|
||||
# extra column — not just stave_columns[1] — keeps the arrangement
|
||||
# consistent with note_count, which sums all columns.
|
||||
for _col, _sp in zip(track['stave_columns'][1:],
|
||||
track['stave_pitches'][1:]):
|
||||
_merge_lh_notes(rs_notes, rs_chords, _collect_column_notes(
|
||||
_col, _sp, **_walk_kwargs))
|
||||
|
||||
# Resolve pending slides now that every note on each string is known.
|
||||
# GPIF slide flags: 1=shift, 2=legato (both slide to the NEXT note on the
|
||||
# string); 4=slide out downwards, 8=slide out upwards (unpitched).
|
||||
@@ -2120,24 +1763,19 @@ def convert_file(
|
||||
try:
|
||||
import gp2notation as _gp2notation
|
||||
_lh_idx = _piano_merge_map.get(track_idx)
|
||||
if _lh_idx is not None:
|
||||
# GPX LH/RH pair (two separate Track elements)
|
||||
_nt_lh_raw = filtered_to_raw.get(_lh_idx, _lh_idx)
|
||||
_nt_lh_sp = tracks[_lh_idx]['string_pitches']
|
||||
elif track.get('num_staves', 1) > 1:
|
||||
# GP8 two-stave piano (one Track with multiple <Staves>)
|
||||
_nt_lh_raw = track['stave_columns'][1]
|
||||
_nt_lh_sp = (track['stave_pitches'][1]
|
||||
if len(track.get('stave_pitches', [])) > 1 else [])
|
||||
else:
|
||||
_nt_lh_raw, _nt_lh_sp = None, []
|
||||
_payload = _gp2notation.convert_track_to_notation(
|
||||
root, raw_idx, track['string_pitches'],
|
||||
instrument='piano',
|
||||
audio_offset=audio_offset,
|
||||
track_name=track['name'],
|
||||
lh_raw_idx=_nt_lh_raw,
|
||||
lh_string_pitches=_nt_lh_sp or None,
|
||||
lh_raw_idx=(
|
||||
filtered_to_raw.get(_lh_idx, _lh_idx)
|
||||
if _lh_idx is not None else None
|
||||
),
|
||||
lh_string_pitches=(
|
||||
tracks[_lh_idx]['string_pitches']
|
||||
if _lh_idx is not None else None
|
||||
),
|
||||
)
|
||||
_gp2notation.write_notation_sidecar(filepath, _payload)
|
||||
except Exception:
|
||||
@@ -2476,15 +2114,7 @@ def _auto_select_gpx(tracks: list[dict]) -> tuple[list[int], dict[int, str]]:
|
||||
if is_bass:
|
||||
selected.append((i, 'bass'))
|
||||
elif is_guitar:
|
||||
# Honor "lead"/"rhythm" in the GP track name so two guitars keep
|
||||
# the author's roles instead of being labelled by appearance order
|
||||
# (which swaps a Rhythm-before-Lead file). Unhinted → positional.
|
||||
if 'lead' in name_l and 'rhythm' not in name_l:
|
||||
selected.append((i, 'guitar_lead'))
|
||||
elif 'rhythm' in name_l and 'lead' not in name_l:
|
||||
selected.append((i, 'guitar_rhythm'))
|
||||
else:
|
||||
selected.append((i, 'guitar'))
|
||||
selected.append((i, 'guitar'))
|
||||
elif is_keys:
|
||||
selected.append((i, 'keys'))
|
||||
|
||||
@@ -2493,48 +2123,19 @@ def _auto_select_gpx(tracks: list[dict]) -> tuple[list[int], dict[int, str]]:
|
||||
if not t['is_drums'] and t.get('note_count', 1) > 0:
|
||||
selected.append((i, 'guitar'))
|
||||
|
||||
indices = []
|
||||
name_map = {}
|
||||
counts: dict[str, int] = {}
|
||||
RS_NAMES = {'bass': ('Bass',), 'keys': ('Keys',),
|
||||
'drums': ('Drums',), 'vocal': ('Vocals',)}
|
||||
used: set[str] = set()
|
||||
|
||||
def _unique(base: str) -> str:
|
||||
if base not in used:
|
||||
used.add(base)
|
||||
return base
|
||||
k = 2
|
||||
while f"{base} {k}" in used:
|
||||
k += 1
|
||||
used.add(f"{base} {k}")
|
||||
return f"{base} {k}"
|
||||
|
||||
# Two passes so name-hinted Lead/Rhythm guitars reserve their canonical role
|
||||
# BEFORE unhinted guitars are filled in — otherwise an unhinted guitar that
|
||||
# appears before a hinted one steals its role (real Rhythm → "Rhythm 2").
|
||||
# Non-guitar roles are handled in pass 1. `name_map` keys by track index so
|
||||
# this does not affect arrangement (selection) order, computed separately.
|
||||
for idx, role in selected:
|
||||
if role == 'guitar':
|
||||
continue
|
||||
if role == 'guitar_lead':
|
||||
base = 'Lead'
|
||||
elif role == 'guitar_rhythm':
|
||||
base = 'Rhythm'
|
||||
else:
|
||||
counts[role] = counts.get(role, 0) + 1
|
||||
c = counts[role]
|
||||
names_for_role = RS_NAMES.get(role, (role.title(),))
|
||||
base = names_for_role[min(c - 1, len(names_for_role) - 1)]
|
||||
if c > len(names_for_role):
|
||||
base = f"{names_for_role[-1]} {c}"
|
||||
name_map[idx] = _unique(base)
|
||||
RS_NAMES = {'guitar': ('Lead', 'Rhythm', 'Combo'), 'bass': ('Bass',), 'keys': ('Keys',), 'drums': ('Drums',), 'vocal': ('Vocals',)}
|
||||
|
||||
for idx, role in selected:
|
||||
if role != 'guitar':
|
||||
continue
|
||||
base = next((r for r in ('Lead', 'Rhythm', 'Combo') if r not in used), 'Combo')
|
||||
name_map[idx] = _unique(base)
|
||||
counts[role] = counts.get(role, 0) + 1
|
||||
c = counts[role]
|
||||
names_for_role = RS_NAMES.get(role, (role.title(),))
|
||||
arr_name = names_for_role[min(c - 1, len(names_for_role) - 1)]
|
||||
if c > len(names_for_role):
|
||||
arr_name = f"{names_for_role[-1]} {c}"
|
||||
indices.append(idx)
|
||||
name_map[idx] = arr_name
|
||||
|
||||
indices = [idx for idx, _role in selected]
|
||||
return indices, name_map
|
||||
|
||||
@@ -3,7 +3,7 @@ lib/gp8_audio_sync.py — Extract embedded audio and sync data from GP8 (.gp) fi
|
||||
|
||||
Guitar Pro 8 can embed a backing track (OGG audio) into a .gp file alongside
|
||||
sync points that map bar positions to exact audio timestamps. This module
|
||||
extracts both, giving FeedBack:
|
||||
extracts both, giving Slopsmith:
|
||||
|
||||
1. A real backing track audio file (OGG) — no MIDI synthesis needed
|
||||
2. A precise audio_offset (seconds) from the FramePadding value
|
||||
@@ -45,7 +45,7 @@ import io
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
_log = logging.getLogger("feedBack.lib.gp8_audio_sync")
|
||||
_log = logging.getLogger("slopsmith.lib.gp8_audio_sync")
|
||||
|
||||
# GP8 embeds the backing track under Content/Assets/ as OGG *or* one of
|
||||
# several other formats (MP3 is common — e.g. tracks rendered straight
|
||||
|
||||
+30
-525
@@ -18,22 +18,8 @@ plugin is installed; graceful ImportError otherwise with clear message).
|
||||
Public API:
|
||||
is_available() -> bool
|
||||
auto_sync(gp_path, audio_path, ...) -> GpSyncData
|
||||
refine_sync(sync, audio_path, ...) -> GpSyncData
|
||||
estimate_audio_offset(gp_path,
|
||||
audio_path) -> float
|
||||
bar_start_times(gp_path) -> list[float]
|
||||
gp_has_expandable_repeats(gp_path) -> bool
|
||||
build_warp_anchors(sync_points,
|
||||
bar_starts) -> list[tuple[float, float]]
|
||||
warp_time(t, anchors) -> float
|
||||
warp_song_times(song, warp) -> None
|
||||
|
||||
The warp helpers (bar_start_times / build_warp_anchors / warp_time /
|
||||
warp_song_times) are librosa-free: they turn a GpSyncData produced by
|
||||
auto_sync (or extracted from a GP8 file) into a piecewise-linear
|
||||
score-time -> audio-time mapping and apply it to a lib.song.Song, so
|
||||
converted charts follow the recording's actual tempo drift instead of a
|
||||
single scalar offset.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -44,7 +30,7 @@ import zipfile
|
||||
import io
|
||||
from pathlib import Path
|
||||
|
||||
_log = logging.getLogger("feedBack.lib.gp_autosync")
|
||||
_log = logging.getLogger("slopsmith.lib.gp_autosync")
|
||||
|
||||
# ── Dependency check ──────────────────────────────────────────────────────────
|
||||
|
||||
@@ -367,22 +353,15 @@ def _synthesise_score_chroma(
|
||||
return chroma
|
||||
|
||||
_GP345_TICKS_PER_QUARTER = 960
|
||||
# PyGuitarPro absolute ticks start at quarterTime (measure 1 begins at tick
|
||||
# 960, not 0). All tick math in this module runs on a 0-based axis (cumulative
|
||||
# measure starts), so raw beat.start values must be shifted by this origin —
|
||||
# mixing the two axes applied every mid-song tempo change a quarter note late
|
||||
# and skewed the synthesised chroma against the bar timeline.
|
||||
_GP345_TICK_ORIGIN = 960
|
||||
|
||||
|
||||
def _gp345_tempo_events(song) -> list[tuple[int, float]]:
|
||||
"""Sorted, tick-deduplicated ``[(tick, bpm)]`` tempo events for a GP3/4/5 song.
|
||||
|
||||
Seeds with the song's initial tempo at tick 0, then appends every
|
||||
``mixTableChange`` tempo. Ticks are normalised to the 0-based axis
|
||||
(raw ``beat.start`` minus ``_GP345_TICK_ORIGIN``). Shared by chroma
|
||||
synthesis and bar-time computation so both use one identical tempo
|
||||
model (mirrors ``gp2rs._build_tempo_map``).
|
||||
``mixTableChange`` tempo. Shared by chroma synthesis and bar-time
|
||||
computation so both use one identical tempo model (mirrors
|
||||
``gp2rs._build_tempo_map``).
|
||||
"""
|
||||
events: list[tuple[int, float]] = [(0, float(song.tempo))]
|
||||
for track in song.tracks:
|
||||
@@ -392,10 +371,7 @@ def _gp345_tempo_events(song) -> list[tuple[int, float]]:
|
||||
if beat.effect and beat.effect.mixTableChange:
|
||||
mtc = beat.effect.mixTableChange
|
||||
if mtc.tempo and mtc.tempo.value > 0:
|
||||
events.append((
|
||||
max(0, beat.start - _GP345_TICK_ORIGIN),
|
||||
float(mtc.tempo.value),
|
||||
))
|
||||
events.append((beat.start, float(mtc.tempo.value)))
|
||||
events.sort(key=lambda e: e[0])
|
||||
seen_ticks: set[int] = set()
|
||||
unique: list[tuple[int, float]] = []
|
||||
@@ -483,9 +459,8 @@ def _synthesise_score_chroma_gp345(
|
||||
for beat in voice.beats:
|
||||
if not beat.notes:
|
||||
continue
|
||||
beat_tick = max(0, beat.start - _GP345_TICK_ORIGIN)
|
||||
beat_secs = tick_to_secs(beat_tick)
|
||||
cur_tempo = tempo_at_tick(beat_tick)
|
||||
beat_secs = tick_to_secs(beat.start)
|
||||
cur_tempo = tempo_at_tick(beat.start)
|
||||
dur_secs = duration_to_secs(beat.duration, cur_tempo)
|
||||
|
||||
for note in beat.notes:
|
||||
@@ -538,75 +513,13 @@ def _dtw_align(
|
||||
Returns wp where wp[i] = [score_frame_index, audio_frame_index].
|
||||
"""
|
||||
import librosa
|
||||
import numpy as np
|
||||
cs = _safe_normalise(chroma_score)
|
||||
ca = _safe_normalise(chroma_audio)
|
||||
# Slope-constrained step pattern ([[1,1],[1,2],[2,1]], Müller's standard
|
||||
# music-sync config): every step advances BOTH axes, bounding the local
|
||||
# tempo ratio to 0.5x-2x. librosa's default steps allow pure
|
||||
# horizontal/vertical runs, and on riff-based music (long self-similar
|
||||
# chroma stretches, e.g. stoner/doom) the flat cost surface let the path
|
||||
# collapse — whole minutes of score mapped onto a single audio frame,
|
||||
# producing garbage sync points. The constrained pattern makes that
|
||||
# degenerate path impossible.
|
||||
steps = np.array([[1, 1], [1, 2], [2, 1]])
|
||||
weights = np.array([1.0, 1.0, 1.0])
|
||||
try:
|
||||
_D, wp = librosa.sequence.dtw(
|
||||
cs, ca, metric='cosine',
|
||||
step_sizes_sigma=steps, weights_mul=weights,
|
||||
)
|
||||
except Exception as exc:
|
||||
# The constrained pattern needs the global length ratio within its
|
||||
# 0.5x-2x slope bounds; a pathological pairing (e.g. a 3-minute tab
|
||||
# against a 20-minute video) is infeasible and librosa raises. Fall
|
||||
# back to the unconstrained path rather than failing the whole sync.
|
||||
_log.warning("gp_autosync: constrained DTW infeasible (%s) — "
|
||||
"falling back to unconstrained steps", exc)
|
||||
_D, wp = librosa.sequence.dtw(cs, ca, metric='cosine')
|
||||
_D, wp = librosa.sequence.dtw(cs, ca, metric='cosine')
|
||||
return wp[::-1] # reverse to forward order
|
||||
|
||||
# ── Sync point extraction from DTW path ──────────────────────────────────────
|
||||
|
||||
def _gpif_bar_starts(root: ET.Element) -> list[float]:
|
||||
"""Score-time (seconds) at the start of each masterbar in a GPIF score.
|
||||
|
||||
Integrates bar durations from the bar-resolution tempo map and each
|
||||
masterbar's time signature — the same time model _synthesise_score_chroma
|
||||
uses, so bar times land where the bars sit in the synthesised chroma.
|
||||
"""
|
||||
tempo_map = _get_tempo_map(root)
|
||||
masterbars = _children(root, 'MasterBars')
|
||||
tempo_iter = iter(tempo_map)
|
||||
next_tb, next_bpm = next(tempo_iter, (999999, tempo_map[0][1]))
|
||||
ct = tempo_map[0][1]
|
||||
t_cur = 0.0
|
||||
bar_starts: list[float] = []
|
||||
for mb_idx, mb in enumerate(masterbars):
|
||||
while mb_idx >= next_tb:
|
||||
ct = next_bpm
|
||||
next_tb, next_bpm = next(tempo_iter, (999999, ct))
|
||||
bar_starts.append(t_cur)
|
||||
ts = mb.findtext('Time', '4/4')
|
||||
try:
|
||||
n_b, d_b = [int(x) for x in ts.split('/')]
|
||||
except ValueError:
|
||||
n_b, d_b = 4, 4
|
||||
t_cur += n_b * (4.0 / d_b) * (60.0 / ct)
|
||||
return bar_starts
|
||||
|
||||
|
||||
def _gp345_measure_start_ticks(song) -> list[int]:
|
||||
"""Cumulative start tick of each measure in a PyGuitarPro song."""
|
||||
starts: list[int] = []
|
||||
cum = 0
|
||||
for mh in song.measureHeaders:
|
||||
starts.append(cum)
|
||||
ts = mh.timeSignature
|
||||
cum += int(ts.numerator * (4.0 / ts.denominator.value) * _GP345_TICKS_PER_QUARTER)
|
||||
return starts
|
||||
|
||||
|
||||
def _extract_sync_points(
|
||||
wp: 'np.ndarray',
|
||||
root: ET.Element,
|
||||
@@ -652,7 +565,22 @@ def _extract_sync_points(
|
||||
if bar_starts_override is not None:
|
||||
bar_starts_score = list(bar_starts_override)
|
||||
else:
|
||||
bar_starts_score = _gpif_bar_starts(root)
|
||||
tempo_iter = iter(tempo_map)
|
||||
next_tb, next_bpm = next(tempo_iter, (999999, tempo_map[0][1]))
|
||||
ct = tempo_map[0][1]
|
||||
t_cur = 0.0
|
||||
bar_starts_score = []
|
||||
for mb_idx, mb in enumerate(masterbars):
|
||||
while mb_idx >= next_tb:
|
||||
ct = next_bpm
|
||||
next_tb, next_bpm = next(tempo_iter, (999999, ct))
|
||||
bar_starts_score.append(t_cur)
|
||||
ts = mb.findtext('Time', '4/4')
|
||||
try:
|
||||
n_b, d_b = [int(x) for x in ts.split('/')]
|
||||
except ValueError:
|
||||
n_b, d_b = 4, 4
|
||||
t_cur += n_b * (4.0 / d_b) * (60.0 / ct)
|
||||
|
||||
# Map each sampled bar to its audio time via the DTW path
|
||||
sync_points: list[SyncPoint] = []
|
||||
@@ -735,224 +663,6 @@ def _tempo_at_bar(tempo_map: list[tuple[int, float]], bar: int) -> float:
|
||||
|
||||
# ── Audio offset estimation ───────────────────────────────────────────────────
|
||||
|
||||
# ── Piecewise time warp (librosa-free) ───────────────────────────────────────
|
||||
#
|
||||
# auto_sync's per-bar sync points describe where each sampled bar of the tab
|
||||
# falls in the real recording. Applying only the scalar audio_offset (bar 1)
|
||||
# assumes the recording holds the authored tempo for the whole song — any
|
||||
# drift accumulates. These helpers build the full piecewise-linear
|
||||
# score-time -> audio-time mapping and apply it to a converted Song, so the
|
||||
# chart follows the recording bar by bar (Songsterr-style sync).
|
||||
|
||||
def bar_start_times(gp_path: str) -> list[float]:
|
||||
"""Score-time (seconds) at the start of every bar of a GP file.
|
||||
|
||||
Uses the same tempo models as auto_sync's chroma synthesis (GPIF
|
||||
bar-resolution map for .gp/.gpx, per-tick integration for .gp3/4/5), so
|
||||
the returned times share an axis with auto_sync's sync points.
|
||||
|
||||
Raises ValueError if the file cannot be parsed, ImportError if the file
|
||||
is GP3/4/5 and PyGuitarPro is not installed.
|
||||
"""
|
||||
try:
|
||||
root = _load_gpif(gp_path)
|
||||
except _Gp345FileError:
|
||||
import guitarpro
|
||||
try:
|
||||
song = guitarpro.parse(gp_path)
|
||||
except Exception as exc:
|
||||
raise ValueError(f"Cannot parse GP3/4/5 file {gp_path!r}: {exc}") from exc
|
||||
tempo_events = _gp345_tempo_events(song)
|
||||
return [
|
||||
_gp345_tick_to_secs(tempo_events, tick)
|
||||
for tick in _gp345_measure_start_ticks(song)
|
||||
]
|
||||
return _gpif_bar_starts(root)
|
||||
|
||||
|
||||
def gp_has_expandable_repeats(gp_path: str) -> bool:
|
||||
"""True when converting `gp_path` expands repeats into a longer timeline
|
||||
than the as-written score auto_sync aligned against.
|
||||
|
||||
gp2rs.convert_file walks the GP3/4/5 playback graph (repeat brackets,
|
||||
voltas, D.S./D.C. directions), so a file using any of those produces an
|
||||
as-performed timeline that auto_sync's as-written sync points cannot be
|
||||
mapped onto. GPIF (.gp/.gpx) conversion is single-pass as-written today,
|
||||
so those files always return False — both sides share one bar order.
|
||||
|
||||
Returns False when the file cannot be parsed (callers fall back to
|
||||
offset-only sync on parse failure anyway).
|
||||
"""
|
||||
if Path(gp_path).suffix.lower() in ('.gp', '.gpx'):
|
||||
return False
|
||||
try:
|
||||
import guitarpro
|
||||
song = guitarpro.parse(gp_path)
|
||||
except Exception:
|
||||
return False
|
||||
for mh in song.measureHeaders:
|
||||
if mh.isRepeatOpen or mh.repeatClose >= 0 or mh.repeatAlternative:
|
||||
return True
|
||||
# Both jump SOURCES (fromDirection: D.C., D.S., Da Coda) and jump
|
||||
# TARGETS (direction: Segno, Coda, Fine) count — a plain Da Capo
|
||||
# needs no target marker, so checking `direction` alone would miss
|
||||
# it while gp2rs's playback walker still expands the jump.
|
||||
if (getattr(mh, 'direction', None) is not None
|
||||
or getattr(mh, 'fromDirection', None) is not None):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def build_warp_anchors(
|
||||
sync_points: list[SyncPoint],
|
||||
bar_starts: list[float],
|
||||
) -> list[tuple[float, float]]:
|
||||
"""Turn sync points into (score_secs, audio_secs) anchor pairs.
|
||||
|
||||
Drops points whose bar index is out of range, points that would break
|
||||
strict monotonicity on either axis (DTW can locally fold on noisy audio;
|
||||
a non-monotonic anchor would make the warp non-invertible and reorder
|
||||
notes), and points whose segment slope implies a physically implausible
|
||||
tempo ratio (outside 0.2x-5x authored). Returns [] when fewer than 2
|
||||
usable anchors remain — callers should fall back to scalar-offset sync
|
||||
in that case.
|
||||
"""
|
||||
anchors: list[tuple[float, float]] = []
|
||||
for sp in sorted(sync_points, key=lambda p: p.bar):
|
||||
if not 0 <= sp.bar < len(bar_starts):
|
||||
continue
|
||||
score_t = bar_starts[sp.bar]
|
||||
audio_t = float(sp.time_secs)
|
||||
if anchors and (score_t <= anchors[-1][0] + 1e-6
|
||||
or audio_t <= anchors[-1][1] + 1e-3):
|
||||
continue
|
||||
if anchors:
|
||||
# Slope sanity gate: a segment whose audio/score tempo ratio is
|
||||
# outside [0.2, 5] is not a performance — it's a DTW fold onto a
|
||||
# repeated section, an abridged recording, or a run of
|
||||
# monotonicity-clamped refine points. Keeping it would crush (or
|
||||
# absurdly stretch) every bar in the span, which is far worse
|
||||
# than interpolating through from the neighbouring anchors.
|
||||
slope = (audio_t - anchors[-1][1]) / (score_t - anchors[-1][0])
|
||||
if not 0.2 <= slope <= 5.0:
|
||||
continue
|
||||
anchors.append((score_t, audio_t))
|
||||
return anchors if len(anchors) >= 2 else []
|
||||
|
||||
|
||||
def warp_time(t: float, anchors: list[tuple[float, float]]) -> float:
|
||||
"""Map a score-time (seconds) to audio-time via piecewise-linear anchors.
|
||||
|
||||
Between anchors: linear interpolation. Outside the anchor range: the
|
||||
nearest segment's slope is extended, so a count-in before bar 1 and the
|
||||
tail after the last sampled bar keep the local tempo ratio.
|
||||
|
||||
`anchors` must be the >=2-point strictly-monotonic list produced by
|
||||
build_warp_anchors.
|
||||
"""
|
||||
lo = 0
|
||||
hi = len(anchors) - 1
|
||||
if t <= anchors[0][0]:
|
||||
seg = (anchors[0], anchors[1])
|
||||
elif t >= anchors[hi][0]:
|
||||
seg = (anchors[hi - 1], anchors[hi])
|
||||
else:
|
||||
# Binary search for the segment containing t
|
||||
while hi - lo > 1:
|
||||
mid = (lo + hi) // 2
|
||||
if anchors[mid][0] <= t:
|
||||
lo = mid
|
||||
else:
|
||||
hi = mid
|
||||
seg = (anchors[lo], anchors[hi])
|
||||
(s0, a0), (s1, a1) = seg
|
||||
slope = (a1 - a0) / (s1 - s0)
|
||||
return a0 + (t - s0) * slope
|
||||
|
||||
|
||||
def warp_song_times(song, warp) -> None:
|
||||
"""Apply a monotonic time-mapping callable to every absolute time in a
|
||||
lib.song.Song, in place.
|
||||
|
||||
Covers beats, sections, song_length, and per-arrangement notes (onset +
|
||||
sustain), chords (incl. chord notes), anchors, hand shapes, per-phrase
|
||||
difficulty levels, tone changes, and tempo overrides. Durations (note
|
||||
sustain, handshape span) are warped as end-start so they stretch with the
|
||||
local tempo ratio; sub-second intra-note envelopes (bend curves, which are
|
||||
relative to the note onset) are left untouched.
|
||||
|
||||
Duck-typed: accepts any object with the lib.song.Song surface.
|
||||
|
||||
Identity-safe: parse_arrangement shares the SAME Note/Chord/Anchor/
|
||||
HandShape objects between the flat arrangement lists and the
|
||||
max-difficulty phrase level, so each object is warped at most once no
|
||||
matter how many containers reference it.
|
||||
"""
|
||||
seen: set[int] = set()
|
||||
|
||||
def _once(obj) -> bool:
|
||||
key = id(obj)
|
||||
if key in seen:
|
||||
return False
|
||||
seen.add(key)
|
||||
return True
|
||||
|
||||
def _warp_notes(notes):
|
||||
for n in notes or []:
|
||||
if not _once(n):
|
||||
continue
|
||||
end = warp(n.time + n.sustain)
|
||||
n.time = warp(n.time)
|
||||
n.sustain = max(0.0, end - n.time)
|
||||
|
||||
def _warp_chords(chords):
|
||||
for c in chords or []:
|
||||
if not _once(c):
|
||||
continue
|
||||
c.time = warp(c.time)
|
||||
_warp_notes(c.notes)
|
||||
|
||||
def _warp_anchors(anchors):
|
||||
for a in anchors or []:
|
||||
if _once(a):
|
||||
a.time = warp(a.time)
|
||||
|
||||
def _warp_handshapes(shapes):
|
||||
for h in shapes or []:
|
||||
if not _once(h):
|
||||
continue
|
||||
start = warp(h.start_time)
|
||||
end = warp(h.end_time)
|
||||
h.start_time = start
|
||||
h.end_time = max(start, end)
|
||||
|
||||
song.song_length = max(0.0, warp(song.song_length))
|
||||
for b in song.beats:
|
||||
b.time = warp(b.time)
|
||||
for s in song.sections:
|
||||
s.start_time = warp(s.start_time)
|
||||
for arr in song.arrangements:
|
||||
_warp_notes(arr.notes)
|
||||
_warp_chords(arr.chords)
|
||||
_warp_anchors(arr.anchors)
|
||||
_warp_handshapes(arr.hand_shapes)
|
||||
for ph in arr.phrases or []:
|
||||
ph.start_time = warp(ph.start_time)
|
||||
ph.end_time = warp(ph.end_time)
|
||||
for lvl in ph.levels or []:
|
||||
_warp_notes(lvl.notes)
|
||||
_warp_chords(lvl.chords)
|
||||
_warp_anchors(lvl.anchors)
|
||||
_warp_handshapes(lvl.hand_shapes)
|
||||
if arr.tones and isinstance(arr.tones, dict):
|
||||
for change in arr.tones.get('changes') or []:
|
||||
if isinstance(change, dict) and isinstance(change.get('t'), (int, float)):
|
||||
change['t'] = warp(float(change['t']))
|
||||
for tempo_ev in arr.tempos or []:
|
||||
if isinstance(tempo_ev, dict) and isinstance(tempo_ev.get('time'), (int, float)):
|
||||
tempo_ev['time'] = warp(float(tempo_ev['time']))
|
||||
|
||||
|
||||
def _estimate_audio_offset(
|
||||
root: ET.Element,
|
||||
audio_path: str,
|
||||
@@ -1222,7 +932,12 @@ def auto_sync(
|
||||
# below line up with the chroma timeline.
|
||||
_tempo_events_gp345 = _gp345_tempo_events(_gp345x_song)
|
||||
# Convert tick events to bar events using actual measure start ticks
|
||||
_measure_starts = _gp345_measure_start_ticks(_gp345x_song)
|
||||
_measure_starts = [] # cumulative tick at start of each bar
|
||||
_cum = 0
|
||||
for _mh2 in _gp345x_song.measureHeaders:
|
||||
_measure_starts.append(_cum)
|
||||
_ts = _mh2.timeSignature
|
||||
_cum += int(_ts.numerator * (4.0 / _ts.denominator.value) * _GP345_TICKS_PER_QUARTER)
|
||||
|
||||
def _tick_to_bar(tick):
|
||||
"""Return 0-based bar index for a given tick position."""
|
||||
@@ -1309,216 +1024,6 @@ def auto_sync(
|
||||
sync_points=sync_points,
|
||||
)
|
||||
|
||||
def refine_sync(
|
||||
sync: GpSyncData,
|
||||
audio_path: str,
|
||||
bars_per_point: int = 8,
|
||||
gp_path: str | None = None,
|
||||
sr: int = _SR,
|
||||
search_radius: float = 0.35,
|
||||
phase_step: float = 0.005,
|
||||
onset_tolerance: float = 0.05,
|
||||
) -> GpSyncData:
|
||||
"""Refine coarse DTW sync points with a per-bar onset phase sweep.
|
||||
|
||||
auto_sync's mid-song points inherit the DTW frame granularity (~186ms at
|
||||
the default hop). This pass re-times a denser grid of bars — every
|
||||
`bars_per_point`-th bar plus the first and last — by sweeping a local
|
||||
beat grid (±`search_radius`s in `phase_step` steps) against detected
|
||||
onsets and keeping the phase that aligns best, narrowing each kept point
|
||||
to roughly the phase-step resolution on percussive material.
|
||||
|
||||
Args:
|
||||
sync: Coarse sync data from auto_sync (or a prior refine).
|
||||
audio_path: The same audio file auto_sync aligned against.
|
||||
bars_per_point: Refined-point density; every Nth bar gets a point.
|
||||
gp_path: Optional path to the GP file. When given, exact
|
||||
per-bar score times (bar_start_times) drive the
|
||||
densified grid; without it the grid is limited to
|
||||
a 4/4 approximation built from the points' authored
|
||||
tempos, and accuracy degrades on odd meters.
|
||||
sr: Analysis sample rate.
|
||||
search_radius: ±seconds around each coarse estimate to sweep.
|
||||
phase_step: Sweep resolution in seconds.
|
||||
onset_tolerance: Max onset-to-click distance that counts as aligned.
|
||||
|
||||
Returns:
|
||||
A new GpSyncData with the refined (and usually denser) points and a
|
||||
recomputed audio_offset. Returns `sync` unchanged when it has no
|
||||
usable points. Quiet bars (fewer than 4 onsets nearby) keep their
|
||||
coarse interpolated time rather than locking onto noise.
|
||||
"""
|
||||
if not sync.sync_points:
|
||||
return sync
|
||||
|
||||
pts = sorted(sync.sync_points, key=lambda p: p.bar)
|
||||
|
||||
bar_starts: list[float] | None = None
|
||||
if gp_path:
|
||||
try:
|
||||
bar_starts = bar_start_times(gp_path)
|
||||
except Exception as exc:
|
||||
_log.warning("refine_sync: bar_start_times(%s) failed (%s) — "
|
||||
"falling back to 4/4 tempo model", gp_path, exc)
|
||||
if bar_starts is None:
|
||||
# Approximate score bar starts from the points' authored tempos,
|
||||
# assuming 4 beats per bar (all GpSyncData carries without the file).
|
||||
max_bar = pts[-1].bar
|
||||
bar_starts = [0.0]
|
||||
ti = 0
|
||||
cur_bpm = pts[0].original_tempo or 120.0
|
||||
for b in range(1, max_bar + 1):
|
||||
while ti + 1 < len(pts) and pts[ti + 1].bar <= b - 1:
|
||||
ti += 1
|
||||
cur_bpm = pts[ti].original_tempo or cur_bpm
|
||||
bar_starts.append(bar_starts[-1] + 4 * 60.0 / max(cur_bpm, 1e-3))
|
||||
|
||||
anchors = build_warp_anchors(pts, bar_starts)
|
||||
if len(anchors) < 2:
|
||||
_log.warning("refine_sync: fewer than 2 usable anchors — returning "
|
||||
"input unchanged")
|
||||
return sync
|
||||
|
||||
# Authored-tempo lookup via the shared bar-map scan (_tempo_at_bar) so
|
||||
# boundary semantics can't drift from the rest of the module.
|
||||
_orig_map = [(p.bar, p.original_tempo or 120.0) for p in pts]
|
||||
|
||||
def _orig_bpm_at(bar: int) -> float:
|
||||
return max(_tempo_at_bar(_orig_map, bar), 1e-3)
|
||||
|
||||
n_bars = len(bar_starts)
|
||||
step = max(1, int(bars_per_point))
|
||||
targets = sorted(set(range(0, n_bars, step)) | {n_bars - 1})
|
||||
|
||||
# Deferred past the pure early-return paths above so degenerate inputs
|
||||
# (no points, <2 anchors) resolve without librosa installed.
|
||||
import librosa
|
||||
import numpy as np
|
||||
|
||||
y, _ = librosa.load(audio_path, sr=sr, mono=True)
|
||||
audio_dur = len(y) / sr
|
||||
hop = 512 # ~23ms at 22050Hz — fine enough for onset timing
|
||||
onset_frames = librosa.onset.onset_detect(
|
||||
y=y, sr=sr, hop_length=hop, backtrack=True
|
||||
)
|
||||
onset_times = np.asarray(
|
||||
librosa.frames_to_time(onset_frames, sr=sr, hop_length=hop)
|
||||
)
|
||||
|
||||
refined: list[tuple[int, float]] = []
|
||||
for b in targets:
|
||||
score_t = bar_starts[b]
|
||||
coarse = warp_time(score_t, anchors)
|
||||
if coarse > audio_dur + 1.0:
|
||||
break # bar falls past the end of the recording
|
||||
# Local beat period in AUDIO time: authored beat period scaled by the
|
||||
# local warp slope (recording tempo / authored tempo around this bar).
|
||||
slope = warp_time(score_t + 1.0, anchors) - coarse
|
||||
slope = min(max(slope, 0.25), 4.0)
|
||||
beat_period = (60.0 / _orig_bpm_at(b)) * slope
|
||||
|
||||
# Keep the scoring grid short: beat_period is estimated from the
|
||||
# coarse anchors (a few % off), and grid drift grows linearly with
|
||||
# distance — 16 beats at 2% error is already ~150ms of skew at the
|
||||
# far end, which drags the sweep. 8 beats bounds that to ~beat noise.
|
||||
grid_span = 8 * beat_period
|
||||
# Clamp the sweep window below half a beat so the neighbouring beat
|
||||
# is never a candidate — on periodic material (steady drums) a grid
|
||||
# shifted by one whole beat scores identically and the sweep could
|
||||
# lock a full beat off. DTW coarse error is ~1 analysis frame, which
|
||||
# this window still covers at all but extreme tempos.
|
||||
radius = min(search_radius, 0.45 * beat_period)
|
||||
w_lo = coarse - radius - onset_tolerance
|
||||
w_hi = coarse + radius + grid_span + onset_tolerance
|
||||
local = onset_times[(onset_times >= w_lo) & (onset_times <= w_hi)]
|
||||
if len(local) < 4:
|
||||
refined.append((b, coarse))
|
||||
continue
|
||||
|
||||
best_t, best_score, best_dist = coarse, -1, 0.0
|
||||
for phase in np.arange(coarse - radius, coarse + radius + 1e-9,
|
||||
phase_step):
|
||||
clicks = np.arange(phase, phase + grid_span, beat_period)
|
||||
score = int(sum(
|
||||
1 for t in local
|
||||
if float(np.min(np.abs(clicks - t))) < onset_tolerance
|
||||
))
|
||||
dist = abs(float(phase) - coarse)
|
||||
# Ties break toward the coarse estimate so a flat score surface
|
||||
# (sustained pads, sparse onsets) can't drag the point sideways.
|
||||
if score > best_score or (score == best_score and dist < best_dist):
|
||||
best_score, best_t, best_dist = score, float(phase), dist
|
||||
|
||||
# A sweep that matched almost nothing found a spurious edge
|
||||
# alignment, not the beat grid — this happens when the true phase
|
||||
# lies outside the (ambiguity-clamped) window, e.g. fast tempos
|
||||
# where the DTW coarse error exceeds half a beat. Keeping the
|
||||
# coarse estimate degrades gracefully instead of locking a
|
||||
# fraction of a beat off.
|
||||
if best_score < 3:
|
||||
refined.append((b, coarse))
|
||||
continue
|
||||
|
||||
# The onset-count score is flat within ±onset_tolerance of the true
|
||||
# phase, so the sweep alone can be off by up to the tolerance. Snap
|
||||
# inside that plateau: shift by the median residual between matched
|
||||
# onsets and their nearest grid click. Only the first few beats
|
||||
# count here — they are nearly insensitive to beat_period error,
|
||||
# while far clicks would leak that error into the residuals.
|
||||
if best_score > 0:
|
||||
clicks = np.arange(best_t, best_t + 4 * beat_period + 1e-9,
|
||||
beat_period)
|
||||
residuals = []
|
||||
for t in local:
|
||||
d = clicks - float(t)
|
||||
j = int(np.argmin(np.abs(d)))
|
||||
if abs(d[j]) < onset_tolerance:
|
||||
residuals.append(-float(d[j])) # onset minus click
|
||||
if residuals:
|
||||
best_t += float(np.median(residuals))
|
||||
refined.append((b, best_t))
|
||||
|
||||
if not refined:
|
||||
return sync
|
||||
|
||||
# Enforce monotonicity: a point refined earlier than its predecessor
|
||||
# would fold the warp. Clamp to a small positive gap.
|
||||
mono: list[tuple[int, float]] = []
|
||||
prev_t: float | None = None
|
||||
for b, t in refined:
|
||||
t = max(t, 0.0)
|
||||
if prev_t is not None and t <= prev_t + 0.02:
|
||||
t = prev_t + 0.02
|
||||
mono.append((b, t))
|
||||
prev_t = t
|
||||
|
||||
# Recompute per-segment modified tempos from the refined times (same
|
||||
# formula _extract_sync_points uses; the last point carries the previous
|
||||
# segment's tempo forward).
|
||||
new_points: list[SyncPoint] = []
|
||||
for i, (b, t) in enumerate(mono):
|
||||
obpm = _orig_bpm_at(b)
|
||||
if i + 1 < len(mono):
|
||||
b2, t2 = mono[i + 1]
|
||||
score_seg = bar_starts[b2] - bar_starts[b]
|
||||
audio_seg = t2 - t
|
||||
mod = obpm * (score_seg / audio_seg) if audio_seg > 1e-3 else obpm
|
||||
mod = max(20.0, min(300.0, mod))
|
||||
else:
|
||||
mod = new_points[-1].modified_tempo if new_points else obpm
|
||||
new_points.append(SyncPoint(
|
||||
bar=b, time_secs=t, modified_tempo=mod, original_tempo=obpm,
|
||||
))
|
||||
|
||||
_log.info("refine_sync: %d points (was %d), audio_offset=%.3fs",
|
||||
len(new_points), len(pts), -new_points[0].time_secs)
|
||||
return GpSyncData(
|
||||
audio_offset=-new_points[0].time_secs,
|
||||
audio_asset_id=sync.audio_asset_id,
|
||||
sync_points=new_points,
|
||||
)
|
||||
|
||||
|
||||
def estimate_audio_offset(gp_path: str, audio_path: str) -> float:
|
||||
"""
|
||||
Estimate the audio_offset for a GP file aligned to an audio file.
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
"""JSONC support — JSON with C-style comments.
|
||||
|
||||
Per feedpak-spec §8: when a manifest pointer resolves to a ``.jsonc`` file, a
|
||||
Reader MUST strip ``//`` line comments and ``/* */`` block comments before
|
||||
parsing the JSON content. This module implements that stripping in a single
|
||||
shared place so every sloppak/feedpak reader in this repo parses ``.jsonc``
|
||||
the same way (string-aware so comment-like text inside JSON strings survives).
|
||||
|
||||
The regex mirrors the reference implementation in ``feedpak-spec/tools/validate.py``.
|
||||
``load_json(path)`` auto-detects ``.jsonc`` by suffix; plain ``.json`` (and any
|
||||
other extension) goes straight through ``json.loads``. Use it as a drop-in
|
||||
replacement for ``json.loads(path.read_text(encoding="utf-8"))``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
# Match JSON string literals (preserved), // line comments, and /* block */
|
||||
# comments. A single combined alternation processed by `sub` with a callback
|
||||
# that keeps strings and replaces comments with the empty string — so
|
||||
# comment-like text inside a string literal is never stripped.
|
||||
_JSONC_STRIP_RE = re.compile(
|
||||
r'"(?:[^"\\]|\\.)*"|' # string literal — keep as-is
|
||||
r'//.*|' # // line comment — strip
|
||||
r'/\*[\s\S]*?\*/', # /* block comment */ — strip
|
||||
)
|
||||
|
||||
|
||||
def parse_jsonc(text: str) -> object:
|
||||
"""Parse a JSONC string, stripping C-style comments before JSON parsing.
|
||||
|
||||
Handles ``//`` line comments and ``/* */`` block comments, respecting
|
||||
string boundaries so that comment-like text inside strings is preserved.
|
||||
Raises ``json.JSONDecodeError`` on malformed JSON (after stripping).
|
||||
"""
|
||||
stripped = _JSONC_STRIP_RE.sub(
|
||||
lambda m: m.group(0) if m.group(0).startswith('"') else '',
|
||||
text,
|
||||
)
|
||||
return json.loads(stripped)
|
||||
|
||||
|
||||
def load_json(path: Path) -> object:
|
||||
"""Read and parse a JSON/JSONC file by path.
|
||||
|
||||
Files ending in ``.jsonc`` are stripped of comments via :func:`parse_jsonc`;
|
||||
all other files are parsed as plain JSON. UTF-8 encoded, matching every
|
||||
other reader in this repo.
|
||||
"""
|
||||
raw = path.read_text(encoding="utf-8")
|
||||
if path.name.lower().endswith(".jsonc"):
|
||||
return parse_jsonc(raw)
|
||||
return json.loads(raw)
|
||||
+11
-11
@@ -1,10 +1,10 @@
|
||||
"""Logging configuration for FeedBack.
|
||||
"""Logging configuration for Slopsmith.
|
||||
|
||||
Call ``configure_logging()`` once at server startup, before any feedBack
|
||||
Call ``configure_logging()`` once at server startup, before any slopsmith
|
||||
module imports that might emit log records.
|
||||
|
||||
Environment variables:
|
||||
LOG_LEVEL — severity threshold for the ``feedBack.*`` logger tree
|
||||
LOG_LEVEL — severity threshold for the ``slopsmith.*`` logger tree
|
||||
(default: INFO). Also accepted: DEBUG, WARNING, ERROR.
|
||||
LOG_FORMAT — "json" for structured output (Loki, ELK, Promtail);
|
||||
"text" (default) for human-readable coloured console output.
|
||||
@@ -43,7 +43,7 @@ def _add_correlation_id(
|
||||
|
||||
|
||||
def configure_logging() -> None:
|
||||
"""Wire up the feedBack logger hierarchy.
|
||||
"""Wire up the slopsmith logger hierarchy.
|
||||
|
||||
Safe to call multiple times; always reflects the current LOG_LEVEL,
|
||||
LOG_FORMAT, and LOG_FILE environment variables.
|
||||
@@ -52,7 +52,7 @@ def configure_logging() -> None:
|
||||
level = getattr(logging, raw_level, None)
|
||||
if not isinstance(level, int):
|
||||
sys.stderr.write(
|
||||
f"[feedBack] WARNING: unrecognised LOG_LEVEL={raw_level!r};"
|
||||
f"[slopsmith] WARNING: unrecognised LOG_LEVEL={raw_level!r};"
|
||||
" falling back to INFO.\n"
|
||||
)
|
||||
level = logging.INFO
|
||||
@@ -60,7 +60,7 @@ def configure_logging() -> None:
|
||||
raw_fmt = os.environ.get("LOG_FORMAT", "text").lower()
|
||||
if raw_fmt not in ("json", "text"):
|
||||
sys.stderr.write(
|
||||
f"[feedBack] WARNING: unrecognised LOG_FORMAT={raw_fmt!r};"
|
||||
f"[slopsmith] WARNING: unrecognised LOG_FORMAT={raw_fmt!r};"
|
||||
" falling back to 'text'.\n"
|
||||
)
|
||||
raw_fmt = "text"
|
||||
@@ -137,17 +137,17 @@ def configure_logging() -> None:
|
||||
handlers.append(fh)
|
||||
except OSError as exc:
|
||||
sys.stderr.write(
|
||||
f"[feedBack] WARNING: could not open LOG_FILE={log_file!r}: {exc}"
|
||||
f"[slopsmith] WARNING: could not open LOG_FILE={log_file!r}: {exc}"
|
||||
" — continuing with console-only logging.\n"
|
||||
)
|
||||
|
||||
_uvicorn_names = ("uvicorn", "uvicorn.error", "uvicorn.access")
|
||||
all_loggers = [logging.getLogger("feedBack")] + [
|
||||
all_loggers = [logging.getLogger("slopsmith")] + [
|
||||
logging.getLogger(n) for n in _uvicorn_names
|
||||
]
|
||||
|
||||
# Collect all unique old handlers across every logger *before* any close so
|
||||
# that a shared handler (feedBack and uvicorn* were intentionally given the
|
||||
# that a shared handler (slopsmith and uvicorn* were intentionally given the
|
||||
# same objects) isn't closed while still attached to another logger tree.
|
||||
old_handlers: set[logging.Handler] = set()
|
||||
for lg in all_loggers:
|
||||
@@ -160,8 +160,8 @@ def configure_logging() -> None:
|
||||
for h in old_handlers:
|
||||
h.close()
|
||||
|
||||
# Install fresh handlers on the feedBack root.
|
||||
root = logging.getLogger("feedBack")
|
||||
# Install fresh handlers on the slopsmith root.
|
||||
root = logging.getLogger("slopsmith")
|
||||
for h in handlers:
|
||||
root.addHandler(h)
|
||||
root.setLevel(level)
|
||||
|
||||
@@ -23,14 +23,14 @@ Engine selection
|
||||
Two transcription paths share a common output:
|
||||
|
||||
* `transcribe_vocals_remote(path, server_url, ...)` — POST the vocal
|
||||
stem to the `/align` endpoint on a feedBack-demucs-server (got-feedBack's
|
||||
stem to the `/align` endpoint on a slopsmith-demucs-server (Byron's
|
||||
reference server already hosts WhisperX alongside Demucs at the same
|
||||
URL).
|
||||
|
||||
* `transcribe_vocals_local(path, ...)` — load WhisperX in-process. Heavy
|
||||
(~3 GB of model weights for `large-v2` + the wav2vec2 aligner) and
|
||||
slow on CPU. Deferred imports of `whisperx`, `torch`, and `soundfile`
|
||||
keep the rest of feedBack free of those dependencies.
|
||||
keep the rest of slopsmith free of those dependencies.
|
||||
|
||||
Callers pick between them based on a `whisperx.server_url` config and
|
||||
fall back as appropriate. This module does not read config — both
|
||||
@@ -58,7 +58,7 @@ import logging
|
||||
from pathlib import Path
|
||||
from typing import Callable, Optional
|
||||
|
||||
log = logging.getLogger("feedBack.lib.lyrics_transcribe")
|
||||
log = logging.getLogger("slopsmith.lib.lyrics_transcribe")
|
||||
|
||||
ProgressCB = Optional[Callable[[float, str, str], None]]
|
||||
|
||||
@@ -179,7 +179,7 @@ _MIN_WORD_DURATION = 0.05
|
||||
|
||||
# Semver for the lyric-transcription artifact contract that gets stamped
|
||||
# into the sloppak manifest's `lyric_transcription` block alongside the
|
||||
# engine + model. Bump per the semantics defined in feedBack#357 (the
|
||||
# engine + model. Bump per the semantics defined in slopsmith#357 (the
|
||||
# parent `stem_separation` RFC):
|
||||
# * patch — metadata-only or implementation fixes; no regeneration
|
||||
# * minor — backward-compatible additions
|
||||
|
||||
-406
@@ -1,406 +0,0 @@
|
||||
"""Text-matching engine for MusicBrainz metadata enrichment (P8).
|
||||
|
||||
Pure functions only — no network, no database, no server imports — so the
|
||||
whole matching pipeline is unit-testable in isolation. server.py owns the
|
||||
throttled HTTP transport and the song_enrichment writes; this module owns:
|
||||
|
||||
* denoise/tokenize: fold community chart-title noise (author suffixes,
|
||||
``(440Hz)``/``(Live)``/``(No Lead)``/``(v2)`` parentheticals, punctuation,
|
||||
diacritics, ``AC DC``/``ACDC``/``AC/DC`` spelling drift) into a comparable
|
||||
token form,
|
||||
* similarity + scoring: token-set similarity on artist+title with year and
|
||||
duration proximity as corroborating bonuses,
|
||||
* tier classification: auto (high) / review (medium) / none (low) — the
|
||||
design rule is that a WRONG match is worse than no match, so the auto
|
||||
tier is deliberately strict and medium confidence goes to a human,
|
||||
* MusicBrainz JSON parsing: normalize ``/ws/2`` recording documents into
|
||||
the flat candidate dicts the review UI and song_enrichment store.
|
||||
"""
|
||||
|
||||
import re
|
||||
import unicodedata
|
||||
|
||||
# ── Tier thresholds ───────────────────────────────────────────────────────────
|
||||
# Combined score = 0.5*artist_sim + 0.5*title_sim + corroboration bonuses
|
||||
# (capped at 1.0). Wrong-match is worse than slow (design §5), so `auto`
|
||||
# additionally requires BOTH fields to individually agree — a perfect title
|
||||
# with a mismatched artist (a cover) must never auto-canonicalize, whatever
|
||||
# the combined threshold is set to. AUTO_MIN is only the DEFAULT: the host
|
||||
# surfaces it as the user-configurable "auto-apply confidence" setting and
|
||||
# passes the chosen value into classify(auto_min=…).
|
||||
AUTO_MIN = 0.90
|
||||
AUTO_ARTIST_MIN = 0.8
|
||||
AUTO_TITLE_MIN = 0.6
|
||||
REVIEW_MIN = 0.65
|
||||
|
||||
YEAR_BONUS = 0.05 # candidate year within ±1 of the chart's year
|
||||
DURATION_BONUS = 0.05 # candidate length within 5s of the chart's audio
|
||||
DURATION_BONUS_LOOSE = 0.025 # …within 15s
|
||||
_DURATION_TIGHT = 5
|
||||
_DURATION_LOOSE = 15
|
||||
|
||||
# Release-group secondary types that mark a NON-canonical release (a live album,
|
||||
# a greatest-hits comp, a remix/DJ set, …). Used both to pick the canonical
|
||||
# studio album for display and to reward studio recordings in ranking.
|
||||
_SECONDARY_SKIP = {
|
||||
"live", "compilation", "remix", "dj-mix", "mixtape/street",
|
||||
"demo", "interview", "audiobook", "spokenword",
|
||||
}
|
||||
|
||||
# ── Denoise ───────────────────────────────────────────────────────────────────
|
||||
# A parenthetical/bracketed group is dropped when it contains any of these
|
||||
# noise terms as a whole word (chart-variant markers, tuning/pitch notes,
|
||||
# performance qualifiers) or when it reads as an author credit ("by X",
|
||||
# "charted by X"). Both sides of a comparison are denoised symmetrically, so
|
||||
# over-stripping a meaningful group costs a little precision but never
|
||||
# produces an asymmetric mismatch.
|
||||
_NOISE_TERMS = (
|
||||
r"440\s*hz", r"a440", r"432\s*hz",
|
||||
r"live", r"acoustic", r"instrumental",
|
||||
r"no\s+(?:lead|rhythm|bass|vocals?|drums)",
|
||||
r"(?:lead|rhythm|bass)\s+only",
|
||||
r"v\d+", r"ver(?:sion)?\s*\d+",
|
||||
r"remaster(?:ed)?(?:\s*\d{4})?", r"re-?recorded?",
|
||||
r"fix(?:ed)?", r"updated?",
|
||||
r"bonus", r"custom",
|
||||
)
|
||||
_NOISE_GROUP_RE = re.compile(
|
||||
r"[(\[][^)\]]*\b(?:" + "|".join(_NOISE_TERMS) + r")\b[^)\]]*[)\]]",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
# Author credits: "(by SomeCharter)", "[charted by X]", "(chart by X)".
|
||||
_AUTHOR_GROUP_RE = re.compile(
|
||||
r"[(\[]\s*(?:chart(?:ed)?\s+)?by\s+[^)\]]*[)\]]", re.IGNORECASE)
|
||||
# Trailing "- by SomeCharter" outside parens.
|
||||
_AUTHOR_TAIL_RE = re.compile(r"\s+-\s+(?:chart(?:ed)?\s+)?by\s+.+$", re.IGNORECASE)
|
||||
|
||||
_PUNCT_RE = re.compile(r"[^\w\s]|_")
|
||||
_WS_RE = re.compile(r"\s+")
|
||||
|
||||
|
||||
def _strip_diacritics(s: str) -> str:
|
||||
return "".join(
|
||||
ch for ch in unicodedata.normalize("NFKD", s)
|
||||
if not unicodedata.combining(ch)
|
||||
)
|
||||
|
||||
|
||||
def denoise(s, *, strip_leading_the: bool = False) -> str:
|
||||
"""Fold a community metadata string into its comparable form:
|
||||
lowercase, diacritics stripped, noise parentheticals and author credits
|
||||
removed, punctuation collapsed to spaces. ``strip_leading_the`` drops a
|
||||
leading "The " — used for ARTIST comparison only ("The Beatles" ==
|
||||
"Beatles"), never titles ("The Trooper" must keep its "the")."""
|
||||
s = str(s or "")
|
||||
s = _NOISE_GROUP_RE.sub(" ", s)
|
||||
s = _AUTHOR_GROUP_RE.sub(" ", s)
|
||||
s = _AUTHOR_TAIL_RE.sub(" ", s)
|
||||
s = _strip_diacritics(s).casefold()
|
||||
s = s.replace("&", " and ")
|
||||
s = _PUNCT_RE.sub(" ", s)
|
||||
s = _WS_RE.sub(" ", s).strip()
|
||||
if strip_leading_the and s.startswith("the "):
|
||||
s = s[4:]
|
||||
return s
|
||||
|
||||
|
||||
def tokens(s, **kw) -> list[str]:
|
||||
d = denoise(s, **kw)
|
||||
return d.split() if d else []
|
||||
|
||||
|
||||
def _compact(toks: list[str]) -> str:
|
||||
return "".join(toks)
|
||||
|
||||
|
||||
def similarity(a, b, *, artist: bool = False) -> float:
|
||||
"""Token-set similarity in [0, 1]. Dice coefficient over the denoised
|
||||
token sets, with a compacted-string equality fold so spelling drift that
|
||||
only moves token boundaries ("ACDC" / "AC DC" / "AC/DC", "Greenday" /
|
||||
"Green Day") counts as identical."""
|
||||
kw = {"strip_leading_the": artist}
|
||||
ta, tb = tokens(a, **kw), tokens(b, **kw)
|
||||
if not ta or not tb:
|
||||
return 0.0
|
||||
if _compact(ta) == _compact(tb):
|
||||
return 1.0
|
||||
sa, sb = set(ta), set(tb)
|
||||
return 2.0 * len(sa & sb) / (len(sa) + len(sb))
|
||||
|
||||
|
||||
def _year_int(v):
|
||||
try:
|
||||
y = int(str(v)[:4])
|
||||
return y if y > 0 else None
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _duration_int(v):
|
||||
try:
|
||||
d = int(round(float(v)))
|
||||
return d if d > 0 else None
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def cand_artist_sim(song: dict, cand: dict) -> float:
|
||||
"""Best artist similarity between the song's reference artist and the
|
||||
candidate's PRIMARY name OR any of its `artist_aliases` (romanized/alternate
|
||||
names). MusicBrainz stores many artists under a non-Latin primary name
|
||||
(大橋純子) with the romanized form ("Junko Ohashi") only as an alias, so a
|
||||
reference typed/derived in romaji scores 0 against the primary but 1.0
|
||||
against the alias. The caller (server) attaches `artist_aliases` only for
|
||||
promising near-misses, so this is a plain max when they're present and the
|
||||
original single comparison when they're not."""
|
||||
best = similarity(song.get("artist"), cand.get("artist"), artist=True)
|
||||
for alias in cand.get("artist_aliases") or []:
|
||||
if best >= 1.0:
|
||||
break
|
||||
s = similarity(song.get("artist"), alias, artist=True)
|
||||
if s > best:
|
||||
best = s
|
||||
return best
|
||||
|
||||
|
||||
def score_candidate(song: dict, cand: dict) -> float:
|
||||
"""Combined confidence that MusicBrainz candidate `cand` is the song the
|
||||
chart transcribes. 0.5*artist + 0.5*title, plus small year/duration
|
||||
corroboration bonuses, capped at 1.0. Missing fields score 0 on their
|
||||
half — classify() separately refuses to auto-match without both."""
|
||||
artist_sim = cand_artist_sim(song, cand)
|
||||
title_sim = similarity(song.get("title"), cand.get("title"))
|
||||
score = 0.5 * artist_sim + 0.5 * title_sim
|
||||
sy, cy = _year_int(song.get("year")), _year_int(cand.get("year"))
|
||||
if sy and cy and abs(sy - cy) <= 1:
|
||||
score += YEAR_BONUS
|
||||
sd, cd = _duration_int(song.get("duration")), _duration_int(cand.get("duration"))
|
||||
if sd and cd:
|
||||
diff = abs(sd - cd)
|
||||
if diff <= _DURATION_TIGHT:
|
||||
score += DURATION_BONUS
|
||||
elif diff <= _DURATION_LOOSE:
|
||||
score += DURATION_BONUS_LOOSE
|
||||
# NB: the studio-vs-live distinction is deliberately NOT scored here — a live
|
||||
# take is still the RIGHT SONG (same title/artist), so it must not change the
|
||||
# auto/review confidence. Canonical-version preference lives in the RANK sort
|
||||
# (rank_candidates) instead, where it only reorders same-song candidates.
|
||||
return min(score, 1.0)
|
||||
|
||||
|
||||
def classify(song: dict, cand: dict, score: float, auto_min: float | None = None) -> str:
|
||||
"""Tier for a scored candidate: 'auto' | 'review' | 'none'.
|
||||
|
||||
`auto` (tier-2) needs the combined score AND per-field agreement AND
|
||||
both fields present — a perfect-title/wrong-artist cover, or a chart
|
||||
with no artist at all, is at best a review item, never an auto match.
|
||||
`auto_min` overrides the default combined-score threshold (the user's
|
||||
"auto-apply confidence" setting); the per-field floors always apply.
|
||||
"""
|
||||
if auto_min is None:
|
||||
auto_min = AUTO_MIN
|
||||
artist_sim = cand_artist_sim(song, cand)
|
||||
title_sim = similarity(song.get("title"), cand.get("title"))
|
||||
if (score >= auto_min and artist_sim >= AUTO_ARTIST_MIN
|
||||
and title_sim >= AUTO_TITLE_MIN):
|
||||
return "auto"
|
||||
if score >= REVIEW_MIN:
|
||||
return "review"
|
||||
return "none"
|
||||
|
||||
|
||||
def rank_candidates(song: dict, candidates: list[dict]) -> list[dict]:
|
||||
"""Score every candidate against the song and return them sorted best-first.
|
||||
The combined `score` caps at 1.0, so a perfect-text-match query (every "AC/DC
|
||||
Highway to Hell" recording) ties at the top — there the studio flag and, when
|
||||
the caller knows the audio length, the duration match break the tie so the
|
||||
canonical studio take wins over live/promo/extended cuts. Each returned dict
|
||||
is a copy carrying `score` (rounded — it's displayed and stored)."""
|
||||
sd = _duration_int(song.get("duration"))
|
||||
# For a chart that IS a live take (build_recording_query keeps live
|
||||
# recordings for these) the studio take is the WRONG recording, so drop the
|
||||
# studio tiebreak — duration proximity + text/mb score then pick the right
|
||||
# live version instead of auto-matching the studio one.
|
||||
prefer_studio = not _LIVE_GROUP_RE.search(str(song.get("title") or ""))
|
||||
|
||||
def _dur_diff(c):
|
||||
cd = _duration_int(c.get("duration"))
|
||||
return abs(sd - cd) if (sd and cd) else 10 ** 6
|
||||
|
||||
ranked = []
|
||||
for cand in candidates or []:
|
||||
c = dict(cand)
|
||||
c["score"] = round(score_candidate(song, cand), 4)
|
||||
ranked.append(c)
|
||||
ranked.sort(
|
||||
key=lambda c: (c["score"],
|
||||
(1 if c.get("studio") else 0) if prefer_studio else 0,
|
||||
-_dur_diff(c), # closest to the audio length
|
||||
c.get("mb_score") or 0),
|
||||
reverse=True)
|
||||
return ranked
|
||||
|
||||
|
||||
# ── MusicBrainz query + response parsing ──────────────────────────────────────
|
||||
|
||||
def _lucene_escape_phrase(s: str) -> str:
|
||||
"""Escape a string for use inside a quoted Lucene phrase."""
|
||||
return s.replace("\\", "\\\\").replace('"', '\\"')
|
||||
|
||||
|
||||
# A parenthetical/bracketed "(Live …)" marker — the live signal denoise() strips
|
||||
# from the title. Mirrors _NOISE_GROUP_RE but for the `live` term only.
|
||||
_LIVE_GROUP_RE = re.compile(r"[(\[][^)\]]*\blive\b[^)\]]*[)\]]", re.IGNORECASE)
|
||||
|
||||
|
||||
def build_recording_query(artist, title, *, loose: bool = False) -> str:
|
||||
"""Lucene query for /ws/2/recording. Built from the DENOISED fields —
|
||||
the noise we strip (author credits, "(Live)", "(v2)") would otherwise
|
||||
poison the search server's own scoring.
|
||||
|
||||
``loose=True`` drops the field-scoped quoted PHRASES for plain AND-ed
|
||||
term groups (``(telephone number) AND (junko ohashi)``). The point:
|
||||
a field phrase like ``artist:"Junko Ohashi"`` only matches MusicBrainz's
|
||||
*primary* artist name — it never searches ALIASES — so a recording stored
|
||||
under a non-Latin primary (大橋純子) whose romanized name is only an alias
|
||||
is invisible to the strict query. A loose term query searches the whole
|
||||
document, aliases included, and surfaces it. Lower precision by design: it
|
||||
is a FALLBACK for when the strict query returns nothing, and its results
|
||||
are re-scored by ``rank_candidates`` (and, for auto-match, gated by the
|
||||
per-field floors), so noise never auto-applies."""
|
||||
t = denoise(title)
|
||||
a = denoise(artist)
|
||||
if loose:
|
||||
# denoise() already reduced each field to lowercase [a-z0-9 and] tokens
|
||||
# (punctuation → spaces, diacritics stripped, & → "and"), so no
|
||||
# Lucene-special character survives to need escaping. Group each field's
|
||||
# terms and require both groups.
|
||||
q = " AND ".join("(%s)" % g for g in (t, a) if g)
|
||||
# Keep the SAME live exclusion as the strict path: the loose query is
|
||||
# lower-precision, and score_candidate doesn't penalize a live take, so
|
||||
# without this a studio chart whose strict query missed could fall back
|
||||
# to — and auto-confirm — a live-only recording. Skipped only when the
|
||||
# source title is itself a live take (mirrors the strict path).
|
||||
if q and not _LIVE_GROUP_RE.search(str(title or "")):
|
||||
q += " AND -secondarytype:Live"
|
||||
return q
|
||||
parts = []
|
||||
if t:
|
||||
parts.append('recording:"%s"' % _lucene_escape_phrase(t))
|
||||
if a:
|
||||
parts.append('artist:"%s"' % _lucene_escape_phrase(a))
|
||||
q = " AND ".join(parts)
|
||||
# Drop live-ONLY recordings (bootlegs, live albums) — the canonical studio
|
||||
# take is never tagged Live, and this is the single biggest source of junk in
|
||||
# a flat recording search. Compilations are deliberately NOT excluded: they
|
||||
# REUSE the studio recording, so filtering them would drop the very recording
|
||||
# we want (verified against MusicBrainz — `-secondarytype:Compilation` cut the
|
||||
# AC/DC studio "Highway to Hell" recording entirely).
|
||||
#
|
||||
# EXCEPT when the source chart is itself a live take: denoise() strips the
|
||||
# "(Live at …)" qualifier from the query, so filtering Live would leave the
|
||||
# genuinely-live chart with NO correct recording. Only a parenthetical marker
|
||||
# counts — a bare title word ("Live and Let Die") is a real word, not a live
|
||||
# tag — mirroring what denoise removes.
|
||||
if q and not _LIVE_GROUP_RE.search(str(title or "")):
|
||||
q += " AND -secondarytype:Live"
|
||||
return q
|
||||
|
||||
|
||||
def _artist_credit(doc: dict) -> tuple[str, str, str]:
|
||||
"""(display name, artist mbid, sort name) from an artist-credit array."""
|
||||
credits = doc.get("artist-credit") or []
|
||||
name = ""
|
||||
for part in credits:
|
||||
if isinstance(part, dict):
|
||||
name += str(part.get("name", "")) + str(part.get("joinphrase", "") or "")
|
||||
else: # ws/2 can emit bare join strings in older serializations
|
||||
name += str(part)
|
||||
first = next((p for p in credits if isinstance(p, dict)), None) or {}
|
||||
artist = first.get("artist") or {}
|
||||
return name, str(artist.get("id", "") or ""), str(artist.get("sort-name", "") or "")
|
||||
|
||||
|
||||
def _is_clean_studio_album(rg: dict) -> bool:
|
||||
"""A release-group that is a primary-type Album with NO non-canonical
|
||||
secondary type (Live / Compilation / Remix / …) — i.e. a studio album."""
|
||||
if str(rg.get("primary-type", "")).lower() != "album":
|
||||
return False
|
||||
secs = {str(s).lower() for s in (rg.get("secondary-types") or [])}
|
||||
return not (secs & _SECONDARY_SKIP)
|
||||
|
||||
|
||||
def _best_release(doc: dict) -> dict:
|
||||
"""Pick the release used for canon album/year: prefer an OFFICIAL studio
|
||||
Album (primary Album with no Live/Compilation/… secondary type), then the
|
||||
earliest date. Falls back to any release when none is clean. {} if none."""
|
||||
releases = [r for r in (doc.get("releases") or []) if isinstance(r, dict)]
|
||||
if not releases:
|
||||
return {}
|
||||
|
||||
def sort_key(r):
|
||||
rg = r.get("release-group") or {}
|
||||
clean = 0 if _is_clean_studio_album(rg) else 1
|
||||
status_ok = 0 if str(r.get("status", "")).lower() == "official" else 1
|
||||
date = str(r.get("date", "") or "9999")
|
||||
# Official FIRST, then prefer a clean studio album: this still surfaces
|
||||
# the studio album over an (official) live/comp album for the display
|
||||
# album/year, but never lets an UNofficial bootleg album outrank an
|
||||
# official single/EP/comp — which `(clean, status_ok, …)` would.
|
||||
return (status_ok, clean, date)
|
||||
|
||||
return sorted(releases, key=sort_key)[0]
|
||||
|
||||
|
||||
def _genres(doc: dict, limit: int = 5) -> list[str]:
|
||||
"""Genre names from a recording doc. Search results carry folksonomy
|
||||
`tags`; lookups with inc=genres carry curated `genres`. Both are
|
||||
[{name, count}] — take the most-voted few."""
|
||||
raw = doc.get("genres") or doc.get("tags") or []
|
||||
entries = [e for e in raw if isinstance(e, dict) and e.get("name")]
|
||||
entries.sort(key=lambda e: e.get("count") or 0, reverse=True)
|
||||
return [str(e["name"]) for e in entries[:limit]]
|
||||
|
||||
|
||||
def parse_recording_doc(doc: dict) -> dict | None:
|
||||
"""Normalize one /ws/2 recording document (search hit or direct lookup)
|
||||
into the flat candidate dict stored in song_enrichment.candidates and
|
||||
rendered by the review drawer. Returns None for malformed docs."""
|
||||
if not isinstance(doc, dict) or not doc.get("id") or not doc.get("title"):
|
||||
return None
|
||||
artist_name, artist_id, artist_sort = _artist_credit(doc)
|
||||
release = _best_release(doc)
|
||||
studio = _is_clean_studio_album(release.get("release-group") or {})
|
||||
length = doc.get("length")
|
||||
try:
|
||||
duration = int(round(float(length) / 1000.0)) if length else None
|
||||
except (TypeError, ValueError):
|
||||
duration = None
|
||||
isrcs = doc.get("isrcs") or []
|
||||
isrcs = [str(i) for i in isrcs if isinstance(i, (str,))]
|
||||
return {
|
||||
"recording_id": str(doc["id"]),
|
||||
"title": str(doc.get("title", "")),
|
||||
"artist": artist_name,
|
||||
"artist_id": artist_id,
|
||||
"artist_sort": artist_sort,
|
||||
"release_id": str(release.get("id", "") or ""),
|
||||
"album": str(release.get("title", "") or ""),
|
||||
"year": str(release.get("date", "") or "")[:4],
|
||||
"duration": duration,
|
||||
"isrc": isrcs[0] if isrcs else "",
|
||||
"genres": _genres(doc),
|
||||
"mb_score": int(doc.get("score") or 0),
|
||||
"studio": studio,
|
||||
}
|
||||
|
||||
|
||||
def parse_search_response(body: dict) -> list[dict]:
|
||||
"""Candidates from a /ws/2/recording search response."""
|
||||
docs = (body or {}).get("recordings") or []
|
||||
out = []
|
||||
for doc in docs:
|
||||
cand = parse_recording_doc(doc)
|
||||
if cand:
|
||||
out.append(cand)
|
||||
return out
|
||||
-4373
File diff suppressed because it is too large
Load Diff
+7
-160
@@ -203,13 +203,7 @@ def convert_midi_track_to_keys_wire(
|
||||
# a foreign track's tempo events do NOT apply to the chosen
|
||||
# track. Merging would mis-time the notes — restrict the tempo
|
||||
# scan to the selected track only.
|
||||
# ``ticks_per_beat`` is 0 for a malformed header and NEGATIVE for SMPTE
|
||||
# division (mido returns the signed short as-is). Both feed the two
|
||||
# divisions below (tempo-table build + tick_to_seconds), so guard here:
|
||||
# 0 would raise ZeroDivisionError and a negative value would yield
|
||||
# negative/garbage times. Use ``> 0`` (not ``or``) so the negative SMPTE
|
||||
# case also falls back to the SMF default.
|
||||
ticks_per_beat = midi.ticks_per_beat if midi.ticks_per_beat > 0 else 480
|
||||
ticks_per_beat = midi.ticks_per_beat
|
||||
raw_events: list[tuple[int, int]] = [(0, 500000)] # default 120 BPM
|
||||
midi_type = getattr(midi, "type", 1)
|
||||
tempo_source = (
|
||||
@@ -358,14 +352,7 @@ def _build_tick_to_seconds(midi: mido.MidiFile, track_index: int) -> Callable[[i
|
||||
- type 1: parallel tracks share the timeline; merge tempo events.
|
||||
- type 2: independent timelines; tempo only from the chosen track.
|
||||
"""
|
||||
# A metrical header carries positive ticks-per-beat. mido reads the SMF
|
||||
# division as a signed short, so an SMPTE-division file surfaces as a
|
||||
# negative value and a malformed header as 0 — both make the two division
|
||||
# sites below divide by a non-positive number (ZeroDivisionError, or
|
||||
# negative seconds that send the bar walk off the rails). Fall back to the
|
||||
# SMF default here, the single place every caller routes ticks through, so
|
||||
# each caller's own fallback is real rather than cosmetic.
|
||||
ticks_per_beat = midi.ticks_per_beat if midi.ticks_per_beat > 0 else 480
|
||||
ticks_per_beat = midi.ticks_per_beat
|
||||
raw_events: list[tuple[int, int]] = [(0, 500000)] # default 120 BPM
|
||||
midi_type = getattr(midi, "type", 1)
|
||||
tempo_source = (
|
||||
@@ -406,141 +393,6 @@ def _build_tick_to_seconds(midi: mido.MidiFile, track_index: int) -> Callable[[i
|
||||
return tick_to_seconds
|
||||
|
||||
|
||||
# Safety valve for the bar walk below: a malformed SMF (absurd tempo + long
|
||||
# trailing meta) could otherwise imply millions of bars. Real charts sit
|
||||
# orders of magnitude below this.
|
||||
_TEMPO_MAP_MAX_BARS = 20000
|
||||
|
||||
|
||||
def convert_midi_tempo_map(midi_path: str, track_index: int = 0) -> dict:
|
||||
"""Extract the song-timeline grid a `.mid` file carries: tempos, time
|
||||
signatures, and a full beat grid — the data the note converters here
|
||||
always computed internally (to bake note times) and then threw away,
|
||||
which left every MIDI import with no bars, no measures, and an implied
|
||||
4/4 no matter what the file said.
|
||||
|
||||
Returns ``{"tempos": [...], "time_signatures": [...], "beats": [...]}``:
|
||||
|
||||
- ``tempos``: ``{time, bpm}`` per tempo event (deduped per tick).
|
||||
- ``time_signatures``: ``{time, ts: [num, den]}`` per signature event —
|
||||
the song-timeline sidecar shape (feedpak-spec §7.4).
|
||||
- ``beats``: one row per beat on the editor grid shape — downbeats carry
|
||||
a running ``measure`` (1, 2, 3, …) plus a ``den`` hint (the signature
|
||||
denominator), interior beats carry ``measure: -1``. The beat unit
|
||||
follows the active signature (6/8 ⇒ six eighth-note rows per bar).
|
||||
|
||||
Event scope mirrors ``_build_tick_to_seconds``: SMF type 0/1 merge meta
|
||||
from all tracks (shared timeline); type 2 reads ONLY ``track_index``
|
||||
(independent timelines — callers must never share one grid across
|
||||
type-2 tracks). Signature changes apply at the NEXT bar boundary when a
|
||||
file places one mid-bar (ill-formed but seen in the wild). All times
|
||||
are computed from absolute ticks through the cumulative tempo table and
|
||||
rounded once at emit — rounding error never accumulates with song
|
||||
length. An SMF with no note events yields empty ``beats``.
|
||||
"""
|
||||
midi = mido.MidiFile(midi_path)
|
||||
# Positive for metrical files; 0 (malformed) or negative (SMPTE division,
|
||||
# read as a signed short) otherwise — fall back so beat_ticks below stays
|
||||
# sane, mirroring the guard inside _build_tick_to_seconds.
|
||||
ticks_per_beat = midi.ticks_per_beat if midi.ticks_per_beat > 0 else 480
|
||||
midi_type = getattr(midi, "type", 1)
|
||||
# Same scope both converters use: type 2 reads only the chosen track
|
||||
# (independent timelines); type 0/1 merge all tracks (shared timeline).
|
||||
source_tracks = (
|
||||
[midi.tracks[track_index]] if midi_type == 2 else midi.tracks
|
||||
)
|
||||
tick_to_seconds = _build_tick_to_seconds(midi, track_index)
|
||||
|
||||
# ── collect meta + the end of musical content in one pass ────────────
|
||||
sig_events: list[tuple[int, int, int]] = []
|
||||
tempo_events: list[tuple[int, int]] = []
|
||||
end_tick = 0
|
||||
for tr in source_tracks:
|
||||
abs_tick = 0
|
||||
for msg in tr:
|
||||
abs_tick += msg.time
|
||||
if msg.type == "time_signature":
|
||||
num = int(getattr(msg, "numerator", 4) or 4)
|
||||
den = int(getattr(msg, "denominator", 4) or 4)
|
||||
if num > 0 and den > 0:
|
||||
sig_events.append((abs_tick, num, den))
|
||||
elif msg.type == "set_tempo":
|
||||
tempo_events.append((abs_tick, int(msg.tempo)))
|
||||
elif msg.type in ("note_on", "note_off"):
|
||||
end_tick = max(end_tick, abs_tick)
|
||||
|
||||
# Dedupe at equal ticks (last wins), matching the tempo-table rule.
|
||||
sig_events.sort(key=lambda e: e[0])
|
||||
sigs: list[tuple[int, int, int]] = []
|
||||
for ev in sig_events:
|
||||
if sigs and sigs[-1][0] == ev[0]:
|
||||
sigs[-1] = ev
|
||||
else:
|
||||
sigs.append(ev)
|
||||
if not sigs or sigs[0][0] > 0:
|
||||
sigs.insert(0, (0, 4, 4))
|
||||
|
||||
tempo_events.sort(key=lambda e: e[0])
|
||||
seen_tempo_ticks: dict[int, int] = {}
|
||||
for ev_tick, ev_tempo in tempo_events:
|
||||
seen_tempo_ticks[ev_tick] = ev_tempo
|
||||
sorted_tempo_ticks = sorted(seen_tempo_ticks)
|
||||
tempos_out: list[dict] = []
|
||||
# Seed the MIDI default (120 BPM) at time 0 when the first tempo event
|
||||
# lands after the start (or there are none). The beat grid already runs
|
||||
# at 120 for the head of the song, so the sidecar must say so too —
|
||||
# symmetric with the (0, 4, 4) default seeded into the signatures above.
|
||||
if not sorted_tempo_ticks or sorted_tempo_ticks[0] > 0:
|
||||
tempos_out.append({"time": 0.0, "bpm": 120.0})
|
||||
for ev_tick in sorted_tempo_ticks:
|
||||
tempos_out.append({
|
||||
"time": round(tick_to_seconds(ev_tick), 3),
|
||||
"bpm": round(60_000_000.0 / seen_tempo_ticks[ev_tick], 3),
|
||||
})
|
||||
|
||||
time_signatures_out = [
|
||||
{"time": round(tick_to_seconds(t), 3), "ts": [num, den]}
|
||||
for t, num, den in sigs
|
||||
]
|
||||
|
||||
# ── walk bars from tick 0 to the end of the notes ────────────────────
|
||||
beats: list[dict] = []
|
||||
if end_tick > 0:
|
||||
cur_tick = 0.0
|
||||
measure = 1
|
||||
sig_idx = 0
|
||||
while cur_tick < end_tick and measure <= _TEMPO_MAP_MAX_BARS:
|
||||
# Active signature: the latest event at or before this bar's
|
||||
# start. Mid-bar events wait for the next boundary by
|
||||
# construction (we only re-read between bars).
|
||||
while (sig_idx + 1 < len(sigs)
|
||||
and sigs[sig_idx + 1][0] <= cur_tick + 1e-6):
|
||||
sig_idx += 1
|
||||
_, num, den = sigs[sig_idx]
|
||||
beat_ticks = ticks_per_beat * 4.0 / den
|
||||
beats.append({
|
||||
"time": round(tick_to_seconds(int(round(cur_tick))), 3),
|
||||
"measure": measure,
|
||||
"den": den,
|
||||
})
|
||||
for k in range(1, num):
|
||||
sub_tick = cur_tick + k * beat_ticks
|
||||
if sub_tick >= end_tick:
|
||||
break
|
||||
beats.append({
|
||||
"time": round(tick_to_seconds(int(round(sub_tick))), 3),
|
||||
"measure": -1,
|
||||
})
|
||||
cur_tick += num * beat_ticks
|
||||
measure += 1
|
||||
|
||||
return {
|
||||
"tempos": tempos_out,
|
||||
"time_signatures": time_signatures_out,
|
||||
"beats": beats,
|
||||
}
|
||||
|
||||
|
||||
# ── Drum track listing (channel-9 only) ──────────────────────────────────────
|
||||
|
||||
# Velocity below this is treated as a ghost note. GM doesn't have an explicit
|
||||
@@ -634,12 +486,10 @@ def convert_drum_track_from_midi(
|
||||
|
||||
Callers can pass an empty dict as ``out_unmapped`` to receive a
|
||||
per-MIDI record of every channel-9 note_on that didn't resolve to a
|
||||
piece-id (``{midi: {"count": int, "times": [float, ...],
|
||||
"velocities": [int, ...]}}``, times/velocities index-aligned and
|
||||
capped at 100 samples per note — velocities carry the source notes'
|
||||
real dynamics so a hand-mapping UI doesn't have to flatten them to a
|
||||
default). The default path skips this capture entirely so MIDIs
|
||||
heavy with cowbell/tambourine/etc. take no extra work.
|
||||
piece-id (``{midi: {"count": int, "times": [float, ...]}}``, times
|
||||
capped at 100 samples per note). The default path skips this
|
||||
capture entirely so MIDIs heavy with cowbell/tambourine/etc. take
|
||||
no extra work.
|
||||
"""
|
||||
offset = float(audio_offset)
|
||||
if not math.isfinite(offset):
|
||||
@@ -677,13 +527,10 @@ def convert_drum_track_from_midi(
|
||||
continue
|
||||
t = tick_to_seconds(abs_tick) + offset
|
||||
entry = out_unmapped.setdefault(
|
||||
midi_note, {"count": 0, "times": [], "velocities": []})
|
||||
midi_note, {"count": 0, "times": []})
|
||||
entry["count"] += 1
|
||||
if len(entry["times"]) < 100:
|
||||
entry["times"].append(round(t, 3))
|
||||
# Index-aligned with times: the note's real dynamics,
|
||||
# so hand-mapping doesn't flatten everything to 100.
|
||||
entry["velocities"].append(int(msg.velocity))
|
||||
continue
|
||||
# Mapped note: compute t once for the raw entry.
|
||||
t = tick_to_seconds(abs_tick) + offset
|
||||
|
||||
+1
-1
@@ -24,7 +24,7 @@ from __future__ import annotations
|
||||
import logging
|
||||
import math
|
||||
|
||||
log = logging.getLogger("feedBack.lib.notation")
|
||||
log = logging.getLogger("slopsmith.lib.notation")
|
||||
|
||||
|
||||
# ── Vocabulary ────────────────────────────────────────────────────────────────
|
||||
|
||||
+5
-21
@@ -114,27 +114,11 @@ def split_hands(notes: list[dict]) -> dict[str, list[dict]]:
|
||||
pitches = sorted(n["midi"] for n in group)
|
||||
span = pitches[-1] - pitches[0]
|
||||
if len(pitches) > 1 and span > HAND_SPLIT_SPAN_SEMITONES:
|
||||
# Prefer middle C as the split boundary when notes straddle it —
|
||||
# this correctly handles bass+treble chords from piano imports where
|
||||
# the largest-gap heuristic picks the wrong split point (e.g.
|
||||
# [G2, E3, C4]: largest gap is G2→E3 but the real split is E3|C4).
|
||||
# BUT only when both resulting hands are themselves playable: a bass
|
||||
# note under a treble voicing that merely dips below C4 (e.g.
|
||||
# [E2, B3, D4, G4]) would otherwise land E2+B3 in one hand — a
|
||||
# 19-semitone span that re-violates HAND_SPLIT_SPAN_SEMITONES. When
|
||||
# the middle-C split produces an unplayable hand, fall back to the
|
||||
# largest internal gap (which correctly isolates E2 there).
|
||||
threshold = None
|
||||
if pitches[0] < MIDDLE_C <= pitches[-1]:
|
||||
_lh = [p for p in pitches if p < MIDDLE_C]
|
||||
_rh = [p for p in pitches if p >= MIDDLE_C]
|
||||
if (_lh[-1] - _lh[0] <= HAND_SPLIT_SPAN_SEMITONES
|
||||
and _rh[-1] - _rh[0] <= HAND_SPLIT_SPAN_SEMITONES):
|
||||
threshold = MIDDLE_C - 1 # lh: midi < MIDDLE_C
|
||||
if threshold is None:
|
||||
gaps = [pitches[i + 1] - pitches[i] for i in range(len(pitches) - 1)]
|
||||
split_after = gaps.index(max(gaps))
|
||||
threshold = pitches[split_after]
|
||||
# Largest internal gap; ties resolve to the lowest such gap so the
|
||||
# left hand keeps the tight low cluster.
|
||||
gaps = [pitches[i + 1] - pitches[i] for i in range(len(pitches) - 1)]
|
||||
split_after = gaps.index(max(gaps))
|
||||
threshold = pitches[split_after] # lh: midi <= threshold
|
||||
for n in group:
|
||||
hands["lh" if n["midi"] <= threshold else "rh"].append(n)
|
||||
else:
|
||||
|
||||
@@ -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/``.
|
||||
"""
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user