diff --git a/assets/plugin.css b/assets/plugin.css index a497ba0..c3e2425 100644 --- a/assets/plugin.css +++ b/assets/plugin.css @@ -1,22 +1,6 @@ -/* note_detect — game-grade scoring UI (live HUD + end-of-song results). - * - * Served by core via the plugin.json "styles" key as - * /api/plugins/note_detect/assets/plugin.css?v= - * so every CSS change MUST bump the plugin.json version (cache-buster). - * - * This sheet is injected BEFORE core's tailwind.min.css, so core wins any - * equal-specificity collision: every selector here is nd-prefixed (or an - * nd-scoped attribute/keyframe) and core utility classes are never - * redefined. - * - * Skin architecture: all components read CSS custom properties; the three - * skins (neon / esports / metal) only swap the property values via the - * data-nd-skin attribute stamped on .nd-instance-root and - * .nd-summary-overlay. Add a skin = add one attribute block. - */ - -/* ── Fonts (SIL OFL — see assets/fonts/OFL-*.txt) ─────────────────────── */ + +/* Theme variables drive the HUD, summaries, and shared-card rendering. */ @font-face { font-family: 'Orbitron'; src: url('fonts/orbitron-700.woff2') format('woff2'); @@ -46,19 +30,17 @@ font-display: swap; } -/* ── Skin variables — defaults are the "neon" arcade skin ─────────────── */ - .nd-instance-root, .nd-summary-overlay, .nd-toast { - --nd-accent: #00f0ff; /* primary accent (score, frames) */ - --nd-accent2: #ff2ec4; /* secondary accent (top multiplier) */ - --nd-hit: #00ff88; /* good / hits */ - --nd-warn: #ffcc00; /* mid accuracy */ - --nd-miss: #ff4444; /* bad / misses */ + --nd-accent: #00f0ff; + --nd-accent2: #ff2ec4; + --nd-hit: #00ff88; + --nd-warn: #ffcc00; + --nd-miss: #ff4444; --nd-text: #e8f6ff; --nd-dim: #7c93a8; - --nd-bg: rgba(6, 10, 24, 0.82); /* panel background COLOR (solid only — image layers go in --nd-texture) */ + --nd-bg: rgba(6, 10, 24, 0.82); --nd-frame: rgba(0, 240, 255, 0.45); --nd-font-display: 'Orbitron', ui-sans-serif, system-ui, sans-serif; --nd-font-grade: 'Orbitron', ui-sans-serif, system-ui, sans-serif; @@ -66,11 +48,7 @@ --nd-glow-current: 0 0 8px currentColor; --nd-radius: 10px; --nd-clip: none; - /* Animated glow ring — a conic-gradient border that sweeps around the - panel (rotation driven by the registered --nd-ring-angle property), - drawn twice: a sharp ring plus a blurred duplicate for the glow. - Skins theme the gradient stops, ring width, speed, and glow - strength; --nd-ring-animation: none freezes it to a static ring. */ + --nd-ring-w: 2px; --nd-ring-overhang: 3px; --nd-ring-stops: #00f0ff, #7a4dff 25%, #ff2ec4 50%, #7a4dff 75%, #00f0ff; @@ -78,21 +56,15 @@ --nd-ring-glow-opacity: 0.8; --nd-ring-animation: nd-ring-spin 4s linear infinite; --nd-bevel: 0 0 22px rgba(0, 240, 255, 0.14); - --nd-texture: none; /* optional panel texture (metal) */ - /* Hero-CTA emphasis device — HOW a skin makes the primary action special. - neon/metal use the glow-ring; a glow-less skin (esports) zeroes the ring - and emphasises with a solid accent border instead. --nd-acc-fill is the - Accuracy hero-number fill (a gradient here; a solid accent where a - gradient would wash out). This per-skin "device" split is exactly what - the host-theme contract should generalise so a feature can't bind to one - skin's language by accident. */ - --nd-hero-ring-idle: 0.5; /* glow-ring opacity at rest */ - --nd-hero-ring-on: 1; /* ...on hover/focus/press */ - --nd-hero-border: transparent; /* solid emphasis border for ring-less skins */ + --nd-texture: none; + + --nd-hero-ring-idle: 0.5; + --nd-hero-ring-on: 1; + --nd-hero-border: transparent; --nd-acc-fill: linear-gradient(135deg, var(--nd-accent), var(--nd-accent2)); } -/* Clean esports / AAA-minimal: one amber accent, no glow, angled corners. */ +/* Skins override tokens without changing component rules. */ [data-nd-skin="esports"] { --nd-accent: #e8b43a; --nd-accent2: #f5f5f4; @@ -109,8 +81,7 @@ --nd-glow-current: none; --nd-radius: 0; --nd-clip: none; - /* Restrained sweep: a dim amber track with one bright comet segment - orbiting slowly. */ + --nd-ring-w: 1px; --nd-ring-overhang: 3px; --nd-ring-stops: rgba(232, 180, 58, 0.22) 0deg, @@ -121,17 +92,13 @@ --nd-ring-animation: nd-ring-spin 7s linear infinite; --nd-bevel: none; --nd-texture: none; - /* No glow here: drop the hero ring and emphasise the primary with a solid - amber border; render the Accuracy number as a solid accent so it doesn't - vanish into a near-white gradient. */ + --nd-hero-ring-idle: 0; --nd-hero-ring-on: 0; --nd-hero-border: var(--nd-accent); --nd-acc-fill: var(--nd-accent); } -/* Rock / metal: brushed-steel panel, hard bevels, stencil grade type. - The grunge texture is an inline feTurbulence SVG — no binary asset. */ [data-nd-skin="metal"] { --nd-accent: #ffb347; --nd-accent2: #ff6b35; @@ -148,8 +115,7 @@ --nd-glow-current: 0 1px 0 rgba(0, 0, 0, 0.85); --nd-radius: 6px; --nd-clip: none; - /* Ember pulse: the steel bevel stays; an amber ring breathes instead - of rotating (nd-ring-pulse animates opacity, not the angle). */ + --nd-ring-w: 2px; --nd-ring-overhang: 2px; --nd-ring-stops: #ffb347, #ff6b35 30%, #8a3c1a 50%, #ff6b35 70%, #ffb347; @@ -160,16 +126,12 @@ inset 0 -2px 5px rgba(0, 0, 0, 0.65), 0 0 0 1px #14161a, 0 2px 6px rgba(0, 0, 0, 0.5); - /* Image stack: grunge noise over the brushed-steel gradient. Both live - here (not in --nd-bg) because components apply background-color and - background-image as separate properties — a lone background-image - would otherwise clobber a gradient-valued background shorthand. */ + --nd-texture: url("data:image/svg+xml;utf8,"), linear-gradient(180deg, #3a3f46, #24272c 55%, #1a1c20); } -/* ── Live HUD ─────────────────────────────────────────────────────────── */ - +/* Live scoring HUD. */ .nd-hud { position: absolute; top: 0.75rem; @@ -188,32 +150,11 @@ clip-path: var(--nd-clip); } -/* v0.3.0 (fee[dB]ack v3) chrome — detected by its persistent Up-Next - pill, which only exists in the v3 player markup: tuck the HUD under - the pill stack and right-align with it. The pill's bottom lands at - ≈81px (#player-hud py-4 16 + time row 20 + gap 8 + pill ~37), and the - glow ring extends --nd-ring-overhang past the panel box on every - side, so both offsets compensate for the ring: the panel sits at 6rem - (sharp ring edge ≈93px → ~12px clear of the pill, the soft blur - fading inside that gap) and the ring's outer edge — the visual edge — - lines up with the pill's right inset. The legacy player keeps the - original spot above. */ body:has(#v3-upnext) .nd-hud { top: 7rem; right: calc(1.25rem + var(--nd-ring-overhang)); } -/* ── Animated glow ring ─────────────────────────────────────────────── - The registered custom property makes --nd-ring-angle interpolable, so - the conic gradient's start angle can be animated — the sweep orbits - the panel. (Without @property support the ring still renders, just - static.) The ring shape is the classic padding+mask trick: paint the - gradient over the whole box, then mask everything except the padding - band so only a --nd-ring-w-thick outline remains. Two layers per - panel: ::after (or .nd-sum-frame::after) is the sharp ring, ::before - the blurred glow copy. The HUD hosts its own pseudos; the results - panel scrolls, so its layers hang off the dedicated .nd-sum-frame - anchor inside the non-scrolling .nd-sum-shell wrapper. */ @property --nd-ring-angle { syntax: ''; inherits: false; @@ -333,7 +274,6 @@ body:has(#v3-upnext) .nd-hud { color: var(--nd-accent); } -/* Drill block keeps its pre-redesign role; only the look is themed. */ .nd-hud .nd-drill { margin-top: 0.5rem; text-align: right; @@ -351,9 +291,6 @@ body:has(#v3-upnext) .nd-hud { color: var(--nd-dim); } -/* ── HUD animations (transform/opacity only; removed-and-readded by JS - to retrigger; suppressed under reduced motion) ───────────────────── */ - @media (prefers-reduced-motion: no-preference) { .nd-pop { animation: nd-pop 0.35s cubic-bezier(0.2, 1.6, 0.4, 1); } .nd-shake { animation: nd-shake 0.4s linear; } @@ -377,8 +314,7 @@ body:has(#v3-upnext) .nd-hud { 100% { filter: brightness(1); } } -/* ── End-of-song results screen ───────────────────────────────────────── */ - +/* End-of-song and end-of-set summaries. */ .nd-summary-overlay { position: fixed; inset: 0; @@ -392,16 +328,14 @@ body:has(#v3-upnext) .nd-hud { color: var(--nd-text); } -/* Anchor for the frame overlay; the panel inside it scrolls, the frame - doesn't. */ .nd-sum-shell { position: relative; max-width: 92vw; } .nd-sum-panel { - position: relative; /* positioning context for the confetti overlay */ - width: 34rem; /* wider framing — fits the per-section practice buttons */ + position: relative; + width: 34rem; max-width: 92vw; max-height: 88vh; overflow-y: auto; @@ -413,7 +347,6 @@ body:has(#v3-upnext) .nd-hud { clip-path: var(--nd-clip); } -/* Accent hairline under the header — fades out toward both edges. */ .nd-sum-header::after { content: ''; display: block; @@ -432,16 +365,13 @@ body:has(#v3-upnext) .nd-hud { margin-bottom: 0.5rem; } -/* Grade stamp + full-combo callout + confetti layer. The stamp starts - oversized/invisible and slams in 1.2 s into the reveal. */ .nd-sum-grade-wrap { position: relative; text-align: center; min-height: 7rem; margin-bottom: 0.3rem; } -/* One-shot celebration burst — overlays the whole panel (it's a direct panel - child now that the letter grade is gone), purely decorative. */ + .nd-sum-confetti { position: absolute; inset: 0; @@ -464,8 +394,7 @@ body:has(#v3-upnext) .nd-hud { .nd-sum-grade[data-grade="S"] { color: var(--nd-accent2); } .nd-sum-grade[data-grade="D"], .nd-sum-grade[data-grade="F"] { color: var(--nd-miss); } -/* Full-combo badge — a real competency, now the celebratory hero in place of - the (removed) letter grade. Centered standalone under the song line. */ + .nd-sum-fc { margin: 0.15rem 0 0.5rem; text-align: center; @@ -490,7 +419,6 @@ body:has(#v3-upnext) .nd-hud { to { opacity: 1; } } -/* Headline: accuracy % + score, count-up driven from JS. */ .nd-sum-headline { display: flex; justify-content: center; @@ -506,22 +434,14 @@ body:has(#v3-upnext) .nd-hud { } .nd-sum-score { color: var(--nd-accent); - /* No text glow on the big readout by default — the outer glow bled into - the digits and hurt legibility (community feedback). The glow is an - opt-in A/B (.nd-glow below) for design review. */ -} -/* Coherence: echo the hero accent gradient on the Accuracy hero number, so the - rainbow reads as an intentional system (frame ring → accuracy → Retry CTA) - rather than one out-of-place button. Score stays flat accent; themes per skin - via --nd-accent/--nd-accent2. */ + +} + .nd-sum-acc-n { - /* Solid accent base — stays legible everywhere, including any engine that - doesn't honour text background-clip (where transparent text would vanish). */ + color: var(--nd-accent); } -/* Apply the clipped accent fill only where supported (Electron/Chrome do; this - keeps the number visible on anything that doesn't). --nd-acc-fill always - resolves to a paint (skin value, else the gradient fallback) — never `none`. */ + @supports ((background-clip: text) or (-webkit-background-clip: text)) { .nd-sum-acc-n { background: var(--nd-acc-fill, linear-gradient(135deg, var(--nd-accent), var(--nd-accent2))); @@ -531,7 +451,7 @@ body:has(#v3-upnext) .nd-hud { color: transparent; } } -/* "Results text glow" A/B setting — re-enables the glow on the big readouts. */ + .nd-glow .nd-sum-score, .nd-glow .nd-sum-acc { text-shadow: var(--nd-glow-accent); @@ -545,9 +465,6 @@ body:has(#v3-upnext) .nd-hud { color: var(--nd-dim); } -/* "Beat your best" delta — one compact line under the headline. Neutral (dim) - for a tie / your standing best; accent-lit only on a genuine new best. Never - shows a negative, so it can't shame a rough run. */ .nd-sum-best { margin: -0.35rem 0 0.65rem; text-align: center; @@ -562,7 +479,6 @@ body:has(#v3-upnext) .nd-hud { text-shadow: var(--nd-glow-current); } -/* Stat rows — staggered slide-in via per-row --row-i delays. */ .nd-sum-stats { display: grid; grid-template-columns: 1fr 1fr; @@ -602,7 +518,6 @@ body:has(#v3-upnext) .nd-hud { .nd-val-bad { color: var(--nd-miss); } .nd-val-accent { color: var(--nd-accent); } -/* XP award row — hidden until the minigames response lands. */ .nd-sum-xp { margin: -0.1rem 0 0.55rem; text-align: center; @@ -612,8 +527,6 @@ body:has(#v3-upnext) .nd-hud { text-shadow: var(--nd-glow-current); } -/* "Focus next" — the one growth-oriented next-step line (replaces the grade's - verdict). Calm, not a failure callout. */ .nd-sum-focus { margin: 0.1rem 0 0.6rem; text-align: center; @@ -628,9 +541,6 @@ body:has(#v3-upnext) .nd-hud { color: var(--nd-accent); } -/* Contextual-hero reason — one quiet line that explains why the emphasised - action is what it is (e.g. drilling the one weak section). Sits just above - the action row; deliberately understated so the hero button leads. */ .nd-sum-hero-reason { margin: 0.2rem 0 0.5rem; text-align: center; @@ -639,7 +549,6 @@ body:has(#v3-upnext) .nd-hud { color: var(--nd-dim); } -/* Per-section / miss-breakdown bars. */ .nd-sum-subhead { margin: 0.55rem 0 0.3rem; font-size: 0.62rem; @@ -687,7 +596,7 @@ body:has(#v3-upnext) .nd-hud { .nd-bar-dim { background: var(--nd-dim); } .nd-bar-alt { background: var(--nd-accent2); } .nd-bar-cool { background: var(--nd-accent); } -/* Section % readout — fixed width so the practice button lines up across rows. */ + .nd-sum-bar-val { flex: 0 0 auto; min-width: 2.4rem; @@ -696,7 +605,7 @@ body:has(#v3-upnext) .nd-hud { color: var(--nd-text); opacity: 0.85; } -/* Per-section "Practice This Section" loop button — compact outline pill. */ + .nd-sum-practice { flex: 0 0 auto; pointer-events: auto; @@ -724,9 +633,6 @@ body:has(#v3-upnext) .nd-hud { color: var(--nd-dim); } -/* Song identity under the header — title gives screenshots context, the sub - line carries artist · instrument (the track played), and the album-art - thumbnail sits to the right of that text. */ .nd-sum-songmeta { display: flex; align-items: center; @@ -735,7 +641,7 @@ body:has(#v3-upnext) .nd-hud { margin: 0.1rem 0 0.5rem; } .nd-sum-song-text { - min-width: 0; /* lets the title ellipsize inside the flex row */ + min-width: 0; text-align: left; } .nd-sum-song-title { @@ -765,7 +671,6 @@ body:has(#v3-upnext) .nd-hud { box-shadow: var(--nd-bevel); } -/* Share row (Copy card / Save) — sits just above the primary actions. */ .nd-sum-share { display: flex; gap: 0.5rem; @@ -776,7 +681,6 @@ body:has(#v3-upnext) .nd-hud { padding: 0.42rem 0.7rem; } -/* Up-Next strip (queue-aware card — playlist "Play All"). */ .nd-sum-upnext { display: flex; flex-wrap: wrap; @@ -813,7 +717,6 @@ body:has(#v3-upnext) .nd-hud { font-size: 0.68rem; } -/* End-of-set summary (after the last queued song): per-song rows. */ .nd-set-rows { margin-top: 0.9rem; max-height: 40vh; @@ -854,9 +757,6 @@ body:has(#v3-upnext) .nd-hud { color: var(--nd-accent); } -/* "Show each song's score card" OFF (queue context): the card collapses to - the Up-Next countdown strip — the score DOM stays (all wiring/animation - paths run unchanged), it's just not shown. Exit stays reachable. */ .nd-sum-scoreless .nd-sum-confetti, .nd-sum-scoreless .nd-sum-fc, .nd-sum-scoreless .nd-sum-headline, @@ -871,7 +771,6 @@ body:has(#v3-upnext) .nd-hud { display: none; } -/* Action buttons. */ .nd-sum-actions { display: flex; gap: 0.5rem; @@ -904,24 +803,17 @@ body:has(#v3-upnext) .nd-hud { } .nd-btn-primary { position: relative; - isolation: isolate; /* contain the glow-ring's stacking to this button */ + isolation: isolate; color: #fff; background: linear-gradient(135deg, var(--nd-accent), var(--nd-accent2)); - /* Solid emphasis border for skins that don't use the glow-ring (esports); - transparent where the ring carries it (neon/metal). */ + border-color: var(--nd-hero-border, transparent); - /* Lift the white label off the magenta end of the gradient (white there is - ~2.6:1); invisible over the cyan end. */ + text-shadow: 0 1px 2px rgba(0, 0, 0, 0.45); transition: background 0.15s, border-color 0.15s, filter 0.15s, transform 0.08s ease; } -/* Hero "glow-ring echo": a miniature of the card-frame ring (.nd-sum-frame), - cut from the SAME --nd-ring-* tokens so it themes per skin and visually - rhymes with the panel border — directly answering "the rainbow doesn't match - the card." Dim + static at rest; it brightens and the sweep wakes only on - hover/focus/press, so the emphasis tracks the user's intent rather than - flashing on its own. */ + .nd-btn-primary::after { content: ''; position: absolute; @@ -933,16 +825,11 @@ body:has(#v3-upnext) .nd-hud { -webkit-mask-composite: xor; mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0); mask-composite: exclude; - opacity: var(--nd-hero-ring-idle, 0.5); /* 0 on glow-less skins → ring off */ + opacity: var(--nd-hero-ring-idle, 0.5); transition: opacity 0.18s ease; pointer-events: none; } -/* Re-assert the gradient fill on hover/focus/press. Without this, the generic - `.nd-btn:hover` background (specificity 0,2,0 vs this base rule's 0,1,0) - replaces the gradient with flat grey — the reported "turns grey on hover" - bug. Grouping hover + focus-visible + active here (also 0,2,0, but later in - source order → wins the tie, no !important) keeps the gradient and gives - keyboard parity. */ + .nd-btn-primary:hover, .nd-btn-primary:focus-visible, .nd-btn-primary:active { @@ -957,8 +844,7 @@ body:has(#v3-upnext) .nd-hud { } .nd-btn-primary:active { transform: translateY(1px) scale(0.985); } -/* Reduced motion: reveal-gated elements must still become visible when the - keyframes are off. */ +/* Preserve visibility and interaction while disabling decorative motion. */ @media (prefers-reduced-motion: reduce) { .nd-sum-grade, .nd-sum-fc, @@ -971,14 +857,14 @@ body:has(#v3-upnext) .nd-hud { width: var(--nd-bar-w, 0%); animation: none; } - /* Glow rings freeze to a static colored border. */ + .nd-hud::before, .nd-hud::after, .nd-sum-frame::before, .nd-sum-frame::after { animation: none; } - /* Hero CTA: still brightens on hover (opacity), but never sweeps or pops. */ + .nd-btn-primary:hover::after, .nd-btn-primary:focus-visible::after, .nd-btn-primary:active::after { @@ -987,9 +873,7 @@ body:has(#v3-upnext) .nd-hud { .nd-btn-primary:active { transform: none; } } -/* ── Save confirmation toast ───────────────────────────────────────────── - Skin-themed, bottom-center, ~6 s. data-nd-skin on the element resolves the - palette (esports/metal override; neon via the base rule above). */ +/* Save and copy confirmation toast. */ .nd-toast { position: fixed; left: 50%; diff --git a/package.json b/package.json index 98485f7..9db6d56 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "slopsmith-plugin-notedetect", - "version": "1.32.0", + "version": "1.32.1", "private": true, "description": "Slopsmith note detection plugin — test harness for detection core", "scripts": { diff --git a/plugin.json b/plugin.json index bb332a3..9520594 100644 --- a/plugin.json +++ b/plugin.json @@ -1,7 +1,7 @@ { "id": "note_detect", "name": "Note Detection", - "version": "1.32.0", + "version": "1.32.1", "private": false, "script": "screen.js", "styles": "assets/plugin.css", diff --git a/routes.py b/routes.py index 6917be2..627bf18 100644 --- a/routes.py +++ b/routes.py @@ -1,56 +1,4 @@ -"""Server routes for the note_detect plugin. - -POST /api/plugins/note_detect/recording - Body: raw bytes of a RIFF/WAVE file (mono PCM is what the browser - encodes; we don't crack it open, just validate the header). - Query: ?slug= (optional, defaults to "recording"). - Returns JSON: { path_in_container, relative_path, filename, bytes }. - -POST /api/plugins/note_detect/live-judgment - Body: JSON object — one judgment record produced by the detector. - Query: ?session= (sanitised; defaults to "default"). - Returns JSON: { ok: true, appended: }. - Appends one JSON line to - ``static/note_detect_recordings/live_.jsonl``. The plugin - streams judgments here only when tuning mode is on (or while - armed-for-training; see /training-bundle below), so steady-state - play has zero overhead. Each line is a self-contained record — - safe to tail / read partially / replay. - -POST /api/plugins/note_detect/training-bundle - Body: JSON { slug, wav_filename, session, manifest, arrangement, upload_url }. - slug — used to name the bundle, and to locate the WAV when - ``wav_filename`` is absent (newest matching - ``note_detect__*.wav``). - wav_filename — exact WAV filename from the /recording response; - preferred over the slug glob so concurrent same-slug - takes can't be paired with the wrong WAV. Optional. - session — locates the live-judgment JSONL written by - /live-judgment (``live_.jsonl``). Optional — - bundle proceeds without it if absent or empty. - manifest — JSON object recorded as-is into ``manifest.json`` - inside the bundle. The server adds schema, created_at, - and resolved filename/bytes fields before writing. - arrangement — optional ground-truth note chart, written as - ``arrangement.json`` inside the bundle. - upload_url — optional pCloud destination override. - Bundles the located files + manifest into - ``training__.zip`` under the recordings directory, then - POSTs the bundle (multipart/form-data) to the curated pCloud public - upload link (``_PCLOUD_UPLOAD_CODE`` below). On upload failure the - local zip is retained so the user can retry manually. Returns JSON - with ``ok`` and either ``pcloud_result`` (success) or ``error``. - -All three endpoints write under ``/note_detect_recordings/``, where -```` is the first writable directory among ``$STATIC_DIR``, -``$CONFIG_DIR``, and ``/app/static``. In Docker, ``$STATIC_DIR`` (or the -``/app/static`` bind mount) is host-reachable, so recordings land there. -In the packaged desktop bundle ``$STATIC_DIR`` is unset and the bundled -static tree is read-only, so recordings fall back to ``$CONFIG_DIR`` — -the user's writable data directory. The base is resolved lazily on the -first write (and cached) so route registration never fails; a host with -no writable candidate at all turns into a clean 500 on save. -""" +"""Local persistence, diagnostics, card export, and training-upload routes.""" import json import os @@ -62,40 +10,18 @@ from pathlib import Path from fastapi import HTTPException, Request -# pCloud public upload link ("puplink") for the curated note_detect -# training set. Anyone with this code can upload to the destination -# folder; they cannot list or read it. Hardcoded as the fallback when -# the client doesn't supply an `upload_url` in the /training-bundle -# POST. This is the single source of truth for the default — the -# settings page fetches it via GET /api/plugins/note_detect/config -# rather than hardcoding its own copy. +# Curated upload destination; settings reads it from the config route. _PCLOUD_UPLOAD_CODE = "itd7ZwmOK8S2D6XSAE1Q9cUPaF5c9WFfk" _PCLOUD_DEFAULT_URL = "https://e.pcloud.com/#/puplink?code=" + _PCLOUD_UPLOAD_CODE _PCLOUD_UPLOAD_URL = "https://eapi.pcloud.com/uploadtolink" -# Regex used to pull a pCloud upload code out of whatever the user -# pastes in the settings field. Anchored on `code=` so a share URL -# (`…/#/puplink?code=ABC`), an API URL -# (`…/uploadtolink?code=ABC`), or any other URL form work. The -# bare-code path is handled separately in `_parse_pcloud_code`. + _PCLOUD_CODE_RE = re.compile(r"code=([A-Za-z0-9_-]+)") -# Bare-code shape: pCloud upload codes are alphanumeric + `_`/`-` -# only, so anything matching the full string is treated as already- -# extracted. + _PCLOUD_BARE_RE = re.compile(r"^[A-Za-z0-9_-]+$") -# Conservative pCloud-safe filename normaliser applied to the name -# sent to pCloud as the `names` parameter: lowercase, ASCII -# alphanumeric + single dashes only, stem capped at 80 chars, -# extension preserved (but lowercased). The `result=2001 "Invalid -# file/folder name"` failures were caused by omitting the `names` -# parameter entirely (see `_upload_to_pcloud`), not by the name's -# contents; this normaliser is kept as defence-in-depth against -# genuinely odd names. Applied ONLY to the name we send pCloud — the -# local zip on disk keeps its original (readable) name so retries / -# forensics stay easy. + _PCLOUD_NAME_STEM_RE = re.compile(r"[^a-z0-9]+") _PCLOUD_NAME_DASH_COLLAPSE_RE = re.compile(r"-+") - def _sanitize_pcloud_filename(name: str) -> str: p = Path(name) stem = (p.stem or "training").lower() @@ -106,78 +32,39 @@ def _sanitize_pcloud_filename(name: str) -> str: stem = "training" if len(stem) > 80: stem = stem[:80].rstrip("-") - # Defensive: extension should look like .zip / .ogg / etc. If - # something exotic slipped through, fall back to .bin so pCloud's - # validator doesn't choke on it. + if not re.fullmatch(r"\.[a-z0-9]{1,8}", ext): ext = ".bin" return stem + ext -# Cap on the bundle size we'll attempt to upload. WAV+JSONL+manifest for -# a 3-minute take is ~15 MB; 64 MB lets longer takes and higher sample -# rates through while still refusing to upload pathological blobs. + +# These limits bound memory, disk, and upload work. _BUNDLE_MAX_BYTES = 64 * 1024 * 1024 -# Cap on the JSON request body for /training-bundle and its /retry. -# The body is manifest + (optionally) the full note chart — a dense -# song's arrangement.json is well under 1 MB, so 16 MB is generous -# while refusing a blob that would balloon memory before the post-zip -# size check can run. + _TRAINING_BODY_MAX_BYTES = 16 * 1024 * 1024 -# pCloud HTTP timeout for the upload POST. Slow links can take a while -# for a 15 MB body; 5 minutes is generous without pinning the request -# slot indefinitely. + _PCLOUD_TIMEOUT_S = 300 -# Subdirectory under the slopsmith static tree where recordings land. -# Bind-mounted via docker-compose (`./static:/app/static`), so the host -# sees these files at `/static/note_detect_recordings/`. _RECORDINGS_REL = "note_detect_recordings" -# Filename slug — strip anything that isn't filesystem-safe. Length cap -# keeps us comfortably under any FS limit even with the timestamp tail. _SLUG_RE = re.compile(r"[^A-Za-z0-9_-]+") _SLUG_MAX = 40 -# Cap to keep a runaway client from filling the disk via the POST body. -# A clean 3-minute recording at 44.1 kHz mono 16-bit PCM is ~15 MB; 32 MB -# leaves headroom for higher sample rates / longer takes while still -# refusing to write multi-GB blobs. _MAX_BYTES = 32 * 1024 * 1024 -# Per-judgment payloads are small (~150 bytes typical), but a buggy -# client could spam huge blobs. Cap individual payloads so the JSONL -# file can't be DoSed into millions of bytes per line. _LIVE_JUDGMENT_MAX_BYTES = 8 * 1024 -# JSONL files for a single session shouldn't exceed this — caps total -# accumulation per session. A 2-minute song produces ~60 KB; this gives -# 100× headroom while still bounding pathological cases. _LIVE_FILE_MAX_BYTES = 8 * 1024 * 1024 -# Results-card PNG save (the "Save" button on the end-of-song card). A -# 1200×630 card is ~100 KB; 16 MB is absurd headroom while refusing a -# pathological blob. _CARD_MAX_BYTES = 16 * 1024 * 1024 -# Allow spaces + a few human-friendly punctuation marks in a saved card name -# (e.g. "Artist - Title - 2026-06-28 1432.png"); everything else collapses to a -# hyphen. Path separators never survive — Path(...).name strips them first — so -# a name can't escape the target directory. -_CARD_NAME_RE = re.compile(r"[^A-Za-z0-9 ._&(),'-]+") +_CARD_NAME_RE = re.compile(r"[^A-Za-z0-9 ._&(),'-]+") def _default_pictures_dir() -> Path: - """The user's Pictures folder — the Save button's default destination. - Not guaranteed to exist; the caller mkdirs it.""" + """Return the default results-card directory.""" return Path.home() / "Pictures" - def _resolve_card_dir(raw: str, auto: bool = False) -> Path: - """Target directory for a saved card: the user-configured folder - (settings) when given, else the default Pictures folder. For an - auto-saved card with no configured folder, default to a dedicated - "feedBack Cards" subfolder of Pictures so the steady stream of per-song - cards doesn't clutter the Pictures root. A supplied path must be - absolute — a relative path would resolve against the server's CWD, not - anywhere the user expects.""" + """Resolve an absolute card destination, using Pictures by default.""" raw = (raw or "").strip() if not raw: base = _default_pictures_dir() @@ -187,10 +74,8 @@ def _resolve_card_dir(raw: str, auto: bool = False) -> Path: raise HTTPException(400, "save folder must be an absolute path") return p - def _sanitize_card_filename(name: str) -> str: - # Reduce to a bare, filesystem-safe .png basename — never a path, so a - # client can't write outside the resolved directory via the name. + base = Path(str(name or "")).name base = _CARD_NAME_RE.sub("-", base) base = re.sub(r"\s+", " ", base).strip(" -_.") or "score-card" @@ -198,23 +83,8 @@ def _sanitize_card_filename(name: str) -> str: base = re.sub(r"\.[^.]*$", "", base) + ".png" return base[:120] - def _parse_pcloud_code(upload_url: str | None) -> str | None: - """Extract the pCloud upload-link code from a user-supplied string. - - Accepts: - - a full share URL: ``https://e.pcloud.com/#/puplink?code=ABC`` - - the API URL form: ``https://eapi.pcloud.com/uploadtolink?code=ABC`` - - any other URL containing ``code=ABC`` somewhere - - a bare code (no URL syntax at all): ``ABC`` - - An empty / missing input returns the curated-default code (the - user wants the default). A *non-empty* input that contains no - parseable code returns ``None`` so the caller can reject it with a - 4xx — silently falling back to the default would route a - contributor's recording to the public curated dataset when they - meant to send it to their own folder. - """ + """Extract a pCloud upload code; reject malformed non-empty values.""" if not upload_url: return _PCLOUD_UPLOAD_CODE s = upload_url.strip() @@ -227,29 +97,14 @@ def _parse_pcloud_code(upload_url: str | None) -> str | None: return s return None - def _sanitize_slug(s: str, default: str = "recording") -> str: - # `default` is parameterised because the same sanitiser feeds the - # recording-filename slug (where "recording" is the obvious fallback) - # AND the live-judgment session id (where each route's docstring - # promises its own fallback — "default" for /live-judgment). If an - # input sanitises to empty, fall back to the caller's chosen tag - # rather than coalescing two unrelated routes onto the same name. + s = (s or "").strip() s = _SLUG_RE.sub("_", s)[:_SLUG_MAX].strip("_") return s or default - async def _read_capped_body(request: Request, max_bytes: int) -> bytes: - """Read the request body, refusing it the moment it exceeds - ``max_bytes``. - - ``request.json()`` / ``request.body()`` buffer the WHOLE body into - memory before any size check can run, so a cap applied afterwards - doesn't protect the process. This checks ``Content-Length`` up - front, then streams the body with a running byte count — a lying or - absent header can't sneak an oversized payload past the cap. - """ + """Read a request body without buffering more than the configured limit.""" cl = request.headers.get("content-length") if cl is not None: try: @@ -257,7 +112,7 @@ async def _read_capped_body(request: Request, max_bytes: int) -> bytes: raise HTTPException( 413, f"request body too large ({cl} bytes > {max_bytes})") except ValueError: - pass # unparseable header — fall through to the streamed count + pass chunks: list = [] total = 0 async for chunk in request.stream(): @@ -268,26 +123,10 @@ async def _read_capped_body(request: Request, max_bytes: int) -> bytes: chunks.append(chunk) return b"".join(chunks) - def setup(app, context): + # Resolve writable storage lazily so registration cannot fail at startup. log = context["log"] - # Resolve the slopsmith static tree from $STATIC_DIR (set by native - # uvicorn launches that don't see the Docker `/app` mount) and fall - # back to the in-container path so this keeps working in compose. - # The mkdir is deferred to the request handler so a missing/un- - # writable static dir at plugin-load time can't take down route - # registration — `/api/plugins/note_detect/recording` would 404 and - # the in-app save would silently fail. - # Recordings need a WRITABLE, user-reachable directory. Try, in order: - # STATIC_DIR — Docker (bind-mounted, host-reachable) / native dev runs - # CONFIG_DIR — desktop bundle: STATIC_DIR is unset there and the - # bundled static tree is read-only, but CONFIG_DIR is the - # user's writable data directory - # /app/static — last-resort Docker default - # The first base that can actually be created AND written wins. It is - # resolved lazily on the first write and cached, so a read-only candidate - # turns into a clean fallback (and only an all-candidates-fail case 500s) - # rather than a load-time crash that 404s the route. + _candidate_dirs = [] if os.environ.get("STATIC_DIR"): _candidate_dirs.append(Path(os.environ["STATIC_DIR"]) / _RECORDINGS_REL) @@ -295,7 +134,7 @@ def setup(app, context): _candidate_dirs.append(Path(os.environ["CONFIG_DIR"]) / _RECORDINGS_REL) _candidate_dirs.append(Path("/app/static") / _RECORDINGS_REL) - _resolved_dir: list = [None] # mutable cell — set on first successful probe + _resolved_dir: list = [None] def _ensure_out_dir() -> Path: if _resolved_dir[0] is not None: @@ -304,11 +143,7 @@ def _ensure_out_dir() -> Path: for cand in _candidate_dirs: try: cand.mkdir(parents=True, exist_ok=True) - # A directory can exist but be read-only (packaged bundle) — - # confirm with a probe file before committing to it. The probe - # name is unique per call (pid + random) so two requests racing - # this lazy init can't unlink each other's probe and spuriously - # fail a directory that is in fact writable. + probe = cand / f".write_test_{os.getpid()}_{secrets.token_hex(6)}" probe.write_bytes(b"") probe.unlink() @@ -327,9 +162,7 @@ def _ensure_out_dir() -> Path: @app.post("/api/plugins/note_detect/recording") async def save_recording(request: Request): body = await request.body() - # Tiny WAVs are almost certainly empty / corrupt — RIFF + fmt + - # data chunks together are 44 bytes minimum even with zero - # samples, so this is a real-input check, not a hard limit. + if not body or len(body) < 44: raise HTTPException(400, "empty or too-short body (expected a WAV file)") if len(body) > _MAX_BYTES: @@ -338,19 +171,15 @@ async def save_recording(request: Request): raise HTTPException(400, "body is not a WAV file (no RIFF/WAVE header)") slug = _sanitize_slug(request.query_params.get("slug", "recording")) - # Include milliseconds + a short random suffix so two saves in - # the same second with the same slug don't overwrite each other - # (two-panel splitscreen scenario, or rapid arm/save cycles). - # `secrets.token_hex(3)` is plenty of entropy for human-scale - # collision avoidance and keeps the filename short. + now = time.time() ts = time.strftime("%Y%m%d_%H%M%S", time.localtime(now)) ms = int((now - int(now)) * 1000) suffix = secrets.token_hex(3) filename = f"note_detect_{slug}_{ts}_{ms:03d}_{suffix}.wav" path = _ensure_out_dir() / filename - # Use a `.tmp` then rename so a crashed write doesn't leave a - # truncated WAV that the harness might pick up next time. + + # Publish recordings atomically. tmp = path.with_suffix(path.suffix + ".tmp") try: tmp.write_bytes(body) @@ -383,15 +212,7 @@ async def append_live_judgment(request: Request): 413, f"judgment too large ({len(body)} bytes > {_LIVE_JUDGMENT_MAX_BYTES})", ) - # Parse + re-emit so we (a) reject malformed JSON early and (b) - # guarantee one self-contained record per line. A buggy client - # POSTing a multi-line string would otherwise corrupt the JSONL - # contract (each line = one valid object). Handle both - # JSONDecodeError (well-formed UTF-8, bad JSON) AND - # UnicodeDecodeError (raw bytes that aren't valid UTF-8) as - # 400s — otherwise the latter trickles up as a 500 from - # `json.loads`, which is misleading to a client sending bad - # input. + try: obj = json.loads(body) except (json.JSONDecodeError, UnicodeDecodeError) as e: @@ -402,16 +223,6 @@ async def append_live_judgment(request: Request): session = _sanitize_slug(request.query_params.get("session", "default"), default="default") path = _ensure_out_dir() / f"live_{session}.jsonl" - # Hard cap on file size — refuse the append rather than truncating - # existing data, so a buggy client can't lose history. NOTE: the - # pre-check + append is racy across concurrent POSTs to the same - # session — two requests can both see `existing` below the cap - # and then both write, briefly exceeding it. In practice this is - # bounded by (concurrent-clients × _LIVE_JUDGMENT_MAX_BYTES), and - # a typical live session has one client per session id, so the - # race is theoretical. If a future scenario (shared session - # across multiple panels) makes it real, the fix is to hold a - # per-session asyncio.Lock around the stat + append. try: existing = path.stat().st_size except FileNotFoundError: @@ -428,9 +239,9 @@ async def append_live_judgment(request: Request): 413, f"live judgment file at cap ({existing} + {len(line_bytes)} > {_LIVE_FILE_MAX_BYTES})", ) - # Append-mode write — POSIX `O_APPEND` makes this atomic per-line - # even under concurrent requests from a split-screen scenario. + try: + # Append mode preserves individual JSONL records across clients. with path.open("ab") as f: f.write(line_bytes) except OSError as e: @@ -442,14 +253,7 @@ async def append_live_judgment(request: Request): @app.post("/api/plugins/note_detect/training-bundle") async def upload_training_bundle(request: Request): - # Body: { slug, wav_filename, session, manifest, arrangement, upload_url }. - # Slug locates the WAV previously written by /recording; session - # locates the JSONL written by /live-judgment (optional); - # arrangement is the client-pinned ground-truth note chart - # (optional). Bundles the WAV + JSONL + arrangement.json with the - # supplied manifest into a zip and POSTs it to pCloud. - # Cap the body as it streams in — refused before an oversized - # manifest/arrangement is ever fully buffered into memory. + raw = await _read_capped_body(request, _TRAINING_BODY_MAX_BYTES) try: body = json.loads(raw) @@ -461,26 +265,18 @@ async def upload_training_bundle(request: Request): slug = _sanitize_slug(body.get("slug", ""), default="") if not slug: raise HTTPException(400, "missing or empty 'slug'") - # session is optional. Do NOT coerce a missing one to a literal - # "default" — that could attach a stale live_default.jsonl from - # an unrelated take. An empty session simply means "no JSONL". + session_raw = body.get("session") if session_raw is not None and not isinstance(session_raw, str): raise HTTPException(400, "'session' must be a string or null") session = _sanitize_slug(session_raw, default="") if session_raw else "" - # `is None` → default to {}; any other non-dict (a list, "", 0) - # is malformed input and rejected — `or {}` would have silently - # swallowed those falsy non-dicts past the type check. + manifest = body.get("manifest") if manifest is None: manifest = {} elif not isinstance(manifest, dict): raise HTTPException(400, "'manifest' must be a JSON object") - # Per-request override for the pCloud destination — the user - # sets this on the settings page. Null/missing falls back to the - # curated default. A non-empty value that parses to no code is a - # 400, NOT a silent fallback — otherwise a typo'd custom link - # would route the contributor's take to the public dataset. + upload_url_override = body.get("upload_url") if upload_url_override is not None and not isinstance(upload_url_override, str): raise HTTPException(400, "'upload_url' must be a string or null") @@ -495,10 +291,6 @@ async def upload_training_bundle(request: Request): base = _ensure_out_dir() - # Locate the WAV. Prefer the exact filename the client got back - # from its /recording save — globbing newest-for-slug can pair - # this take's manifest/JSONL/arrangement with another panel's - # WAV when two takes share a slug (splitscreen / rapid takes). wav_filename = body.get("wav_filename") if wav_filename is not None and not isinstance(wav_filename, str): raise HTTPException(400, "'wav_filename' must be a string or null") @@ -515,8 +307,7 @@ async def upload_training_bundle(request: Request): raise HTTPException(404, f"recording not found: {cand.name}") wav_path = cand if wav_path is None: - # Fallback for callers that didn't pass wav_filename: newest - # WAV matching the slug. + wav_candidates = sorted( base.glob(f"note_detect_{slug}_*.wav"), key=lambda p: p.stat().st_mtime, @@ -530,24 +321,15 @@ async def upload_training_bundle(request: Request): ) wav_path = wav_candidates[0] - # JSONL is optional — the client may have armed for training - # without tuningMode on, or no judgments may have been streamed - # yet. A missing session or missing file is a soft-skip. jsonl_path = (base / f"live_{session}.jsonl") if session else None has_jsonl = bool(jsonl_path) and jsonl_path.exists() and jsonl_path.is_file() - # Compose server-authoritative manifest fields. The nested - # sections we merge into must be objects: a client sending e.g. - # "audio": "x" would otherwise make the `**` spread raise - # TypeError and 500 instead of a clean 400. manifest = dict(manifest) for _sect in ("audio", "detect_stream"): if _sect in manifest and not isinstance(manifest[_sect], dict): raise HTTPException( 400, f"manifest '{_sect}' must be a JSON object if present") - # schema / created_at identify THIS bundle format and build time - # — assign unconditionally so a stale/malformed client value - # can't mislabel the bundle. + manifest["schema"] = "note_detect.training_bundle.v1" manifest["created_at"] = datetime.now(timezone.utc).isoformat() manifest["audio"] = { @@ -555,9 +337,7 @@ async def upload_training_bundle(request: Request): "filename": wav_path.name, "bytes": wav_path.stat().st_size, } - # detect_stream must reflect what's actually in the zip — set it - # when a JSONL is bundled, drop any client-supplied section when - # one isn't (else the manifest references a missing file). + if has_jsonl: manifest["detect_stream"] = { **(manifest.get("detect_stream") or {}), @@ -567,18 +347,11 @@ async def upload_training_bundle(request: Request): else: manifest.pop("detect_stream", None) - # Ground-truth note chart supplied by the client (hw.getNotes / - # getChords pinned at song:ended) — the training labels for the - # recorded audio. Written into the bundle as arrangement.json. - # Optional: a host that exposes no chart sends null. arrangement = body.get("arrangement") if arrangement is not None and not isinstance(arrangement, dict): raise HTTPException(400, "'arrangement' must be a JSON object or null") arrangement_json = None - # `is not None`, not truthiness — a provided-but-empty object - # ({}) is a valid chart submission and must still be written, to - # match the documented contract (any arrangement object gets an - # arrangement.json). Only a missing/null arrangement is omitted. + if arrangement is not None: arrangement_json = json.dumps(arrangement, indent=2, sort_keys=True) notes = arrangement.get("notes") @@ -589,13 +362,9 @@ async def upload_training_bundle(request: Request): "chord_count": len(chords) if isinstance(chords, list) else None, } else: - # No arrangement.json in the zip — drop any client-supplied - # arrangement_chart so the manifest can't claim a chart the - # bundle doesn't contain. + manifest.pop("arrangement_chart", None) - # Write the bundle zip. Filename mirrors the WAV's timestamp tail - # so a take and its bundle sort adjacently in the recordings dir. bundle_name = "training_" + wav_path.stem.removeprefix("note_detect_") + ".zip" bundle_path = base / bundle_name tmp_path = bundle_path.with_suffix(bundle_path.suffix + ".tmp") @@ -617,10 +386,10 @@ async def upload_training_bundle(request: Request): except OSError: pass raise HTTPException(500, f"could not write training bundle: {e}") + # Retain failed bundles so retry can send the exact take. bundle_size = bundle_path.stat().st_size if bundle_size > _BUNDLE_MAX_BYTES: - # Keep the zip on disk so the user can inspect / shrink it, - # but don't ship a multi-GB blob to pCloud. + raise HTTPException( 413, f"bundle too large ({bundle_size} bytes > {_BUNDLE_MAX_BYTES}); " @@ -640,9 +409,7 @@ async def upload_training_bundle(request: Request): try: pcloud_result = await _upload_to_pcloud(bundle_path, pcloud_filename, pcloud_code) except Exception as e: - # Local bundle is retained so the user can retry. Don't 500 - # — the upload-failed-but-bundle-exists state is a valid - # outcome the UI surfaces differently from "no bundle". + log.warning( "pCloud upload failed (%s); bundle retained at %s, pcloud_filename=%s", e, bundle_path, pcloud_filename, @@ -653,10 +420,7 @@ async def upload_training_bundle(request: Request): "local_bundle": str(bundle_path), "relative_path": rel, "bundle_filename": bundle_name, - # The name we ACTUALLY sent to pCloud (sanitized). Surfaced - # so the UI can tell us "did the new sanitization even run" - # — a tail of underscores here would mean the Python server - # is still on stale code. + "pcloud_filename": pcloud_filename, "bytes": bundle_size, } @@ -676,12 +440,7 @@ async def upload_training_bundle(request: Request): @app.post("/api/plugins/note_detect/save-card") async def save_card(request: Request): - # Body: raw PNG bytes (the results card the browser rendered on a - # canvas). Query: ?dir=&name=. Writes the - # PNG to the user's configured folder (default: their Pictures - # folder) and returns the absolute path so the UI can confirm it. - # Works in both the web testbed and the desktop bundle since both - # run this local server as the user. + body = await _read_capped_body(request, _CARD_MAX_BYTES) if not body or body[:8] != b"\x89PNG\r\n\x1a\n": raise HTTPException(400, "body is not a PNG image") @@ -691,8 +450,7 @@ async def save_card(request: Request): try: target.mkdir(parents=True, exist_ok=True) path = target / name - # Auto-save preserves every take: never overwrite an existing card — - # append " (2)", " (3)", … on a name clash (same song, same minute). + if auto and path.exists(): stem, suffix = path.stem, path.suffix i = 2 @@ -715,21 +473,12 @@ async def save_card(request: Request): @app.get("/api/plugins/note_detect/config") async def get_config(): - # Single source of truth for the default pCloud upload - # destination. settings.html fetches this on load so the default - # code lives in exactly one place (here) instead of being - # duplicated — and kept in sync by hand — in the renderer. + return {"pcloud_default_url": _PCLOUD_DEFAULT_URL} @app.post("/api/plugins/note_detect/training-bundle/retry") async def retry_training_bundle(request: Request): - # Re-upload a bundle zip that a previous /training-bundle call - # wrote to disk but failed to push to pCloud. Body: - # { local_bundle, upload_url }. No re-bundling — the existing zip - # is sent verbatim, so a retry is cheap and the user keeps the - # exact take they recorded. - # Cap the body as it streams in — refused before an oversized - # manifest/arrangement is ever fully buffered into memory. + raw = await _read_capped_body(request, _TRAINING_BODY_MAX_BYTES) try: body = json.loads(raw) @@ -753,11 +502,8 @@ async def retry_training_bundle(request: Request): ) base = _ensure_out_dir() - # Security: the client hands us a path, so confine it to the - # recordings directory and require the training-bundle naming - # shape. Anything else — a traversal, an arbitrary file — is - # rejected; this endpoint must never upload a file the bundle - # flow itself didn't create. + + # Retry accepts only bundles created inside this plugin's storage directory. try: bundle_path = Path(local_bundle).resolve() bundle_path.relative_to(base.resolve()) @@ -770,8 +516,7 @@ async def retry_training_bundle(request: Request): raise HTTPException(404, f"bundle not found: {bundle_path}") bundle_size = bundle_path.stat().st_size - # Same size guard as /training-bundle — a retry must not become a - # way to push an arbitrarily large zip past the cap. + if bundle_size > _BUNDLE_MAX_BYTES: raise HTTPException( 413, @@ -815,30 +560,19 @@ async def retry_training_bundle(request: Request): } async def _upload_to_pcloud(file_path: Path, filename: str, code: str) -> dict: - # Stdlib-only (urllib) — the plugin must not hard-depend on a - # third-party HTTP client (`requests` etc.) that isn't in - # slopsmith's requirements. The urlopen call is sync, so wrap it - # in a thread: a slow upload (15 MB over a residential up-link) - # must not stall the event loop and starve other plugins. + # Run urllib in a worker so uploads do not block the event loop. + import urllib.parse import urllib.request import anyio def _post() -> dict: - # pCloud's `uploadtolink` expects a POST with multipart/form-data - # AND the stored filename supplied as the `names` query - # parameter. It does NOT read the filename from the multipart - # part — without `names` it rejects every upload with - # `result=2001 "Invalid file/folder name"` (verified: the same - # 2001 fires even for a request with no file at all, so it is - # the missing `names`, not the file's name, that trips it). - # The multipart file part must be field name `file`. + query = urllib.parse.urlencode( {"code": code, "nopartial": "1", "names": filename}) url = f"{_PCLOUD_UPLOAD_URL}?{query}" file_bytes = file_path.read_bytes() - # Hand-built multipart/form-data body. The boundary is random - # so it can't collide with the zip's bytes. + boundary = "----slopsmithND" + secrets.token_hex(16) preamble = ( f"--{boundary}\r\n" @@ -867,8 +601,7 @@ def _post() -> dict: f"pCloud returned non-JSON response (HTTP {status}): " f"{raw[:200]!r}" ) from e - # pCloud encodes errors as a JSON 200 with `result != 0` — - # don't rely on HTTP status alone. + if data.get("result") != 0: raise RuntimeError( f"pCloud rejected upload: result={data.get('result')}, " diff --git a/screen.js b/screen.js index bd9025c..ff7695f 100644 --- a/screen.js +++ b/screen.js @@ -1,218 +1,25 @@ -// Note Detection plugin -// -// Factory pattern — `createNoteDetector(options)` returns an independent -// detector instance with its own audio pipeline, scoring, HUD, timers, -// draw hook, and DOM subtree. A default singleton (`window.noteDetect`) -// is created on load for the standard single-panel case; additional -// instances can be constructed via `window.createNoteDetector(...)` by -// plugins like splitscreen that need per-panel detection. -// -// Originally proposed by topkoa in PR #2 on this repo; this takeover -// re-applies the factory design on top of 5-string-bass (#14), -// per-note hit/miss events (#12), CI (#13), and HPS (#15) which all -// landed after his branch diverged. Co-Authored-By: topkoa. -// -// ── What this revision adds and why ─────────────────────────────────────── -// -// BACKGROUND: WHY CHORD DETECTION NEEDED A DIFFERENT APPROACH -// -// YIN, HPS, and CREPE are all monophonic pitch detectors — they return one -// frequency from the full mixed signal. That works well for single notes, but -// a guitar chord produces 2–6 simultaneous fundamentals plus their harmonics -// all overlapping in the spectrum. The detectors lock onto whichever string -// is loudest (usually the lowest) and score the whole chord against that one -// pitch, silently missing every other note. This revision adds a parallel -// detection path for chords that avoids the problem entirely. -// -// The core insight (from a design brief accompanying this change): instead of -// asking "what pitch is playing?" — which is hard for chords — ask "is there -// energy near the frequency I *expect* on string S right now?" That is a much -// simpler question. Because the arrangement XML already tells us exactly which -// string plays which fret at every moment, we can compute the expected -// frequency per string and check for it independently in that string's -// frequency band. This turns one hard polyphonic detection problem into N easy -// monophonic band-energy checks, one per string. -// -// The existing YIN/HPS/CREPE path is left completely intact for single notes, -// where it already works well. The constraint path is additive: it activates -// only when the chart has ≥2 simultaneous notes in the timing window. -// -// ── CHANGE 1: 8-string guitar tuning ───────────────────────────────────── -// -// _ND_TUNING_GUITAR_8 added: [30, 35, 40, 45, 50, 55, 59, 64] -// That is F#1 B1 E2 A2 D3 G3 B3 E4 — standard Ibanez/Schecter 8-string -// tuning, a perfect fourth below the 7-string low B. -// -// _ndStandardMidiFor() now branches on stringCount === 8 before the existing -// 7-string check. Every downstream function — MIDI mapping, display labels, -// and the new constraint band calculator — derives from this table, so no -// other callsites required changes. -// -// ── CHANGE 2: Dynamic string-count sizing (prerequisite for changes 1 & 3) ─ -// -// Previously, `tuningOffsets` was initialised as a hardcoded 6-element array -// and never resized. Every call that passed `tuningOffsets.length` as the -// stringCount argument to mapping helpers was therefore always passing 6, -// regardless of what instrument was actually loaded. This silently produced -// wrong frequency bands for 5-string bass, 7-string guitar, and would have -// been completely broken for 8-string guitar. -// -// Fix: a new `currentStringCount` variable is set at enable() time from -// `hw.getSongInfo().tuning.length` — the authoritative source. All three -// call sites that were passing `tuningOffsets.length` into mapping helpers -// now use `currentStringCount` instead. This was a prerequisite for both -// 8-string support and for the constraint checker computing correct frequency -// bands on non-6-string instruments. -// -// ── CHANGE 3: Constraint-based chord detection ──────────────────────────── -// -// Three new module-level functions (after _ndHpsDetect, before _ndLoadCrepe): -// -// _ndStringBandHz(stringIdx, arrangement, stringCount, offsets, capo) -// Returns [loHz, hiHz] for a given string covering frets 0–24, with ±10% -// headroom for tuning offsets, capo, and bent notes. Derived from the -// tuning tables rather than hardcoded, so all instrument types are covered. -// -// _ndBandEnergy(magnitudes, binHz, loHz, hiHz) -// Measures the fraction of total spectrum energy (0..1) that falls in a -// frequency band, operating on the magnitude spectrum from _ndFftMagnitude. -// NOTE: reuses the module-level FFT scratch buffers (_ndFftInterleavedScratch, -// _ndFftMagnitudesScratch). This is safe because the FFT is synchronous and -// JS is single-threaded — see the existing comment on those buffers. If this -// code is ever moved to an AudioWorklet or Web Worker, per-call scratch -// buffers would be needed instead. -// -// _ndConstraintCheckString(buffer, sampleRate, stringIdx, fret, ...) -// The core per-string check. Calls _ndFftMagnitude once (which reuses the -// scratch), measures band energy for this string's frequency range, and -// optionally verifies that the dominant bin in the band is within -// pitchCheckCents of the expected frequency. Returns { hit, bandEnergy, -// centsDiff }. energyThreshold and pitchCheckCents are caller-adjustable -// to support technique-specific loosening (see change 4). -// -// _ndScoreChord(buffer, sampleRate, chordNotes, ..., minHitRatio) -// Runs _ndConstraintCheckString for each note in a chord group, applies -// per-technique threshold adjustments (see change 4), and returns -// { score, hitStrings, totalStrings, results, isHit } where isHit is -// true if score >= minHitRatio. -// -// ROUTING IN matchNotes(): -// Candidate notes (from the chart's timing window) are now bucketed by -// timestamp. A bucket with 1 note goes through the existing MIDI comparison -// against the YIN/HPS/CREPE result, unchanged. A bucket with ≥2 notes runs -// polyphonic chord scoring. The browser path calls _ndScoreChord on the -// accumulated `pendingBuffer` (same audio just analysed for pitch). The -// slopsmith-desktop bridge path dispatches the chord context over the -// `audio:scoreChord` IPC, where the native JUCE ChordScorer reads from -// the engine's own input ring — no audio buffer crosses IPC. Both paths -// return the same { score, hitStrings, totalStrings, isHit, results[] } -// shape. Each string's individual result is stored in noteResults so the -// draw overlay can colour fret gems per-note. The chord hit/miss is -// counted as a single judgment and fires a notedetect:hit event with -// { chord: true, hitStrings, totalStrings, score } instead of the usual -// { note, expectedMidi }. -// -// ── CHANGE 4: Technique-aware thresholds ───────────────────────────────── -// -// The arrangement XML includes technique flags on individual notes. _ndScoreChord -// reads these from the chord note objects and adjusts thresholds before calling -// _ndConstraintCheckString: -// -// ho / po (hammer-on / pull-off) -// No fresh pick attack, so string energy will be lower than a picked note. -// energyThreshold is halved from 0.03 to 0.015. -// -// b / sl (bend / slide) -// Pitch is moving continuously during the note. pitchCheckCents is widened -// to at least 100 cents (a semitone) so a note mid-bend still registers. -// -// hm (harmonic) -// The fundamental is suppressed; the audible pitch is at 2x or 1.5x the -// fret frequency. Pitch checking against the fundamental is unreliable, so -// pitchCheckCents is set to 0 (energy-only check). A proper harmonic -// frequency check (checking at 2x/1.5x) is a known TODO — see the comment -// inside _ndScoreChord. -// -// ── CHANGE 5: chordHitRatio setting ────────────────────────────────────── -// -// The fraction of a chord's strings that must register energy to count as a -// hit. Default 0.6 (60% — e.g. 4 of 6 strings for a full barre chord). Lower -// values suit beginners or players using lighter touches on inner strings; -// higher values enforce stricter accuracy. -// -// Exposed in the settings panel as "Chord Leniency" (slider: 25–100%). -// Persisted in localStorage under the existing _ND_STORAGE_KEY alongside all -// other settings. Loaded and clamped to [0.25, 1] on construction so a stale -// persisted value can't put scoring in a state the slider can't represent. -// -// ── CHANGE 6: HUD chord display ────────────────────────────────────────── -// -// The cyan detected-note line in the HUD (`.nd-hud-detected`) previously only -// showed output when a confident single-note detection existed. It now also -// shows the most recent chord constraint result when no single note is detected, -// e.g. "chord 4/6 (66%)". This gives the player real-time visibility into -// whether the constraint scorer is seeing their strings ring, which is useful -// for diagnosing audio input issues and tuning the Chord Leniency setting. -// lastChordScore / lastChordHit / lastChordTotal are reset with the rest of -// scoring state in resetScoring(). - -// ── Module-level shared state ────────────────────────────────────────────── - -// Shared state anchored on `window` so multiple evaluations of this -// file (HMR, accidental double