Compare commits

..
Author SHA1 Message Date
Byron GamatosandClaude Opus 4.8 9cb3e4eaae test(gp2rs): compare ebeat times by value, not string
The 6-decimal beat-time write makes _assert_ebeats' exact-string compare fail
("0.500" vs "0.500000"). These tests only assert spacing, so parse both sides
to float — precision-agnostic, no need to rewrite every parametrized list.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 13:06:37 +02:00
ChrisBeWithYouandClaude Opus 4.8 233f5280e8 fix(gp2rs): write beat times at 6-decimal precision
The editor/timeline derives per-bar BPM from beat spans
(bpm = beats*60/span), which amplifies rounding: at millisecond
(3-decimal) precision a constant-tempo GP import (e.g. 140 BPM) shows a
spurious per-bar "tempo drift" of ~0.05-0.7 BPM because most bar lengths
don't land on a ms boundary (worse for fast/odd meters). gp2rs computes
these beat times exactly from the GP tempo map, so the only precision
loss is the ebeat/startBeat format string. Writing them at 6 decimals
(microseconds) makes the derived tempo match GP's authored value.

Verified on GP5 imports (Highway to Hell 116, Equivalence 140, Living
After Midnight 138): the derived per-bar BPM collapses from two drifting
values to the single authored constant.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013JgxKh99UAeQqmhzSc73tv
Signed-off-by: ChrisBeWithYou <christian.a.cowan@gmail.com>
2026-07-09 00:17:43 -05:00
13 changed files with 22 additions and 126 deletions
+2 -3
View File
@@ -90,7 +90,7 @@ Best practices:
- `extract_meta()` — metadata extraction callable
- `meta_db` — shared MetadataDB instance
- `library_providers` — shared library provider registry for source-aware browsing
- `register_library_provider(provider)` — register a plugin-provided library source. Providers expose `id`, `label`, optional `kind`/`capabilities`, optional `slow: true` (or `is_slow`/`slow_mode`) when responses may take a while, and callable `query_page`, `query_artists`, `query_stats`, and `tuning_names` methods. Providers with `art.read` may also expose `get_art(song_id)` returning one of: a `Response` object (any media type, served as-is); raw `bytes` or `bytearray` (**assumed PNG** — use a `Response` or a `dict` with `content`+`media_type` keys for JPEG/WebP or other formats); a URL string (http/https → 302 redirect; other schemes are rejected with 400); a filesystem path string or `Path` (served as a file with auto-detected media type); or a `dict` with a `url`, `path`, or `content` key. Providers with `song.sync` may expose `sync_song(song_id)` returning `None` (success with no local file) or a `dict` — the dict is passed through as the JSON response and should include `filename`/`local_filename` if a local playable file was produced.
- `register_library_provider(provider)` — register a plugin-provided library source. Providers expose `id`, `label`, optional `kind`/`capabilities`, and callable `query_page`, `query_artists`, `query_stats`, and `tuning_names` methods. Providers with `art.read` may also expose `get_art(song_id)` returning one of: a `Response` object (any media type, served as-is); raw `bytes` or `bytearray` (**assumed PNG** — use a `Response` or a `dict` with `content`+`media_type` keys for JPEG/WebP or other formats); a URL string (http/https → 302 redirect; other schemes are rejected with 400); a filesystem path string or `Path` (served as a file with auto-detected media type); or a `dict` with a `url`, `path`, or `content` key. Providers with `song.sync` may expose `sync_song(song_id)` returning `None` (success with no local file) or a `dict` — the dict is passed through as the JSON response and should include `filename`/`local_filename` if a local playable file was produced.
- `unregister_library_provider(provider_id)` — remove a plugin-provided library source by id. The built-in `local` provider cannot be removed.
- `get_sloppak_cache_dir()` — sloppak cache path
- `load_sibling(name)` — loads a sibling module from this plugin's directory under a unique, namespaced module name. See "Sibling imports" below.
@@ -585,13 +585,12 @@ a local pointer + code map.
pytest # Run all tests
pytest tests/test_song.py -v # Specific file
pytest -k "round_trip" -v # Pattern match
uv run pytest # Run through the locked local uv environment
```
- Framework: pytest
- Config: `pyproject.toml` sets `pythonpath = [".", "lib"]` and `testpaths = ["tests"]`
- CI: GitHub Actions runs pytest on push/PR to main (Python 3.12)
- Test dependencies: `requirements-test.txt`; uv reads the mirrored `test` dependency group in `pyproject.toml`
- Test dependencies: `requirements-test.txt`
## Tuning the note_detect plugin
-2
View File
@@ -80,8 +80,6 @@ A plugin that only reads public events should declare `observer` and no command
A plugin that registers a remote client or generated library source declares itself as a `library` provider. The backend registration call is still made from `routes.py` with `context["register_library_provider"](...)`; the native browser library capability turns the provider registry into runtime provider participants. A thin server wrapper that only exposes the local library over HTTP should not declare `library` as a provider unless it also registers a provider in the library registry.
Provider objects may set `slow: true` when their backing connection can take a while to answer. Core exposes that flag through `/api/library/providers` and the browser library capability so library views show explicit loading indicators instead of looking idle during the wait.
```json
{
"id": "remote_library_client",
+10 -3
View File
@@ -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.050.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)))
-8
View File
@@ -5038,7 +5038,6 @@ class LibraryProviderRegistry:
"kind": self.provider_field(provider, "kind", "local" if provider_id == "local" else "remote"),
"capabilities": sorted(self.provider_capabilities(provider)),
"owner_plugin_id": owner_plugin_id,
"slow": self.provider_slow(provider),
"default": provider_id == "local",
}
@@ -5059,13 +5058,6 @@ class LibraryProviderRegistry:
return ""
return label.strip()
def provider_slow(self, provider: object) -> bool:
return bool(
self.provider_field(provider, "slow", False)
or self.provider_field(provider, "is_slow", False)
or self.provider_field(provider, "slow_mode", False)
)
def _declared_capabilities(self, provider: object) -> set[str]:
"""Return only the capabilities explicitly declared on the provider object."""
raw = self.provider_field(provider, "capabilities", ())
+1 -7
View File
@@ -1871,10 +1871,7 @@ function _setLibraryLoadingMessage(containerId, countId, message) {
const count = document.getElementById(countId);
if (count) count.textContent = 'Loading source...';
if (container) {
container.innerHTML = `<div role="status" aria-live="polite" class="rounded-xl border border-gray-800/50 bg-dark-700/30 px-4 py-6 text-sm text-gray-300 flex items-center gap-3">
<span class="inline-block h-4 w-4 rounded-full border-2 border-gray-600 border-t-accent animate-spin" aria-hidden="true"></span>
<span>${esc(message || 'Loading library...')}</span>
</div>`;
container.innerHTML = `<div class="rounded-xl border border-gray-800/50 bg-dark-700/30 px-4 py-6 text-sm text-gray-300">${esc(message || 'Loading library...')}</div>`;
}
}
@@ -1883,9 +1880,6 @@ function _libraryLoadingText() {
if (!provider || provider.id === 'local' || provider.kind === 'local') {
return 'Loading library...';
}
if (provider.slow === true) {
return `Loading ${provider.label || provider.id}... this source may take a while.`;
}
return `Connecting to ${provider.label || provider.id}...`;
}
-1
View File
@@ -86,7 +86,6 @@
// operation is still completing through the provider.
const COMMAND_TIMEOUTS_MS = {
'audio-mix': { 'get-fader-value': 2100, 'set-fader-value': 2100 },
'library': { 'refresh-providers': 15000, 'sync-song': 15000 },
'midi-input': { 'discover': 15000, 'open-source': 15000 },
};
function _commandTimeoutFor(capability, commandName) {
-3
View File
@@ -13,7 +13,6 @@
label: 'My Library',
kind: 'local',
capabilities: ['library.read', 'art.read', 'song.play', 'favorite.write', 'metadata.write', 'retune.write'],
slow: false,
default: true,
});
const PROVIDER_OPERATIONS = Object.freeze({
@@ -73,7 +72,6 @@
label: String(provider.label || provider.name || providerId),
kind: String(provider.kind || (providerId === 'local' ? 'local' : 'remote')),
capabilities: _strings(provider.capabilities),
slow: provider.slow === true || provider.is_slow === true || provider.slow_mode === true,
default: provider.default === true || providerId === 'local',
};
}
@@ -143,7 +141,6 @@
label: provider.label || providerId,
kind: provider.kind || (providerId === 'local' ? 'local' : 'remote'),
capabilities: _strings(provider.capabilities),
slow: provider.slow === true,
ownerPluginId: _ownerPluginId(provider) || null,
default: provider.default === true || providerId === 'local',
},
+1 -1
View File
File diff suppressed because one or more lines are too long
+3 -28
View File
@@ -64,7 +64,6 @@
filters: { arr_has: [], arr_lacks: [], stem_has: [], stem_lacks: [], lyrics: '', tunings: [], mastery: [], match: [], genre: [] },
page: 0, total: 0, loading: false, built: false, accuracy: {}, tuningNames: [], genres: [],
providers: [],
artistCatalog: [], renderedHash: '',
scrollBound: false,
songsById: {}, selectMode: false, selected: new Set(),
@@ -443,24 +442,6 @@
async function jget(url) { try { const r = await fetch(url); return r.ok ? r.json() : null; } catch (e) { return null; } }
function activeProvider() {
return (state.providers || []).find((provider) => provider.id === state.provider) || { id: state.provider || 'local', label: 'My Library', kind: 'local' };
}
function providerLoadingText() {
const provider = activeProvider();
if (!provider || provider.id === 'local' || provider.kind === 'local') return 'Loading library...';
if (provider.slow === true) return 'Loading ' + (provider.label || provider.id) + '... this source may take a while.';
return 'Connecting to ' + (provider.label || provider.id) + '...';
}
function loadingPanelHtml() {
return '<div role="status" aria-live="polite" class="rounded-lg border border-fb-border/50 bg-fb-card/50 px-4 py-5 text-sm text-fb-textDim flex items-center gap-3" style="grid-column:1/-1">' +
'<span class="inline-block h-4 w-4 rounded-full border-2 border-fb-border border-t-fb-primary animate-spin" aria-hidden="true"></span>' +
'<span>' + esc(providerLoadingText()) + '</span>' +
'</div>';
}
// ── Provider-aware song helpers ────────────────────────────────────────
// Remote library providers (feedBack-plugin-remote-library-*) expose songs
// by provider-owned id with their own art/sync/play flow. Reuse the legacy
@@ -2109,10 +2090,6 @@
grid.style.top = '0px';
const sizer = _sizerEl();
if (sizer) sizer.style.height = '0px';
const countEl = document.getElementById('v3-songs-count');
if (countEl) countEl.textContent = 'Loading source...';
grid.innerHTML = loadingPanelHtml();
if (sizer) sizer.style.height = grid.offsetHeight + 'px';
}
state.loading = true;
await _loadPage(0);
@@ -2408,7 +2385,7 @@
async function loadAlbums() {
const host = document.getElementById('v3-songs-albums');
if (!host) return;
host.innerHTML = '<div role="status" aria-live="polite" class="text-fb-textDim text-sm flex items-center gap-3"><span class="inline-block h-4 w-4 rounded-full border-2 border-fb-border border-t-fb-primary animate-spin" aria-hidden="true"></span><span>' + esc(providerLoadingText()) + '</span></div>';
host.innerHTML = '<p class="text-fb-textDim text-sm">Loading…</p>';
const data = await jget('/api/library/albums?' + queryParams().toString());
const albums = (data && data.albums) || [];
if (!albums.length) { host.innerHTML = '<p class="text-fb-textDim text-sm py-8 text-center">No albums match.</p>'; return; }
@@ -2793,7 +2770,7 @@
// (e.g. toggling select mode) restores them instead of collapsing all.
const openArtists = new Set(
[...host.querySelectorAll('details[open]')].map((d) => d.getAttribute('data-artist')));
host.innerHTML = '<div role="status" aria-live="polite" class="text-fb-textDim text-sm flex items-center gap-3"><span class="inline-block h-4 w-4 rounded-full border-2 border-fb-border border-t-fb-primary animate-spin" aria-hidden="true"></span><span>' + esc(providerLoadingText()) + '</span></div>';
host.innerHTML = '<p class="text-fb-textDim text-sm">Loading…</p>';
// Page through ALL artists — the endpoint clamps size to 100, so a
// single request would silently truncate libraries with >100 artists.
const artists = [];
@@ -3480,14 +3457,12 @@
const snap = await fn.call(lp);
if (snap && Array.isArray(snap.providers)) {
state.provider = snap.current || (snap.providers[0] && snap.providers[0].id) || 'local';
state.providers = snap.providers;
return snap.providers;
}
}
} catch (e) { /* */ }
const data = await jget('/api/library/providers');
state.providers = (data && data.providers) || [{ id: 'local', label: 'My Library', kind: 'local' }];
return state.providers;
return (data && data.providers) || [{ id: 'local', label: 'My Library' }];
}
async function render() {
+1 -31
View File
@@ -77,34 +77,4 @@ test('failed no-op registrations do not block reload and rehydrate replacement',
assert.equal(participants.length, 1);
assert.equal(result.status, 'applied');
assert.equal(result.payload.generation, 2);
});
test('library long-running commands override the default handler timeout', async () => {
const window = loadCapabilities();
const api = window.feedBack.capabilities;
const delay = ms => new Promise(resolve => setTimeout(resolve, ms));
api.registerParticipant('stems', {
stems: {
roles: ['owner'],
commands: ['slow-probe'],
handlers: { 'slow-probe': async () => { await delay(300); return { outcome: 'handled' }; } },
runtime: true,
},
});
const timedOut = await api.dispatch({ capability: 'stems', command: 'slow-probe', source: 'test' });
assert.equal(timedOut.outcome, 'failed');
assert.match(timedOut.reason, /timed out after 250 ms/);
api.registerParticipant('core.library', {
library: {
roles: ['owner'],
commands: ['sync-song'],
handlers: { 'sync-song': async () => { await delay(300); return { outcome: 'handled', payload: { ok: true } }; } },
runtime: true,
},
});
const synced = await api.dispatch({ capability: 'library', command: 'sync-song', source: 'test' });
assert.equal(synced.status, 'applied');
assert.equal(synced.payload.ok, true);
});
});
-36
View File
@@ -1,36 +0,0 @@
// Pins the slow-library-provider loading states in the classic and v3 library
// surfaces. Providers can declare `slow: true`; the UI should show explicit
// wait copy plus a status region instead of silently blanking while fetches run.
'use strict';
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const root = path.join(__dirname, '..', '..');
const SONGS = fs.readFileSync(path.join(root, 'static', 'v3', 'songs.js'), 'utf8');
const APP = fs.readFileSync(path.join(root, 'static', 'app.js'), 'utf8');
test('v3 Songs slow providers get explicit loading copy', () => {
assert.match(SONGS, /function providerLoadingText\(\)[\s\S]*?provider\.slow\s*===\s*true[\s\S]*?this source may take a while\./,
'v3 providerLoadingText must branch on provider.slow and explain the longer wait');
assert.match(SONGS, /function loadingPanelHtml\(\)[\s\S]*?providerLoadingText\(\)/,
'v3 grid loading panel must render the provider-aware loading message');
});
test('v3 loading indicators are announced as polite status regions', () => {
assert.match(SONGS, /function loadingPanelHtml\(\)[\s\S]*?role="status"\s+aria-live="polite"/,
'v3 grid loading panel must be a polite status region');
assert.match(SONGS, /loadAlbums[\s\S]*role="status"\s+aria-live="polite"[\s\S]*providerLoadingText\(\)/,
'v3 albums loading state must be a polite status region');
assert.match(SONGS, /loadTree[\s\S]*role="status"\s+aria-live="polite"[\s\S]*providerLoadingText\(\)/,
'v3 list loading state must be a polite status region');
});
test('classic library loading indicator is announced and uses slow provider copy', () => {
assert.match(APP, /function _setLibraryLoadingMessage[\s\S]*?role="status"\s+aria-live="polite"/,
'classic library loading card must be a polite status region');
assert.match(APP, /function _libraryLoadingText\(\)[\s\S]*?provider\.slow\s*===\s*true[\s\S]*?this source may take a while\./,
'classic library loading text must branch on provider.slow');
});
+4 -1
View File
@@ -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),
-2
View File
@@ -50,7 +50,6 @@ class FakeLibraryProvider:
id = "remote:frodo"
label = "Frodo's Library"
kind = "remote"
slow = True
capabilities = ("library.read", "art.read", "song.sync")
def __init__(self):
@@ -171,7 +170,6 @@ def test_registered_provider_handles_library_endpoints(server_mod, client):
assert remote["label"] == "Frodo's Library"
assert remote["kind"] == "remote"
assert remote["default"] is False
assert remote["slow"] is True
assert remote["capabilities"] == ["art.read", "library.read", "song.sync"]
songs = client.get("/api/library", params={