mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-09-11 06:54:31 +00:00
* Tuner: passive "different tuning" badge cue that names the retune
Building on the coverage check: when you enter a song your current
instrument doesn't cover, the topbar tuner badge gets an amber ring + a
tooltip naming the change (e.g. "retune B->A", or "the reference pitch"
for an A440 vs A432 mismatch). Advisory only -- it never auto-opens the
panel; recomputed on song:ready, cleared on song-load / leaving the
player.
Refactors the coverage check into a structured report
(window._tunerAutoOpen.coverageReport -> { covered, retune:[{from,to}],
reference, cantCover }); the boolean gate now wraps it. The cue is
CSS-free (inline ring + native tooltip, no Tailwind rebuild) and no-ops
when the tuner plugin is absent.
Touches static/v3/badges.js (cue) + plugins/tuner/screen.js (report).
v3-only. Stacked on #656 (issue E stage 2.5/3). The splitscreen-suppress
and no-usable-input guards move to E2 (the playback gate).
Tests: tests/js/tuner_auto_open.test.js (report names the strings,
reference mismatch, badge wiring).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QbexxfTt8q2tAn436MqGWF
* feat(tuner): read/write the live per-instrument working tuning — both-directions retune prompt (working-tuning PR 3) (#660)
The §4 coverage check compared each song against the player's fixed
instrument-profile tuning, so the tuner only ever prompted *away* from a "home"
tuning (E -> Drop C#) and stayed silent coming back (Drop C# -> E), even though
the player had physically retuned.
_playerTuning() now reads the host's live per-instrument working tuning
(window.feedBack.workingTuning, keyed by the selected instrument from
/api/settings) instead of re-deriving from the static settings tuning, so
coverage is measured against what the instrument is ACTUALLY in and prompts both
directions. On clearing an auto-opened tuner, _publishWorkingTuning() writes that
song's tuning as the instrument's live working tuning ('assumed' — PR 4's
explicit "I tuned / Skip" refines the write-point), so the next song is judged
against where the player now is.
Per-instrument (guitar vs bass tracked separately). Feature-detected: falls back
to the static /api/settings tuning when the working-tuning capability is absent,
so the 27 existing coverage tests are unchanged. Builds on PR 1 (host
workingTuning) + PR 2 (instrument->chart routing).
Tests: tests/js/tuner_auto_open.test.js — +2 (both-directions coverage via a live
Drop-D working tuning; publish-on-clear targets the right instrument slot); 29
pass total.
Claude-Session: https://claude.ai/code/session_01QbexxfTt8q2tAn436MqGWF
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* fix(tuner): transactional open + fail-closed auto-open config (tuner-E #655 review) (#681)
Two review fixes for the auto-open opt-in+persist stage:
- enable() wasn't transactional. The panel (with the ×/Skip buttons) is shown
before `await _tunerAudio.start()`, and `_state.enabled` was only set after it.
A ×/Skip dismiss during that await hit disable() with wasEnabled=false, then
enable() completed and flipped enabled on — an enabled-but-hidden zombie. Guard
the open with an `_openGen` token bumped on every enable()/disable(); after the
audio-start await, bail if superseded instead of enabling. Closes #675.
- Config wasn't fail-closed. routes.py normalized the opt-in with
bool(data.get("autoOpenOnTuningChange", False)), so "false"/"0"/junk coerced to
True. Accept only a real JSON boolean. Closes #676.
Tests: tuner_auto_open.test.js (dismiss-mid-open stays disabled — fails without
the token guard), test_config.py (auto-open default-false + fail-closed on
non-bool). 34 JS + 24 config tests green.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(tuner): coverage stays conservative when the instrument is unknown (tuner-E #656 review) (#682)
_playerTuning() is documented as conservative ("missing data → not covered → still
prompt"), but when /api/settings carried no instrument identity (a fresh profile:
_default_settings() omits instrument/string_count/tuning) it invented guitar/6/440/
standard, so an unconfigured player was treated as 6-string E-standard and coverage
suppressed the auto-open (and badge cue) for matching songs. The post-#660 rewrite
only returned null when the whole fetch failed (!s), not when settings existed but
lacked an instrument.
Now return null unless there's a confident identity — any of instrument/string_count/
tuning in settings, or live working-tuning offsets. A configured standard guitar still
covers a standard song (no regression). Closes #677.
Tests: tuner_auto_open.test.js — empty-settings → not covered (fails without the fix);
configured standard guitar → still covered.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(tuner): badge coverage cue staleness + unknown-as-warning + dedupe (tuner-E #657 review) (#683)
Three review fixes for the passive "different tuning" badge cue stage:
- Stale async cue (#678): _refreshCoverageCue awaited coverageReport then wrote the
DOM unconditionally, so a slow /api/settings fetch could restore the previous
song's amber ring after song:loading / leaving the player. Add a monotonic token
bumped on every refresh and both clear paths; apply the awaited report only if the
token still matches.
- "Unknown" rendered as "needs retune" (#679): the plugin returns a conservative
all-false report on a fetch hiccup; the cue painted that as an amber "retune the
reference pitch" ring. Collapse a no-signal report (not covered, no reference /
retune / cantCover) to null (no cue) via _meaningfulReport(). A genuine not-covered
report always carries reference / retune / cantCover, so real cues are preserved.
- Duplicate /api/settings fetch (#680): the auto-open gate and the badge cue both
call coverageReport() per song:ready. Cache the coverage promise per song (keyed by
session + tuning + centOffset) so they share one fetch; invalidate on song:loading,
instrument:changed, and working-tuning-changed so it can't go stale within a song.
Tests: tuner_auto_open.test.js — concurrent reports share one fetch, a new song
refetches (fails without the cache). 34 JS tests green. Codex-reviewed.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Byron Gamatos <xasiklas@gmail.com>
163 lines
6.5 KiB
Python
163 lines
6.5 KiB
Python
"""Tuner plugin — persist last selected tuning and custom tunings in config_dir."""
|
|
|
|
import json
|
|
import logging
|
|
from pathlib import Path
|
|
from fastapi import FastAPI, Request
|
|
from fastapi.responses import Response
|
|
|
|
DEFAULT_TUNING = "Standard"
|
|
DEFAULT_INSTRUMENT = "guitar-6"
|
|
|
|
_INSTRUMENT_BY_STRING_COUNT = {4: "bass-4", 5: "bass-5", 7: "guitar-7", 8: "guitar-8"}
|
|
|
|
|
|
def _migrate_custom_tuning(name: str, value) -> dict:
|
|
"""Return {instrument, strings} for both old flat-list and new dict formats."""
|
|
if isinstance(value, list):
|
|
instrument = _INSTRUMENT_BY_STRING_COUNT.get(len(value), "guitar-6")
|
|
return {"instrument": instrument, "strings": value}
|
|
if isinstance(value, dict) and "strings" in value:
|
|
return value
|
|
return {"instrument": "guitar-6", "strings": []}
|
|
|
|
|
|
def setup(app: FastAPI, context: dict):
|
|
config_dir = Path(context["config_dir"])
|
|
config_file = config_dir / "tuner.json"
|
|
log = context.get("log") or logging.getLogger("feedBack.plugin.tuner")
|
|
|
|
def _read() -> dict:
|
|
defaults = {
|
|
"lastTuning": DEFAULT_TUNING,
|
|
"lastInstrument": DEFAULT_INSTRUMENT,
|
|
"freeTune": False,
|
|
"customTunings": {},
|
|
"disabledTunings": [],
|
|
"showFloatingButton": True,
|
|
"visualizationMode": "default",
|
|
"audioInputMode": "auto",
|
|
"autoOpenOnTuningChange": False,
|
|
}
|
|
if not config_file.exists():
|
|
return defaults
|
|
try:
|
|
data = json.loads(config_file.read_text(encoding="utf-8"))
|
|
if not isinstance(data, dict):
|
|
return defaults
|
|
|
|
res = {}
|
|
res["lastTuning"] = str(data.get("lastTuning", DEFAULT_TUNING))
|
|
res["lastInstrument"] = str(data.get("lastInstrument", DEFAULT_INSTRUMENT))
|
|
res["freeTune"] = bool(data.get("freeTune", False))
|
|
res["customTunings"] = data.get("customTunings", {})
|
|
res["disabledTunings"] = data.get("disabledTunings", [])
|
|
res["showFloatingButton"] = bool(data.get("showFloatingButton", True))
|
|
res["visualizationMode"] = str(data.get("visualizationMode", "default"))
|
|
raw_mode = str(data.get("audioInputMode", "auto"))
|
|
res["audioInputMode"] = raw_mode if raw_mode in ("auto", "browser") else "auto"
|
|
# Fail closed: only a real JSON boolean enables the opt-in. A hand-edited /
|
|
# migrated / bad-client value (e.g. the string "false" or "0") must NOT be
|
|
# coerced to True by bool().
|
|
_auto_open = data.get("autoOpenOnTuningChange", False)
|
|
res["autoOpenOnTuningChange"] = _auto_open if isinstance(_auto_open, bool) else False
|
|
|
|
if not isinstance(res["customTunings"], dict):
|
|
res["customTunings"] = {}
|
|
if not isinstance(res["disabledTunings"], list):
|
|
res["disabledTunings"] = []
|
|
|
|
# Migrate custom tunings from old flat-list format
|
|
res["customTunings"] = {
|
|
name: _migrate_custom_tuning(name, val)
|
|
for name, val in res["customTunings"].items()
|
|
}
|
|
|
|
# Strip legacy disabledTunings entries that lack compound "instrument:name" format
|
|
res["disabledTunings"] = [
|
|
e for e in res["disabledTunings"]
|
|
if isinstance(e, str) and ":" in e
|
|
]
|
|
|
|
return res
|
|
except Exception:
|
|
return defaults
|
|
|
|
def _write(data: dict) -> None:
|
|
config_dir.mkdir(parents=True, exist_ok=True)
|
|
current = _read()
|
|
# Strip keys that belong to core, not to this plugin's config.
|
|
for key in ("defaultTunings", "referencePitch"):
|
|
data = {k: v for k, v in data.items() if k != key}
|
|
current.update(data)
|
|
config_file.write_text(json.dumps(current, indent=2), encoding="utf-8")
|
|
|
|
def _get_custom_tunings() -> dict:
|
|
"""Return custom tunings in DEFAULT_TUNINGS format: {instrument: {name: [freqs]}}."""
|
|
cfg = _read()
|
|
result: dict[str, dict] = {}
|
|
for name, val in cfg.get("customTunings", {}).items():
|
|
inst = val.get("instrument", "guitar-6")
|
|
strings = val.get("strings", [])
|
|
if strings:
|
|
result.setdefault(inst, {})[name] = strings
|
|
return result
|
|
|
|
# Register this plugin as a tuning provider for its custom tunings.
|
|
context["register_tuning_provider"]("tuner", _get_custom_tunings)
|
|
log.info("tuner: registered custom tuning provider")
|
|
|
|
_viz_dir = Path(__file__).parent / "visualization"
|
|
_viz_assets_dir = Path(__file__).parent / "visualization" / "assets"
|
|
_workers_dir = Path(__file__).parent / "workers"
|
|
_utils_dir = Path(__file__).parent / "utils"
|
|
|
|
_ASSET_MEDIA_TYPES = {".svg": "image/svg+xml", ".png": "image/png"}
|
|
|
|
def _serve_js_from(base_dir: Path, filename: str) -> Response:
|
|
target = (base_dir / filename).resolve()
|
|
try:
|
|
target.relative_to(base_dir.resolve())
|
|
except ValueError:
|
|
return Response("", status_code=404)
|
|
if target.suffix == ".js" and target.is_file():
|
|
return Response(target.read_text(encoding="utf-8"), media_type="application/javascript")
|
|
return Response("", status_code=404)
|
|
|
|
def _serve_asset_from(base_dir: Path, filename: str) -> Response:
|
|
target = (base_dir / filename).resolve()
|
|
try:
|
|
target.relative_to(base_dir.resolve())
|
|
except ValueError:
|
|
return Response("", status_code=404)
|
|
media_type = _ASSET_MEDIA_TYPES.get(target.suffix.lower())
|
|
if media_type and target.is_file():
|
|
return Response(target.read_bytes(), media_type=media_type)
|
|
return Response("", status_code=404)
|
|
|
|
@app.get("/api/plugins/tuner/visualization/{filename}")
|
|
def get_viz_file(filename: str):
|
|
return _serve_js_from(_viz_dir, filename)
|
|
|
|
@app.get("/api/plugins/tuner/viz-assets/{filename}")
|
|
def get_viz_asset(filename: str):
|
|
return _serve_asset_from(_viz_assets_dir, filename)
|
|
|
|
@app.get("/api/plugins/tuner/workers/{filename}")
|
|
def get_worker_file(filename: str):
|
|
return _serve_js_from(_workers_dir, filename)
|
|
|
|
@app.get("/api/plugins/tuner/utils/{filename}")
|
|
def get_utils_file(filename: str):
|
|
return _serve_js_from(_utils_dir, filename)
|
|
|
|
@app.get("/api/plugins/tuner/config")
|
|
def get_config():
|
|
return _read()
|
|
|
|
@app.post("/api/plugins/tuner/config")
|
|
async def set_config(req: Request):
|
|
body = await req.json()
|
|
_write(body)
|
|
return {"ok": True}
|