diff --git a/CHANGELOG.md b/CHANGELOG.md index e095438..35092fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,7 +27,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - **Perf harness now measures 2D-highway frame time (R3c gate).** `scripts/perf-baseline.mjs` gains a `--song` mode that reports per-frame draw-cost p50/p95/p99 (draw-tagged via `highway.addDrawHook`), the metric that gates the `highway.js` split. Maintainer/CI-only; baseline recorded in `docs/perf-baseline.md`. -- **`routers/` — extracting `server.py`'s route layer, cheapest-first (R3).** Each PR moves a cohesive route group into a `fastapi.APIRouter` under `lib/routers/`, mounted with `app.include_router(...)` at its original site (FastAPI matches in registration order; the full route table stays byte-identical). Bodies are verbatim — only the decorator receiver (`@app` → `@router`) and singleton reads (`meta_db` → `appstate.meta_db`, resolved at call time) change. So far: `audio_effects` (5), `artist_aliases` (5), `loops` (3), `playlists` (12 + covers), `ws_highway` (the 902-line highway chart WebSocket), `chart` (split/unsplit/work/fileinfo — unblocked by the DLC-path substrate), `library_extras`, `wanted`, `shop`, `progression`, `profile`, `stats` (the `/api/stats/{path}` catch-all stays registered last so it can't shadow `/recent` `/best` `/top`), `version` (`/api/version`; VERSION-file lookup adjusted for the router subdir depth), and `diagnostics` (`/api/diagnostics/export|preview|hardware`; the plugins-root lookup adjusted for the router subdir depth, `_running_version` reached through the `appstate` seam, pure payload-cap helpers re-exported for the `server._diag_*` tests). The DLC library-path resolution (`_get_dlc_dir`, pure `_resolve_dlc_path`) moved to `lib/dlc_paths.py`, reading paths through the seam; `config_dir`/`dlc_dir`/`dlc_dir_env` now ride the `appstate` seam (env-derived, so the pop-and-reimport fixtures reconfigure it for free), and the shared request-field sanitizer `_clean_str` moved to `lib/reqfields.py`. The next cut is picked by a dependency-closure scan that ranks groups by how many `monkeypatch.setattr(server, …)` targets they'd drag along. +- **`routers/` — extracting `server.py`'s route layer, cheapest-first (R3).** Each PR moves a cohesive route group into a `fastapi.APIRouter` under `lib/routers/`, mounted with `app.include_router(...)` at its original site (FastAPI matches in registration order; the full route table stays byte-identical). Bodies are verbatim — only the decorator receiver (`@app` → `@router`) and singleton reads (`meta_db` → `appstate.meta_db`, resolved at call time) change. So far: `audio_effects` (5), `artist_aliases` (5), `loops` (3), `playlists` (12 + covers), `ws_highway` (the 902-line highway chart WebSocket), `chart` (split/unsplit/work/fileinfo — unblocked by the DLC-path substrate), `library_extras`, `wanted`, `shop`, `progression`, `profile`, `stats` (the `/api/stats/{path}` catch-all stays registered last so it can't shadow `/recent` `/best` `/top`), `version` (`/api/version`; VERSION-file lookup adjusted for the router subdir depth), and `diagnostics` (`/api/diagnostics/export|preview|hardware`; the plugins-root lookup adjusted for the router subdir depth, `_running_version` reached through the `appstate` seam, pure payload-cap helpers re-exported for the `server._diag_*` tests), and `tunings` (`/api/tunings`; the pure `config.json` reader moved to `lib/appconfig.py`, the tuning-provider registry read through the `appstate` seam so plugin-contributed tunings still merge). The DLC library-path resolution (`_get_dlc_dir`, pure `_resolve_dlc_path`) moved to `lib/dlc_paths.py`, reading paths through the seam; `config_dir`/`dlc_dir`/`dlc_dir_env` now ride the `appstate` seam (env-derived, so the pop-and-reimport fixtures reconfigure it for free), and the shared request-field sanitizer `_clean_str` moved to `lib/reqfields.py`. The next cut is picked by a dependency-closure scan that ranks groups by how many `monkeypatch.setattr(server, …)` targets they'd drag along. - **`routers/` — the first extracted route module (R3).** The five audio-effects mapping endpoints move out of `server.py` into `lib/routers/audio_effects.py` as a `fastapi.APIRouter`, mounted with `app.include_router(...)` **at the point in the file diff --git a/docs/size-exemptions.md b/docs/size-exemptions.md index a979476..daac2fa 100644 --- a/docs/size-exemptions.md +++ b/docs/size-exemptions.md @@ -55,8 +55,8 @@ without a *signed* exemption" is unenforceable. ## Planned, NOT exempt (owned by split plans — listed so nothing falls between states) core `static/app.js` (11,852) · `static/highway.js` (4,168, whole file) · `server.py` -(6,960 — was 14,037; ratcheted by the R3 `MetadataDB` + `AudioEffectsMappingDB` -extractions and fourteen `routers/` modules) · +(6,917 — was 14,037; ratcheted by the R3 `MetadataDB` + `AudioEffectsMappingDB` +extractions and fifteen `routers/` modules) · `lib/metadata_db.py` (4,373 — new in R3; the `MetadataDB` class alone is 4,018 lines and is a monolith in its own right, to be split per-table once the router train lands) · `static/v3/songs.js` (4,134) · `static/capabilities/audio-session.js` diff --git a/lib/appconfig.py b/lib/appconfig.py new file mode 100644 index 0000000..18830e6 --- /dev/null +++ b/lib/appconfig.py @@ -0,0 +1,28 @@ +"""Reading the app's config.json — the one shared, pure helper (R3). + +Extracted verbatim from server.py so route modules that need a config value +(reference pitch, server_config, …) can read it without reaching back into the +host file. server.py re-imports it, so its ~11 call sites and any +`server._load_config` test reference keep resolving unchanged. +""" + +import json + + +def _load_config(config_file): + """Read and parse config.json. Returns the parsed dict, or None if + the file is missing, unreadable, invalid JSON, or parses to a + non-dict (e.g. the file contains `[]` or `42`). Callers treat None + as "fall back to defaults". Shared between GET and POST so both + handle bad files the same way.""" + if not config_file.exists(): + return None + try: + # Explicit UTF-8: save_settings()/import write config.json as + # UTF-8 bytes, so the read must not depend on the platform's + # default text encoding (cp1252 on Windows would mojibake or + # UnicodeDecodeError on a non-ASCII DLC path). + parsed = json.loads(config_file.read_text(encoding="utf-8")) + except Exception: + return None + return parsed if isinstance(parsed, dict) else None diff --git a/lib/appstate.py b/lib/appstate.py index 3c90d88..526b4b5 100644 --- a/lib/appstate.py +++ b/lib/appstate.py @@ -61,6 +61,10 @@ copies a hardcoded file list — that regression is what moved this file here. # The singletons routers may read. Every name here must also be a `_SLOTS` key. meta_db = None audio_effect_mappings = None +# The tuning-provider registry instance (built-ins + plugin-contributed). A +# stable object mutated in place via register()/unregister() — injected here by +# reference so routers read the same registry plugins populate. +tuning_providers = None # Config paths. server.py derives these from the environment (fresh on every # import, so the ~49 pop-and-reimport fixtures keep working) and injects them @@ -90,7 +94,7 @@ builtin_diagnostic_filename = None running_version = None _SLOTS = frozenset({ - "meta_db", "audio_effect_mappings", + "meta_db", "audio_effect_mappings", "tuning_providers", "config_dir", "dlc_dir", "dlc_dir_env", "static_dir", "sloppak_cache_dir", "audio_cache_dir", "get_progression_content", "builtin_diagnostic_filename", diff --git a/lib/routers/tunings.py b/lib/routers/tunings.py new file mode 100644 index 0000000..723fe6d --- /dev/null +++ b/lib/routers/tunings.py @@ -0,0 +1,46 @@ +"""The merged tuning catalog (/api/tunings). + +Extracted verbatim from server.py (R3) except @app->@router, CONFIG_DIR-> +appstate.config_dir, _load_config imported from lib/appconfig, and the tuning +registry read through the appstate seam (appstate.tuning_providers — the same +instance plugins register into via the plugin_context in server.py). +""" + +from fastapi import APIRouter + +import appstate +from appconfig import _load_config +from tunings import DEFAULT_REFERENCE_PITCH, TUNING_PRESET_MIDIS, freqs_to_midis + +router = APIRouter() + + +@router.get("/api/tunings") +def get_tunings(): + cfg = _load_config(appstate.config_dir / "config.json") or {} + ref = cfg.get("reference_pitch", DEFAULT_REFERENCE_PITCH) + try: + ref = float(ref) + if not (430.0 <= ref <= 450.0): + ref = DEFAULT_REFERENCE_PITCH + except (TypeError, ValueError): + ref = DEFAULT_REFERENCE_PITCH + merged = appstate.tuning_providers.get_merged(ref) + # tuningMidis: the same catalog as exact integer MIDI notes (low → high). + # Built-ins come straight from TUNING_PRESET_MIDIS (no float round-trip); + # provider-contributed entries are recovered from their frequencies at the + # served reference pitch. Every consumer today (the v3 badges, plugins) + # reconstructs midis client-side via log2 — a rounding footgun at non-440 + # references — so serve the integers once, host-side. Additive: the + # existing referencePitch/tunings shape is unchanged. + tuning_midis: dict[str, dict[str, list[int]]] = {} + for key, names in merged.items(): + builtin = TUNING_PRESET_MIDIS.get(key, {}) + resolved: dict[str, list[int]] = {} + for name, freqs in names.items(): + midis = builtin.get(name) or freqs_to_midis(freqs, ref) + if midis: + resolved[name] = list(midis) + if resolved: + tuning_midis[key] = resolved + return {"referencePitch": ref, "tunings": merged, "tuningMidis": tuning_midis} diff --git a/server.py b/server.py index 04dd9f1..513666a 100644 --- a/server.py +++ b/server.py @@ -26,10 +26,11 @@ from fastapi.staticfiles import StaticFiles from fastapi.responses import FileResponse, JSONResponse, RedirectResponse, Response, StreamingResponse from safepath import safe_join +from appconfig import _load_config from tunings import ( DEFAULT_REFERENCE_PITCH, DEFAULT_TUNINGS, PROFILE_IDS, PROFILE_PATHWAYS, - TUNING_PRESET_MIDIS, apply_flat_instrument_patch_to_profiles, - apply_reference_pitch, freqs_to_midis, normalize_instrument_profile, + apply_flat_instrument_patch_to_profiles, + apply_reference_pitch, normalize_instrument_profile, normalize_instrument_profiles, settings_with_instrument_profiles, tuning_name, ) @@ -56,6 +57,7 @@ from dlc_paths import _get_dlc_dir, _resolve_dlc_path import appstate # Extracted route modules. They import `appstate`, never `server` — one-way graph. from routers import audio_effects, artist_aliases, loops, playlists, ws_highway, chart, wanted, library_extras, shop, progression, profile, stats, version, diagnostics +from routers import tunings as tunings_router import sloppak as sloppak_mod import loosefolder as loosefolder_mod # Pure text-matching engine for MusicBrainz enrichment (P8): denoise/score/ @@ -1171,6 +1173,7 @@ def _get_progression_content() -> dict: appstate.configure( get_progression_content=_get_progression_content, builtin_diagnostic_filename=_builtin_diagnostic_filename, + tuning_providers=tuning_providers, ) @@ -4816,54 +4819,8 @@ def _default_settings(): } -def _load_config(config_file): - """Read and parse config.json. Returns the parsed dict, or None if - the file is missing, unreadable, invalid JSON, or parses to a - non-dict (e.g. the file contains `[]` or `42`). Callers treat None - as "fall back to defaults". Shared between GET and POST so both - handle bad files the same way.""" - if not config_file.exists(): - return None - try: - # Explicit UTF-8: save_settings()/import write config.json as - # UTF-8 bytes, so the read must not depend on the platform's - # default text encoding (cp1252 on Windows would mojibake or - # UnicodeDecodeError on a non-ASCII DLC path). - parsed = json.loads(config_file.read_text(encoding="utf-8")) - except Exception: - return None - return parsed if isinstance(parsed, dict) else None - - -@app.get("/api/tunings") -def get_tunings(): - cfg = _load_config(CONFIG_DIR / "config.json") or {} - ref = cfg.get("reference_pitch", DEFAULT_REFERENCE_PITCH) - try: - ref = float(ref) - if not (430.0 <= ref <= 450.0): - ref = DEFAULT_REFERENCE_PITCH - except (TypeError, ValueError): - ref = DEFAULT_REFERENCE_PITCH - merged = tuning_providers.get_merged(ref) - # tuningMidis: the same catalog as exact integer MIDI notes (low → high). - # Built-ins come straight from TUNING_PRESET_MIDIS (no float round-trip); - # provider-contributed entries are recovered from their frequencies at the - # served reference pitch. Every consumer today (the v3 badges, plugins) - # reconstructs midis client-side via log2 — a rounding footgun at non-440 - # references — so serve the integers once, host-side. Additive: the - # existing referencePitch/tunings shape is unchanged. - tuning_midis: dict[str, dict[str, list[int]]] = {} - for key, names in merged.items(): - builtin = TUNING_PRESET_MIDIS.get(key, {}) - resolved: dict[str, list[int]] = {} - for name, freqs in names.items(): - midis = builtin.get(name) or freqs_to_midis(freqs, ref) - if midis: - resolved[name] = list(midis) - if resolved: - tuning_midis[key] = resolved - return {"referencePitch": ref, "tunings": merged, "tuningMidis": tuning_midis} +# GET /api/tunings → routers/tunings.py (R3, reads config + appstate.tuning_providers) +app.include_router(tunings_router.router) @app.get("/api/settings")