mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-08-11 03:09:57 +00:00
Merge remote-tracking branch 'origin/main' into feat/feedpak-jsonc
Signed-off-by: topkoa <topkoa@gmail.com> # Conflicts: # CHANGELOG.md
This commit is contained in:
+3
-3
@@ -7,7 +7,7 @@ import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
log = logging.getLogger("slopsmith.lib.audio")
|
||||
log = logging.getLogger("feedBack.lib.audio")
|
||||
|
||||
# Maximum length of any single decoder-error fragment that we surface to
|
||||
# the client. ffmpeg can emit multi-kB build-configuration / version
|
||||
@@ -123,7 +123,7 @@ def _scrub_quoted_match(match: re.Match) -> str:
|
||||
|
||||
def _bundled_bin_dir() -> Path | None:
|
||||
"""Resolve the desktop bundle's resources/bin/ directory if we're
|
||||
running inside one. Layout: resources/slopsmith/lib/audio.py →
|
||||
running inside one. Layout: resources/feedBack/lib/audio.py →
|
||||
resources/bin/. Gate on vgmstream-cli's presence so we don't
|
||||
misidentify random parent dirs (e.g. Docker's `/bin`, dev
|
||||
layouts where parents[2] resolves to the repo root) — vgmstream-cli
|
||||
@@ -284,7 +284,7 @@ def _scrub_paths(text: str, *paths: str) -> str:
|
||||
"""Replace absolute filesystem paths in `text` with their basenames.
|
||||
|
||||
Decoder error strings get joined into the RuntimeError that
|
||||
`convert_wem` raises, and slopsmith surfaces that text in the
|
||||
`convert_wem` raises, and feedBack surfaces that text in the
|
||||
browser as `audio_error`. Leaking install / user / DLC paths to the
|
||||
client is a needless info disclosure, so before any decoder error
|
||||
leaves this module we strip absolute paths down to their final
|
||||
|
||||
+24
-24
@@ -130,7 +130,7 @@ ENV_ALLOWLIST = (
|
||||
"LOG_LEVEL",
|
||||
"LOG_FORMAT",
|
||||
"LOG_FILE",
|
||||
"SLOPSMITH_RUNTIME",
|
||||
"FEEDBACK_RUNTIME",
|
||||
"PORT",
|
||||
"HOST",
|
||||
"TZ",
|
||||
@@ -154,13 +154,13 @@ def _safe_json_dumps(obj) -> str:
|
||||
return json.dumps({"error": "unserializable payload"}, indent=2)
|
||||
|
||||
|
||||
def _system_version(slopsmith_version: str, redactor=None) -> dict:
|
||||
def _system_version(feedBack_version: str, redactor=None) -> dict:
|
||||
executable = sys.executable
|
||||
if redactor is not None:
|
||||
executable = redactor.redact_text(executable)
|
||||
return {
|
||||
"schema": "system.version.v1",
|
||||
"slopsmith_version": slopsmith_version,
|
||||
"feedBack_version": feedBack_version,
|
||||
"python": {
|
||||
"version": platform.python_version(),
|
||||
"implementation": platform.python_implementation(),
|
||||
@@ -233,7 +233,7 @@ def _summarize_payload(path: str, parsed) -> dict | None:
|
||||
py = parsed.get("python") or {}
|
||||
os_ = parsed.get("os") or {}
|
||||
return {
|
||||
"slopsmith": parsed.get("slopsmith_version"),
|
||||
"feedBack": parsed.get("feedBack_version"),
|
||||
"python": py.get("version"),
|
||||
"os": os_.get("system"),
|
||||
}
|
||||
@@ -338,7 +338,7 @@ def _git_info(plugin_dir: Path) -> dict | None:
|
||||
"""Return git short SHA + remote URL for a plugin checkout.
|
||||
|
||||
Pure-Python — reads `.git/HEAD` and `.git/config` directly so this
|
||||
works in containers without the `git` binary installed (slopsmith's
|
||||
works in containers without the `git` binary installed (feedBack's
|
||||
runtime image is minimal). Plugins are gitlinks (see CLAUDE.md);
|
||||
the SHA is the most reliable "what build is this" identifier.
|
||||
|
||||
@@ -393,7 +393,7 @@ def _system_plugins(loaded_plugins: list[dict], plugins_root: "Path | list[Path]
|
||||
show up in the bundle.
|
||||
|
||||
*plugins_root* accepts a single Path, a list of Paths (to cover both
|
||||
the built-in ``plugins/`` directory and ``SLOPSMITH_PLUGINS_DIR``), or
|
||||
the built-in ``plugins/`` directory and ``FEEDBACK_PLUGINS_DIR``), or
|
||||
None to skip orphan detection entirely.
|
||||
|
||||
Plugin directories not in ``LOADED_PLUGINS`` appear in ``orphans``.
|
||||
@@ -484,7 +484,7 @@ def _system_plugins(loaded_plugins: list[dict], plugins_root: "Path | list[Path]
|
||||
# plugin failed to load — common when requirements.txt installs
|
||||
# fail in a read-only container). Accepts a single Path, a list of
|
||||
# Paths (to cover both the built-in plugins/ dir and
|
||||
# SLOPSMITH_PLUGINS_DIR), or None.
|
||||
# FEEDBACK_PLUGINS_DIR), or None.
|
||||
orphans: list[dict] = []
|
||||
if plugins_root is not None:
|
||||
roots: list[Path] = plugins_root if isinstance(plugins_root, list) else [plugins_root]
|
||||
@@ -840,11 +840,11 @@ def _redact_value(value: object, redactor: "Redactor") -> object:
|
||||
|
||||
|
||||
README_TEMPLATE = """\
|
||||
Slopsmith Diagnostics Bundle
|
||||
FeedBack Diagnostics Bundle
|
||||
============================
|
||||
|
||||
Generated: {exported_at}
|
||||
Slopsmith: {slopsmith_version}
|
||||
FeedBack: {feedBack_version}
|
||||
Runtime: {runtime_kind}
|
||||
Redacted: {redacted}
|
||||
|
||||
@@ -1005,7 +1005,7 @@ def _build_files_meta(files: dict[str, bytes]) -> list[dict]:
|
||||
|
||||
def _assemble_files_and_notes(
|
||||
*,
|
||||
slopsmith_version: str,
|
||||
feedBack_version: str,
|
||||
config_dir: Path,
|
||||
dlc_dir: Path | None,
|
||||
log_file: Path | None,
|
||||
@@ -1038,7 +1038,7 @@ def _assemble_files_and_notes(
|
||||
if include.get("system", True):
|
||||
# Pass the redactor so python.executable is redacted when paths
|
||||
# should be hidden (it often lives under $HOME or a per-user venv).
|
||||
ver_payload = _safe_json_dumps(_system_version(slopsmith_version, redactor=redactor)).encode("utf-8")
|
||||
ver_payload = _safe_json_dumps(_system_version(feedBack_version, redactor=redactor)).encode("utf-8")
|
||||
files["system/version.json"] = ver_payload
|
||||
env_payload = _safe_json_dumps(_system_env(redactor=redactor)).encode("utf-8")
|
||||
files["system/env.json"] = env_payload
|
||||
@@ -1125,7 +1125,7 @@ def _assemble_files_and_notes(
|
||||
files.update(plugin_files)
|
||||
|
||||
# Per-plugin client-side contributions from
|
||||
# window.slopsmith.diagnostics.contribute(plugin_id, payload).
|
||||
# window.feedBack.diagnostics.contribute(plugin_id, payload).
|
||||
# Gated on the same "plugins" toggle as backend plugin diagnostics.
|
||||
if include.get("plugins", True) and client_contributions and isinstance(client_contributions, dict):
|
||||
# Build the set of actually-loaded plugin IDs so we only accept
|
||||
@@ -1160,7 +1160,7 @@ def _assemble_files_and_notes(
|
||||
|
||||
def _make_manifest(
|
||||
*,
|
||||
slopsmith_version: str,
|
||||
feedBack_version: str,
|
||||
runtime_kind: str,
|
||||
redact: bool,
|
||||
files: dict[str, bytes],
|
||||
@@ -1170,7 +1170,7 @@ def _make_manifest(
|
||||
return {
|
||||
"schema": BUNDLE_SCHEMA,
|
||||
"exported_at": _now_iso(),
|
||||
"slopsmith_version": slopsmith_version,
|
||||
"feedBack_version": feedBack_version,
|
||||
"runtime": runtime_kind,
|
||||
"redacted": redact,
|
||||
"files": _build_files_meta(files),
|
||||
@@ -1181,7 +1181,7 @@ def _make_manifest(
|
||||
|
||||
def build_bundle(
|
||||
*,
|
||||
slopsmith_version: str,
|
||||
feedBack_version: str,
|
||||
config_dir: Path,
|
||||
dlc_dir: Path | None,
|
||||
log_file: Path | None,
|
||||
@@ -1198,7 +1198,7 @@ def build_bundle(
|
||||
) -> tuple[bytes, str, dict]:
|
||||
"""Returns (zip_bytes, filename, manifest_dict)."""
|
||||
files, notes, runtime_kind, redactor = _assemble_files_and_notes(
|
||||
slopsmith_version=slopsmith_version,
|
||||
feedBack_version=feedBack_version,
|
||||
config_dir=config_dir,
|
||||
dlc_dir=dlc_dir,
|
||||
log_file=log_file,
|
||||
@@ -1215,7 +1215,7 @@ def build_bundle(
|
||||
)
|
||||
|
||||
manifest = _make_manifest(
|
||||
slopsmith_version=slopsmith_version,
|
||||
feedBack_version=feedBack_version,
|
||||
runtime_kind=runtime_kind,
|
||||
redact=redact,
|
||||
files=files,
|
||||
@@ -1225,7 +1225,7 @@ def build_bundle(
|
||||
|
||||
readme = README_TEMPLATE.format(
|
||||
exported_at=manifest["exported_at"],
|
||||
slopsmith_version=slopsmith_version,
|
||||
feedBack_version=feedBack_version,
|
||||
runtime_kind=runtime_kind,
|
||||
redacted=redact,
|
||||
)
|
||||
@@ -1259,13 +1259,13 @@ def build_bundle(
|
||||
for path, payload in sorted(files.items()):
|
||||
zf.writestr(path, payload)
|
||||
|
||||
filename = f"slopsmith-diag-{slopsmith_version}-{_now_filename_slug()}.zip"
|
||||
filename = f"feedBack-diag-{feedBack_version}-{_now_filename_slug()}.zip"
|
||||
return buf.getvalue(), filename, manifest
|
||||
|
||||
|
||||
def preview_bundle(
|
||||
*,
|
||||
slopsmith_version: str,
|
||||
feedBack_version: str,
|
||||
config_dir: Path,
|
||||
dlc_dir: Path | None,
|
||||
log_file: Path | None,
|
||||
@@ -1303,7 +1303,7 @@ def preview_bundle(
|
||||
for p in loaded_plugins
|
||||
]
|
||||
files, notes, runtime_kind, redactor = _assemble_files_and_notes(
|
||||
slopsmith_version=slopsmith_version,
|
||||
feedBack_version=feedBack_version,
|
||||
config_dir=config_dir,
|
||||
dlc_dir=dlc_dir,
|
||||
log_file=log_file,
|
||||
@@ -1336,7 +1336,7 @@ def preview_bundle(
|
||||
if key not in files:
|
||||
files[key] = _CALLABLE_PREVIEW_PLACEHOLDER
|
||||
# Frontend plugins (those with a screen or script) may call
|
||||
# window.slopsmith.diagnostics.contribute() and produce a
|
||||
# window.feedBack.diagnostics.contribute() and produce a
|
||||
# plugins/<id>/client.json in the real export. Advertise a
|
||||
# placeholder so the preview file tree is accurate.
|
||||
if p.get("has_screen") or p.get("has_script"):
|
||||
@@ -1377,14 +1377,14 @@ def preview_bundle(
|
||||
}).encode("utf-8")
|
||||
|
||||
manifest = _make_manifest(
|
||||
slopsmith_version=slopsmith_version,
|
||||
feedBack_version=feedBack_version,
|
||||
runtime_kind=runtime_kind,
|
||||
redact=redact,
|
||||
files=files,
|
||||
notes=notes,
|
||||
redactor=redactor,
|
||||
)
|
||||
filename = f"slopsmith-diag-{slopsmith_version}-{_now_filename_slug()}.zip"
|
||||
filename = f"feedBack-diag-{feedBack_version}-{_now_filename_slug()}.zip"
|
||||
return {
|
||||
"filename": filename,
|
||||
"manifest": manifest,
|
||||
|
||||
@@ -16,6 +16,8 @@ import platform
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from env_compat import getenv_compat
|
||||
|
||||
SCHEMA = "system.hardware.v1"
|
||||
|
||||
|
||||
@@ -41,7 +43,7 @@ def detect_runtime() -> dict:
|
||||
nvidia-smi / psutil CPU probes.
|
||||
"""
|
||||
out: dict = {"kind": "bare", "in_docker": False, "in_kubernetes": False}
|
||||
env_runtime = os.environ.get("SLOPSMITH_RUNTIME", "").strip().lower()
|
||||
env_runtime = (getenv_compat("FEEDBACK_RUNTIME", "") or "").strip().lower()
|
||||
if env_runtime in ("electron", "docker", "bare"):
|
||||
out["kind"] = env_runtime
|
||||
if Path("/.dockerenv").exists():
|
||||
@@ -65,7 +67,7 @@ def detect_runtime() -> dict:
|
||||
import psutil # type: ignore
|
||||
|
||||
parent = psutil.Process(os.getppid()).name().lower()
|
||||
if "electron" in parent or "slopsmith" in parent:
|
||||
if "electron" in parent or "feedBack" in parent:
|
||||
out["kind"] = "electron"
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -8,7 +8,7 @@ different salts so tokens cannot be cross-correlated between exports.
|
||||
Stable token grammar (see docs/diagnostics-bundle-spec.md):
|
||||
<DLC_DIR> — DLC root path
|
||||
<HOME> — user's home directory
|
||||
<CONFIG_DIR> — slopsmith config dir
|
||||
<CONFIG_DIR> — feedBack config dir
|
||||
<song:hash8> — song filename / basename (8 hex chars)
|
||||
<ip:hash6> — IPv4 / IPv6 address (6 hex chars)
|
||||
<redacted> — bearer tokens, key=/token= query strings
|
||||
|
||||
+1
-1
@@ -21,7 +21,7 @@ from __future__ import annotations
|
||||
import logging
|
||||
import math
|
||||
|
||||
log = logging.getLogger("slopsmith.lib.drums")
|
||||
log = logging.getLogger("feedBack.lib.drums")
|
||||
|
||||
|
||||
# ── Piece vocabulary ──────────────────────────────────────────────────────────
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
"""Backward-compatible environment lookup for the slopsmith -> feedBack rename.
|
||||
|
||||
Canonical configuration variables are now ``FEEDBACK_*``. Deployments that
|
||||
predate the rename may still set the old ``SLOPSMITH_*`` names (docker-compose
|
||||
overrides, shell profiles, CI), so we honour those as a fallback. New code
|
||||
should always read the canonical ``FEEDBACK_*`` name and let this shim resolve
|
||||
the legacy alias.
|
||||
|
||||
Flat-importable, no import-time IO or global state (constitution P-V).
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
_CANON_PREFIX = "FEEDBACK_"
|
||||
_LEGACY_PREFIX = "SLOPSMITH_"
|
||||
_TRUE_VALUES = {"1", "true", "yes", "on"}
|
||||
|
||||
|
||||
def getenv_compat(name, default=None):
|
||||
"""``os.environ.get`` with a legacy ``SLOPSMITH_*`` fallback.
|
||||
|
||||
For a canonical ``FEEDBACK_<X>`` name, returns the value of ``FEEDBACK_<X>``
|
||||
if set, else ``SLOPSMITH_<X>`` if set, else ``default``. Names that do not
|
||||
start with ``FEEDBACK_`` behave exactly like ``os.environ.get``.
|
||||
"""
|
||||
value = os.environ.get(name)
|
||||
if value is not None:
|
||||
return value
|
||||
if name.startswith(_CANON_PREFIX):
|
||||
legacy = os.environ.get(_LEGACY_PREFIX + name[len(_CANON_PREFIX):])
|
||||
if legacy is not None:
|
||||
return legacy
|
||||
return default
|
||||
|
||||
|
||||
def env_flag_compat(name):
|
||||
"""Parse a conventional boolean env flag, honouring the legacy alias."""
|
||||
return (getenv_compat(name, "") or "").strip().lower() in _TRUE_VALUES
|
||||
+12
-10
@@ -8,7 +8,9 @@ import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
log = logging.getLogger("slopsmith.lib.gp2midi")
|
||||
from env_compat import getenv_compat
|
||||
|
||||
log = logging.getLogger("feedBack.lib.gp2midi")
|
||||
|
||||
import guitarpro
|
||||
from midiutil import MIDIFile
|
||||
@@ -152,15 +154,15 @@ def _find_soundfont() -> str | None:
|
||||
"""Locate a .sf2 soundfont for MIDI rendering.
|
||||
|
||||
Precedence:
|
||||
1. ``SLOPSMITH_SOUNDFONT`` env var (user override / desktop-app-supplied)
|
||||
1. ``FEEDBACK_SOUNDFONT`` env var (user override / desktop-app-supplied)
|
||||
2. Bundled ``<RESOURCESPATH>/soundfonts/*.sf2`` (Electron desktop builds)
|
||||
3. Common system locations per OS.
|
||||
"""
|
||||
override = os.environ.get("SLOPSMITH_SOUNDFONT")
|
||||
override = getenv_compat("FEEDBACK_SOUNDFONT")
|
||||
if override:
|
||||
if os.path.isfile(override):
|
||||
return override
|
||||
log.warning("SLOPSMITH_SOUNDFONT is set to %r but that file does not exist; falling back to other sources", override)
|
||||
log.warning("FEEDBACK_SOUNDFONT is set to %r but that file does not exist; falling back to other sources", override)
|
||||
|
||||
resources = os.environ.get("RESOURCESPATH")
|
||||
if resources:
|
||||
@@ -187,10 +189,10 @@ def _find_soundfont() -> str | None:
|
||||
elif sys.platform == "win32":
|
||||
appdata = os.environ.get("APPDATA")
|
||||
if appdata:
|
||||
# "Slopsmith" matches slopsmith-desktop's Electron productName
|
||||
# (app.getPath('userData') resolves to %APPDATA%\Slopsmith on Windows).
|
||||
# "FeedBack" matches feedBack-desktop's Electron productName
|
||||
# (app.getPath('userData') resolves to %APPDATA%\FeedBack on Windows).
|
||||
for pattern in (
|
||||
os.path.join(appdata, "Slopsmith", "soundfonts", "*.sf2"),
|
||||
os.path.join(appdata, "FeedBack", "soundfonts", "*.sf2"),
|
||||
os.path.join(appdata, "SoundFonts", "*.sf2"),
|
||||
):
|
||||
candidates += sorted(glob.glob(pattern))
|
||||
@@ -218,16 +220,16 @@ def _soundfont_install_hint() -> str:
|
||||
"or FluidR3_GM from musical-artifacts.com) and either place the .sf2 "
|
||||
"file in /usr/local/share/sounds/sf2/ (Intel) or "
|
||||
"/opt/homebrew/share/sounds/sf2/ (Apple Silicon), or set the "
|
||||
"SLOPSMITH_SOUNDFONT environment variable to its full path."
|
||||
"FEEDBACK_SOUNDFONT environment variable to its full path."
|
||||
)
|
||||
if sys.platform == "win32":
|
||||
return (
|
||||
"Download a soundfont (e.g. GeneralUser GS from schristiancollins.com or "
|
||||
"FluidR3_GM from musical-artifacts.com) and either place the .sf2 file in "
|
||||
"%APPDATA%\\Slopsmith\\soundfonts\\ or set the SLOPSMITH_SOUNDFONT "
|
||||
"%APPDATA%\\FeedBack\\soundfonts\\ or set the FEEDBACK_SOUNDFONT "
|
||||
"environment variable to its full path."
|
||||
)
|
||||
return "Set SLOPSMITH_SOUNDFONT to the full path of a .sf2 file."
|
||||
return "Set FEEDBACK_SOUNDFONT to the full path of a .sf2 file."
|
||||
|
||||
|
||||
def _fluidsynth_install_hint() -> str:
|
||||
|
||||
+3
-3
@@ -19,8 +19,8 @@ bar-indexed tempo map, per-beat rhythm durations (dots + tuplets; see
|
||||
``_beat_secs`` for the one deliberate double-dot divergence), and
|
||||
``_note_midi`` — so the
|
||||
notation beats line up with the RS-XML notes the highway plays (see
|
||||
slopsmith#618 for the longer-term goal of sharing the note-building walk
|
||||
itself, and slopsmith#261 for the time-signature-denominator pitfalls the
|
||||
feedBack#618 for the longer-term goal of sharing the note-building walk
|
||||
itself, and feedBack#261 for the time-signature-denominator pitfalls the
|
||||
``beat_groups`` emission here exists to avoid re-introducing).
|
||||
|
||||
Where this plugs in: ``gp2rs_gpx.convert_file`` calls
|
||||
@@ -43,7 +43,7 @@ from pathlib import Path
|
||||
|
||||
import notation as notation_mod
|
||||
|
||||
log = logging.getLogger("slopsmith.lib.gp2notation")
|
||||
log = logging.getLogger("feedBack.lib.gp2notation")
|
||||
|
||||
|
||||
# GPX NoteValue string → notation duration denominator (sloppak-spec §5.3:
|
||||
|
||||
+140
-27
@@ -1,5 +1,6 @@
|
||||
"""Convert Guitar Pro files (.gp5/.gp4/.gp3) to arrangement XML."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import xml.etree.ElementTree as ET
|
||||
@@ -9,7 +10,7 @@ from pathlib import Path
|
||||
|
||||
import guitarpro
|
||||
|
||||
log = logging.getLogger("slopsmith.lib.gp2rs")
|
||||
log = logging.getLogger("feedBack.lib.gp2rs")
|
||||
|
||||
_YEAR_RE = re.compile(r"\b(1[89]\d{2}|20\d{2})\b")
|
||||
|
||||
@@ -56,6 +57,8 @@ class RsNote:
|
||||
fret: int
|
||||
sustain: float = 0.0
|
||||
bend: float = 0.0
|
||||
bend_intent: int = 0
|
||||
bend_values: list | None = None
|
||||
slide_to: int = -1
|
||||
slide_unpitch_to: int = -1
|
||||
hammer_on: bool = False
|
||||
@@ -69,6 +72,9 @@ class RsNote:
|
||||
tremolo: bool = False
|
||||
tap: bool = False
|
||||
link_next: bool = False
|
||||
# Teaching mark (§6.2.2): fret-hand finger (-1 unset, 0 thumb..4 pinky).
|
||||
# Display only — never used for grading.
|
||||
fret_finger: int = -1
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -191,6 +197,77 @@ def _duration_to_seconds(duration: guitarpro.Duration, tempo: float) -> float:
|
||||
return beats * (60.0 / tempo)
|
||||
|
||||
|
||||
# pyguitarpro models bend-point x-positions on 0..BendEffect.maxPosition (12)
|
||||
# across the note's duration; y-values are half-quarter-tone units where 12 = 6
|
||||
# semitones, so semitones = value / 2.0 (matches the scalar `bend` derivation).
|
||||
_GP_BEND_MAX_POSITION = 12
|
||||
|
||||
|
||||
def _bend_intent_from_values(values: list[float]) -> int:
|
||||
"""Classify a bend gesture (§6.2.1) from its time-ordered semitone values:
|
||||
0 up, 1 release, 2 pre-bend, 3 pre-bend-and-release, 4 round-trip."""
|
||||
if not values:
|
||||
return 0
|
||||
eps = 0.05
|
||||
first, last, peak = values[0], values[-1], max(values)
|
||||
if first > eps:
|
||||
if last <= eps:
|
||||
return 3 # pre-bent, then released to pitch
|
||||
if last < first - eps:
|
||||
return 1 # held bend let down
|
||||
return 2 # pre-bend held
|
||||
if peak > eps and last <= eps:
|
||||
return 4 # bend up and back down
|
||||
return 0 # plain bend up
|
||||
|
||||
|
||||
def _gp_bend_shape(bend, duration_secs: float):
|
||||
"""From a pyguitarpro ``BendEffect``, return ``(peak, intent, curve)``.
|
||||
|
||||
``peak`` is the bend's peak in semitones (the scalar ``bn``); ``intent`` is
|
||||
the §6.2.1 ``bt`` code; ``curve`` is the time-stamped ``bnv`` list
|
||||
(``[{t: seconds-from-onset, v: semitones}]``) or ``None`` when there's no
|
||||
usable shape (no points, or a zero-length note collapsing every point to
|
||||
``t=0``)."""
|
||||
pts = sorted(bend.points or [], key=lambda p: p.position)
|
||||
if not pts:
|
||||
return 0.0, 0, None
|
||||
values = [round(p.value / 2.0, 1) for p in pts]
|
||||
peak = round(max(values), 1)
|
||||
intent = _bend_intent_from_values(values)
|
||||
curve = None
|
||||
if duration_secs > 0 and len(pts) >= 2:
|
||||
curve = [
|
||||
{"t": round(duration_secs * (p.position / _GP_BEND_MAX_POSITION), 3),
|
||||
"v": v}
|
||||
for p, v in zip(pts, values)
|
||||
]
|
||||
return peak, intent, curve
|
||||
|
||||
|
||||
def _bend_shape_xml_attrs(n: "RsNote") -> dict:
|
||||
"""Optional bend-shape XML attributes for a <note>/<chordNote>, default-
|
||||
omitted: `bendIntent` only when non-zero, `bendValues` (a JSON-encoded
|
||||
[{t,v}] curve) only when present. `_parse_note` (lib/song.py) reads these
|
||||
back so a GP-imported bend curve survives import → wire → highway."""
|
||||
attrs: dict = {}
|
||||
if n.bend_intent:
|
||||
attrs["bendIntent"] = str(int(n.bend_intent))
|
||||
if n.bend_values:
|
||||
attrs["bendValues"] = json.dumps(n.bend_values, separators=(",", ":"))
|
||||
return attrs
|
||||
|
||||
|
||||
def _finger_xml_attrs(n: "RsNote") -> dict:
|
||||
"""Optional teaching-mark XML attribute for a <note>/<chordNote>: `fretFinger`
|
||||
only when set (!= -1). `_parse_note` (lib/song.py) reads it back so a
|
||||
GP-imported fret-hand finger survives import → wire → highway. Display only;
|
||||
never used for grading (§6.2.2)."""
|
||||
if getattr(n, "fret_finger", -1) != -1:
|
||||
return {"fretFinger": str(int(n.fret_finger))}
|
||||
return {}
|
||||
|
||||
|
||||
def _tempo_at_tick(tick: int, tempo_map: list[TempoEvent]) -> float:
|
||||
"""Get the tempo at a given tick."""
|
||||
result = tempo_map[0].tempo
|
||||
@@ -460,6 +537,19 @@ def _gp_string_to_rs(gp_string: int, num_strings: int) -> int:
|
||||
return num_strings - gp_string
|
||||
|
||||
|
||||
def _gp_finger_to_rs(fingering) -> int:
|
||||
"""Coerce a pyguitarpro ``Fingering`` enum to an RS fret-hand finger int.
|
||||
|
||||
Fingering values are ``unknown=-2, open=-1, thumb=0, index=1, middle=2,
|
||||
annular=3, little=4`` — already the RS finger integers for 0..4. Anything
|
||||
open/unknown/out-of-range collapses to ``-1`` (unset), so we never invent a
|
||||
finger. Teaching mark only (§6.2.2); never used for grading."""
|
||||
val = getattr(fingering, "value", fingering)
|
||||
if not isinstance(val, int) or val < 0 or val > 4:
|
||||
return -1
|
||||
return val
|
||||
|
||||
|
||||
def _chord_fingers(chord, frets: list[int], num_strings: int) -> list[int]:
|
||||
"""Per-string fingering for a chord template, in RS string order.
|
||||
|
||||
@@ -735,12 +825,13 @@ def convert_track(
|
||||
# Techniques
|
||||
eff = note.effect
|
||||
if eff.bend and eff.bend.points:
|
||||
# pyguitarpro bend point values are in quarter-tones
|
||||
# (maxValue 12 = 3 whole tones = 6 semitones), so
|
||||
# semitones = value / 2. The old /100.0 made every bend
|
||||
# round to 0 (a whole-tone bend is value 4 -> 0.04).
|
||||
max_bend = max(p.value for p in eff.bend.points)
|
||||
rn.bend = round(max_bend / 2.0, 1)
|
||||
# `bn` is the peak; `bnv`/`bt` describe the shape over
|
||||
# time (§6.2.1). semitones = value / 2 (maxValue 12 = 6
|
||||
# semitones); the old /100.0 made every bend round to 0.
|
||||
peak, intent, curve = _gp_bend_shape(eff.bend, dur)
|
||||
rn.bend = peak
|
||||
rn.bend_intent = intent
|
||||
rn.bend_values = curve
|
||||
|
||||
if eff.hammer:
|
||||
# HO vs PO from pitch direction off the prior note on the
|
||||
@@ -788,6 +879,11 @@ def convert_track(
|
||||
if eff.tremoloPicking:
|
||||
rn.tremolo = True
|
||||
|
||||
# Fret-hand fingering -> fg teaching mark (§6.2.2). Same
|
||||
# Fingering enum + value convention as the chord path.
|
||||
rn.fret_finger = _gp_finger_to_rs(
|
||||
getattr(eff, "leftHandFinger", None))
|
||||
|
||||
# Whammy / tremolo bar (beat-level dive/raise). RS has no
|
||||
# whammy attribute, so approximate the pitch movement as an
|
||||
# unpitched slide: a dive slides down, a raise slides up, by
|
||||
@@ -1026,9 +1122,19 @@ def _build_xml(
|
||||
|
||||
# Tuning. RS2014 schema names 6 string slots; we always emit those
|
||||
# for compatibility, and emit additional string6+ attributes (up to
|
||||
# `len(tuning)-1`) for 7+ string arrangements. Slopsmith parses
|
||||
# `len(tuning)-1`) for 7+ string arrangements. FeedBack parses
|
||||
# them; the format ignores them.
|
||||
#
|
||||
# `stringCount` records the AUTHORITATIVE string count (== len(tuning)),
|
||||
# because the 6-slot padding above erases the 4-vs-5-vs-6-string
|
||||
# distinction for standard tunings (a 4-string bass, 5-string bass and
|
||||
# 6-string guitar are otherwise byte-identical, all string0..5 = 0).
|
||||
# parse_arrangement trims `tuning` back to this on read so downstream
|
||||
# string-count derivation (song.arrangement_string_count, the editor's
|
||||
# _stringCountFor) sees the real width instead of guessing. RS2014 and
|
||||
# any other consumer simply ignore the unknown attribute.
|
||||
tuning_el = ET.SubElement(root, "tuning")
|
||||
tuning_el.set("stringCount", str(len(tuning)))
|
||||
for i in range(max(6, len(tuning))):
|
||||
tuning_el.set(f"string{i}", str(tuning[i] if i < len(tuning) else 0))
|
||||
ET.SubElement(root, "capo").text = "0"
|
||||
@@ -1098,6 +1204,8 @@ def _build_xml(
|
||||
"tap": "1" if n.tap else "0",
|
||||
"ignore": "0",
|
||||
}
|
||||
attrs.update(_bend_shape_xml_attrs(n))
|
||||
attrs.update(_finger_xml_attrs(n))
|
||||
ET.SubElement(notes_el, "note", **attrs)
|
||||
|
||||
# Chords
|
||||
@@ -1108,25 +1216,30 @@ def _build_xml(
|
||||
chordId=str(ch.template_idx),
|
||||
highDensity="0", strum="down")
|
||||
for cn in ch.notes:
|
||||
ET.SubElement(chord_el, "chordNote",
|
||||
time=f"{cn.time:.3f}",
|
||||
string=str(cn.string),
|
||||
fret=str(cn.fret),
|
||||
sustain=f"{cn.sustain:.3f}",
|
||||
bend=f"{cn.bend:.1f}" if cn.bend else "0",
|
||||
hammerOn="1" if cn.hammer_on else "0",
|
||||
pullOff="1" if cn.pull_off else "0",
|
||||
slideTo=str(cn.slide_to),
|
||||
slideUnpitchTo=str(cn.slide_unpitch_to),
|
||||
harmonic="1" if cn.harmonic else "0",
|
||||
harmonicPinch="1" if cn.harmonic_pinch else "0",
|
||||
palmMute="1" if cn.palm_mute else "0",
|
||||
mute="1" if cn.mute else "0",
|
||||
vibrato="1" if cn.vibrato else "0",
|
||||
tremolo="1" if cn.tremolo else "0",
|
||||
accent="1" if cn.accent else "0",
|
||||
linkNext="1" if cn.link_next else "0",
|
||||
tap="1" if cn.tap else "0", ignore="0")
|
||||
cn_attrs = {
|
||||
"time": f"{cn.time:.3f}",
|
||||
"string": str(cn.string),
|
||||
"fret": str(cn.fret),
|
||||
"sustain": f"{cn.sustain:.3f}",
|
||||
"bend": f"{cn.bend:.1f}" if cn.bend else "0",
|
||||
"hammerOn": "1" if cn.hammer_on else "0",
|
||||
"pullOff": "1" if cn.pull_off else "0",
|
||||
"slideTo": str(cn.slide_to),
|
||||
"slideUnpitchTo": str(cn.slide_unpitch_to),
|
||||
"harmonic": "1" if cn.harmonic else "0",
|
||||
"harmonicPinch": "1" if cn.harmonic_pinch else "0",
|
||||
"palmMute": "1" if cn.palm_mute else "0",
|
||||
"mute": "1" if cn.mute else "0",
|
||||
"vibrato": "1" if cn.vibrato else "0",
|
||||
"tremolo": "1" if cn.tremolo else "0",
|
||||
"accent": "1" if cn.accent else "0",
|
||||
"linkNext": "1" if cn.link_next else "0",
|
||||
"tap": "1" if cn.tap else "0",
|
||||
"ignore": "0",
|
||||
}
|
||||
cn_attrs.update(_bend_shape_xml_attrs(cn))
|
||||
cn_attrs.update(_finger_xml_attrs(cn))
|
||||
ET.SubElement(chord_el, "chordNote", **cn_attrs)
|
||||
|
||||
# Anchors
|
||||
anchors_el = ET.SubElement(level, "anchors", count=str(len(anchors)))
|
||||
|
||||
+257
-46
@@ -1,7 +1,7 @@
|
||||
"""
|
||||
lib/gp2rs_gpx.py — Guitar Pro 6 (.gpx) support shim for gp2rs.
|
||||
|
||||
Drop this file into slopsmith/lib/ alongside gp2rs.py.
|
||||
Drop this file into feedBack/lib/ alongside gp2rs.py.
|
||||
No third-party dependencies — pure Python stdlib only.
|
||||
|
||||
Public API mirrors the two functions that the editor plugin calls:
|
||||
@@ -20,7 +20,7 @@ from pathlib import Path
|
||||
|
||||
from safepath import safe_join
|
||||
|
||||
_log = logging.getLogger("slopsmith.lib.gp2rs_gpx")
|
||||
_log = logging.getLogger("feedBack.lib.gp2rs_gpx")
|
||||
|
||||
|
||||
def _safe_filename_stem(name: str) -> str:
|
||||
@@ -239,12 +239,20 @@ def _gpif_tracks(root: ET.Element) -> list[dict]:
|
||||
_bars_by_id = {b.get('id'): b for b in (root.find('Bars') or [])}
|
||||
_voices_by_id = {v.get('id'): v for v in (root.find('Voices') or [])}
|
||||
_beats_by_id = {b.get('id'): b for b in (root.find('Beats') or [])}
|
||||
_notes_by_id = {n.get('id'): n for n in (root.find('Notes') or [])}
|
||||
|
||||
def _note_count_for_raw(raw_idx: int) -> int:
|
||||
# Total note count for the track (sum of notes across all its beats).
|
||||
# This is the single source of truth: list_tracks surfaces it as the
|
||||
# 'notes' field, and _auto_select_gpx uses (count == 0) to skip empty
|
||||
# tracks — so the graph is walked once here, not again in list_tracks.
|
||||
# Count of notes that ACTUALLY become RS notes for the track. This is
|
||||
# the single source of truth: list_tracks surfaces it as the 'notes'
|
||||
# field (the importer's per-track preview count) and _auto_select_gpx
|
||||
# uses (count == 0) to skip empty tracks — so the graph is walked once
|
||||
# here, not again in list_tracks.
|
||||
#
|
||||
# Tie-DESTINATION notes are excluded: a tied note is folded into the
|
||||
# previous note as extended sustain (see the `_note_is_tie` skips in
|
||||
# convert_file), so it never becomes a separate RS note. Counting them
|
||||
# made the preview overstate the result (e.g. 260 shown, 241 imported);
|
||||
# excluding them makes the preview match what the user actually gets.
|
||||
n = 0
|
||||
for mb in _masterbars:
|
||||
bar_ids = mb.findtext('Bars', '').split()
|
||||
@@ -263,9 +271,11 @@ def _gpif_tracks(root: ET.Element) -> list[dict]:
|
||||
beat = _beats_by_id.get(bid)
|
||||
if beat is None:
|
||||
continue
|
||||
notes_text = beat.findtext('Notes', '').strip()
|
||||
if notes_text:
|
||||
n += len(notes_text.split())
|
||||
for nid in beat.findtext('Notes', '').split():
|
||||
note_el = _notes_by_id.get(nid)
|
||||
if note_el is not None and _note_is_tie(note_el):
|
||||
continue
|
||||
n += 1
|
||||
return n
|
||||
|
||||
result = []
|
||||
@@ -457,6 +467,31 @@ _GPIF_FINGER_MAP = {
|
||||
'pinky': 4, 'little': 4,
|
||||
}
|
||||
|
||||
# Per-note <LeftFingering> teaching mark (§6.2.2). Unlike the chord-diagram
|
||||
# <Position finger=".."> path above, GPIF stores a single note's fret-hand
|
||||
# finger as a direct <Note> child element with the classical p-i-m-a-c letter
|
||||
# codes (verified against GP8 exports), mapped to the same RS finger integers
|
||||
# (open = -1, thumb = 0, index = 1, middle = 2, annular/ring = 3, little = 4).
|
||||
_GPIF_LEFT_FINGERING_MAP = {
|
||||
'open': -1, 'none': -1, '': -1,
|
||||
'p': 0, 'thumb': 0,
|
||||
'i': 1, 'index': 1,
|
||||
'm': 2, 'middle': 2,
|
||||
'a': 3, 'annular': 3, 'ring': 3,
|
||||
'c': 4, 'little': 4, 'pinky': 4,
|
||||
}
|
||||
|
||||
|
||||
def _gpif_left_fingering(note_el) -> int:
|
||||
"""Read a GPIF <Note>'s fret-hand finger (<LeftFingering>) -> RS finger int.
|
||||
|
||||
Returns -1 (unset) when absent or unrecognised — never fabricates a finger.
|
||||
Teaching mark only (§6.2.2); never used for grading."""
|
||||
raw = (note_el.findtext('LeftFingering') or '').strip().lower()
|
||||
if not raw:
|
||||
return -1
|
||||
return _GPIF_LEFT_FINGERING_MAP.get(raw, -1)
|
||||
|
||||
|
||||
def _rs_string_order(string_pitches: list[int]) -> dict[int, int]:
|
||||
"""Map each GPIF string index → RS string index (0 = lowest pitch).
|
||||
@@ -697,6 +732,20 @@ def _note_has_vibrato(note_el: ET.Element, prop_map: dict) -> bool:
|
||||
return 'Vibrato' in prop_map or note_el.find('Vibrato') is not None
|
||||
|
||||
|
||||
def _beat_has_tremolo(beat_el: ET.Element) -> bool:
|
||||
"""True if a GP7/GP8 beat carries tremolo picking.
|
||||
|
||||
GPIF encodes tremolo picking as a DIRECT beat-level
|
||||
``<Tremolo>1/8</Tremolo>`` child of ``<Beat>`` (the value is the rate). The
|
||||
RS note model has a single boolean tremolo flag with no rate, so the rate is
|
||||
intentionally ignored — any tremolo-picked beat maps to note tremolo across
|
||||
it. Matched as a direct child (not ``.//``) so it is never confused with the
|
||||
whammy-bar ``VibratoWTremBar`` Property, a separate beat-level effect
|
||||
handled elsewhere.
|
||||
"""
|
||||
return beat_el.find('Tremolo') is not None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# list_tracks — mirrors gp2rs.list_tracks interface
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -1147,6 +1196,59 @@ def _gpx_bend_scale(root: ET.Element) -> float:
|
||||
return 50.0 if peak <= 400 else 2500.0
|
||||
|
||||
|
||||
def _gpx_bend_float(tp: dict, name: str):
|
||||
"""Read a GPIF bend `<Property><Float>` value from the property map, or None."""
|
||||
el = tp.get(name)
|
||||
if el is None:
|
||||
return None
|
||||
try:
|
||||
return float(el.findtext('Float') or 0)
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
def _gpx_bend_shape(tp: dict, divisor: float, sustain: float):
|
||||
"""Build ``(peak, intent, curve)`` from a GPIF note's bend Properties (§6.2.1).
|
||||
|
||||
GPIF describes a bend as origin / middle / destination value+offset pairs;
|
||||
`value / divisor` is semitones (divisor auto-detected per file) and the
|
||||
`*Offset` Properties are 0..100 (percent of the note's duration). Produces a
|
||||
bnv curve of up to three points (mapping each offset to seconds-from-onset),
|
||||
or ``None`` when there's no usable shape (no points, flat-zero, or a
|
||||
zero-length note). When an offset Property is absent the stage falls back to
|
||||
an evenly-spaced default (origin 0%, middle 50%, destination 100%).
|
||||
|
||||
NOTE: offset Property names should be confirmed against a real GP8 export;
|
||||
the value path matches the existing scalar-bend extraction either way."""
|
||||
from gp2rs import _bend_intent_from_values # lazy: gp2rs<->gpx circular
|
||||
stages = (
|
||||
('BendOriginValue', 'BendOriginOffset', 0.0),
|
||||
('BendMiddleValue', 'BendMiddleOffset1', 50.0),
|
||||
('BendDestinationValue', 'BendDestinationOffset', 100.0),
|
||||
)
|
||||
pts = []
|
||||
for vkey, okey, default_off in stages:
|
||||
v = _gpx_bend_float(tp, vkey)
|
||||
if v is None:
|
||||
continue
|
||||
off = _gpx_bend_float(tp, okey)
|
||||
if off is None:
|
||||
off = default_off
|
||||
off = max(0.0, min(100.0, off))
|
||||
pts.append((off, round(v / divisor, 1)))
|
||||
if not pts:
|
||||
return 0.0, 0, None
|
||||
pts.sort(key=lambda p: p[0])
|
||||
values = [v for _, v in pts]
|
||||
peak = round(max(values), 1)
|
||||
intent = _bend_intent_from_values(values)
|
||||
curve = None
|
||||
if peak > 0 and sustain > 0 and len(pts) >= 2:
|
||||
curve = [{"t": round(sustain * (off / 100.0), 3), "v": v}
|
||||
for off, v in pts]
|
||||
return peak, intent, curve
|
||||
|
||||
|
||||
def _resolve_pending_slides(rs_notes, rs_chords, pending_slides):
|
||||
"""Resolve GP slide flags collected during the beat loop into RS slide
|
||||
fields, now that every note on each string is known.
|
||||
@@ -1280,9 +1382,69 @@ def convert_file(
|
||||
track_indices, _piano_merge_map = _find_piano_pairs(track_indices, tracks, names)
|
||||
|
||||
output_files = []
|
||||
# Counts of auto-named guitar/bass arrangements so far, so multiple guitars
|
||||
# get distinct RS roles (Lead, Rhythm, Combo, …) instead of all "Lead".
|
||||
_role_counts: dict[str, int] = {}
|
||||
# All auto-assigned arrangement names handed out so far, so multiple
|
||||
# arrangements get distinct labels (Lead, Rhythm, Combo, Bass, Bass 2, …)
|
||||
# and the name-aware and positional guitar paths never collide.
|
||||
_used_arr_names: set[str] = set()
|
||||
|
||||
def _unique_arr_name(base: str) -> str:
|
||||
"""Return `base`, or `base 2`/`base 3`/… if it's already been used."""
|
||||
if base not in _used_arr_names:
|
||||
_used_arr_names.add(base)
|
||||
return base
|
||||
k = 2
|
||||
while f"{base} {k}" in _used_arr_names:
|
||||
k += 1
|
||||
name = f"{base} {k}"
|
||||
_used_arr_names.add(name)
|
||||
return name
|
||||
|
||||
_KEYS_PROGS = set(range(0, 8)) | set(range(16, 24)) | {80, 81, 82, 83}
|
||||
|
||||
def _auto_guitar_hint(track_idx: int):
|
||||
"""For a track that auto-resolves to a guitar arrangement, return its
|
||||
role hint: 'lead', 'rhythm', or '' (unhinted). None when the track is
|
||||
NOT an auto-named guitar (explicitly named, bass, drum, vocal, keys).
|
||||
Mirrors the per-track classification in the conversion loop below."""
|
||||
if track_idx >= len(tracks) or names.get(track_idx):
|
||||
return None
|
||||
t = tracks[track_idx]
|
||||
if t['is_drums'] or _is_vocal_track(t):
|
||||
return None
|
||||
low = t['name'].lower()
|
||||
sp = t['string_pitches']
|
||||
prog = t['midi_program']
|
||||
if (isinstance(prog, int) and 32 <= prog <= 39) or (bool(sp) and max(sp) <= 48) or 'bass' in low:
|
||||
return None # bass
|
||||
if (not sp and prog in _KEYS_PROGS) or any(kw in low for kw in ('piano', 'keys', 'keyboard', 'organ')):
|
||||
return None # keys
|
||||
if 'lead' in low and 'rhythm' not in low:
|
||||
return 'lead'
|
||||
if 'rhythm' in low and 'lead' not in low:
|
||||
return 'rhythm'
|
||||
return '' # guitar, no role hint
|
||||
|
||||
# Two-pass guitar role naming, resolved up front so the per-track loop just
|
||||
# looks names up. Reserve every name-hinted Lead/Rhythm first, THEN fill
|
||||
# unhinted guitars into the remaining canonical roles. A single pass would
|
||||
# let an unhinted guitar that appears BEFORE a hinted one steal its role,
|
||||
# pushing the real Lead/Rhythm to a non-canonical "Rhythm 2" that the
|
||||
# downstream name-based path classification doesn't recognise.
|
||||
_guitar_name_by_idx: dict[int, str] = {}
|
||||
_unhinted_guitars: list[int] = []
|
||||
for _ti in track_indices:
|
||||
hint = _auto_guitar_hint(_ti)
|
||||
if hint is None:
|
||||
continue
|
||||
if hint == 'lead':
|
||||
_guitar_name_by_idx[_ti] = _unique_arr_name('Lead')
|
||||
elif hint == 'rhythm':
|
||||
_guitar_name_by_idx[_ti] = _unique_arr_name('Rhythm')
|
||||
else:
|
||||
_unhinted_guitars.append(_ti)
|
||||
for _ti in _unhinted_guitars:
|
||||
base = next((r for r in ('Lead', 'Rhythm', 'Combo') if r not in _used_arr_names), 'Combo')
|
||||
_guitar_name_by_idx[_ti] = _unique_arr_name(base)
|
||||
|
||||
for track_idx in track_indices:
|
||||
if track_idx >= len(tracks):
|
||||
@@ -1330,16 +1492,12 @@ def convert_file(
|
||||
)
|
||||
low = track['name'].lower()
|
||||
if is_bass or 'bass' in low:
|
||||
_bc = _role_counts.get('bass', 0)
|
||||
_role_counts['bass'] = _bc + 1
|
||||
arr_name = 'Bass' if _bc == 0 else f'Bass {_bc + 1}'
|
||||
arr_name = _unique_arr_name('Bass')
|
||||
else:
|
||||
# Distinct guitar roles by appearance order so two guitars
|
||||
# don't both become "Lead": Lead, Rhythm, Combo, then Combo N.
|
||||
_gc = _role_counts.get('guitar', 0)
|
||||
_role_counts['guitar'] = _gc + 1
|
||||
_roles = ('Lead', 'Rhythm', 'Combo')
|
||||
arr_name = _roles[_gc] if _gc < len(_roles) else f'Combo {_gc - 1}'
|
||||
# Guitar role was resolved up front (two-pass, honoring
|
||||
# "lead"/"rhythm" in the GP track name so a Rhythm-before-Lead
|
||||
# file isn't swapped by positional assignment).
|
||||
arr_name = _guitar_name_by_idx.get(track_idx) or _unique_arr_name('Lead')
|
||||
|
||||
# Vocal tracks get their own converter — outputs vocals XML, not notes XML
|
||||
if is_vocal:
|
||||
@@ -1547,6 +1705,11 @@ def convert_file(
|
||||
rn.vibrato = True
|
||||
if 'LeftHandTapping' in _tp or 'Tapped' in _tp:
|
||||
rn.tap = True
|
||||
# Fret-hand fingering -> fg teaching mark
|
||||
# (§6.2.2). <LeftFingering> is a direct <Note>
|
||||
# child, not a <Property>, so read it off
|
||||
# note_el rather than the property map.
|
||||
rn.fret_finger = _gpif_left_fingering(note_el)
|
||||
if 'HarmonicType' in _tp:
|
||||
_ht = (_tp['HarmonicType'].findtext('HType')
|
||||
or '').strip().lower()
|
||||
@@ -1564,21 +1727,21 @@ def convert_file(
|
||||
rn.pull_off = True
|
||||
else:
|
||||
rn.hammer_on = True
|
||||
# Bend: peak amount (GPIF bend value → semitones,
|
||||
# scale auto-detected per file in _bend_divisor).
|
||||
# Bend: `bn` is the peak; `bnv`/`bt` capture
|
||||
# the shape over time (§6.2.1). value/divisor
|
||||
# = semitones (scale auto-detected per file).
|
||||
if 'Bended' in _tp:
|
||||
_bv = 0.0
|
||||
for _bk in ('BendDestinationValue',
|
||||
'BendMiddleValue', 'BendOriginValue'):
|
||||
_be = _tp.get(_bk)
|
||||
if _be is not None:
|
||||
try:
|
||||
_bv = max(_bv, float(
|
||||
_be.findtext('Float') or 0))
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
if _bv > 0:
|
||||
rn.bend = round(_bv / _bend_divisor, 1)
|
||||
# Use the beat duration `dur`, not
|
||||
# `rn.sustain` (zeroed for notes <= 0.2s),
|
||||
# so short bends keep their bnv curve —
|
||||
# matching the GP5 path, which maps over
|
||||
# the raw note duration.
|
||||
_peak, _intent, _curve = _gpx_bend_shape(
|
||||
_tp, _bend_divisor, dur)
|
||||
if _peak > 0:
|
||||
rn.bend = _peak
|
||||
rn.bend_intent = _intent
|
||||
rn.bend_values = _curve
|
||||
# Slide flags: 1/2 = pitched slide to the next
|
||||
# note; 4 = slide out down, 8 = out up. Resolved
|
||||
# post-loop (needs the next note on the string).
|
||||
@@ -1613,6 +1776,17 @@ def convert_file(
|
||||
for _bn in beat_rs_notes:
|
||||
_bn.vibrato = True
|
||||
|
||||
# Tremolo picking: GP7/GP8 encodes the rate as a
|
||||
# beat-level <Tremolo>1/8</Tremolo> child. The note
|
||||
# model has a single tremolo flag (no rate), so map
|
||||
# any tremolo-picked beat to note tremolo across it.
|
||||
# Independent of vibrato above — a note can carry
|
||||
# both. (Beat-level <Tremolo>, not the whammy
|
||||
# VibratoWTremBar Property, which is handled above.)
|
||||
if _beat_has_tremolo(beat_el):
|
||||
for _bn in beat_rs_notes:
|
||||
_bn.tremolo = True
|
||||
|
||||
if len(beat_rs_notes) == 1:
|
||||
rs_notes.append(beat_rs_notes[0])
|
||||
elif len(beat_rs_notes) > 1:
|
||||
@@ -2209,7 +2383,15 @@ def _auto_select_gpx(tracks: list[dict]) -> tuple[list[int], dict[int, str]]:
|
||||
if is_bass:
|
||||
selected.append((i, 'bass'))
|
||||
elif is_guitar:
|
||||
selected.append((i, 'guitar'))
|
||||
# Honor "lead"/"rhythm" in the GP track name so two guitars keep
|
||||
# the author's roles instead of being labelled by appearance order
|
||||
# (which swaps a Rhythm-before-Lead file). Unhinted → positional.
|
||||
if 'lead' in name_l and 'rhythm' not in name_l:
|
||||
selected.append((i, 'guitar_lead'))
|
||||
elif 'rhythm' in name_l and 'lead' not in name_l:
|
||||
selected.append((i, 'guitar_rhythm'))
|
||||
else:
|
||||
selected.append((i, 'guitar'))
|
||||
elif is_keys:
|
||||
selected.append((i, 'keys'))
|
||||
|
||||
@@ -2218,19 +2400,48 @@ def _auto_select_gpx(tracks: list[dict]) -> tuple[list[int], dict[int, str]]:
|
||||
if not t['is_drums'] and t.get('note_count', 1) > 0:
|
||||
selected.append((i, 'guitar'))
|
||||
|
||||
indices = []
|
||||
name_map = {}
|
||||
counts: dict[str, int] = {}
|
||||
RS_NAMES = {'guitar': ('Lead', 'Rhythm', 'Combo'), 'bass': ('Bass',), 'keys': ('Keys',), 'drums': ('Drums',), 'vocal': ('Vocals',)}
|
||||
RS_NAMES = {'bass': ('Bass',), 'keys': ('Keys',),
|
||||
'drums': ('Drums',), 'vocal': ('Vocals',)}
|
||||
used: set[str] = set()
|
||||
|
||||
def _unique(base: str) -> str:
|
||||
if base not in used:
|
||||
used.add(base)
|
||||
return base
|
||||
k = 2
|
||||
while f"{base} {k}" in used:
|
||||
k += 1
|
||||
used.add(f"{base} {k}")
|
||||
return f"{base} {k}"
|
||||
|
||||
# Two passes so name-hinted Lead/Rhythm guitars reserve their canonical role
|
||||
# BEFORE unhinted guitars are filled in — otherwise an unhinted guitar that
|
||||
# appears before a hinted one steals its role (real Rhythm → "Rhythm 2").
|
||||
# Non-guitar roles are handled in pass 1. `name_map` keys by track index so
|
||||
# this does not affect arrangement (selection) order, computed separately.
|
||||
for idx, role in selected:
|
||||
if role == 'guitar':
|
||||
continue
|
||||
if role == 'guitar_lead':
|
||||
base = 'Lead'
|
||||
elif role == 'guitar_rhythm':
|
||||
base = 'Rhythm'
|
||||
else:
|
||||
counts[role] = counts.get(role, 0) + 1
|
||||
c = counts[role]
|
||||
names_for_role = RS_NAMES.get(role, (role.title(),))
|
||||
base = names_for_role[min(c - 1, len(names_for_role) - 1)]
|
||||
if c > len(names_for_role):
|
||||
base = f"{names_for_role[-1]} {c}"
|
||||
name_map[idx] = _unique(base)
|
||||
|
||||
for idx, role in selected:
|
||||
counts[role] = counts.get(role, 0) + 1
|
||||
c = counts[role]
|
||||
names_for_role = RS_NAMES.get(role, (role.title(),))
|
||||
arr_name = names_for_role[min(c - 1, len(names_for_role) - 1)]
|
||||
if c > len(names_for_role):
|
||||
arr_name = f"{names_for_role[-1]} {c}"
|
||||
indices.append(idx)
|
||||
name_map[idx] = arr_name
|
||||
if role != 'guitar':
|
||||
continue
|
||||
base = next((r for r in ('Lead', 'Rhythm', 'Combo') if r not in used), 'Combo')
|
||||
name_map[idx] = _unique(base)
|
||||
|
||||
indices = [idx for idx, _role in selected]
|
||||
return indices, name_map
|
||||
|
||||
@@ -3,7 +3,7 @@ lib/gp8_audio_sync.py — Extract embedded audio and sync data from GP8 (.gp) fi
|
||||
|
||||
Guitar Pro 8 can embed a backing track (OGG audio) into a .gp file alongside
|
||||
sync points that map bar positions to exact audio timestamps. This module
|
||||
extracts both, giving Slopsmith:
|
||||
extracts both, giving FeedBack:
|
||||
|
||||
1. A real backing track audio file (OGG) — no MIDI synthesis needed
|
||||
2. A precise audio_offset (seconds) from the FramePadding value
|
||||
@@ -45,7 +45,7 @@ import io
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
_log = logging.getLogger("slopsmith.lib.gp8_audio_sync")
|
||||
_log = logging.getLogger("feedBack.lib.gp8_audio_sync")
|
||||
|
||||
# GP8 embeds the backing track under Content/Assets/ as OGG *or* one of
|
||||
# several other formats (MP3 is common — e.g. tracks rendered straight
|
||||
|
||||
+1
-1
@@ -30,7 +30,7 @@ import zipfile
|
||||
import io
|
||||
from pathlib import Path
|
||||
|
||||
_log = logging.getLogger("slopsmith.lib.gp_autosync")
|
||||
_log = logging.getLogger("feedBack.lib.gp_autosync")
|
||||
|
||||
# ── Dependency check ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
+11
-11
@@ -1,10 +1,10 @@
|
||||
"""Logging configuration for Slopsmith.
|
||||
"""Logging configuration for FeedBack.
|
||||
|
||||
Call ``configure_logging()`` once at server startup, before any slopsmith
|
||||
Call ``configure_logging()`` once at server startup, before any feedBack
|
||||
module imports that might emit log records.
|
||||
|
||||
Environment variables:
|
||||
LOG_LEVEL — severity threshold for the ``slopsmith.*`` logger tree
|
||||
LOG_LEVEL — severity threshold for the ``feedBack.*`` logger tree
|
||||
(default: INFO). Also accepted: DEBUG, WARNING, ERROR.
|
||||
LOG_FORMAT — "json" for structured output (Loki, ELK, Promtail);
|
||||
"text" (default) for human-readable coloured console output.
|
||||
@@ -43,7 +43,7 @@ def _add_correlation_id(
|
||||
|
||||
|
||||
def configure_logging() -> None:
|
||||
"""Wire up the slopsmith logger hierarchy.
|
||||
"""Wire up the feedBack logger hierarchy.
|
||||
|
||||
Safe to call multiple times; always reflects the current LOG_LEVEL,
|
||||
LOG_FORMAT, and LOG_FILE environment variables.
|
||||
@@ -52,7 +52,7 @@ def configure_logging() -> None:
|
||||
level = getattr(logging, raw_level, None)
|
||||
if not isinstance(level, int):
|
||||
sys.stderr.write(
|
||||
f"[slopsmith] WARNING: unrecognised LOG_LEVEL={raw_level!r};"
|
||||
f"[feedBack] WARNING: unrecognised LOG_LEVEL={raw_level!r};"
|
||||
" falling back to INFO.\n"
|
||||
)
|
||||
level = logging.INFO
|
||||
@@ -60,7 +60,7 @@ def configure_logging() -> None:
|
||||
raw_fmt = os.environ.get("LOG_FORMAT", "text").lower()
|
||||
if raw_fmt not in ("json", "text"):
|
||||
sys.stderr.write(
|
||||
f"[slopsmith] WARNING: unrecognised LOG_FORMAT={raw_fmt!r};"
|
||||
f"[feedBack] WARNING: unrecognised LOG_FORMAT={raw_fmt!r};"
|
||||
" falling back to 'text'.\n"
|
||||
)
|
||||
raw_fmt = "text"
|
||||
@@ -137,17 +137,17 @@ def configure_logging() -> None:
|
||||
handlers.append(fh)
|
||||
except OSError as exc:
|
||||
sys.stderr.write(
|
||||
f"[slopsmith] WARNING: could not open LOG_FILE={log_file!r}: {exc}"
|
||||
f"[feedBack] WARNING: could not open LOG_FILE={log_file!r}: {exc}"
|
||||
" — continuing with console-only logging.\n"
|
||||
)
|
||||
|
||||
_uvicorn_names = ("uvicorn", "uvicorn.error", "uvicorn.access")
|
||||
all_loggers = [logging.getLogger("slopsmith")] + [
|
||||
all_loggers = [logging.getLogger("feedBack")] + [
|
||||
logging.getLogger(n) for n in _uvicorn_names
|
||||
]
|
||||
|
||||
# Collect all unique old handlers across every logger *before* any close so
|
||||
# that a shared handler (slopsmith and uvicorn* were intentionally given the
|
||||
# that a shared handler (feedBack and uvicorn* were intentionally given the
|
||||
# same objects) isn't closed while still attached to another logger tree.
|
||||
old_handlers: set[logging.Handler] = set()
|
||||
for lg in all_loggers:
|
||||
@@ -160,8 +160,8 @@ def configure_logging() -> None:
|
||||
for h in old_handlers:
|
||||
h.close()
|
||||
|
||||
# Install fresh handlers on the slopsmith root.
|
||||
root = logging.getLogger("slopsmith")
|
||||
# Install fresh handlers on the feedBack root.
|
||||
root = logging.getLogger("feedBack")
|
||||
for h in handlers:
|
||||
root.addHandler(h)
|
||||
root.setLevel(level)
|
||||
|
||||
@@ -23,14 +23,14 @@ Engine selection
|
||||
Two transcription paths share a common output:
|
||||
|
||||
* `transcribe_vocals_remote(path, server_url, ...)` — POST the vocal
|
||||
stem to the `/align` endpoint on a slopsmith-demucs-server (Byron's
|
||||
stem to the `/align` endpoint on a feedBack-demucs-server (got-feedBack's
|
||||
reference server already hosts WhisperX alongside Demucs at the same
|
||||
URL).
|
||||
|
||||
* `transcribe_vocals_local(path, ...)` — load WhisperX in-process. Heavy
|
||||
(~3 GB of model weights for `large-v2` + the wav2vec2 aligner) and
|
||||
slow on CPU. Deferred imports of `whisperx`, `torch`, and `soundfile`
|
||||
keep the rest of slopsmith free of those dependencies.
|
||||
keep the rest of feedBack free of those dependencies.
|
||||
|
||||
Callers pick between them based on a `whisperx.server_url` config and
|
||||
fall back as appropriate. This module does not read config — both
|
||||
@@ -58,7 +58,7 @@ import logging
|
||||
from pathlib import Path
|
||||
from typing import Callable, Optional
|
||||
|
||||
log = logging.getLogger("slopsmith.lib.lyrics_transcribe")
|
||||
log = logging.getLogger("feedBack.lib.lyrics_transcribe")
|
||||
|
||||
ProgressCB = Optional[Callable[[float, str, str], None]]
|
||||
|
||||
@@ -179,7 +179,7 @@ _MIN_WORD_DURATION = 0.05
|
||||
|
||||
# Semver for the lyric-transcription artifact contract that gets stamped
|
||||
# into the sloppak manifest's `lyric_transcription` block alongside the
|
||||
# engine + model. Bump per the semantics defined in slopsmith#357 (the
|
||||
# engine + model. Bump per the semantics defined in feedBack#357 (the
|
||||
# parent `stem_separation` RFC):
|
||||
# * patch — metadata-only or implementation fixes; no regeneration
|
||||
# * minor — backward-compatible additions
|
||||
|
||||
+1
-1
@@ -24,7 +24,7 @@ from __future__ import annotations
|
||||
import logging
|
||||
import math
|
||||
|
||||
log = logging.getLogger("slopsmith.lib.notation")
|
||||
log = logging.getLogger("feedBack.lib.notation")
|
||||
|
||||
|
||||
# ── Vocabulary ────────────────────────────────────────────────────────────────
|
||||
|
||||
+4
-4
@@ -31,7 +31,7 @@ from tunings import tuning_name
|
||||
import sloppak as sloppak_mod
|
||||
import loosefolder as loosefolder_mod
|
||||
|
||||
log = logging.getLogger("slopsmith.scan_worker")
|
||||
log = logging.getLogger("feedBack.scan_worker")
|
||||
|
||||
|
||||
def _relpath(f: Path, dlc: Path) -> str:
|
||||
@@ -53,7 +53,7 @@ def _extract_meta_sloppak(path: Path) -> dict:
|
||||
meta["tuning_sort_key"] = sum(offsets)
|
||||
meta["tuning_offsets"] = " ".join(str(o) for o in offsets)
|
||||
meta["format"] = "sloppak"
|
||||
# `extract_meta` already populates `stem_ids` (slopsmith#129);
|
||||
# `extract_meta` already populates `stem_ids` (feedBack#129);
|
||||
# default to empty for older callers / mocks.
|
||||
meta.setdefault("stem_ids", [])
|
||||
# Compute smart names for sloppak arrangements using name-based fallback
|
||||
@@ -109,7 +109,7 @@ def _extract_meta_for_file(path: Path, dlc_root=None) -> dict:
|
||||
the root it already resolved; in-process callers can pass the resolver
|
||||
itself (e.g. `_get_dlc_dir`) to keep the lookup lazy.
|
||||
|
||||
Slopsmith reads only its own `.sloppak` format and loose-folder XML
|
||||
FeedBack reads only its own `.sloppak` format and loose-folder XML
|
||||
songs. Encrypted/proprietary archive formats are not supported and are
|
||||
silently ignored (empty metadata) rather than decrypted.
|
||||
"""
|
||||
@@ -121,7 +121,7 @@ def _extract_meta_for_file(path: Path, dlc_root=None) -> dict:
|
||||
if loosefolder_mod.is_loose_song(path):
|
||||
root = dlc_root() if callable(dlc_root) else dlc_root
|
||||
return _extract_meta_loosefolder(path, root)
|
||||
# Unknown/unsupported shape — return empty metadata. Slopsmith never
|
||||
# Unknown/unsupported shape — return empty metadata. FeedBack never
|
||||
# reads encrypted archive formats.
|
||||
return {
|
||||
"title": "", "artist": "", "album": "", "year": "",
|
||||
|
||||
+46
-4
@@ -21,13 +21,19 @@ import zipfile
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
log = logging.getLogger("slopsmith.lib.sloppak")
|
||||
log = logging.getLogger("feedBack.lib.sloppak")
|
||||
|
||||
# The feedpak format version this build targets / writes (manifest
|
||||
# `feedpak_version`, a semver string per spec §4). Readers tolerate any version
|
||||
# (additive/MINOR compatibility); writers stamp this.
|
||||
FEEDPAK_VERSION = "1.2.0"
|
||||
|
||||
# Package suffixes. The format is byte-identical regardless of suffix; `.feedpak`
|
||||
# is the current write extension, `.sloppak` the legacy one we still read.
|
||||
FEEDPAK_EXT = ".feedpak"
|
||||
SLOPPAK_EXT = ".sloppak"
|
||||
SONG_EXTS = (FEEDPAK_EXT, SLOPPAK_EXT) # accepted on read/discovery
|
||||
|
||||
import yaml
|
||||
|
||||
from jsonc import load_json
|
||||
@@ -48,8 +54,12 @@ import notation as notation_mod
|
||||
# ── Format detection ──────────────────────────────────────────────────────────
|
||||
|
||||
def is_sloppak(path: Path) -> bool:
|
||||
"""True if path looks like a sloppak (zip file or directory)."""
|
||||
return path.name.lower().endswith(".sloppak")
|
||||
"""True if path looks like a song package (zip file or directory).
|
||||
|
||||
Accepts both the current `.feedpak` suffix and the legacy `.sloppak` one —
|
||||
same on-disk format, either form.
|
||||
"""
|
||||
return path.name.lower().endswith(SONG_EXTS)
|
||||
|
||||
|
||||
# ── Source resolution (zip unpack cache + directory passthrough) ──────────────
|
||||
@@ -357,6 +367,14 @@ class LoadedSloppak:
|
||||
# song.arrangements (not to manifest["arrangements"]) — skipped entries are
|
||||
# absent so indexing by song.arrangements index is safe.
|
||||
arrangement_ids: list[str | None] = field(default_factory=list)
|
||||
# Manifest-relative path to the single full-mix audio file, taken from the
|
||||
# manifest `original_audio:` key (e.g. "original/full.ogg"). This is the
|
||||
# pre-separation mixdown that exists alongside the per-instrument `stems`.
|
||||
# None when the key is absent, points outside source_dir, or the file is
|
||||
# missing on disk. Served to the front-end via the highway WS as
|
||||
# `original_audio_url`; the stems plugin uses it to play the untouched mix
|
||||
# when every stem slider is at unity (and the separate stems otherwise).
|
||||
original_audio: str | None = None
|
||||
|
||||
|
||||
def load_song(
|
||||
@@ -798,6 +816,29 @@ def load_song(
|
||||
}
|
||||
|
||||
_fpv = manifest.get("feedpak_version")
|
||||
# Optional full-mix audio — manifest `original_audio:` key. The single
|
||||
# pre-separation mixdown that ships alongside the per-instrument stems.
|
||||
# Same permissive, path-traversal-guarded posture as drum_tab above: a
|
||||
# missing/escaping/absent file simply leaves the full mix unavailable (the
|
||||
# player falls back to the separate stems) rather than aborting the load.
|
||||
# We store the manifest-relative string so server.py can build its URL the
|
||||
# same way it builds stem URLs (via the /api/sloppak/.../file/ endpoint).
|
||||
original_audio_data: str | None = None
|
||||
original_audio_rel = manifest.get("original_audio")
|
||||
if isinstance(original_audio_rel, str) and original_audio_rel.strip():
|
||||
rel = original_audio_rel.strip()
|
||||
try:
|
||||
oa_path = (source_dir / rel).resolve()
|
||||
oa_path.relative_to(source_dir.resolve())
|
||||
except ValueError:
|
||||
log.warning("sloppak: original_audio path %r escapes source_dir — skipped", rel)
|
||||
oa_path = None
|
||||
except OSError as e:
|
||||
log.warning("sloppak: original_audio path resolution failed (%s) — skipped", e)
|
||||
oa_path = None
|
||||
if oa_path is not None and oa_path.is_file():
|
||||
original_audio_data = rel
|
||||
|
||||
return LoadedSloppak(
|
||||
song=song,
|
||||
stems=stems,
|
||||
@@ -811,6 +852,7 @@ def load_song(
|
||||
keys=keys_data,
|
||||
notation_by_id=notation_by_id_data,
|
||||
arrangement_ids=arrangement_ids_acc,
|
||||
original_audio=original_audio_data,
|
||||
)
|
||||
|
||||
|
||||
@@ -882,6 +924,6 @@ def extract_meta(path: Path) -> dict:
|
||||
"arrangements": arrangements,
|
||||
"has_lyrics": has_lyrics,
|
||||
"stem_count": stem_count,
|
||||
# slopsmith#129: per-stem filter needs the id list, not just count.
|
||||
# feedBack#129: per-stem filter needs the id list, not just count.
|
||||
"stem_ids": stem_ids,
|
||||
}
|
||||
|
||||
+293
-11
@@ -8,7 +8,7 @@ import logging
|
||||
import math
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
log = logging.getLogger("slopsmith.lib.song")
|
||||
log = logging.getLogger("feedBack.lib.song")
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -20,6 +20,13 @@ class Note:
|
||||
slide_to: int = -1
|
||||
slide_unpitch_to: int = -1
|
||||
bend: float = 0.0
|
||||
# Bend shape (§6.2.1, feedpak 1.4.0). `bend` stays the peak magnitude;
|
||||
# `bend_intent` is the gesture (0 up, 1 release, 2 pre-bend,
|
||||
# 3 pre-bend-release, 4 round-trip) and `bend_values` is the optional
|
||||
# time-stamped curve [{t: seconds-from-onset, v: semitones}], authoritative
|
||||
# when present. Both default-omitted on the wire; older readers ignore them.
|
||||
bend_intent: int = 0
|
||||
bend_values: list | None = None
|
||||
hammer_on: bool = False
|
||||
pull_off: bool = False
|
||||
harmonic: bool = False
|
||||
@@ -36,6 +43,18 @@ class Note:
|
||||
slap: bool = False
|
||||
right_hand: int = -1
|
||||
pick_direction: int = -1
|
||||
# Teaching marks (§6.2.2, feedpak 1.5.0) — display/teaching only; a grader
|
||||
# MUST NEVER use these to judge whether a note was played correctly.
|
||||
# `fret_finger` is the fret-hand finger (-1 unset, 0 thumb, 1..4
|
||||
# index/middle/ring/pinky — same convention as a chord template's fingers);
|
||||
# `strum_group` is a strum/rake key (>= -1, default -1; notes sharing a value
|
||||
# >= 0 are one gesture, with `pick_direction` giving its direction);
|
||||
# `scale_degree` is the note's pitch class as a chromatic offset 0..11 above
|
||||
# the active key's tonic (default -1, MAY be derived from keys.json). All
|
||||
# three default-omitted on the wire; older readers ignore them.
|
||||
fret_finger: int = -1
|
||||
strum_group: int = -1
|
||||
scale_degree: int = -1
|
||||
ignore: bool = False
|
||||
|
||||
|
||||
@@ -46,6 +65,17 @@ class ChordTemplate:
|
||||
frets: list[int]
|
||||
display_name: str = ""
|
||||
arpeggio: bool = False
|
||||
# Harmony annotation (§6.6) — key-independent voicing type, e.g. "open",
|
||||
# "triad", "shell", "drop2", "barre". Display/teaching only, never grading.
|
||||
voicing: str = ""
|
||||
# Harmony annotation (§6.6) — the CAGED shape the fingering derives from,
|
||||
# one of "C"/"A"/"G"/"E"/"D" ("" = unset). Display/teaching only, never grading.
|
||||
caged: str = ""
|
||||
# Harmony annotation (§6.6) — chromatic semitone offsets 0..11 above the
|
||||
# chord root marking the quality-defining tones (e.g. dom7 -> [4, 10]).
|
||||
# snake_case attr; rides the wire as camelCase "guideTones" (like
|
||||
# display_name -> "displayName"). Display/teaching only, never grading.
|
||||
guide_tones: list = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -54,6 +84,10 @@ class Chord:
|
||||
chord_id: int
|
||||
notes: list[Note] = field(default_factory=list)
|
||||
high_density: bool = False
|
||||
# Harmony annotation (§6.3.1) — key-dependent harmonic function on the chord
|
||||
# INSTANCE: {rn: str, q: str, deg: int 0..11}. All three keys required when
|
||||
# present (see _validate_fn). Display/teaching only, never grading.
|
||||
fn: dict | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -90,10 +124,10 @@ class PhraseLevel:
|
||||
"""One difficulty tier's worth of note/chord/anchor/hand-shape data for a
|
||||
single phrase iteration. the arrangement XML stores these as `<level
|
||||
difficulty="N">` blocks that repeat for every difficulty tier the chart
|
||||
author wrote; slopsmith used to collapse them to the phrase's
|
||||
author wrote; feedBack used to collapse them to the phrase's
|
||||
maxDifficulty and throw the rest away. Keeping them around lets the
|
||||
highway render a "master difficulty" slider that picks a per-phrase
|
||||
difficulty tier at render time (slopsmith#48)."""
|
||||
difficulty tier at render time (feedBack#48)."""
|
||||
|
||||
difficulty: int
|
||||
notes: list[Note] = field(default_factory=list)
|
||||
@@ -141,7 +175,7 @@ class Arrangement:
|
||||
# `base`/`changes` drive the highway tone-change markers; `definitions`
|
||||
# feed the Tones plugin gear panel.
|
||||
tones: dict | None = None
|
||||
# arrangement XML <arrangementProperties> flags for smart naming (slopsmith feat/arrangement).
|
||||
# arrangement XML <arrangementProperties> flags for smart naming (feedBack feat/arrangement).
|
||||
# Populated from the XML; default False/0 for sloppak / GP-imported sources.
|
||||
path_lead: bool = False
|
||||
path_rhythm: bool = False
|
||||
@@ -221,6 +255,23 @@ def note_to_wire(n: Note) -> dict:
|
||||
out["pkd"] = n.pick_direction
|
||||
if n.ignore:
|
||||
out["ig"] = True
|
||||
# Bend shape (§6.2.1) — default-omitted: `bt` only when non-zero, `bnv`
|
||||
# only when a curve is present. Mirrors the spec's "omit fields equal to
|
||||
# their default" so a plain bend stays a single `bn` scalar on the wire.
|
||||
if n.bend_intent:
|
||||
out["bt"] = int(n.bend_intent)
|
||||
if n.bend_values:
|
||||
out["bnv"] = [
|
||||
{"t": round(p["t"], 3), "v": round(p["v"], 1)}
|
||||
for p in n.bend_values
|
||||
]
|
||||
# Teaching marks (§6.2.2) — default-omitted, mirroring rh/pkd above.
|
||||
if n.fret_finger != -1:
|
||||
out["fg"] = n.fret_finger
|
||||
if n.strum_group != -1:
|
||||
out["ch"] = n.strum_group
|
||||
if n.scale_degree != -1:
|
||||
out["sd"] = n.scale_degree
|
||||
return out
|
||||
|
||||
|
||||
@@ -232,12 +283,19 @@ def chord_note_to_wire(cn: Note) -> dict:
|
||||
|
||||
|
||||
def chord_to_wire(c: Chord) -> dict:
|
||||
return {
|
||||
out = {
|
||||
"t": round(c.time, 3),
|
||||
"id": c.chord_id,
|
||||
"hd": c.high_density,
|
||||
"notes": [chord_note_to_wire(cn) for cn in c.notes],
|
||||
}
|
||||
# Harmony function (§6.3.1) — default-omitted, mirroring bend `bnv`. Re-validate
|
||||
# on emit (not just decode) so a directly-constructed Chord can't put a partial
|
||||
# or out-of-range fn on the wire, which would fail the schema's required-keys rule.
|
||||
fn = _validate_fn(c.fn)
|
||||
if fn:
|
||||
out["fn"] = fn
|
||||
return out
|
||||
|
||||
|
||||
def anchor_to_wire(a: Anchor) -> dict:
|
||||
@@ -254,7 +312,7 @@ def hand_shape_to_wire(h: HandShape) -> dict:
|
||||
|
||||
|
||||
def chord_template_to_wire(ct: ChordTemplate) -> dict:
|
||||
return {
|
||||
out = {
|
||||
"name": ct.name,
|
||||
# ChordTemplate.display_name defaults to "" on the dataclass, but
|
||||
# the spec defaults displayName to name. Fall back here so
|
||||
@@ -266,6 +324,40 @@ def chord_template_to_wire(ct: ChordTemplate) -> dict:
|
||||
"fingers": list(ct.fingers),
|
||||
"frets": list(ct.frets),
|
||||
}
|
||||
# Harmony voicing (§6.6) — default-omitted, only when non-empty.
|
||||
if ct.voicing:
|
||||
out["voicing"] = ct.voicing
|
||||
# CAGED shape + guide tones (§6.6) — default-omitted, mirroring voicing.
|
||||
# Sanitize on EMIT too (not just on decode): a directly-constructed template
|
||||
# must not be able to write a non-enum `caged` or an out-of-range `guideTone`
|
||||
# to the wire (the spec constrains caged to C/A/G/E/D and guideTones to 0..11).
|
||||
_caged = _sanitize_caged(ct.caged)
|
||||
if _caged:
|
||||
out["caged"] = _caged
|
||||
_guide_tones = _sanitize_guide_tones(ct.guide_tones)
|
||||
if _guide_tones:
|
||||
out["guideTones"] = _guide_tones
|
||||
return out
|
||||
|
||||
|
||||
# §6.6 CAGED shape enum — the only values accepted off the wire.
|
||||
_CAGED_SHAPES = ("C", "A", "G", "E", "D")
|
||||
|
||||
|
||||
def _sanitize_caged(val) -> str:
|
||||
"""A wire `caged` is kept only when it is one of the CAGED shape letters;
|
||||
anything else (None, int, list, unknown string) falls back to ""."""
|
||||
return val if isinstance(val, str) and val in _CAGED_SHAPES else ""
|
||||
|
||||
|
||||
def _sanitize_guide_tones(val) -> list:
|
||||
"""A wire `guideTones` is kept only as the int entries in 0..11; non-list
|
||||
input, non-ints (bool is an int subclass — rejected), and out-of-range
|
||||
values are dropped so a malformed value can't round-trip."""
|
||||
if not isinstance(val, list):
|
||||
return []
|
||||
return [v for v in val
|
||||
if isinstance(v, int) and not isinstance(v, bool) and 0 <= v <= 11]
|
||||
|
||||
|
||||
def _wire_int_optional(v, default=-1):
|
||||
@@ -283,6 +375,129 @@ def _wire_int_optional(v, default=-1):
|
||||
return default
|
||||
|
||||
|
||||
def _sanitize_bend_curve(raw):
|
||||
"""Clean a time-stamped bend curve (``[{t, v}]``, §6.2.1): keep entries with
|
||||
a finite, non-bool numeric ``t`` and ``v``, coerced to float and sorted by
|
||||
``t``. Non-list / absent / all-invalid input -> ``None`` so an empty curve
|
||||
round-trips as *omitted*, never ``[]``. ``t`` is seconds from the note
|
||||
onset; ``v`` is semitones (same scale as the scalar ``bn`` peak)."""
|
||||
if not isinstance(raw, list):
|
||||
return None
|
||||
out: list[dict] = []
|
||||
for p in raw:
|
||||
if not isinstance(p, dict):
|
||||
continue
|
||||
t = p.get("t")
|
||||
v = p.get("v")
|
||||
if (not isinstance(t, (int, float)) or isinstance(t, bool)
|
||||
or not math.isfinite(t)):
|
||||
continue
|
||||
if (not isinstance(v, (int, float)) or isinstance(v, bool)
|
||||
or not math.isfinite(v)):
|
||||
continue
|
||||
out.append({"t": float(t), "v": float(v)})
|
||||
if not out:
|
||||
return None
|
||||
out.sort(key=lambda e: e["t"])
|
||||
return out
|
||||
|
||||
|
||||
# Natural-note letter -> pitch class (0 = C). Used to parse a keys.json key
|
||||
# name's tonic for scale-degree derivation (§6.2.2 / §7.7).
|
||||
_KEY_LETTER_PC = {"C": 0, "D": 2, "E": 4, "F": 5, "G": 7, "A": 9, "B": 11}
|
||||
|
||||
|
||||
def key_to_tonic_pc(key) -> int | None:
|
||||
"""Parse a keys.json key name (§7.7) to its tonic pitch class 0..11.
|
||||
|
||||
Reads only the leading note letter plus optional accidentals — e.g. ``"E"``,
|
||||
``"Em"``, ``"A#m"``, ``"Bb"``, ``"F#"`` -> 4, 4, 10, 10, 6. The mode/quality
|
||||
suffix (``m``/``maj``/``min``/scale name) is irrelevant to the tonic and is
|
||||
ignored. Returns ``None`` for anything not starting with a valid note letter,
|
||||
so callers can leave ``sd`` unset rather than guess. Used only for teaching
|
||||
marks; never for grading."""
|
||||
if not isinstance(key, str):
|
||||
return None
|
||||
s = key.strip()
|
||||
if not s:
|
||||
return None
|
||||
pc = _KEY_LETTER_PC.get(s[0].upper())
|
||||
if pc is None:
|
||||
return None
|
||||
# Consume any run of accidentals directly after the letter (``#``/``b``/
|
||||
# unicode ♯/♭); stop at the first non-accidental (start of the mode suffix).
|
||||
for ch in s[1:]:
|
||||
if ch in ("#", "♯"):
|
||||
pc += 1
|
||||
elif ch in ("b", "♭"):
|
||||
pc -= 1
|
||||
else:
|
||||
break
|
||||
return pc % 12
|
||||
|
||||
|
||||
def scale_degree_for_pitch(midi_pitch: int, tonic_pc: int) -> int:
|
||||
"""Chromatic scale degree 0..11 of ``midi_pitch`` above tonic ``tonic_pc``
|
||||
(§6.2.2): the pitch class distance in semitones, 0 = tonic, 7 = fifth.
|
||||
Display/teaching only — MUST NEVER feed a grader."""
|
||||
return (int(midi_pitch) - int(tonic_pc)) % 12
|
||||
|
||||
|
||||
# Open-string base MIDI per string count, index 0 = lowest string. Mirrors
|
||||
# app.js `_TUNING_BASE_MIDI` / highway_3d `_baseOpenStringMidis` so a derived
|
||||
# scale degree agrees with the tuner + open-string labels. `arr.tuning` carries
|
||||
# per-string OFFSETS from standard (not absolute pitch), so the sounding open
|
||||
# pitch is `base + offset (+ capo)` — see `note_pitch_midi`.
|
||||
_TUNING_BASE_MIDI = {
|
||||
4: [28, 33, 38, 43],
|
||||
5: [23, 28, 33, 38, 43],
|
||||
6: [40, 45, 50, 55, 59, 64],
|
||||
7: [35, 40, 45, 50, 55, 59, 64],
|
||||
8: [30, 35, 40, 45, 50, 55, 59, 64],
|
||||
}
|
||||
|
||||
|
||||
def base_open_string_midis(string_count: int, is_bass: bool) -> list[int]:
|
||||
"""Standard open-string base MIDI list for an arrangement, index 0 = lowest.
|
||||
|
||||
Mirrors app.js `_tuningOffsetsToFreqs`: a 4/5-string *bass* uses its own low
|
||||
base, while a 4/5-string non-bass (a guitar voicing) borrows the low strings
|
||||
of the 6-string base; 6/7/8 use their own. Unknown counts fall back to the
|
||||
6-string base."""
|
||||
n = int(string_count)
|
||||
if n in (4, 5):
|
||||
return _TUNING_BASE_MIDI[n] if is_bass else _TUNING_BASE_MIDI[6]
|
||||
return _TUNING_BASE_MIDI.get(n, _TUNING_BASE_MIDI[6])
|
||||
|
||||
|
||||
def pitch_from_base(base: list[int], capo: int, tuning: list[int],
|
||||
string: int, fret: int) -> int | None:
|
||||
"""Absolute sounding MIDI for one string+fret, given a precomputed open-string
|
||||
``base`` (from :func:`base_open_string_midis`) and the arrangement's tuning
|
||||
OFFSETS + capo. None when ``string`` has no tuning entry. Single source of the
|
||||
pitch formula so the per-note hot path can hoist ``base`` out of the loop."""
|
||||
if not (0 <= string < len(tuning)) or not base:
|
||||
return None
|
||||
root = base[string] if string < len(base) else base[-1]
|
||||
return root + int(tuning[string]) + int(capo) + int(fret)
|
||||
|
||||
|
||||
def note_pitch_midi(arr: "Arrangement", note: "Note") -> int | None:
|
||||
"""Absolute sounding MIDI pitch of ``note`` on arrangement ``arr``, or None
|
||||
when its string index has no tuning entry.
|
||||
|
||||
Pitch = standard base for the string + the arrangement's per-string tuning
|
||||
OFFSET + capo + fret, matching the client's open-string/tuner math. Used to
|
||||
derive the ``sd`` teaching mark (§6.2.2); display only, never grading.
|
||||
O(notes) via ``arrangement_string_count`` — for a whole arrangement, hoist
|
||||
the base with :func:`base_open_string_midis` and call :func:`pitch_from_base`
|
||||
per note instead."""
|
||||
is_bass = "bass" in (arr.name or "").lower()
|
||||
base = base_open_string_midis(arrangement_string_count(arr), is_bass)
|
||||
return pitch_from_base(base, int(getattr(arr, "capo", 0) or 0),
|
||||
arr.tuning or [], note.string, note.fret)
|
||||
|
||||
|
||||
def note_from_wire(d: dict, time: float | None = None) -> Note:
|
||||
return Note(
|
||||
time=float(d.get("t", time if time is not None else 0.0)),
|
||||
@@ -292,6 +507,8 @@ def note_from_wire(d: dict, time: float | None = None) -> Note:
|
||||
slide_to=int(d.get("sl", -1)),
|
||||
slide_unpitch_to=int(d.get("slu", -1)),
|
||||
bend=float(d.get("bn", 0.0)),
|
||||
bend_intent=_wire_int_optional(d.get("bt"), 0),
|
||||
bend_values=_sanitize_bend_curve(d.get("bnv")),
|
||||
hammer_on=bool(d.get("ho", False)),
|
||||
pull_off=bool(d.get("po", False)),
|
||||
harmonic=bool(d.get("hm", False)),
|
||||
@@ -310,10 +527,38 @@ def note_from_wire(d: dict, time: float | None = None) -> Note:
|
||||
# the XML side's `_int_optional`.
|
||||
right_hand=_wire_int_optional(d.get("rh"), -1),
|
||||
pick_direction=_wire_int_optional(d.get("pkd"), -1),
|
||||
# Teaching marks (§6.2.2) — display only, never used for grading.
|
||||
fret_finger=_wire_int_optional(d.get("fg"), -1),
|
||||
strum_group=_wire_int_optional(d.get("ch"), -1),
|
||||
scale_degree=_wire_int_optional(d.get("sd"), -1),
|
||||
ignore=bool(d.get("ig", False)),
|
||||
)
|
||||
|
||||
|
||||
def _validate_fn(raw) -> dict | None:
|
||||
"""Validate an optional chord harmony function (§6.3.1).
|
||||
|
||||
Returns a clean ``{"rn", "q", "deg"}`` dict only when ``raw`` is an object
|
||||
with a non-empty ``rn`` string, a non-empty ``q`` string, and an int ``deg``
|
||||
in 0..11. Any malformed / missing-key / out-of-range input -> ``None`` so a
|
||||
partial fn (which would fail the schema's required-keys rule) never rides the
|
||||
wire. Display/teaching only — MUST NEVER feed a grader. Mirrors the
|
||||
drop-to-default tolerance of `_sanitize_bend_curve`."""
|
||||
if not isinstance(raw, dict):
|
||||
return None
|
||||
rn = raw.get("rn")
|
||||
q = raw.get("q")
|
||||
deg = raw.get("deg")
|
||||
if not isinstance(rn, str) or not rn.strip():
|
||||
return None
|
||||
if not isinstance(q, str) or not q.strip():
|
||||
return None
|
||||
# bool is an int subclass — reject it so `deg=True` can't pass as 1.
|
||||
if not isinstance(deg, int) or isinstance(deg, bool) or not (0 <= deg <= 11):
|
||||
return None
|
||||
return {"rn": rn.strip(), "q": q.strip(), "deg": deg}
|
||||
|
||||
|
||||
def chord_from_wire(d: dict) -> Chord:
|
||||
t = float(d.get("t", 0.0))
|
||||
return Chord(
|
||||
@@ -321,6 +566,7 @@ def chord_from_wire(d: dict) -> Chord:
|
||||
chord_id=int(d.get("id", 0)),
|
||||
high_density=bool(d.get("hd", False)),
|
||||
notes=[note_from_wire(cn, time=t) for cn in d.get("notes", [])],
|
||||
fn=_validate_fn(d.get("fn")),
|
||||
)
|
||||
|
||||
|
||||
@@ -376,7 +622,7 @@ def arrangement_string_count(arr: Arrangement) -> int:
|
||||
"""Derive the active arrangement's string count.
|
||||
|
||||
Used by the server to emit ``stringCount`` in the song_info
|
||||
WebSocket payload (slopsmith-plugin-3dhighway#7).
|
||||
WebSocket payload (feedBack-plugin-3dhighway#7).
|
||||
|
||||
The arrangement XML schema always emits 6 ``<tuning>`` slots regardless
|
||||
of instrument (bass charts populate `string0`–`string3` and pad
|
||||
@@ -673,7 +919,11 @@ def arrangement_from_wire(d: dict) -> Arrangement:
|
||||
display_name=ct.get("displayName", ct.get("name", "")),
|
||||
arpeggio=bool(ct.get("arp", False)),
|
||||
fingers=list(ct.get("fingers", [-1] * 6)),
|
||||
frets=list(ct.get("frets", [-1] * 6)))
|
||||
frets=list(ct.get("frets", [-1] * 6)),
|
||||
voicing=(ct.get("voicing")
|
||||
if isinstance(ct.get("voicing"), str) else ""),
|
||||
caged=_sanitize_caged(ct.get("caged")),
|
||||
guide_tones=_sanitize_guide_tones(ct.get("guideTones")))
|
||||
for ct in d.get("templates", [])
|
||||
],
|
||||
# `phrases` is optional — absent on single-level sources / older
|
||||
@@ -768,6 +1018,18 @@ def _chord_high_density(elem: ET.Element) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _parse_bend_values(n):
|
||||
"""Read a `bendValues` JSON attribute (GP import emits it; §6.2.1) and
|
||||
sanitize it into a [{t,v}] curve, or None when absent/malformed."""
|
||||
raw = n.get("bendValues")
|
||||
if not raw:
|
||||
return None
|
||||
try:
|
||||
return _sanitize_bend_curve(json.loads(raw))
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
def _parse_note(n) -> Note:
|
||||
return Note(
|
||||
time=_float(n, "time"),
|
||||
@@ -777,6 +1039,8 @@ def _parse_note(n) -> Note:
|
||||
slide_to=_int(n, "slideTo", -1),
|
||||
slide_unpitch_to=_int(n, "slideUnpitchTo", -1),
|
||||
bend=_float(n, "bend"),
|
||||
bend_intent=_int(n, "bendIntent", 0),
|
||||
bend_values=_parse_bend_values(n),
|
||||
hammer_on=_bool(n, "hammerOn"),
|
||||
pull_off=_bool(n, "pullOff"),
|
||||
harmonic=_bool(n, "harmonic"),
|
||||
@@ -793,6 +1057,10 @@ def _parse_note(n) -> Note:
|
||||
slap=_bool(n, "slap"),
|
||||
right_hand=_int_optional(n, "rightHand", -1),
|
||||
pick_direction=_int_optional(n, "pickDirection", -1),
|
||||
# Teaching mark (§6.2.2): GP import writes `fretFinger`; strum_group /
|
||||
# scale_degree are authored downstream (editor / derived), not in chart
|
||||
# XML, so they have no attribute to read here.
|
||||
fret_finger=_int_optional(n, "fretFinger", -1),
|
||||
ignore=_bool(n, "ignore"),
|
||||
)
|
||||
|
||||
@@ -822,6 +1090,20 @@ def parse_arrangement(xml_path: str) -> Arrangement:
|
||||
while el.get(f"string{i}") is not None:
|
||||
tuning.append(_int(el, f"string{i}"))
|
||||
i += 1
|
||||
# Authoritative string count, written by the GP/RS serializer
|
||||
# (gp2rs._build_xml). The schema pads `<tuning>` to 6 slots, which
|
||||
# erases the 4-vs-5-vs-6-string distinction for standard tunings;
|
||||
# when the real count was recorded, trim the padded tail so
|
||||
# arrangement_string_count / the editor see 4 or 5 instead of 6.
|
||||
# Absent (archive / legacy sources) → leave the 6-slot tuning as-is.
|
||||
sc = el.get("stringCount")
|
||||
if sc is not None:
|
||||
try:
|
||||
n = int(sc)
|
||||
except (TypeError, ValueError):
|
||||
n = 0
|
||||
if 1 <= n <= len(tuning):
|
||||
tuning = tuning[:n]
|
||||
|
||||
# Capo
|
||||
capo = 0
|
||||
@@ -1008,7 +1290,7 @@ def parse_arrangement(xml_path: str) -> Arrangement:
|
||||
def _collect_from_parsed(parsed, t_start, t_end):
|
||||
"""Append a pre-parsed level's time-clipped slice to the flat
|
||||
arrangement lists. Used for the max-mastery merge that preserves
|
||||
the pre-slopsmith#48 behaviour for existing consumers."""
|
||||
the pre-feedBack#48 behaviour for existing consumers."""
|
||||
lv_notes, lv_chords, lv_anchors, lv_hand_shapes = _extract_level_slice(
|
||||
parsed, t_start, t_end
|
||||
)
|
||||
@@ -1027,7 +1309,7 @@ def parse_arrangement(xml_path: str) -> Arrangement:
|
||||
_collect_from_parsed(best, 0.0, float("inf"))
|
||||
|
||||
# Per-phrase difficulty data for the master-difficulty slider
|
||||
# (slopsmith#48). Only populated when the XML has multiple levels AND
|
||||
# (feedBack#48). Only populated when the XML has multiple levels AND
|
||||
# phrase data — left as None for single-level sources so the frontend
|
||||
# knows to disable the slider.
|
||||
phrases: list[Phrase] | None = None
|
||||
@@ -1177,7 +1459,7 @@ def _convert_sng_to_xml(extracted_dir: str):
|
||||
"""No-op stub.
|
||||
|
||||
Historically this converted proprietary encrypted ``.notechart`` arrangement
|
||||
files to XML via an external tool. That path has been removed: slopsmith
|
||||
files to XML via an external tool. That path has been removed: feedBack
|
||||
reads only its own ``.sloppak`` format and loose-folder/GP/MusicXML-derived
|
||||
arrangement XML, and never decodes or decrypts proprietary archives. Kept
|
||||
as a no-op so ``load_song`` (which loads plain arrangement XML/JSON from a
|
||||
|
||||
+1
-1
@@ -10,7 +10,7 @@ source of truth, so the change survives both incremental and full rescans.
|
||||
only the keys present are overwritten, so an edit of just the title can't blank
|
||||
out the artist.
|
||||
|
||||
Only slopsmith's own ``.sloppak`` format (zip- or directory-form) is writable.
|
||||
Only feedBack's own ``.sloppak`` format (zip- or directory-form) is writable.
|
||||
Unknown / unsupported shapes return False and the caller keeps the DB-only
|
||||
update.
|
||||
"""
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
"""Regenerate ``static/tailwind.min.css`` over the full installed-plugin set.
|
||||
|
||||
Core's committed (and image-baked) stylesheet is built scanning only the
|
||||
in-tree plugins. A plugin installed at runtime — into ``SLOPSMITH_PLUGINS_DIR``
|
||||
in-tree plugins. A plugin installed at runtime — into ``FEEDBACK_PLUGINS_DIR``
|
||||
— ships Tailwind classes the sheet never saw, so it renders unstyled. The
|
||||
Play CDN's runtime JIT that used to cover this was removed (slopsmith#411),
|
||||
Play CDN's runtime JIT that used to cover this was removed (feedBack#411),
|
||||
so we rebuild the sheet ourselves with node + the pinned ``tailwindcss``,
|
||||
scanning the baked-in plugins *and* the user plugins dir.
|
||||
|
||||
@@ -24,7 +24,9 @@ import tempfile
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
log = logging.getLogger("slopsmith.tailwind")
|
||||
from env_compat import getenv_compat
|
||||
|
||||
log = logging.getLogger("feedBack.tailwind")
|
||||
|
||||
# Pin matches scripts/build-tailwind.sh and the Dockerfile build stage so every
|
||||
# sheet — committed, image-baked, and runtime-regenerated — comes from the same
|
||||
@@ -45,7 +47,7 @@ APP_DIR = Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
def _user_plugins_dir() -> Path | None:
|
||||
raw = os.environ.get("SLOPSMITH_PLUGINS_DIR", "").strip()
|
||||
raw = (getenv_compat("FEEDBACK_PLUGINS_DIR", "") or "").strip()
|
||||
if not raw:
|
||||
return None
|
||||
p = Path(raw)
|
||||
|
||||
+3
-3
@@ -1,13 +1,13 @@
|
||||
"""Tone helpers for sloppak playback.
|
||||
|
||||
A slopsmith arrangement may carry a tone block — the initial tone name plus
|
||||
A feedBack arrangement may carry a tone block — the initial tone name plus
|
||||
in-song tone switches — embedded inline in the arrangement JSON (see
|
||||
``lib/song.py`` ``arrangement_to_wire`` / the ``tones`` wire key). This module
|
||||
turns that already-embedded block into the (base, changes) payload the highway
|
||||
WebSocket sends to the client.
|
||||
|
||||
The proprietary-archive tone-extraction path (lifting tone definitions out of
|
||||
an unpacked encrypted archive) has been removed. Slopsmith reads tones only
|
||||
an unpacked encrypted archive) has been removed. FeedBack reads tones only
|
||||
from its own ``.sloppak`` / arrangement JSON; it never reads or decrypts
|
||||
proprietary archive formats.
|
||||
"""
|
||||
@@ -18,7 +18,7 @@ import logging
|
||||
import math
|
||||
import re
|
||||
|
||||
log = logging.getLogger("slopsmith.lib.tones")
|
||||
log = logging.getLogger("feedBack.lib.tones")
|
||||
|
||||
|
||||
def tokens(s: str) -> set[str]:
|
||||
|
||||
+6
-6
@@ -5,7 +5,7 @@ isolated vocals + per-syllable lyric timing (both produced by the
|
||||
WhisperX fallback or shipped in the source archive), the /pitch endpoint
|
||||
runs CREPE over the vocals stem and returns one MIDI note per supplied
|
||||
timing token. The result lands in `<sloppak>/vocal_pitch.json` in the
|
||||
shape the got-feedback/feedback-plugin-lyrics-karaoke renderer
|
||||
shape the got-feedback/feedBack-plugin-lyrics-karaoke renderer
|
||||
already consumes:
|
||||
|
||||
{"version": 1, "notes": [{"t": float, "d": float, "midi": int}, ...]}
|
||||
@@ -23,18 +23,18 @@ runs locally. Adding a local CREPE path here would mean pulling
|
||||
`crepe` + `tensorflow` as plugin deps (~500 MB+ on top of the
|
||||
existing torch/demucs/whisperx). Deferred until users hit the gap.
|
||||
If you need a local fallback today, install
|
||||
`got-feedback/feedback-plugin-lyrics-karaoke` and let its local
|
||||
`got-feedback/feedBack-plugin-lyrics-karaoke` and let its local
|
||||
pYIN run when the server isn't reachable.
|
||||
|
||||
Cache key parity with stem_separation / lyric_transcription
|
||||
───────────────────────────────────────────────────────────
|
||||
A `pitch_extraction` manifest block mirrors the shape introduced by
|
||||
slopsmith#357: `{engine, model, version}`. Today engine is fixed at
|
||||
feedBack#357: `{engine, model, version}`. Today engine is fixed at
|
||||
`"crepe"` (the server's choice) and model at `"v1"` (server doesn't
|
||||
yet expose the CREPE capacity dial it uses internally; this is the
|
||||
requested value, same caveat as `lyric_transcription.model`). The
|
||||
schema version is independent of the upstream CREPE version and bumps
|
||||
per slopsmith's contract:
|
||||
per feedBack's contract:
|
||||
* patch — metadata-only or implementation fixes
|
||||
* minor — backward-compatible additions
|
||||
* major — output shape / semantics changed; existing
|
||||
@@ -50,7 +50,7 @@ import math
|
||||
from pathlib import Path
|
||||
from typing import Callable, Optional
|
||||
|
||||
log = logging.getLogger("slopsmith.lib.vocal_pitch")
|
||||
log = logging.getLogger("feedBack.lib.vocal_pitch")
|
||||
|
||||
ProgressCB = Optional[Callable[[float, str, str], None]]
|
||||
|
||||
@@ -72,7 +72,7 @@ def extract_pitch_remote(
|
||||
) -> list[dict]:
|
||||
"""POST the vocal stem + lyric timings to `{server_url}/pitch`.
|
||||
|
||||
`lyrics` is the same `[{t, d, w}, ...]` list slopsmith writes to
|
||||
`lyrics` is the same `[{t, d, w}, ...]` list feedBack writes to
|
||||
`lyrics.json`. The endpoint only consumes `t` + `d` (it doesn't
|
||||
need the word text), but we pass the full payload through —
|
||||
slimmer to forward what we already have than to project.
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@ import logging
|
||||
import struct
|
||||
import os
|
||||
|
||||
log = logging.getLogger("slopsmith.lib.wem_decode")
|
||||
log = logging.getLogger("feedBack.lib.wem_decode")
|
||||
|
||||
|
||||
def convert_wem_to_ogg(wem_path: str, output_path: str) -> bool:
|
||||
|
||||
Reference in New Issue
Block a user