mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-09-11 10:54:30 +00:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
27164ec5ba | ||
|
|
e134f5c802 | ||
|
|
f1bae9774c | ||
|
|
751209b80e |
@@ -7,6 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Changed
|
||||
- **`MetadataDB` moved out of `server.py` into `lib/metadata_db.py` (R3, move-only).**
|
||||
The library metadata cache — the `MetadataDB` class (4,018 lines) plus the query
|
||||
helpers it owns (keyset paging cursors, the tuning grouping key, smart-arrangement
|
||||
naming, tag normalisation, the startup DB-restore swap) — now lives in its own flat
|
||||
`lib/` module. `server.py` drops from **14,037 → 9,705 lines** and keeps the
|
||||
`meta_db` singleton, so `server.meta_db` and `server.app` resolve exactly as before
|
||||
and every route is untouched. The only non-verbatim change is the seam that lets the
|
||||
class leave `server.py`: `MetadataDB.__init__` now takes `config_dir` explicitly
|
||||
(`meta_db = MetadataDB(CONFIG_DIR)`) instead of reading the module-level `CONFIG_DIR`,
|
||||
which also means `lib/metadata_db.py` performs no IO at import (Principle V). Logging
|
||||
still goes through the `feedBack.server` logger, so existing log filters and `caplog`
|
||||
assertions resolve to the same logger object. `tests/test_settings_export_library_db.py`
|
||||
now imports `_apply_pending_db_restore` from `metadata_db` (the test moved with its
|
||||
subject); no other test changed. Every moved block is byte-identical to its
|
||||
`server.py` original.
|
||||
|
||||
### Added
|
||||
- **Plugins can ship an ES-module `src/` tree (module-migration rails, R0).** The host gains three things so a plugin can move off a single global-scope `screen.js` IIFE onto native ES modules with **no build step**: (1) a new sandboxed `GET /api/plugins/{id}/src/{path}` route that serves a plugin's `src/` source subtree, containment-checked by the same `safe_join` guard as `assets/` (traversal/absolute/NUL → 404); (2) the live-edit cache contract — `Cache-Control: no-cache` + a weak mtime/size `ETag` + `If-None-Match`→`304` — applied to `src/`, `screen.js`, and `assets/` (previously `screen.js` sent no cache headers and `assets/` emitted an ETag but never revalidated), so an edited module reloads on refresh while unchanged ones `304`; and (3) `scriptType`/`minHost` passthrough from `plugin.json` to `/api/plugins`, with the loader injecting a plugin that declares `"scriptType":"module"` as `<script type="module">` (its screen.js becomes `import './src/main.js'`). A `<script type=module>` fires its load event only after its whole static-import graph evaluates, preserving the loader's completion-by-`onload` + `_loadingPluginId` contract. Classic plugins are unaffected; `minHost` is passthrough-only for now (enforcement deferred). Tests: `tests/test_plugin_src_route.py` (serve/media-type/traversal/304/no-stale-304/screen.js+assets conditional), `tests/js/plugin_loader_script_type.test.js` (guarded module injection).
|
||||
- **Module-migration governance & rails (R0).** Constitution amended to **v1.2.0**: Principle II now names native ES modules as a first-class, *build-free* extension mechanism (the `scriptType:"module"` load path, both plugins and — over time — core's `static/js/`), keeping the no-bundler/no-transpiler/source-served rule intact; Operating Constraints gains a "Module load contract" clause (a `<script type=module>` load event awaits the whole static-import graph, so completion-by-`onload` is preserved; per-visit re-init comes from the `screen:changed` event, not screen.js re-execution). Mirrored into `CLAUDE.md`. New `docs/plugin-modules.md` (the migration playbook — layering, import-time purity, `import.meta.url` assets, the ETag live-edit loop) and `docs/size-exemptions.md` (the signed 1,500-line size-norm register; Byron signs core/bundled rows, Christian the authored virtuoso row). Adds a **maintainer/CI-only** ESLint gate (`eslint.config.js` + a `lint` CI job): `max-lines` warns at 1,500 as a non-blocking ratchet (ceilings for exempt files mirror the register), and `import-x/no-unresolved` + `import-x/no-cycle` hard-error on ES-module graphs — dormant until module code lands, never on the serve/Docker path.
|
||||
|
||||
@@ -54,8 +54,11 @@ without a *signed* exemption" is unenforceable.
|
||||
|
||||
## Planned, NOT exempt (owned by split plans — listed so nothing falls between states)
|
||||
|
||||
core `static/app.js` (11,821) · `static/highway.js` (4,154, whole file) · `server.py`
|
||||
(13,948) · `static/v3/songs.js` (4,134) · `static/capabilities/audio-session.js`
|
||||
core `static/app.js` (11,852) · `static/highway.js` (4,168, whole file) · `server.py`
|
||||
(9,705 — was 14,037; ratcheted by the R3 `MetadataDB` extraction) ·
|
||||
`lib/metadata_db.py` (4,373 — new in R3; the `MetadataDB` class alone is 4,018 lines
|
||||
and is a monolith in its own right, to be split per-table once the router train
|
||||
lands) · `static/v3/songs.js` (4,134) · `static/capabilities/audio-session.js`
|
||||
(2,974) · `plugins/highway_3d/screen.js` (15,656) · `plugins/keys_highway_3d/screen.js`
|
||||
(3,780) · `plugins/drum_highway_3d/screen.js` (3,597) — and every monolith with a PR
|
||||
train in the refactor plan. Test files (e.g. `tests/test_plugins.py`) are out of scope
|
||||
|
||||
+10
-3
@@ -1114,7 +1114,7 @@ def _build_xml(
|
||||
ET.SubElement(root, "arrangement").text = arrangement
|
||||
ET.SubElement(root, "offset").text = f"{audio_offset:.3f}"
|
||||
ET.SubElement(root, "songLength").text = f"{song_length:.3f}"
|
||||
ET.SubElement(root, "startBeat").text = f"{beats[0].time:.3f}" if beats else "0.000"
|
||||
ET.SubElement(root, "startBeat").text = f"{beats[0].time:.6f}" if beats else "0.000000"
|
||||
ET.SubElement(root, "averageTempo").text = str(tempo)
|
||||
ET.SubElement(root, "artistName").text = artist
|
||||
ET.SubElement(root, "albumName").text = album
|
||||
@@ -1139,10 +1139,17 @@ def _build_xml(
|
||||
tuning_el.set(f"string{i}", str(tuning[i] if i < len(tuning) else 0))
|
||||
ET.SubElement(root, "capo").text = "0"
|
||||
|
||||
# Ebeats
|
||||
# Ebeats — write beat times at MICROSECOND (6-decimal) precision, not
|
||||
# millisecond (3-decimal). The editor/timeline DERIVES per-bar BPM from beat
|
||||
# spans (bpm = beats·60/span), which amplifies any rounding: at 3 decimals a
|
||||
# constant-tempo GP (e.g. 140) shows a spurious ±0.05–0.7 BPM per-bar drift
|
||||
# (worse for fast/odd meters) because most bar lengths don't land on a ms
|
||||
# boundary. gp2rs computes these times exactly from the GP tempo map, so the
|
||||
# only loss is this format string — 6 decimals makes the derived tempo match
|
||||
# GP's authored value. (Everything else stays at :.3f; only beats drive tempo.)
|
||||
ebeats = ET.SubElement(root, "ebeats", count=str(len(beats)))
|
||||
for b in beats:
|
||||
ET.SubElement(ebeats, "ebeat", time=f"{b.time:.3f}", measure=str(b.measure))
|
||||
ET.SubElement(ebeats, "ebeat", time=f"{b.time:.6f}", measure=str(b.measure))
|
||||
|
||||
# Sections
|
||||
sections_el = ET.SubElement(root, "sections", count=str(len(sections)))
|
||||
|
||||
+4373
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -3,7 +3,7 @@
|
||||
This module is deliberately kept apart from ``server.py`` so that
|
||||
``ProcessPoolExecutor`` workers can import and unpickle ``_scan_one``
|
||||
without dragging in ``server.py``'s import-time side effects
|
||||
(``configure_logging()``, ``meta_db = MetadataDB()`` opening/migrating
|
||||
(``configure_logging()``, ``meta_db = MetadataDB(CONFIG_DIR)`` opening/migrating
|
||||
SQLite, and ``register_plugin_api(app)`` registering routes).
|
||||
|
||||
The background scan spawns its pool with the ``spawn`` start method (see
|
||||
|
||||
@@ -98,6 +98,22 @@ def open_midis_to_freqs(midis: list[int], reference_pitch: float = DEFAULT_REFER
|
||||
return [round(midi_to_freq(m, reference_pitch), 2) for m in midis]
|
||||
|
||||
|
||||
def freqs_to_midis(freqs: list[float], reference_pitch: float = DEFAULT_REFERENCE_PITCH) -> list[int] | None:
|
||||
"""Return absolute open-string MIDI notes for frequencies at the supplied
|
||||
A4 reference — the inverse of open_midis_to_freqs. None if any entry is
|
||||
non-numeric or non-positive (a provider could hand us anything)."""
|
||||
out: list[int] = []
|
||||
for f in freqs:
|
||||
try:
|
||||
f = float(f)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if f <= 0:
|
||||
return None
|
||||
out.append(int(round(69 + 12 * math.log2(f / reference_pitch))))
|
||||
return out
|
||||
|
||||
|
||||
def tuning_offsets_from_midis(instrument_key: str, midis: list[int]) -> list[int] | None:
|
||||
"""Return semitone offsets from the instrument's standard open strings."""
|
||||
standard = STANDARD_OPEN_MIDIS.get(instrument_key)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"id": "highway_3d",
|
||||
"name": "3D Highway",
|
||||
"version": "3.31.4",
|
||||
"version": "3.31.5",
|
||||
"type": "visualization",
|
||||
"bundled": true,
|
||||
"script": "screen.js",
|
||||
|
||||
@@ -548,9 +548,20 @@
|
||||
ctrl.ownsActx = !(fogAudio && fogAudio.ctx);
|
||||
ctrl.actx = (fogAudio && fogAudio.ctx) || new Ctx();
|
||||
if (ctrl.actx.state === 'suspended' && ctrl.actx.resume) ctrl.actx.resume().catch(() => {});
|
||||
// Seed the DRAWING BUFFER (canvas.width/height) to the device-pixel
|
||||
// render size and report that SAME size to Butterchurn. Its on-screen
|
||||
// pass viewports to the reported size but never sizes the output canvas
|
||||
// itself — leaving the buffer at the 300x150 default blits the whole
|
||||
// visualizer into a corner that CSS then stretches across the highway.
|
||||
// pixelRatio:1 because DPR is now folded into the reported size, so
|
||||
// buffer == viewport == internal texsize (no double-counting).
|
||||
const _bcRatio0 = Math.min(window.devicePixelRatio || 1, 1.5);
|
||||
const _bcW0 = Math.max(1, Math.round((sz.w || 1280) * _bcRatio0));
|
||||
const _bcH0 = Math.max(1, Math.round((sz.h || 720) * _bcRatio0));
|
||||
canvas.width = _bcW0; canvas.height = _bcH0;
|
||||
ctrl.viz = bc.createVisualizer(ctrl.actx, canvas, {
|
||||
width: sz.w || 1280, height: sz.h || 720,
|
||||
pixelRatio: Math.min(window.devicePixelRatio || 1, 1.5), textureRatio: 1,
|
||||
width: _bcW0, height: _bcH0,
|
||||
pixelRatio: 1, textureRatio: 1,
|
||||
});
|
||||
if (_bcIsDesktop()) {
|
||||
try {
|
||||
@@ -584,6 +595,27 @@
|
||||
ctrl.actx = null; ctrl.viz = null; ctrl.dead = true;
|
||||
_bcControllers.delete(ctrl);
|
||||
});
|
||||
// Size the Butterchurn output: set the canvas DRAWING BUFFER to the
|
||||
// device-pixel render size AND report that same size, so buffer ==
|
||||
// on-screen viewport == full fill. Butterchurn never sizes the output
|
||||
// canvas itself; the previous code set only CSS size, leaving the buffer
|
||||
// at the 300x150 default -> the viz showed a stretched lower-left corner
|
||||
// (worse the larger the panel). Ratio reuses the highway's DPR budget.
|
||||
function _bcApplySize(cssW, cssH) {
|
||||
if (!(cssW > 0 && cssH > 0)) return;
|
||||
ctrl.lastW = cssW; ctrl.lastH = cssH;
|
||||
const ratio = Math.min(window.devicePixelRatio || 1, 1.5);
|
||||
const bw = Math.max(1, Math.round(cssW * ratio)), bh = Math.max(1, Math.round(cssH * ratio));
|
||||
if (canvas.width !== bw) canvas.width = bw;
|
||||
if (canvas.height !== bh) canvas.height = bh;
|
||||
const wpx = cssW + 'px', hpx = cssH + 'px';
|
||||
// Confine ALL layers to exactly the highway-canvas rect so the opaque
|
||||
// backdrop can't bleed over the transport bar above the highway.
|
||||
[ctrl.canvas, ctrl.backdrop, ctrl.scrim, ctrl.tint].forEach((el) => {
|
||||
if (el) { el.style.width = wpx; el.style.height = hpx; el.style.right = 'auto'; el.style.bottom = 'auto'; }
|
||||
});
|
||||
if (ctrl.viz && ctrl.viz.setRendererSize) { try { ctrl.viz.setRendererSize(bw, bh); } catch (e) {} }
|
||||
}
|
||||
return {
|
||||
applySettings() { ctrl.applySettings(); },
|
||||
dead() { return ctrl.dead; },
|
||||
@@ -612,18 +644,11 @@
|
||||
if (!ctrl.viz || !s.enabled) return; // skip GPU work when the bg is off
|
||||
const sz = sizeProvider && sizeProvider();
|
||||
if (sz && sz.w > 0 && sz.h > 0 && (sz.w !== ctrl.lastW || sz.h !== ctrl.lastH)) {
|
||||
ctrl.lastW = sz.w; ctrl.lastH = sz.h;
|
||||
const wpx = sz.w + 'px', hpx = sz.h + 'px';
|
||||
// Confine ALL layers to exactly the highway-canvas rect so the opaque
|
||||
// backdrop can't bleed over the transport bar above the highway.
|
||||
[ctrl.canvas, ctrl.backdrop, ctrl.scrim, ctrl.tint].forEach((el) => {
|
||||
if (el) { el.style.width = wpx; el.style.height = hpx; el.style.right = 'auto'; el.style.bottom = 'auto'; }
|
||||
});
|
||||
try { ctrl.viz.setRendererSize(sz.w, sz.h); } catch (e) {}
|
||||
_bcApplySize(sz.w, sz.h);
|
||||
}
|
||||
try { ctrl.viz.render(); } catch (e) {}
|
||||
},
|
||||
resize(w, h) { if (ctrl.viz && ctrl.viz.setRendererSize) { try { ctrl.viz.setRendererSize(w, h); } catch (e) {} ctrl.lastW = w; ctrl.lastH = h; } },
|
||||
resize(w, h) { _bcApplySize(w, h); },
|
||||
destroy() {
|
||||
ctrl.dead = true;
|
||||
_bcControllers.delete(ctrl);
|
||||
|
||||
+4
-1
@@ -121,7 +121,10 @@ def _converter_ebeats(converter, numerator, denominator, tempo_changes=None):
|
||||
def _assert_ebeats(converter, numerator, denominator, expected_times, tempo_changes=None):
|
||||
ebeats = _converter_ebeats(converter, numerator, denominator, tempo_changes)
|
||||
|
||||
assert [ebeat.get("time") for ebeat in ebeats] == expected_times
|
||||
# Compare by value, not string: beat times are written at 6-decimal
|
||||
# (microsecond) precision so the derived per-bar tempo matches the authored
|
||||
# GP value, but these tests only care about the spacing, not the format.
|
||||
assert [float(ebeat.get("time")) for ebeat in ebeats] == [float(t) for t in expected_times]
|
||||
assert [ebeat.get("measure") for ebeat in ebeats] == [
|
||||
"1",
|
||||
*["-1"] * (len(expected_times) - 1),
|
||||
|
||||
@@ -50,7 +50,7 @@ def client(tmp_path, monkeypatch):
|
||||
# Point CONFIG_DIR at a per-test temp path BEFORE server's
|
||||
# import-time side effects run. server.py reads CONFIG_DIR from the
|
||||
# environment at module load (line 35) and immediately constructs
|
||||
# `meta_db = MetadataDB()` at module level, which calls
|
||||
# `meta_db = MetadataDB(CONFIG_DIR)` at module level, which calls
|
||||
# CONFIG_DIR.mkdir(...) and opens a sqlite file — a plain
|
||||
# post-import monkeypatch on server.CONFIG_DIR wouldn't catch those
|
||||
# side effects, and the real user config dir would get written to.
|
||||
|
||||
@@ -22,6 +22,11 @@ from pathlib import Path
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
# The startup DB swap lives with the DB layer it guards, not with server.py.
|
||||
# metadata_db reads no environment at import, so a plain module import is safe
|
||||
# alongside the env-patched `server_mod` fixture below.
|
||||
from metadata_db import _apply_pending_db_restore
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def server_mod(tmp_path, monkeypatch):
|
||||
@@ -219,7 +224,7 @@ def test_apply_pending_db_restore_swaps_and_clears_sidecars(server_mod, tmp_path
|
||||
(tmp_path / "web_library.db-shm").write_bytes(b"OLD-SHM")
|
||||
(tmp_path / "web_library.db.restore").write_bytes(new_db)
|
||||
|
||||
server_mod._apply_pending_db_restore(tmp_path)
|
||||
_apply_pending_db_restore(tmp_path)
|
||||
|
||||
assert main.read_bytes() == new_db # swapped in
|
||||
assert not (tmp_path / "web_library.db.restore").exists()
|
||||
@@ -234,7 +239,7 @@ def test_apply_pending_db_restore_discards_corrupt_keeps_live(server_mod, tmp_pa
|
||||
main.write_bytes(b"LIVE-GOOD-DB")
|
||||
(tmp_path / "web_library.db.restore").write_bytes(b"SQLite format 3\x00" + b"\xff" * 64)
|
||||
|
||||
server_mod._apply_pending_db_restore(tmp_path)
|
||||
_apply_pending_db_restore(tmp_path)
|
||||
|
||||
assert main.read_bytes() == b"LIVE-GOOD-DB" # live DB preserved
|
||||
assert not (tmp_path / "web_library.db.restore").exists() # bad restore dropped
|
||||
@@ -242,7 +247,7 @@ def test_apply_pending_db_restore_discards_corrupt_keeps_live(server_mod, tmp_pa
|
||||
|
||||
def test_apply_pending_db_restore_noop_without_staging(server_mod, tmp_path):
|
||||
(tmp_path / "web_library.db").write_bytes(b"LIVE")
|
||||
server_mod._apply_pending_db_restore(tmp_path) # nothing staged
|
||||
_apply_pending_db_restore(tmp_path) # nothing staged
|
||||
assert (tmp_path / "web_library.db").read_bytes() == b"LIVE"
|
||||
|
||||
|
||||
@@ -266,7 +271,7 @@ def test_full_db_backup_restore_round_trip(client, server_mod, tmp_path):
|
||||
# Simulate a restart: close the live conn, apply the staged restore,
|
||||
# reopen — the song is back.
|
||||
server_mod.meta_db.conn.close()
|
||||
server_mod._apply_pending_db_restore(tmp_path)
|
||||
_apply_pending_db_restore(tmp_path)
|
||||
conn = sqlite3.connect(str(tmp_path / "web_library.db"))
|
||||
try:
|
||||
rows = conn.execute(
|
||||
|
||||
@@ -249,3 +249,30 @@ def test_flat_string_count_patch_resets_incompatible_named_tuning():
|
||||
patched = apply_flat_instrument_patch_to_profiles(settings, {"string_count": 7})
|
||||
assert patched["string_count"] == 7
|
||||
assert patched["tuning"] == "Standard"
|
||||
|
||||
|
||||
# ── freqs_to_midis (the /api/tunings tuningMidis inverse) ────────────────────
|
||||
|
||||
def test_freqs_to_midis_round_trips_every_builtin_at_440():
|
||||
from tunings import freqs_to_midis
|
||||
for key, presets in TUNING_PRESET_MIDIS.items():
|
||||
for name, midis in presets.items():
|
||||
assert freqs_to_midis(open_midis_to_freqs(midis)) == midis, f"{key}/{name}"
|
||||
|
||||
|
||||
def test_freqs_to_midis_round_trips_at_nonstandard_reference():
|
||||
# The consumer footgun this exists to kill: frequencies served at a 432/450
|
||||
# reference must recover the SAME integer midis when inverted at that
|
||||
# reference (client-side log2-at-440 reconstruction drifts here).
|
||||
from tunings import freqs_to_midis
|
||||
for ref in (430.0, 432.0, 444.0, 450.0):
|
||||
for midis in (TUNING_PRESET_MIDIS["guitar-8"]["Standard"], TUNING_PRESET_MIDIS["bass-5"]["Standard"]):
|
||||
freqs = open_midis_to_freqs(midis, ref)
|
||||
assert freqs_to_midis(freqs, ref) == midis, f"ref={ref}"
|
||||
|
||||
|
||||
def test_freqs_to_midis_rejects_garbage():
|
||||
from tunings import freqs_to_midis
|
||||
assert freqs_to_midis([82.41, 0]) is None # non-positive
|
||||
assert freqs_to_midis([82.41, "x"]) is None # non-numeric
|
||||
assert freqs_to_midis([]) == [] # vacuously fine
|
||||
|
||||
Reference in New Issue
Block a user