mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-09-11 14:24:31 +00:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
95d721b795 | ||
|
|
de7ced6429 | ||
|
|
6f433d9cea | ||
|
|
35742c4fae |
@@ -48,6 +48,17 @@ jobs:
|
||||
python -m pip install --upgrade pip
|
||||
pip install -r requirements.txt -r requirements-test.txt
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install Node dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Typecheck (tsc --noEmit)
|
||||
run: npm run typecheck
|
||||
|
||||
- name: Run pytest
|
||||
run: pytest
|
||||
|
||||
|
||||
@@ -41,12 +41,17 @@ 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
|
||||
(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.slopsmith`).
|
||||
(slopsmith-desktop#110). No React, Vue, Svelte, bundler, or transpiler
|
||||
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. `tsc --noEmit`
|
||||
with `allowJs` / `checkJs` is permitted as a CI / dev-time type check
|
||||
— JSDoc type annotations and `// @ts-check` directives in core `.js`
|
||||
files are encouraged. TypeScript stays a dev-only devDependency; emit
|
||||
-mode TypeScript, `.ts` / `.tsx` source, JSX, frameworks, and bundlers
|
||||
remain forbidden in core. New features extend `app.js` and the existing
|
||||
globals (`window.playSong`, `window.showScreen`,
|
||||
`window.createHighway`, `window.slopsmith`).
|
||||
|
||||
**Non-negotiable rules**
|
||||
|
||||
@@ -63,6 +68,11 @@ features extend `app.js` and the existing globals (`window.playSong`,
|
||||
and does not re-apply the base reset core already provides. Plugins
|
||||
MUST NOT load the Tailwind Play CDN (or any runtime CSS JIT) — the
|
||||
same no-CDN, build-free-at-serve rule that binds core binds plugins.
|
||||
- `tsc --noEmit` with `allowJs` / `checkJs` is permitted as a CI /
|
||||
dev-time type check. JSDoc type annotations and `// @ts-check`
|
||||
directives in core `.js` files are encouraged. TypeScript stays a
|
||||
dev-only devDependency — emit-mode TypeScript, `.ts` / `.tsx` source,
|
||||
JSX, frameworks, and bundlers remain forbidden in core.
|
||||
- New UI state lives in `localStorage` (or a backend endpoint), not in a
|
||||
framework store.
|
||||
- Naming: camelCase JS, kebab-case CSS, snake_case plugin IDs. Player
|
||||
@@ -117,6 +127,12 @@ format. Both must keep playing across releases.
|
||||
- Existing arrangement IDs, sloppak manifests, and the highway
|
||||
WebSocket message shape are stable contracts. Breaking changes
|
||||
require a CHANGELOG entry under "Migration notes".
|
||||
- The plugin-facing JS contracts — `window.slopsmith`, `window.highway`,
|
||||
the highway WebSocket message shape, and the `setRenderer`
|
||||
visualization contract — are declared in `static/slopsmith.d.ts` and
|
||||
form part of this stable-contract surface. The `.d.ts` is the typed
|
||||
source of truth; a breaking change to it requires the same CHANGELOG
|
||||
entry under "Migration notes".
|
||||
|
||||
### V. Pure-Function Core Libraries, Tested
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- **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.
|
||||
|
||||
### Added
|
||||
- Optional JS type checking via `tsc --noEmit`. Added a `static/slopsmith.d.ts` ambient declaration file — the typed source of truth for the plugin-facing contract (`window.slopsmith`, `window.highway`, the highway WebSocket message shape, the `setRenderer` visualization contract, the keyboard-shortcut API) — plus a `tsconfig.json`, a `typecheck` npm script, and a CI step. TypeScript is a dev-only devDependency: no build/emit step, no runtime dependency, every shipped `.js` file stays byte-identical. Core `.js` files opt in individually with a `// @ts-check` directive (currently `static/app.js` and `static/highway.js`); files without it are unaffected.
|
||||
- **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.
|
||||
|
||||
@@ -547,6 +547,7 @@ Sloppak is the preferred format for new features. The [Stems plugin](https://git
|
||||
- **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 (slopsmith-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
|
||||
- **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.
|
||||
- **Plugin contract types** — the plugin-facing JS surface (`window.slopsmith`, `window.highway`, the highway WebSocket message shape, the `setRenderer` contract, the keyboard-shortcut API) is declared in `static/slopsmith.d.ts`. That file is the typed source of truth — consult it for exact signatures, and update it when you change a contract. Core `.js` files opt into checking with a `// @ts-check` directive on line 1; `npm run typecheck` (`tsc --noEmit`, dev/CI-only) verifies them. No build or emit step — the shipped files are plain JS.
|
||||
|
||||
## Backend Conventions
|
||||
|
||||
|
||||
Generated
+16
-1
@@ -9,7 +9,8 @@
|
||||
"version": "1.0.0",
|
||||
"license": "AGPL-3.0-only",
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.59.1"
|
||||
"@playwright/test": "^1.59.1",
|
||||
"typescript": "^5.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
@@ -74,6 +75,20 @@
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.17"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+5
-3
@@ -8,9 +8,11 @@
|
||||
"test:headed": "playwright test --headed",
|
||||
"test:debug": "playwright test --debug",
|
||||
"test:js": "node --test tests/js/*.test.js 'tests/plugins/*/js/*.test.js'",
|
||||
"install:playwright": "playwright install chromium"
|
||||
"install:playwright": "playwright install chromium",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.59.1"
|
||||
"@playwright/test": "^1.59.1",
|
||||
"typescript": "^5.4"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
# Implementation Plan: Note-Detection Capability Domain
|
||||
|
||||
**Status**: Draft stub. This plan records scope, dependencies, and the migration-gate posture so the slice can be scheduled. The full plan/data-model/contracts/tasks are generated when the slice starts.
|
||||
|
||||
## Scope
|
||||
|
||||
Introduce a `note-detection` capability domain: a per-binding, chart-decoupled, multi-consumer control plane over the existing detection DSP. It exposes two primitives — a monophonic pitch estimate and a polyphonic note-set verification verdict — each scored against a requester-supplied tuning context, and consolidates today's two fragmented surfaces (`slopsmithMinigames.scoring.createContinuous` and `window.noteDetect`) behind one contract. It does **not** implement DSP and does **not** own consumer judgment.
|
||||
|
||||
See `spec.md` for requirements, entities, and success criteria.
|
||||
|
||||
## Dependencies / ordering
|
||||
|
||||
- **Depends on** Spec 006 (audio-input domain — source identity, open-session state; consumed, not redefined) and Spec 007 (audio-monitoring domain). 007 is currently paused (PR #667); this slice should not start until 006/007 are settled enough to consume.
|
||||
- **Builds on** the capability-pipeline runtime (Spec 002, PR #245) and follows the migration standard (Spec 003).
|
||||
- **Interim bridge already shipped/in-flight**: notedetect `setVerifyTarget(notes, ctx)` (plugin PR #62). Its per-call tuning context is the forward-compatible seed of this domain's per-binding context; the SlopScale consumer adapter (fork PR) and Chord Sprint are the first non-chart requesters.
|
||||
|
||||
## Migration gate (per Spec 003)
|
||||
|
||||
This slice must pass the central + per-domain migration checklist:
|
||||
- Per-slice legacy inventory: the chart-coupled `note_detect` scoring/verify path, Step Mode verify consumption, minigames YIN scoring, and the `setVerifyTarget` bridge.
|
||||
- Staged deprecation gates + compatibility-bridge accounting (record legacy handoffs; native wins on overlap).
|
||||
- Diagnostics/Inspector expectations: bindings, provider attribution, per-binding context summary, outcomes — redaction-safe, no raw audio.
|
||||
- Removal gate: legacy detection handoffs removed only after consumers migrate, migration notes are published, and external usage review completes.
|
||||
|
||||
## Providers
|
||||
|
||||
- Desktop: JUCE engine verifier (harmonic-comb `scoreChord`, bass temporal-persistence floor) + monophonic pitch.
|
||||
- Web/dev: JS harmonic-comb / YIN fallback.
|
||||
Both sit behind one provider abstraction so DSP improvements land once and reach all consumers.
|
||||
|
||||
## Explicitly out of scope
|
||||
|
||||
Detection DSP/model accuracy, consumer judgment/scoring UX, audio-input source ownership (006), monitoring lifecycle (007), recording, playback transport (008), plugin installation, and tunings outside the provider's current tables (e.g. 6-string bass).
|
||||
@@ -1,129 +0,0 @@
|
||||
# Feature Specification: Note-Detection Capability Domain
|
||||
|
||||
**Status**: Draft stub — scheduling placeholder for the next capability-domain slice after audio-input (006) and audio-monitoring (007). Authored 2026-06-06 from a concrete non-chart consumer requirement (SlopScale, Chord Sprint). Full plan / data-model / contracts / tasks to be generated when the slice is scheduled, passing the Spec 003 migration gate.
|
||||
|
||||
## Why now
|
||||
|
||||
The capability-domain roadmap has been laying foundations *for* this slice from the start: Spec 002 names note detection on the roadmap; Spec 003 lists it among the domains that must pass the migration gate; Spec 004 defines `audio-input` named source identity explicitly so "a later note-detection domain needs per-source binding … without inheriting a single global detector assumption"; Specs 006/007 already carry `{ requesterId: 'note_detect', purpose: 'note-detection' }` requesters. This spec turns that anticipated slice into a concrete one, driven by a real consumer that the current surfaces cannot serve.
|
||||
|
||||
**Concrete trigger.** Detection capability is today fragmented across two surfaces and coupled to the host chart:
|
||||
- `slopsmithMinigames.scoring.createContinuous` — monophonic YIN, reachable from contained playback but weak (no chords, 70 Hz floor, distortion-unprobed).
|
||||
- `window.noteDetect` verify/scoring — the strong harmonic-comb verifier, but its tuning/arrangement state is mutated by the host's loaded song (`song:loaded`), so it is a **single global detector** that two consumers with different tuning needs fight over.
|
||||
|
||||
A contained-playback consumer (SlopScale runs its own transport and computes targets from the *player's real instrument*, not the chart's nominal tuning; Chord Sprint similarly) has no clean way to use the strong verifier against its own tuning. The interim bridge (notedetect `setVerifyTarget(notes, ctx)`, PR #62 on the plugin repo) proves the requirement and is forward-compatible with this domain's per-binding context, but it still relies on a single shared detector instance. This domain is the long-term home.
|
||||
|
||||
## Clarifications
|
||||
|
||||
### Session 2026-06-06
|
||||
- Q: Does this domain perform detection DSP itself? → A: No. It is a capability/control plane over existing detection providers (the desktop JUCE engine verifier and the JS harmonic-comb / YIN fallback). The DSP stays where it is; the domain gives it a per-binding, chart-decoupled, multi-consumer contract.
|
||||
- Q: Does this domain own scoring/judgment (hit windows, gems, tiers)? → A: No. Consumers own judgment semantics. The domain exposes detection PRIMITIVES only: a monophonic pitch estimate, and a "is this (string,fret) note-set ringing now?" verification verdict against caller-supplied tuning. (Doctrine: host owns detection DSP; consumers own judgment.)
|
||||
- Q: Is detection bound to the host highway's loaded song? → A: No. That single-global-detector coupling is the problem this slice removes. Each requester binds its own tuning context.
|
||||
|
||||
## User Scenarios & Testing *(mandatory)*
|
||||
|
||||
### User Story 1 - Verify against the player's own tuning from contained playback (Priority: P1)
|
||||
|
||||
A contained-playback consumer (SlopScale) that runs its own transport and has no host song loaded asks "is the expected note/chord ringing now?" against the *player's* instrument tuning, and receives polyphonic, distortion-robust verdicts — without reading plugin-private globals and without the result being perturbed by whatever song the host highway has open.
|
||||
|
||||
**Why P1**: This is the requirement no current surface satisfies; it is the reason the slice exists.
|
||||
|
||||
**Acceptance**:
|
||||
- **Given** no host song is loaded, **When** a requester opens a detection binding with its own arrangement + tuning and registers a target note set, **Then** it receives verification verdicts scored against that tuning.
|
||||
- **Given** the host loads or switches a song underneath, **When** the requester's binding is active, **Then** its verdicts are unaffected (no shared-state perturbation).
|
||||
|
||||
### User Story 2 - Two consumers detect concurrently with different tunings (Priority: P2)
|
||||
|
||||
The highway (chart tuning) and a minigame (player tuning) request detection at the same time, each against its own context, without a single global detector's mutable arrangement/tuning state being clobbered by the other.
|
||||
|
||||
**Why P2**: Spec 004 deliberately avoided the single-global-detector assumption for exactly this; the domain realizes it.
|
||||
|
||||
**Acceptance**:
|
||||
- **Given** two active detection bindings with different arrangement/tuning, **When** both score concurrently, **Then** neither alters the other's tuning context or verdicts.
|
||||
|
||||
### User Story 3 - One detection capability, two primitives (Priority: P3)
|
||||
|
||||
A consumer that needs a live monophonic pitch (a tuner, a pitch strip) and a consumer that needs polyphonic note-set verification (a chord drill) use the **same** capability domain — not two unrelated surfaces (`createContinuous` vs `noteDetect`) — so DSP improvements (the bass temporal-persistence floor, distortion handling, future models) land once and reach every consumer.
|
||||
|
||||
**Acceptance**:
|
||||
- **Given** a single capability contract, **When** a consumer requests a monophonic pitch primitive or a polyphonic verify primitive, **Then** both are served by one provider over one input binding.
|
||||
- **Given** a DSP improvement lands in the provider, **When** any consumer requests detection, **Then** it benefits without consumer changes.
|
||||
|
||||
### User Story 4 - Migrate detection consumers and providers safely (Priority: P4)
|
||||
|
||||
The existing chart-coupled `note_detect` path, the minigames YIN scoring, Step Mode's verify consumption, and the bridge `setVerifyTarget(notes, ctx)` all migrate onto the domain behind the Spec 003 migration gate, with compatibility bridges and a removal gate, leaving each app area cleaner.
|
||||
|
||||
**Acceptance**:
|
||||
- **Given** the domain exists, **When** a legacy detection handoff occurs during migration, **Then** it is mapped into domain diagnostics and recorded as a compatibility bridge hit.
|
||||
- **Given** a new detection consumer is added after this slice, **When** it needs detection, **Then** it uses the domain rather than a new legacy-only handoff.
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- No microphone / insecure context / no detection provider → detection bindings report unavailable; consumers degrade (scoring disables, never blocks).
|
||||
- A requester's declared tuning references strings the provider's tuning tables cannot represent (e.g. 6-string bass) → bounded `incompatible` outcome, not a silent NaN verdict.
|
||||
- Host song-switch while a player-tuning binding is active → the binding's context is unchanged.
|
||||
- Capo / drop tunings / re-tunings → the binding carries the real open-string pitches; no double transposition.
|
||||
- Polyphony the provider cannot resolve (heavy distortion, sub-floor strings) → verdict reports it honestly rather than guessing.
|
||||
|
||||
## Requirements *(mandatory)*
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
- **FR-001**: System MUST provide an authoritative note-detection control plane for opening a detection binding, registering/clearing a target, requesting verification verdicts, requesting a monophonic pitch estimate, and reporting provider/availability state.
|
||||
- **FR-002**: System MUST let each requester supply its own tuning context (arrangement, per-string tuning as absolute open MIDI or standard-tuning offsets, capo, string count) per binding, and MUST score that binding only against that context.
|
||||
- **FR-003**: System MUST NOT bind detection to the host highway's loaded song or any single global arrangement/tuning state; concurrent bindings with different contexts MUST NOT perturb one another.
|
||||
- **FR-004**: System MUST consume audio-input (Spec 006) source identity and open-session state for its capture rather than redefining input or assuming one global detector.
|
||||
- **FR-005**: System MUST expose detection as PRIMITIVES — a monophonic pitch estimate and a polyphonic note-set verification verdict — and MUST NOT perform consumer-side judgment (hit windows, streaks, gems, accuracy, tiers).
|
||||
- **FR-006**: System MUST provide a timing-free verification mode (score a registered target every frame independent of any playhead) so a frozen-playhead or self-transported consumer can ask "is this note-set ringing now?".
|
||||
- **FR-007**: System MUST surface per-binding verdict detail sufficient for consumer judgment (at minimum: overall hit, and per-string/per-note ring state for a multi-note target) without exposing raw audio buffers or sample data.
|
||||
- **FR-008**: System MUST report distinct outcomes for unavailable, denied, degraded, failed, no-provider, unsupported-context, and incompatible-version, rather than silently producing a verdict.
|
||||
- **FR-009**: System MUST reject or degrade tuning contexts the provider cannot represent (unsupported arrangement/string-count) with an `incompatible` outcome and MUST NOT emit NaN/garbage verdicts.
|
||||
- **FR-010**: System MUST route every detection request through one provider abstraction so DSP improvements reach all consumers at once (single source of truth), with the desktop engine verifier and a JS fallback as interchangeable providers.
|
||||
- **FR-011**: System MUST preserve the existing chart-coupled detection path during the compatibility period by mapping it onto the domain, recording compatibility bridge hits.
|
||||
- **FR-012**: System MUST document the migration path for detection providers and requesters (the chart `note_detect` consumer, Step Mode verify, minigames YIN scoring, and the `setVerifyTarget` bridge), including a removal gate, per Spec 003.
|
||||
- **FR-013**: System MUST emit observable events when bindings open/close, targets change, verdicts are produced, and availability changes.
|
||||
- **FR-014**: System MUST include active bindings, provider attribution, per-binding context summary, availability, and recent outcomes in diagnostics, redaction-safe.
|
||||
- **FR-015**: System MUST NOT expose raw audio buffers, sample/waveform data, or live capture handles through detection state, verdicts, diagnostics, or capability payloads.
|
||||
- **FR-016**: System MUST leave audio-input source ownership, monitoring lifecycle, recording, playback transport, and plugin installation outside this feature except as state it consumes.
|
||||
- **FR-017**: System MUST avoid creating new legacy-only detection integration points once the native domain exists.
|
||||
|
||||
### Key Entities
|
||||
|
||||
- **Detection Binding**: A requester-owned, context-scoped detection session over a selected audio-input source — carries the requester's tuning context and target, independent of any host song. Multiple bindings coexist.
|
||||
- **Tuning Context**: Arrangement + per-string tuning (absolute open MIDI or standard offsets) + capo + string count, supplied by the requester; the only tuning a binding's verdicts are scored against.
|
||||
- **Verify Target**: A registered note set (string/fret + technique flags) the binding scores against live audio every frame, independent of any playhead.
|
||||
- **Verification Verdict**: A bounded result — overall hit, per-note/per-string ring state, score, hit/total counts — with no raw audio.
|
||||
- **Pitch Estimate**: A monophonic frequency/MIDI + confidence primitive (the tuner/pitch-strip use case), the consolidation target for `createContinuous`.
|
||||
- **Detection Provider**: The participant performing DSP — the desktop JUCE engine verifier, or the JS harmonic-comb / YIN fallback — interchangeable behind the domain contract.
|
||||
- **Detection Requester**: A consumer (note_detect chart path, Step Mode, SlopScale, Chord Sprint, a tuner) that needs detection primitives but owns its own judgment.
|
||||
- **Compatibility Bridge Hit**: A record that a legacy chart-coupled or minigames-YIN detection handoff was used during migration.
|
||||
- **Detection Outcome**: A bounded diagnostic record (provider, binding, requester, status, outcome, safe reason) with no live handles or sample data.
|
||||
|
||||
## Success Criteria *(mandatory)*
|
||||
|
||||
### Measurable Outcomes
|
||||
|
||||
- **SC-001**: A contained-playback requester with no host song loaded receives verification verdicts scored against its own tuning in 100% of focused scenarios.
|
||||
- **SC-002**: A host song-switch while a player-tuning binding is active changes that binding's verdicts in 0% of focused scenarios.
|
||||
- **SC-003**: Two concurrent bindings with different arrangement/tuning never alter each other's context or verdicts in 100% of focused scenarios.
|
||||
- **SC-004**: A polyphonic (chord) target produces a real per-note/overall verdict — not an all-or-nothing exemption — in 100% of focused chord scenarios.
|
||||
- **SC-005**: A DSP improvement landed in the provider reaches every domain consumer with zero consumer code changes in representative cases.
|
||||
- **SC-006**: Unsupported tuning contexts produce an `incompatible` outcome and zero NaN/garbage verdicts in 100% of focused scenarios.
|
||||
- **SC-007**: 100% of detection verdicts, state snapshots, and diagnostics contain zero raw audio buffers, sample/waveform data, or live capture handles.
|
||||
- **SC-008**: New detection consumers added after this slice use the domain rather than a new legacy-only handoff in 100% of reviewed cases.
|
||||
- **SC-009**: A maintainer can identify provider, binding context, availability, and outcome for a representative detection failure in under 5 minutes from diagnostics/inspector.
|
||||
|
||||
## Assumptions
|
||||
|
||||
- Audio-input (006) and audio-monitoring (007) slices are available as foundation; this slice consumes their source identity and monitoring facts rather than redefining them.
|
||||
- The detection DSP (desktop JUCE engine verifier; JS harmonic-comb / YIN fallback) already exists and is correct; this slice gives it a per-binding, chart-decoupled, multi-consumer contract — it does not reimplement DSP.
|
||||
- Consumers retain ownership of judgment semantics (hit windows, streaks, gems, accuracy, tiers); the domain provides primitives only.
|
||||
- The notedetect `setVerifyTarget(notes, ctx)` bridge (plugin PR #62) is the interim, forward-compatible step; its per-call context maps onto this domain's per-binding context.
|
||||
- Detection is sensitive: raw audio must never cross the capability boundary.
|
||||
- Existing chart-coupled and minigames-YIN detection paths may coexist during migration behind the Spec 003 gate.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Detection DSP/model implementation or accuracy improvements (owned by the provider plugins).
|
||||
- Consumer judgment/scoring UX (gems, tiers, accuracy) — owned by each requester.
|
||||
- Audio-input source ownership/selection (006), monitoring lifecycle (007), recording, playback transport (008), and plugin installation.
|
||||
- 6-string bass and other tunings outside the provider's current tuning tables (tracked separately).
|
||||
Vendored
+403
@@ -0,0 +1,403 @@
|
||||
/**
|
||||
* Slopsmith core — ambient plugin-contract declarations.
|
||||
*
|
||||
* This file is the typed source of truth for the plugin-facing JS surface:
|
||||
* `window.slopsmith`, `window.highway`, the highway WebSocket message shape,
|
||||
* and the `setRenderer` visualization contract. It is loaded into the `tsc`
|
||||
* program (see `tsconfig.json` `include`) as ambient types — core files that
|
||||
* carry `// @ts-check` pick these globals up without any `import`.
|
||||
*
|
||||
* Per the Constitution (Principle IV) these contracts are stable: a breaking
|
||||
* change here requires a CHANGELOG entry under "Migration notes".
|
||||
*
|
||||
* Hand-maintained — keep in sync with the live source. The runtime files
|
||||
* (`static/diagnostics.js`, `audio-mixer.js`, `tour-engine.js`,
|
||||
* `lottie-api.js`) are declared here but not themselves `@ts-check`'d yet.
|
||||
*/
|
||||
|
||||
// ─── Chart data wire shapes ─────────────────────────────────────────────────
|
||||
|
||||
/** A single fretted note as streamed over the highway WebSocket. */
|
||||
interface SlopsmithNote {
|
||||
/** Time, seconds. */
|
||||
t: number;
|
||||
/** String index (0 = lowest). */
|
||||
s: number;
|
||||
/** Fret number (0 = open). */
|
||||
f: number;
|
||||
/** Sustain length, seconds. */
|
||||
sus?: number;
|
||||
/** Hammer-on flag. */
|
||||
ho?: number;
|
||||
/** Pull-off flag. */
|
||||
po?: number;
|
||||
/** Slide-to fret. */
|
||||
sl?: number;
|
||||
/** Bend flag / amount. */
|
||||
bn?: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/** A chord event: a cluster of notes sharing a time. */
|
||||
interface SlopsmithChord {
|
||||
/** Time, seconds. */
|
||||
t: number;
|
||||
/** Component notes (string/fret/sustain, no own `t`). */
|
||||
notes: Array<Omit<SlopsmithNote, 't'> & { t?: number }>;
|
||||
/** Index into the chord-templates table. */
|
||||
id?: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface SlopsmithBeat { time: number; measure: number; }
|
||||
interface SlopsmithSection { time: number; name: string; }
|
||||
interface SlopsmithAnchor { time: number; fret: number; width: number; }
|
||||
interface SlopsmithChordTemplate { name: string; frets: number[]; fingers?: number[]; }
|
||||
interface SlopsmithLyric { w: string; t: number; d: number; }
|
||||
interface SlopsmithToneChange { time: number; name: string; }
|
||||
|
||||
/** Per-note scoring judgment published via `highway.setNoteStateProvider`. */
|
||||
interface SlopsmithNoteState {
|
||||
state: 'hit' | 'active' | 'miss';
|
||||
alpha: number;
|
||||
color: string | null;
|
||||
}
|
||||
|
||||
/** A scorer callback: returns a judgment for a chart note, or nullish. */
|
||||
type SlopsmithNoteStateProvider = (
|
||||
note: SlopsmithNote,
|
||||
chartTime: number,
|
||||
) => SlopsmithNoteState | 'hit' | 'active' | 'miss' | null | undefined;
|
||||
|
||||
// ─── Highway WebSocket protocol ─────────────────────────────────────────────
|
||||
|
||||
/** `song_info` — song metadata frame. */
|
||||
interface SlopsmithSongInfo {
|
||||
type: 'song_info';
|
||||
title: string;
|
||||
artist: string;
|
||||
arrangement: string;
|
||||
arrangement_index: number;
|
||||
arrangements: Array<{ name: string; index: number }>;
|
||||
duration: number;
|
||||
/** Tuning offsets — length 6 for guitar, 4 for bass. */
|
||||
tuning: number[];
|
||||
capo: number;
|
||||
format: 'sloppak' | 'loose' | string;
|
||||
/** `null` when audio is unavailable. */
|
||||
audio_url: string | null;
|
||||
/** Non-null only when `audio_url` is null. */
|
||||
audio_error: string | null;
|
||||
/** Always present; empty array when there are no split stems. */
|
||||
stems: string[];
|
||||
/** Chart `<offset>`, seconds. Absent for sources without one. */
|
||||
offset?: number;
|
||||
/** Active-arrangement string count, when the server emits it. */
|
||||
stringCount?: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* A frame streamed over `/ws/highway/{filename}`. Discriminated on `type`;
|
||||
* the case set is the `switch (msg.type)` in `highway.js`.
|
||||
*/
|
||||
type SlopsmithHighwayMessage =
|
||||
| { type: 'loading'; stage: string }
|
||||
| SlopsmithSongInfo
|
||||
| { type: 'beats'; data: SlopsmithBeat[] }
|
||||
| { type: 'sections'; data: SlopsmithSection[] }
|
||||
| { type: 'anchors'; data: SlopsmithAnchor[] }
|
||||
| { type: 'chord_templates'; data: SlopsmithChordTemplate[] }
|
||||
| { type: 'lyrics'; data: SlopsmithLyric[] }
|
||||
| { type: 'tone_changes'; base: string; data: SlopsmithToneChange[] }
|
||||
| { type: 'notes'; data: SlopsmithNote[] }
|
||||
| { type: 'chords'; data: SlopsmithChord[] }
|
||||
| { type: 'handshapes'; data: unknown[] }
|
||||
| { type: 'phrases'; data: unknown[]; total: number }
|
||||
| { type: 'ready' };
|
||||
|
||||
// ─── Visualization renderer (setRenderer) contract ──────────────────────────
|
||||
|
||||
/**
|
||||
* Per-frame snapshot handed to a renderer's `draw()` / `init()`. All chart
|
||||
* arrays are difficulty-filter-aware.
|
||||
*/
|
||||
interface SlopsmithRenderBundle {
|
||||
currentTime: number;
|
||||
songInfo: SlopsmithSongInfo | Record<string, never>;
|
||||
isReady: boolean;
|
||||
notes: SlopsmithNote[];
|
||||
chords: SlopsmithChord[];
|
||||
anchors: SlopsmithAnchor[];
|
||||
beats: SlopsmithBeat[];
|
||||
sections: SlopsmithSection[];
|
||||
chordTemplates: SlopsmithChordTemplate[];
|
||||
stringCount: number;
|
||||
lyrics: SlopsmithLyric[];
|
||||
toneChanges: SlopsmithToneChange[];
|
||||
toneBase: string;
|
||||
mastery: number;
|
||||
hasPhraseData: boolean;
|
||||
inverted: boolean;
|
||||
lefty: boolean;
|
||||
renderScale: number;
|
||||
lyricsVisible: boolean;
|
||||
/** 2D-highway depth projection for a time offset. */
|
||||
project: (tOffset: number) => { x: number; y: number; scale: number };
|
||||
/** 2D-highway horizontal fret position. */
|
||||
fretX: (fret: number, scale: number, w: number) => number;
|
||||
/** Per-note scoring state, or null when no provider is registered. */
|
||||
getNoteState: (note: SlopsmithNote, chartTime: number) => SlopsmithNoteState | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A renderer instance returned by a `window.slopsmithViz_<id>` factory.
|
||||
* Subject to multiple `init() … destroy()` cycles on one instance.
|
||||
*/
|
||||
interface SlopsmithRenderer {
|
||||
/** Required canvas context type; defaults to `'2d'` when omitted. */
|
||||
contextType?: '2d' | 'webgl2';
|
||||
init(canvas: HTMLCanvasElement, bundle: SlopsmithRenderBundle): void;
|
||||
draw(bundle: SlopsmithRenderBundle): void;
|
||||
resize?(w: number, h: number): void;
|
||||
destroy?(): void;
|
||||
}
|
||||
|
||||
/** A `window.slopsmithViz_<id>` factory: fresh renderer per call. */
|
||||
interface SlopsmithVizFactory {
|
||||
(): SlopsmithRenderer;
|
||||
/** Auto-mode predicate; static on the factory, not the instance. */
|
||||
matchesArrangement?: (songInfo: SlopsmithSongInfo | Record<string, never>) => unknown;
|
||||
/** Optional static mirror of the instance `contextType`. */
|
||||
contextType?: '2d' | 'webgl2';
|
||||
}
|
||||
|
||||
// ─── window.highway — the renderer API ──────────────────────────────────────
|
||||
|
||||
/** The object returned by `createHighway()` and exposed as `window.highway`. */
|
||||
interface SlopsmithHighway {
|
||||
init(canvas: HTMLCanvasElement, container?: HTMLElement | null): void;
|
||||
resize(): void;
|
||||
connect(wsUrl: string, opts?: {
|
||||
onError?: (err: string) => void;
|
||||
onReady?: () => void;
|
||||
[key: string]: unknown;
|
||||
}): void;
|
||||
reconnect(filename: string, arrangement: number): void;
|
||||
stop(): void;
|
||||
|
||||
setRenderScale(scale: number): void;
|
||||
getRenderScale(): number;
|
||||
getInverted(): boolean;
|
||||
setInverted(v: boolean): void;
|
||||
getLefty(): boolean;
|
||||
setLefty(on: boolean): void;
|
||||
|
||||
setMastery(fraction: number): void;
|
||||
getMastery(): number;
|
||||
hasPhraseData(): boolean;
|
||||
|
||||
setTime(t: number): void;
|
||||
getTime(): number;
|
||||
setAvOffset(ms: number): void;
|
||||
getAvOffset(): number;
|
||||
getBPM(t: number): number;
|
||||
getBeats(): SlopsmithBeat[];
|
||||
getAudioElement(): HTMLAudioElement | null;
|
||||
|
||||
setVisible(v: boolean | null): void;
|
||||
isVisible(): boolean;
|
||||
|
||||
getNotes(): SlopsmithNote[];
|
||||
getChords(): SlopsmithChord[];
|
||||
getChordTemplates(): SlopsmithChordTemplate[];
|
||||
getToneChanges(): SlopsmithToneChange[];
|
||||
getToneBase(): string;
|
||||
getSections(): SlopsmithSection[];
|
||||
getSongInfo(): SlopsmithSongInfo | Record<string, never>;
|
||||
getStringCount(): number;
|
||||
|
||||
addDrawHook(fn: (ctx: CanvasRenderingContext2D, w: number, h: number) => void): void;
|
||||
removeDrawHook(fn: (ctx: CanvasRenderingContext2D, w: number, h: number) => void): void;
|
||||
fireDrawHooks(ctx: CanvasRenderingContext2D, w: number, h: number): void;
|
||||
|
||||
setNoteStateProvider(fn: SlopsmithNoteStateProvider | null): void;
|
||||
getNoteStateProvider(): SlopsmithNoteStateProvider | null;
|
||||
getNoteState(note: SlopsmithNote, chartTime: number): SlopsmithNoteState | null;
|
||||
|
||||
project(tOffset: number): { x: number; y: number; scale: number };
|
||||
fretX(fret: number, scale: number, w: number): number;
|
||||
fillTextUnmirrored(text: string, x: number, y: number): void;
|
||||
|
||||
toggleLyrics(): void;
|
||||
getLyricsVisible(): boolean;
|
||||
setLyricsVisible(v: boolean): void;
|
||||
setOnLyricsChange(fn: (visible: boolean) => void): void;
|
||||
|
||||
setRenderer(r: SlopsmithRenderer | null | undefined): void;
|
||||
isDefaultRenderer(): boolean;
|
||||
}
|
||||
|
||||
// ─── window.slopsmith — event bus + namespaces ──────────────────────────────
|
||||
|
||||
/** Named event payloads carried on `window.slopsmith` CustomEvents (`event.detail`). */
|
||||
interface SlopsmithEventMap {
|
||||
'song:ready': { songInfo: SlopsmithSongInfo };
|
||||
'song:play': unknown;
|
||||
'song:pause': unknown;
|
||||
'song:seek': { from: number; to: number; reason: string | null };
|
||||
'highway:visibility': { visible: boolean; canvas: HTMLCanvasElement };
|
||||
'highway:canvas-replaced': {
|
||||
oldCanvas: HTMLCanvasElement;
|
||||
newCanvas: HTMLCanvasElement;
|
||||
contextType: '2d' | 'webgl2';
|
||||
};
|
||||
'viz:reverted': unknown;
|
||||
[event: string]: unknown;
|
||||
}
|
||||
|
||||
/** A labeled audio fader registered with the mixer (slopsmith#87). */
|
||||
interface SlopsmithFaderSpec {
|
||||
id: string;
|
||||
label: string;
|
||||
unit?: string;
|
||||
min: number;
|
||||
max: number;
|
||||
step: number;
|
||||
defaultValue: number;
|
||||
getValue: () => number;
|
||||
setValue: (v: number) => void;
|
||||
}
|
||||
|
||||
/** `window.slopsmith.audio` — mixer + song-volume surface (audio-mixer.js). */
|
||||
interface SlopsmithAudioApi {
|
||||
registerFader(spec: SlopsmithFaderSpec): void;
|
||||
unregisterFader(id: string): void;
|
||||
getFaders(): SlopsmithFaderSpec[];
|
||||
openMixer(): void;
|
||||
closeMixer(restoreFocus?: boolean): void;
|
||||
toggleMixer(): void;
|
||||
applySongVolume(v?: number | null): Promise<number>;
|
||||
readSongVolume(): number;
|
||||
}
|
||||
|
||||
/** `window.slopsmith.diagnostics` — client diagnostics surface (diagnostics.js). */
|
||||
interface SlopsmithDiagnosticsApi {
|
||||
contribute(pluginId: string, payload: unknown): void;
|
||||
snapshot(): unknown;
|
||||
snapshotConsole(): unknown;
|
||||
snapshotHardware(): Promise<unknown>;
|
||||
snapshotUa(): unknown;
|
||||
snapshotLocalStorage(): unknown;
|
||||
snapshotContributions(): unknown;
|
||||
}
|
||||
|
||||
/** `window.slopsmith` — the plugin event bus (an `EventTarget`). */
|
||||
interface SlopsmithApi extends EventTarget {
|
||||
currentSong: unknown;
|
||||
isPlaying: boolean;
|
||||
navigate(screenId: string, params?: Record<string, unknown>): void;
|
||||
getNavParams(): Record<string, unknown>;
|
||||
emit<K extends keyof SlopsmithEventMap>(event: K, detail: SlopsmithEventMap[K]): void;
|
||||
emit(event: string, detail?: unknown): void;
|
||||
on(event: string, fn: (e: Event) => void, options?: AddEventListenerOptions | boolean): void;
|
||||
off(event: string, fn: (e: Event) => void, options?: EventListenerOptions | boolean): void;
|
||||
setLoop(a: number, b: number): unknown;
|
||||
clearLoop(): void;
|
||||
getLoop(): { loopA: number | null; loopB: number | null };
|
||||
/** Attached by audio-mixer.js after it loads. */
|
||||
audio?: SlopsmithAudioApi;
|
||||
/** Attached by diagnostics.js early in `<head>`. */
|
||||
diagnostics?: SlopsmithDiagnosticsApi;
|
||||
/** Per-session map of loaded plugin screen.js versions. Owned by app.js. */
|
||||
_loadedPluginScripts?: Map<string, string>;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
// ─── Keyboard-shortcut API ──────────────────────────────────────────────────
|
||||
|
||||
/** A keyboard shortcut registration (see `window.registerShortcut`). */
|
||||
interface SlopsmithShortcutSpec {
|
||||
/** `e.key` value or `e.code`. */
|
||||
key: string;
|
||||
description: string;
|
||||
scope?: 'global' | 'player' | 'library' | 'settings' | string;
|
||||
condition?: () => boolean;
|
||||
handler: (e: KeyboardEvent) => void;
|
||||
/** Optional modifier-key requirements (ctrl/alt/shift/meta). */
|
||||
modifiers?: { ctrl?: boolean; alt?: boolean; shift?: boolean; meta?: boolean } | null;
|
||||
}
|
||||
|
||||
/** A panel-scoped shortcut registry returned by `createShortcutPanel`. */
|
||||
interface SlopsmithShortcutPanel {
|
||||
registerShortcut(spec: SlopsmithShortcutSpec): void;
|
||||
unregisterShortcut(key: string, scope?: string): boolean;
|
||||
clearShortcuts(): void;
|
||||
}
|
||||
|
||||
// ─── Global augmentations ───────────────────────────────────────────────────
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
/** Plugin event bus + namespaces. Owned by app.js. */
|
||||
slopsmith: SlopsmithApi;
|
||||
/** The shared highway renderer instance. */
|
||||
highway: SlopsmithHighway;
|
||||
/** Factory for additional highway instances (splitscreen panels). */
|
||||
createHighway(): SlopsmithHighway;
|
||||
/** Loads and plays a song into the player. */
|
||||
playSong(filename: string, arrangement?: number): Promise<void>;
|
||||
/** Switches the active single-page-app screen. */
|
||||
showScreen(id: string): Promise<void>;
|
||||
|
||||
/** Keyboard-shortcut registry. */
|
||||
registerShortcut(options: SlopsmithShortcutSpec): void;
|
||||
unregisterShortcut(key: string, scope?: string): boolean;
|
||||
createShortcutPanel(id: string): SlopsmithShortcutPanel;
|
||||
setActiveShortcutPanel(id: string): void;
|
||||
getActiveShortcutPanel(): string;
|
||||
clearWindowShortcuts(windowId: string): number;
|
||||
getShortcutWindowId(): string;
|
||||
isInShortcutPanel(): boolean;
|
||||
getGlobalShortcutContext(): unknown;
|
||||
_setDebugShortcuts(enabled: boolean): void;
|
||||
_listShortcuts(): void;
|
||||
_testShortcut(key: string, scope?: string): unknown;
|
||||
/** Debug-only internals exposed by app.js. */
|
||||
_panels?: unknown;
|
||||
_getCurrentContext?: () => unknown;
|
||||
_isShortcutActive?: (shortcut: unknown, ctx: unknown) => boolean;
|
||||
|
||||
/** Desktop JUCE audio bridge — present only in slopsmith-desktop. */
|
||||
jucePlayer?: unknown;
|
||||
_juceMode?: boolean;
|
||||
_juceAudioUrl?: string | null;
|
||||
|
||||
/** Native desktop bridge — present only when running inside slopsmith-desktop. */
|
||||
slopsmithDesktop?: any;
|
||||
/** Demo analytics hook — real impl set by demo.js, else null. */
|
||||
slopsmithDemoTrack?: ((event: string, props?: unknown) => void) | null;
|
||||
|
||||
/** Lottie animation helper (lottie-api.js). */
|
||||
slopsmithLottie?: unknown;
|
||||
/** Guided-tour engine (tour-engine.js). */
|
||||
slopsmithTour?: unknown;
|
||||
|
||||
/**
|
||||
* Visualization factories — one per `type: "visualization"` plugin,
|
||||
* keyed `slopsmithViz_<id>`. Indexed access is intentionally loose.
|
||||
*/
|
||||
[vizFactory: `slopsmithViz_${string}`]: SlopsmithVizFactory;
|
||||
}
|
||||
|
||||
/** Bare global — assigned via `window.registerShortcut =` in app.js. */
|
||||
function registerShortcut(options: SlopsmithShortcutSpec): void;
|
||||
/** Bare global — assigned via `window.unregisterShortcut =` in app.js. */
|
||||
function unregisterShortcut(key: string, scope?: string): boolean;
|
||||
/** Bare global — assigned via `window.createShortcutPanel =` in app.js. */
|
||||
function createShortcutPanel(id: string): SlopsmithShortcutPanel;
|
||||
/** Bare global — assigned via `window.setActiveShortcutPanel =` in app.js. */
|
||||
function setActiveShortcutPanel(id: string): void;
|
||||
}
|
||||
|
||||
export {};
|
||||
+10
-59
@@ -16,18 +16,7 @@ const APP_JS = path.join(__dirname, '..', '..', 'static', 'app.js');
|
||||
function extractFunction(src, signature) {
|
||||
const start = src.indexOf(signature);
|
||||
if (start === -1) throw new Error(`extractFunction: '${signature}' not found in app.js`);
|
||||
let scan = start + signature.length;
|
||||
if (src[scan] === '(') {
|
||||
let parenDepth = 1;
|
||||
scan++;
|
||||
while (scan < src.length && parenDepth > 0) {
|
||||
const ch = src[scan];
|
||||
if (ch === '(') parenDepth++;
|
||||
else if (ch === ')') parenDepth--;
|
||||
scan++;
|
||||
}
|
||||
}
|
||||
const openBrace = src.indexOf('{', scan);
|
||||
const openBrace = src.indexOf('{', start);
|
||||
let depth = 1;
|
||||
let i = openBrace + 1;
|
||||
while (i < src.length && depth > 0) {
|
||||
@@ -42,12 +31,8 @@ function extractFunction(src, signature) {
|
||||
|
||||
function buildSandbox() {
|
||||
const seekCalls = [];
|
||||
const sectionPracticeModeCalls = [];
|
||||
const transportEvents = [];
|
||||
const sandbox = {
|
||||
seekCalls,
|
||||
sectionPracticeModeCalls,
|
||||
transportEvents,
|
||||
// Mutable state (declared as `var` in eval prelude so it lives on
|
||||
// the sandbox global and the extracted functions can read/write).
|
||||
// The actual values are set below.
|
||||
@@ -81,13 +66,6 @@ function buildSandbox() {
|
||||
// updateLoopUI references formatTime for the label; we don't
|
||||
// assert on the label text in these tests, so a stub is enough.
|
||||
formatTime: (s) => String(s),
|
||||
window: {
|
||||
slopsmith: {
|
||||
playback: {
|
||||
transportEvent: (...args) => transportEvents.push(args),
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
vm.createContext(sandbox);
|
||||
return sandbox;
|
||||
@@ -99,15 +77,7 @@ function loadFunctions(sandbox, src) {
|
||||
const code = `
|
||||
var loopA = null;
|
||||
var loopB = null;
|
||||
var _loopMutationGen = 0;
|
||||
var _sectionPracticeSelected = -1;
|
||||
var _sectionPracticeWholeSection = false;
|
||||
var _sectionPracticeSavedPartIndex = 0;
|
||||
function _setSectionPracticeMode(on, opts) {
|
||||
sectionPracticeModeCalls.push({ on, opts: opts || {} });
|
||||
}
|
||||
function _updateSectionPracticeHighlight(ct) {}
|
||||
${extractFunction(src, 'function clearLoop(')}
|
||||
${extractFunction(src, 'function clearLoop()')}
|
||||
${extractFunction(src, 'function _syncSavedLoopSelection()')}
|
||||
${extractFunction(src, 'async function setLoop(')}
|
||||
${extractFunction(src, 'function updateLoopUI()')}
|
||||
@@ -210,30 +180,6 @@ test('clearLoop resets loopA/loopB to null', async () => {
|
||||
const { loopA, loopB } = sandbox.__getLoop();
|
||||
assert.equal(loopA, null);
|
||||
assert.equal(loopB, null);
|
||||
assert.equal(sandbox.sectionPracticeModeCalls.length, 1);
|
||||
assert.equal(sandbox.sectionPracticeModeCalls[0].on, false);
|
||||
// Field-wise: vm-context objects break deepStrictEqual across realms.
|
||||
assert.equal(sandbox.sectionPracticeModeCalls[0].opts.skipClearLoop, true);
|
||||
});
|
||||
|
||||
test('loop helpers emit transport snapshots by default and can suppress adapter echoes', async () => {
|
||||
const src = fs.readFileSync(APP_JS, 'utf8');
|
||||
const sandbox = buildSandbox();
|
||||
loadFunctions(sandbox, src);
|
||||
|
||||
await sandbox.__setLoop(5, 10);
|
||||
sandbox.__clearLoop();
|
||||
|
||||
assert.equal(sandbox.transportEvents.length, 2);
|
||||
assert.equal(sandbox.transportEvents[0][0], 'loop-set');
|
||||
assert.equal(JSON.stringify(sandbox.transportEvents[0][1].loop), JSON.stringify({ startTime: 5, endTime: 10, enabled: true, state: 'active' }));
|
||||
assert.equal(sandbox.transportEvents[1][0], 'loop-cleared');
|
||||
assert.equal(JSON.stringify(sandbox.transportEvents[1][1].loop), JSON.stringify({ enabled: false, state: 'inactive' }));
|
||||
|
||||
sandbox.transportEvents.length = 0;
|
||||
await sandbox.__setLoop(7, 11, { emitTransportEvent: false });
|
||||
sandbox.__clearLoop({ emitTransportEvent: false });
|
||||
assert.equal(sandbox.transportEvents.length, 0);
|
||||
});
|
||||
|
||||
test('window.slopsmith API surface declares setLoop/clearLoop/getLoop', () => {
|
||||
@@ -242,9 +188,14 @@ test('window.slopsmith API surface declares setLoop/clearLoop/getLoop', () => {
|
||||
// renaming silently.
|
||||
const src = fs.readFileSync(APP_JS, 'utf8');
|
||||
// Find the slopsmith Object.assign block and check method presence.
|
||||
const m = src.match(/window\.slopsmith\s*=\s*Object\.assign\(_slopsmithBus,\s*\{([\s\S]*?)\}\);\s*if \(_slopsmithExisting/);
|
||||
assert.ok(m, 'slopsmith Object.assign block not found');
|
||||
const block = m[1];
|
||||
// Tolerates an optional `/** @type {...} */ (` JSDoc cast prefix on the
|
||||
// assignment (added when app.js opted into `// @ts-check`).
|
||||
const startMatch = src.match(
|
||||
/window\.slopsmith\s*=\s*(?:\/\*\*[\s\S]*?\*\/\s*\(\s*)?Object\.assign\((?:new EventTarget\(\)|_\w+),\s*\{/);
|
||||
assert.ok(startMatch, 'slopsmith Object.assign block not found');
|
||||
// The setLoop/clearLoop/getLoop methods live within the literal that
|
||||
// follows; a bounded slice is robust against `})`-containing bodies.
|
||||
const block = src.slice(startMatch.index, startMatch.index + 2000);
|
||||
assert.match(block, /setLoop\s*\(/, 'setLoop method missing from slopsmith API');
|
||||
assert.match(block, /clearLoop\s*\(/, 'clearLoop method missing from slopsmith API');
|
||||
assert.match(block, /getLoop\s*\(/, 'getLoop method missing from slopsmith API');
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"allowJs": true,
|
||||
"checkJs": false,
|
||||
"noEmit": true,
|
||||
"strict": true,
|
||||
"target": "es2022",
|
||||
"lib": ["es2022", "dom", "dom.iterable"],
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["static/**/*.js", "static/**/*.d.ts"],
|
||||
"exclude": ["static/vendor/**", "node_modules"]
|
||||
}
|
||||
Reference in New Issue
Block a user