Clean release snapshot

This commit is contained in:
byrongamatos
2026-06-16 18:47:13 +02:00
commit 6c110398b4
574 changed files with 162566 additions and 0 deletions
+523
View File
@@ -0,0 +1,523 @@
"""Audio extraction and conversion for Rocksmith CDLC."""
import logging
import os
import re
import shutil
import subprocess
from pathlib import Path
log = logging.getLogger("slopsmith.lib.audio")
# Maximum length of any single decoder-error fragment that we surface to
# the client. ffmpeg can emit multi-kB build-configuration / version
# banners on failure, which would make the WebSocket `audio_error`
# payload huge and bury the actionable bit.
_MAX_DECODER_DETAIL_CHARS = 500
def _basename_any_path(raw: str) -> str:
"""Cross-platform basename: recognises both `/` and `\\` as
separators regardless of host, and strips trailing separators before
splitting so a directory match collapses to its final segment
instead of the empty string.
`os.path.basename` is platform-specific — on POSIX it only treats
`/` as a separator, so a Windows path emitted by a decoder running
inside a cross-platform error log would leak through verbatim."""
candidate = raw.rstrip("/\\")
if not candidate:
return raw
last = max(candidate.rfind("/"), candidate.rfind("\\"))
base = candidate[last + 1:] if last >= 0 else candidate
return base or raw
# Lines that match this regex are version / build banner output that
# ffmpeg (and friends) emit before the actual error message. They're
# never actionable on their own — the useful error is somewhere after.
_BANNER_LINE_RE = re.compile(
r"""^\s*(
ffmpeg\sversion # e.g. "ffmpeg version 4.4.2-..."
| built\swith # " built with gcc ..."
| configuration: # " configuration: ..."
| lib(av\w+|sw\w+|postproc) # " libavutil 56.70.100"
| Stream\smapping: # ffmpeg's "Stream mapping:" header
| Input\s\# # "Input #0, wav, from ..."
| Output\s\# # "Output #0, mp3, ..."
| Duration: # " Duration: ..."
| Press\s\[q\] # interactive prompts
)""",
re.VERBOSE,
)
def _truncate_detail(text: str, limit: int = _MAX_DECODER_DETAIL_CHARS) -> str:
"""Shrink a multi-line decoder stderr blob to one actionable line
under `limit` characters.
ffmpeg-style failures start with a multi-line version / build /
config banner and put the actual error after it, so naive
"first non-empty line" picks the banner. Skip lines matching
`_BANNER_LINE_RE` and prefer the first remaining non-empty line.
If every line matched the banner pattern (shouldn't happen in
practice but cover the case), fall back to the first non-empty
line so we don't end up emitting an empty string."""
lines = [ln for ln in text.splitlines() if ln.strip()]
actionable = next(
(ln for ln in lines if not _BANNER_LINE_RE.match(ln)),
None,
)
if actionable is None:
actionable = lines[0] if lines else text.strip()
actionable = actionable.strip()
if len(actionable) <= limit:
return actionable
return actionable[:limit - 1].rstrip() + ""
# Unquoted absolute paths: stop at whitespace or common delimiters. The
# quote characters are excluded so the quoted-path pass (below) can
# claim those matches instead.
#
# Drive-letter Windows paths support both separator conventions —
# `C:\Users\…` and `C:/Users/…`. Native Windows APIs and many tools
# (PowerShell, .NET, ffmpeg with -i C:/...) emit the forward-slash form,
# and the unquoted-path branch alone can't handle that case without it
# because the body `[^\s"'`<>|]+` would never have matched the
# colon-then-slash prefix.
_UNQUOTED_ABS_PATH_RE = re.compile(
r"""(?:
(?:[A-Za-z]:[\\/] | \\\\) # Windows: C:\… / C:/… or UNC \\host\
| / # POSIX: leading /
)
[^\s"'`<>|]+
""",
re.VERBOSE,
)
# Quoted absolute paths (single, double, or backtick quotes). Captures
# the opening quote so the replacement can keep the quoting wrapper
# intact while collapsing the inner path to its basename. Allows spaces
# in the path — that's the whole reason quotes get used in stderr
# output (`C:\Program Files\…`, `/Users/Alice/My Secrets/…`).
_QUOTED_ABS_PATH_RE = re.compile(
r"""(['"`])
((?:[A-Za-z]:[\\/] | \\\\ | /)
[^'"`\n]+)
\1
""",
re.VERBOSE,
)
def _scrub_unquoted_match(match: re.Match) -> str:
return _basename_any_path(match.group(0))
def _scrub_quoted_match(match: re.Match) -> str:
quote = match.group(1)
inner = match.group(2)
return f"{quote}{_basename_any_path(inner)}{quote}"
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 →
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
is bundled on every desktop platform and isn't a typical system
binary, so it's a precise signature for the desktop layout."""
bundled = Path(__file__).resolve().parents[2] / "bin"
if any((bundled / n).is_file() for n in ("vgmstream-cli", "vgmstream-cli.exe")):
return bundled
return None
def _bundled_or_path(name: str) -> str | None:
"""Prefer the bundled binary on desktop, fall back to PATH lookup.
Necessary because Electron's child PATH on macOS / Linux puts
user-installed binaries (Homebrew `/opt/homebrew/bin`, /usr/local)
before our `resources/bin`, so `shutil.which` alone picks up the
user's binary — which may have been built without the features
we rely on (e.g. Homebrew ffmpeg formulas that omit libvorbis)."""
bundled = _bundled_bin_dir()
if bundled is not None:
for fname in (name, f"{name}.exe"):
cand = bundled / fname
if cand.is_file():
return str(cand)
return shutil.which(name)
def _repo_root() -> Path:
"""Return the repository root for local binary fallbacks."""
return Path(__file__).resolve().parent.parent
def _resolve_executable(candidate: str | None) -> str | None:
"""Resolve either a command name on PATH or an explicit executable path.
Explicit paths must refer to a regular file (directories often satisfy
os.access(..., X_OK) on POSIX but cannot be exec'd by subprocess)."""
if not candidate:
return None
if os.path.sep in candidate or (os.path.altsep and os.path.altsep in candidate):
path = Path(candidate).expanduser()
if path.is_file() and os.access(path, os.X_OK):
return str(path.resolve())
return None
return shutil.which(candidate)
def _vgmstream_cmd(resolution_notes: list[str] | None = None) -> str | None:
"""Return the best available vgmstream-cli executable.
Resolution order:
1. `VGMSTREAM_CLI` env var (explicit override — must beat everything
else so a user can force a known-good binary when the bundled or
system one is broken)
2. Bundled `resources/bin/vgmstream-cli` (desktop)
3. `vgmstream-cli` on PATH
4. Repo-local build outputs (autotools `.libs/`, CMake `build/cli/`,
and the in-tree `vgmstream/cli/` location), checked for both Unix
and Windows (`.exe`) names so a local `cmake --build` discovered
off-PATH still works.
`vgmstream123` is intentionally excluded: it is a player-style frontend
with a different argument schema and cannot be invoked with the
`-o <wav> <wem>` interface the rest of this module assumes.
`resolution_notes` (optional): when provided, the resolver appends
human-readable warnings about resolution-time problems (e.g. a
`VGMSTREAM_CLI` value that didn't resolve). Callers that surface
decode failures to the user can fold these into the final error
message so the user understands why their override was ignored
instead of seeing only the generic "no decoder found" guidance."""
env_value = os.environ.get("VGMSTREAM_CLI")
explicit = _resolve_executable(env_value)
if explicit:
return explicit
if env_value:
# The env var is documented as an explicit override, so silently
# falling through when it's set to a stale or non-executable path
# is misleading. Log a warning so the user sees why their override
# didn't take, but don't raise — we still want the next fallback
# to succeed if e.g. PATH has a working binary.
log.warning(
"VGMSTREAM_CLI=%r is set but does not resolve to an "
"executable file; falling through to other candidates",
env_value,
)
if resolution_notes is not None:
# Don't echo the env's full value back to the user — it's an
# absolute path; the basename + "ignored" is enough to point
# them at their misconfiguration without leaking layout.
# `_basename_any_path` (not `os.path.basename`) so a Windows
# value on a POSIX host still collapses correctly.
resolution_notes.append(
f"VGMSTREAM_CLI={_basename_any_path(env_value) or '<set>'!r}"
" is not an executable file and was ignored"
)
bundled_dir = _bundled_bin_dir()
if bundled_dir is not None:
for fname in ("vgmstream-cli", "vgmstream-cli.exe"):
cand = bundled_dir / fname
# Same exec check we apply to env/repo-local candidates —
# a present-but-not-executable file (lost +x after a tar
# extract, marked unreadable, etc.) would otherwise be
# returned here and block the perfectly fine PATH binary
# below from getting a chance.
if cand.is_file() and os.access(cand, os.X_OK):
return str(cand)
on_path = shutil.which("vgmstream-cli")
if on_path:
return on_path
root = _repo_root()
for rel in (
"vgmstream/build/cli/vgmstream-cli",
"vgmstream/build/cli/vgmstream-cli.exe",
"vgmstream/cli/vgmstream-cli",
"vgmstream/cli/vgmstream-cli.exe",
"vgmstream/cli/.libs/vgmstream-cli",
"vgmstream/cli/.libs/vgmstream-cli.exe",
):
resolved = _resolve_executable(str(root / rel))
if resolved:
return resolved
return None
def _ffmpeg_cmd() -> str | None:
"""Return the path to ffmpeg, preferring the bundled binary."""
return _bundled_or_path("ffmpeg")
def _ffmpeg_wav_to_ogg(ffmpeg: str, wav: Path, out_ogg: Path) -> subprocess.CompletedProcess:
"""Encode WAV → Ogg Vorbis. Prefers libvorbis (external, full quality);
if the ffmpeg build lacks it (some Homebrew formulas no longer set
--enable-libvorbis), retries with ffmpeg's built-in `vorbis` encoder
under `-strict experimental`. Same .ogg container either way; the
built-in path produces a lower-quality file but always works."""
r = subprocess.run(
[ffmpeg, "-y", "-i", str(wav), "-c:a", "libvorbis", "-q:a", "5", str(out_ogg)],
capture_output=True,
)
if r.returncode == 0 and out_ogg.exists() and out_ogg.stat().st_size >= 100:
return r
if b"Unknown encoder 'libvorbis'" not in (r.stderr or b""):
return r
return subprocess.run(
[ffmpeg, "-y", "-i", str(wav),
"-c:a", "vorbis", "-strict", "experimental", "-q:a", "5", str(out_ogg)],
capture_output=True,
)
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
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
segment.
Two-pass approach:
1. Replace each *known* path (decoder binary, input WEM, intended
output) verbatim so its basename survives even when the path
contains characters the generic regex's character class
excludes (e.g. quoted arguments).
2. Run the generic absolute-path regex over the remainder so
paths the decoder emitted itself ("could not open
/unrelated/private/file") also get redacted to their
basename. Decoders sometimes log paths the caller never
passed in (e.g. plugin search paths, dynamic loader paths),
and those are exactly the ones the caller can't enumerate."""
out = text
for p in paths:
if not p:
continue
out = out.replace(p, _basename_any_path(p))
# Quoted paths first so the unquoted pass doesn't claim part of a
# quoted match — paths with spaces only survive when quoted, so we
# need that branch to win there.
out = _QUOTED_ABS_PATH_RE.sub(_scrub_quoted_match, out)
out = _UNQUOTED_ABS_PATH_RE.sub(_scrub_unquoted_match, out)
return out
def _decode_wem_to_wav(vgmstream: str, wem_path: str, wav_path: str) -> tuple[bool, str]:
"""Decode a WEM file to WAV using vgmstream and return status + detail.
Catches launch-time OSError (wrong architecture, missing dynamic loader,
permission errors a stat-check can't predict) so callers can record the
failure and fall through to ffmpeg / ww2ogg instead of crashing. The
returned detail is scrubbed of absolute paths because callers fold it
into the user-facing decode error."""
try:
r = subprocess.run(
[vgmstream, "-o", wav_path, wem_path],
capture_output=True,
text=True,
# `errors='replace'` — without this, a vgmstream build that
# emits non-UTF-8 bytes (corrupt input, locale mismatch) makes
# subprocess.run raise UnicodeDecodeError and bypass the
# failure-aggregation path the caller relies on.
errors="replace",
timeout=120,
)
except (OSError, subprocess.TimeoutExpired) as exc:
# Log the full path server-side for ops, but keep the client-facing
# detail path-neutral. OSError/TimeoutExpired stringify with the
# command path or filename in many cases (e.g. exec-format errors
# quote the filename, TimeoutExpired stringifies the cmd list), so
# the exc text itself also needs scrubbing.
log.warning("vgmstream launch failed (%s): %s", vgmstream, exc)
scrubbed = _scrub_paths(str(exc), vgmstream, wem_path, wav_path)
return False, f"failed to invoke {_basename_any_path(vgmstream)}: {_truncate_detail(scrubbed)}"
if r.returncode == 0 and os.path.exists(wav_path) and os.path.getsize(wav_path) > 0:
return True, ""
# Strip both streams before choosing which to report — a whitespace-only
# stderr would otherwise suppress a useful stdout message and the caller
# would see only "exit code N".
err = (r.stderr or "").strip()
out = (r.stdout or "").strip()
detail = err or out or f"exit code {r.returncode}"
return False, _truncate_detail(_scrub_paths(detail, vgmstream, wem_path, wav_path))
def find_wem_files(extracted_dir: str) -> list[str]:
"""Find WEM audio files, sorted largest first (full song before preview)."""
wem_files = list(Path(extracted_dir).rglob("*.wem"))
wem_files.sort(key=lambda p: p.stat().st_size, reverse=True)
return [str(f) for f in wem_files]
def convert_wem(wem_path: str, output_base: str) -> str:
"""
Convert a WEM file to a playable format.
Returns path to the converted audio file.
"""
# `errors` holds *attempted-decoder* failures (something ran, didn't
# work); `resolution_notes` holds *configuration* warnings (e.g.
# a stale VGMSTREAM_CLI). Keeping them separate matters for the
# final branch: if every decoder is missing entirely we want the
# actionable "install vgmstream-cli" guidance, not "Failed to
# decode" — but the resolution note should still ride along on
# either path so a misconfigured user understands why their
# override was ignored.
errors: list[str] = []
resolution_notes: list[str] = []
# Try vgmstream-cli → WAV → MP3 (best browser compatibility).
vgmstream = _vgmstream_cmd(resolution_notes=resolution_notes)
if vgmstream:
wav = output_base + ".wav"
ok, detail = _decode_wem_to_wav(vgmstream, wem_path, wav)
if ok:
ffmpeg = _ffmpeg_cmd()
if ffmpeg:
mp3 = output_base + ".mp3"
# Same OSError/timeout protection as the direct-fallback
# ffmpeg calls below — vgmstream decoded fine, but a
# wrong-arch / missing-loader ffmpeg would otherwise
# raise raw out of convert_wem instead of letting us
# fall back to returning the decoded WAV.
try:
r2 = subprocess.run(
[ffmpeg, "-y", "-i", wav, "-b:a", "192k", mp3],
capture_output=True,
timeout=120,
)
except (OSError, subprocess.TimeoutExpired) as exc:
log.warning("ffmpeg MP3-transcode launch failed (%s): %s", ffmpeg, exc)
r2 = None
if r2 is not None and r2.returncode == 0 and os.path.exists(mp3):
os.remove(wav)
return mp3
return wav
errors.append(f"vgmstream: {detail}")
# Try ffmpeg directly (some builds handle Wwise). Wrap subprocess.run
# in try/except like _decode_wem_to_wav does — a wrong-architecture
# or broken-loader ffmpeg binary would otherwise raise OSError out of
# convert_wem and the browser would receive the raw exception text
# (including absolute paths) instead of the scrubbed aggregated
# decoder error, while also skipping the ww2ogg fallback below.
ffmpeg = _ffmpeg_cmd()
if ffmpeg:
mp3 = output_base + ".mp3"
try:
r = subprocess.run(
[ffmpeg, "-y", "-i", wem_path, "-b:a", "192k", mp3],
capture_output=True,
timeout=120,
)
except (OSError, subprocess.TimeoutExpired) as exc:
log.warning("ffmpeg launch failed (%s): %s", ffmpeg, exc)
errors.append(
f"ffmpeg mp3: failed to invoke {_basename_any_path(ffmpeg)}: "
+ _truncate_detail(_scrub_paths(str(exc), ffmpeg, wem_path, mp3))
)
r = None
if r is not None:
if r.returncode == 0 and os.path.exists(mp3) and os.path.getsize(mp3) > 0:
return mp3
stderr = (r.stderr or b'').decode(errors='replace').strip() or f"exit code {r.returncode}"
errors.append(
f"ffmpeg mp3: {_truncate_detail(_scrub_paths(stderr, ffmpeg, wem_path, mp3))}"
)
# Try WAV output as fallback
wav = output_base + ".wav"
try:
r = subprocess.run(
[ffmpeg, "-y", "-i", wem_path, wav],
capture_output=True,
timeout=120,
)
except (OSError, subprocess.TimeoutExpired) as exc:
log.warning("ffmpeg launch failed (%s): %s", ffmpeg, exc)
errors.append(
f"ffmpeg wav: failed to invoke {_basename_any_path(ffmpeg)}: "
+ _truncate_detail(_scrub_paths(str(exc), ffmpeg, wem_path, wav))
)
r = None
if r is not None:
if r.returncode == 0 and os.path.exists(wav) and os.path.getsize(wav) > 0:
return wav
stderr = (r.stderr or b'').decode(errors='replace').strip() or f"exit code {r.returncode}"
errors.append(
f"ffmpeg wav: {_truncate_detail(_scrub_paths(stderr, ffmpeg, wem_path, wav))}"
)
# Try ww2ogg — same launch-failure protection as ffmpeg above.
# `shutil.which` confirms the file is executable, not that the kernel
# can actually exec it (wrong arch / missing loader still raise here).
ww2ogg = shutil.which("ww2ogg")
if ww2ogg:
ogg = output_base + ".ogg"
try:
r = subprocess.run(
[ww2ogg, wem_path, "-o", ogg],
capture_output=True,
timeout=120,
)
except (OSError, subprocess.TimeoutExpired) as exc:
log.warning("ww2ogg launch failed (%s): %s", ww2ogg, exc)
errors.append(
f"ww2ogg: failed to invoke {_basename_any_path(ww2ogg)}: "
+ _truncate_detail(_scrub_paths(str(exc), ww2ogg, wem_path, ogg))
)
r = None
if r is not None:
if r.returncode == 0 and os.path.exists(ogg) and os.path.getsize(ogg) > 0:
return ogg
stderr = (r.stderr or b'').decode(errors='replace').strip() or f"exit code {r.returncode}"
errors.append(
f"ww2ogg: {_truncate_detail(_scrub_paths(stderr, ww2ogg, wem_path, ogg))}"
)
_INSTALL_GUIDANCE = (
"Install vgmstream-cli:\n"
" Manjaro/Arch: yay -S vgmstream-cli-bin\n"
" Or set VGMSTREAM_CLI to a built binary, e.g. vgmstream/cli/vgmstream-cli"
)
user_msg_prefix = " | ".join(resolution_notes) + (" | " if resolution_notes else "")
if errors:
# Something ran and failed. `wem_path` is the on-disk input
# path, often deep inside the user's DLC dir — log the full
# path for ops, but keep the client-facing error to just the
# filename. If vgmstream itself was never resolved (only ffmpeg
# or ww2ogg tried-and-failed), append the install guidance —
# ffmpeg is commonly present and often can't decode Wwise WEMs,
# so without this hint a user missing the primary decoder
# never sees "install vgmstream-cli" guidance.
suffix = ""
if not vgmstream:
suffix = f" | (Hint: {_INSTALL_GUIDANCE})"
log.warning("Decode failed for %s: %s",
wem_path, " | ".join([*resolution_notes, *errors]))
raise RuntimeError(
f"Failed to decode WEM {_basename_any_path(wem_path)}: "
+ user_msg_prefix + " | ".join(errors) + suffix
)
# No decoder ran at all — give the actionable guidance, with the
# resolution note prefixed so a user who *did* set VGMSTREAM_CLI
# (incorrectly) understands why their override didn't help.
raise RuntimeError(
user_msg_prefix + "No WEM audio decoder found. " + _INSTALL_GUIDANCE
)
File diff suppressed because it is too large Load Diff
+239
View File
@@ -0,0 +1,239 @@
"""Backend hardware probe for diagnostic bundles.
Produces a `system.hardware.v1`-shaped dict — see
docs/diagnostics-bundle-spec.md.
All probes are best-effort and never raise. Missing tools, missing
permissions, container masking — every case yields a structured note in
the output rather than a 500 on the export endpoint.
"""
from __future__ import annotations
import json
import os
import platform
import subprocess
from pathlib import Path
SCHEMA = "system.hardware.v1"
def _safe_run(cmd: list[str], timeout: float = 2.0) -> tuple[int, str, str]:
try:
proc = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=timeout,
check=False,
)
return proc.returncode, proc.stdout or "", proc.stderr or ""
except (FileNotFoundError, subprocess.TimeoutExpired, OSError):
return 127, "", ""
def detect_runtime() -> dict:
"""Cheap runtime-kind detection (env var + cgroup checks, no subprocess).
Exported as a public function so callers that don't need the full
hardware probe can still obtain the runtime kind without paying for
nvidia-smi / psutil CPU probes.
"""
out: dict = {"kind": "bare", "in_docker": False, "in_kubernetes": False}
env_runtime = os.environ.get("SLOPSMITH_RUNTIME", "").strip().lower()
if env_runtime in ("electron", "docker", "bare"):
out["kind"] = env_runtime
if Path("/.dockerenv").exists():
out["in_docker"] = True
if out["kind"] == "bare":
out["kind"] = "docker"
cgroup = Path("/proc/1/cgroup")
if cgroup.exists():
try:
txt = cgroup.read_text(errors="ignore")
if "docker" in txt or "containerd" in txt or "kubepods" in txt:
out["in_docker"] = True
if out["kind"] == "bare":
out["kind"] = "docker"
except OSError:
pass
if os.environ.get("KUBERNETES_SERVICE_HOST"):
out["in_kubernetes"] = True
if out["kind"] == "bare":
try:
import psutil # type: ignore
parent = psutil.Process(os.getppid()).name().lower()
if "electron" in parent or "slopsmith" in parent:
out["kind"] = "electron"
except Exception:
pass
return out
def _probe_os() -> dict:
return {
"system": platform.system(),
"release": platform.release(),
"version": platform.version(),
"machine": platform.machine(),
}
def _probe_cpu(notes: list[str]) -> dict:
out: dict = {
"brand": None,
"arch": platform.machine(),
"cores_logical": os.cpu_count(),
"cores_physical": None,
"freq_mhz_current": None,
"freq_mhz_max": None,
}
try:
import psutil # type: ignore
out["cores_physical"] = psutil.cpu_count(logical=False)
freq = psutil.cpu_freq()
if freq:
out["freq_mhz_current"] = round(freq.current) if freq.current else None
out["freq_mhz_max"] = round(freq.max) if freq.max else None
except Exception as e:
notes.append(f"psutil cpu probe failed: {e}")
try:
import cpuinfo # type: ignore
info = cpuinfo.get_cpu_info() or {}
out["brand"] = info.get("brand_raw") or info.get("brand") or None
except Exception as e:
notes.append(f"py-cpuinfo probe failed: {e}")
# Fallback: platform.processor() is reliable on Windows + some Linux,
# useless on macOS Apple Silicon (returns 'arm').
proc = platform.processor()
if proc and proc.lower() not in ("arm", "i386"):
out["brand"] = proc
return out
def _probe_memory(notes: list[str]) -> dict:
out: dict = {"total_bytes": None, "available_bytes": None}
try:
import psutil # type: ignore
vm = psutil.virtual_memory()
out["total_bytes"] = int(vm.total)
out["available_bytes"] = int(vm.available)
except Exception as e:
notes.append(f"psutil memory probe failed: {e}")
return out
def _probe_gpu_nvidia() -> list[dict]:
rc, stdout, _ = _safe_run(
[
"nvidia-smi",
"--query-gpu=name,driver_version,memory.total",
"--format=csv,noheader,nounits",
]
)
if rc != 0 or not stdout.strip():
return []
gpus: list[dict] = []
for line in stdout.strip().splitlines():
parts = [p.strip() for p in line.split(",")]
if len(parts) < 3:
continue
try:
mem_mb = int(parts[2])
except ValueError:
mem_mb = None
gpus.append({
"source": "nvidia-smi",
"name": parts[0],
"driver": parts[1],
"memory_total_mb": mem_mb,
})
return gpus
def _probe_gpu_rocm() -> list[dict]:
rc, stdout, _ = _safe_run(
["rocm-smi", "--showproductname", "--showdriverversion", "--json"]
)
if rc != 0 or not stdout.strip():
return []
try:
data = json.loads(stdout)
except json.JSONDecodeError:
return []
gpus: list[dict] = []
for card_id, card in (data or {}).items():
if not isinstance(card, dict):
continue
gpu: dict = {
"source": "rocm-smi",
"id": card_id,
"name": card.get("Card series") or card.get("Card model") or "AMD GPU",
}
driver = card.get("Driver version") or card.get("driver_version")
if driver:
gpu["driver"] = driver
gpus.append(gpu)
return gpus
def _probe_gpu_macos() -> list[dict]:
if platform.system() != "Darwin":
return []
rc, stdout, _ = _safe_run(
["system_profiler", "SPDisplaysDataType", "-json"], timeout=4.0
)
if rc != 0 or not stdout.strip():
return []
try:
data = json.loads(stdout)
except json.JSONDecodeError:
return []
gpus: list[dict] = []
for card in data.get("SPDisplaysDataType", []) or []:
gpus.append({
"source": "system_profiler",
"name": card.get("sppci_model") or card.get("_name") or "GPU",
"vendor": card.get("spdisplays_vendor"),
"metal_support": card.get("spdisplays_metalfamily"),
})
return gpus
def _probe_gpus(notes: list[str]) -> list[dict]:
gpus: list[dict] = []
gpus.extend(_probe_gpu_nvidia())
gpus.extend(_probe_gpu_rocm())
gpus.extend(_probe_gpu_macos())
if not gpus:
notes.append(
"no GPU probes succeeded — nvidia-smi/rocm-smi/system_profiler absent or denied"
)
return gpus
def collect() -> dict:
"""Build a `system.hardware.v1` dict. Never raises."""
notes: list[str] = []
runtime = detect_runtime()
cpu = _probe_cpu(notes)
memory = _probe_memory(notes)
gpu = _probe_gpus(notes)
if runtime["in_docker"]:
notes.append(
"container masks host CPU/RAM — values reflect container limits, not host"
)
return {
"schema": SCHEMA,
"runtime": runtime,
"os": _probe_os(),
"cpu": cpu,
"memory": memory,
"gpu": gpu,
"notes": notes,
}
+158
View File
@@ -0,0 +1,158 @@
"""Redaction primitives for diagnostic bundles.
A `Redactor` carries the per-bundle salt and substitution caches so that
identical inputs (e.g. the same song path appearing in 50 log lines)
produce identical output tokens (`<song:a3f1c2>`). Different bundles get
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
<song:hash8> — song filename / basename (8 hex chars)
<ip:hash6> — IPv4 / IPv6 address (6 hex chars)
<redacted> — bearer tokens, key=/token= query strings
"""
from __future__ import annotations
import hashlib
import re
import secrets
from pathlib import Path
_IPV4_RE = re.compile(r"\b(?:\d{1,3}\.){3}\d{1,3}\b")
_IPV6_RE = re.compile(
r"(?<![A-Fa-f0-9:])"
r"(?:[A-Fa-f0-9]{1,4}:){2,7}[A-Fa-f0-9]{1,4}"
r"(?![A-Fa-f0-9:])"
)
_BEARER_RE = re.compile(r"(?i)\bBearer\s+[A-Za-z0-9._\-+/=]+")
_URL_USERINFO_RE = re.compile(r"(?i)(https?://)[^@/\s]+@")
_QSTRING_SECRET_RE = re.compile(
r"(?i)\b(api[_-]?key|key|token|secret|password|pwd|auth)=([^\s&\"']+)"
)
_SONG_FILENAME_RE = re.compile(
r"\b[\w()'\-+&,.!?\[\]]+\.(?:psarc|sloppak|wem|ogg|mp3|wav)\b",
re.IGNORECASE,
)
class Redactor:
def __init__(
self,
dlc_dir: Path | None = None,
home_dir: Path | None = None,
config_dir: Path | None = None,
) -> None:
self._salt = secrets.token_hex(8)
self._dlc_dir = self._normalize(dlc_dir)
self._home_dir = self._normalize(home_dir)
self._config_dir = self._normalize(config_dir)
self._song_cache: dict[str, str] = {}
self._ip_cache: dict[str, str] = {}
self.counts: dict[str, int] = {
"paths_replaced": 0,
"ips_replaced": 0,
"song_names_replaced": 0,
"secrets_replaced": 0,
}
@staticmethod
def _normalize(p: Path | None) -> str | None:
if p is None:
return None
# Resolve only when the path exists, so callers can pass a
# synthetic prefix (tests, container-mapped paths) without
# having Path.resolve() rewrite a missing /dlc/songs to
# C:\dlc\songs on Windows.
try:
if p.exists():
s = str(p.resolve())
else:
s = str(p)
except (OSError, RuntimeError):
s = str(p)
return s if s and s != "." else None
def _hash(self, value: str, n: int) -> str:
h = hashlib.sha256()
h.update(self._salt.encode())
h.update(value.encode())
return h.hexdigest()[:n]
def _replace_path_prefix(self, text: str, prefix: str | None, token: str) -> str:
if not prefix:
return text
# Match both forward- and backslash variants — Windows paths
# appear with backslashes in tracebacks, Linux with slashes.
candidates = {prefix, prefix.replace("/", "\\"), prefix.replace("\\", "/")}
replaced = text
for cand in candidates:
if not cand:
continue
count = replaced.count(cand)
if count:
replaced = replaced.replace(cand, token)
self.counts["paths_replaced"] += count
return replaced
def _redact_song(self, m: re.Match) -> str:
name = m.group(0)
token = self._song_cache.get(name)
if token is None:
token = f"<song:{self._hash(name, 8)}>"
self._song_cache[name] = token
self.counts["song_names_replaced"] += 1
return token
def _redact_ip(self, m: re.Match) -> str:
ip = m.group(0)
# Skip obvious non-IPs: dotted version numbers, sloppy fragments.
if ip.count(".") == 3:
try:
if not all(0 <= int(p) <= 255 for p in ip.split(".")):
return ip
except ValueError:
return ip
token = self._ip_cache.get(ip)
if token is None:
token = f"<ip:{self._hash(ip, 6)}>"
self._ip_cache[ip] = token
self.counts["ips_replaced"] += 1
return token
def _redact_secret_qstring(self, m: re.Match) -> str:
self.counts["secrets_replaced"] += 1
return f"{m.group(1)}=<redacted>"
def _redact_bearer(self, _m: re.Match) -> str:
self.counts["secrets_replaced"] += 1
return "Bearer <redacted>"
def _redact_url_userinfo(self, m: re.Match) -> str:
self.counts["secrets_replaced"] += 1
return f"{m.group(1)}<redacted>@"
def redact_text(self, text: str) -> str:
if not isinstance(text, str) or not text:
return text
# Path prefixes first (longest-match) so song-name regex never
# eats a path component.
text = self._replace_path_prefix(text, self._dlc_dir, "<DLC_DIR>")
text = self._replace_path_prefix(text, self._config_dir, "<CONFIG_DIR>")
text = self._replace_path_prefix(text, self._home_dir, "<HOME>")
text = _SONG_FILENAME_RE.sub(self._redact_song, text)
text = _IPV6_RE.sub(self._redact_ip, text)
text = _IPV4_RE.sub(self._redact_ip, text)
# URL userinfo before query-string secrets so user:pass@ is caught
# even when the URL also has token= in the query string.
text = _URL_USERINFO_RE.sub(self._redact_url_userinfo, text)
text = _QSTRING_SECRET_RE.sub(self._redact_secret_qstring, text)
text = _BEARER_RE.sub(self._redact_bearer, text)
return text
def redact_lines(self, lines):
for line in lines:
yield self.redact_text(line)
+289
View File
@@ -0,0 +1,289 @@
"""Drum kit vocabulary, presets, and drum_tab.json helpers.
The canonical drum payload in a sloppak is a top-level `drum_tab.json` file
referenced from `manifest.yaml` via the `drum_tab:` key (see
`docs/sloppak-spec.md` §5.3). This module is the source of truth for:
- the closed list of drum piece-ids that a `drum_tab.json` may reference,
- their default GM percussion MIDI notes and visual category,
- preset lane configurations for the drums plugin,
- a permissive validator + short-key wire helper used by both the writer
side (importers) and the reader side (sloppak loader + highway WS).
The schema is intentionally extensible: unknown piece-ids round-trip through
the loader so a newer sloppak can still play on an older client that just
doesn't have visuals for the new piece. Validation is strict only on the
top-level shape (`version`, `kit`, `hits` types).
"""
from __future__ import annotations
import logging
import math
log = logging.getLogger("slopsmith.lib.drums")
# ── Piece vocabulary ──────────────────────────────────────────────────────────
#
# Each entry pins a closed piece-id to its default General MIDI percussion
# note(s), a category (kick/drum/cymbal — drives default shape rendering), and
# a default colour. The drums plugin reads this map on startup and uses the
# defaults to seed the user's lane configuration; users can override colours
# and shapes per lane in localStorage.
PIECES: dict[str, dict] = {
# Kick — full-width bar across all non-kick lanes.
"kick": {"midi": [35, 36], "category": "kick", "shape": "bar", "color": "#f59e0b"},
# Drums proper — rectangles. Toms ordered hi→floor.
"snare": {"midi": [38, 40], "category": "drum", "shape": "rect", "color": "#ef4444"},
"snare_xstick": {"midi": [37], "category": "drum", "shape": "rect_hatched", "color": "#dc2626"},
"tom_hi": {"midi": [50, 48], "category": "drum", "shape": "rect", "color": "#eab308"},
"tom_mid": {"midi": [47, 45], "category": "drum", "shape": "rect", "color": "#ca8a04"},
"tom_low": {"midi": [43], "category": "drum", "shape": "rect", "color": "#a16207"},
"tom_floor": {"midi": [41], "category": "drum", "shape": "rect", "color": "#854d0e"},
# Cymbals — circles. Open/closed hi-hat are distinct piece-ids, not a
# per-hit articulation flag, because hit detection must reject a
# closed-hat strike on an open-hat note (and vice versa).
"hh_closed": {"midi": [42], "category": "cymbal", "shape": "circle_filled", "color": "#22d3ee"},
"hh_open": {"midi": [46], "category": "cymbal", "shape": "circle_ring", "color": "#06b6d4"},
"hh_pedal": {"midi": [44], "category": "cymbal", "shape": "circle_small_x", "color": "#0891b2"},
# Stack — two cymbals stacked for a trashy/choked effect. GM has no
# standard for it; we reuse 30 (in GM's extended-percussion range,
# unused by real drum-kit MIDIs).
"stack": {"midi": [30], "category": "cymbal", "shape": "circle_jagged", "color": "#94a3b8"},
"crash_l": {"midi": [49], "category": "cymbal", "shape": "circle", "color": "#84cc16"},
"crash_r": {"midi": [57], "category": "cymbal", "shape": "circle", "color": "#65a30d"},
"splash": {"midi": [55], "category": "cymbal", "shape": "circle_small", "color": "#a3e635"},
"china": {"midi": [52], "category": "cymbal", "shape": "circle_jagged", "color": "#4d7c0f"},
"ride": {"midi": [51, 59], "category": "cymbal", "shape": "circle", "color": "#3b82f6"},
"ride_bell": {"midi": [53], "category": "cymbal", "shape": "circle_dot", "color": "#1d4ed8"},
# Bell cymbal — a small mounted bell, distinct from the ride's bell.
# No GM standard; we reuse 80 ("Mute Triangle"), unused in real
# drum-kit MIDIs.
"bell": {"midi": [80], "category": "cymbal", "shape": "circle_dot", "color": "#fde047"},
}
# Reverse map MIDI note → piece-id. First piece-id whose `midi` list contains
# the note wins (PIECES is iteration-ordered so the "preferred" piece-id for a
# shared MIDI is the one declared earlier). Built once at import time.
_MIDI_TO_PIECE: dict[int, str] = {}
for _pid, _meta in PIECES.items():
for _m in _meta["midi"]:
_MIDI_TO_PIECE.setdefault(_m, _pid)
def midi_to_piece(midi: int) -> str | None:
"""Return the canonical piece-id for a GM percussion MIDI note, or None
if the note isn't mapped (e.g. cowbell, tambourine — extensible later)."""
return _MIDI_TO_PIECE.get(int(midi))
def piece_to_default_midi(piece: str) -> list[int]:
"""Return the GM MIDI notes that map to `piece` by default. Empty list for
unknown piece-ids — callers should treat that as "unmapped" rather than
crashing, so a newer sloppak's unknown piece round-trips silently."""
entry = PIECES.get(piece)
return list(entry["midi"]) if entry else []
def piece_default_shape(piece: str) -> str:
"""Default rendering shape for a piece-id. `"rect"` fallback so an
unknown piece still draws something the user can see."""
entry = PIECES.get(piece)
return entry["shape"] if entry else "rect"
def piece_default_color(piece: str) -> str:
"""Default colour for a piece-id. Neutral grey fallback for unknown."""
entry = PIECES.get(piece)
return entry["color"] if entry else "#9ca3af"
def piece_category(piece: str) -> str:
"""Category (`kick`/`drum`/`cymbal`) — `"drum"` fallback for unknown."""
entry = PIECES.get(piece)
return entry["category"] if entry else "drum"
# ── Preset lane configurations ────────────────────────────────────────────────
#
# Each preset is a list of `lane` dicts. A lane carries:
# - `pieces`: list of piece-ids that route to this lane (multiple → shared)
# - `label`: short header text
# Visual fields (color, shape, weight) are optional; the renderer falls back
# to the per-piece defaults above. The drums plugin layers user customisation
# on top of these.
PRESET_RB4 = [
{"pieces": ["kick"], "label": "Ki"},
{"pieces": ["snare", "snare_xstick"], "label": "Sn"},
{"pieces": ["hh_closed", "hh_open", "hh_pedal"], "label": "HH"},
{"pieces": ["tom_hi", "tom_mid"], "label": "T"},
{"pieces": ["tom_low", "tom_floor"], "label": "FT"},
{"pieces": ["crash_l", "crash_r", "splash", "china", "stack"], "label": "Cr"},
{"pieces": ["ride", "ride_bell", "bell"], "label": "Ri"},
]
# 8-lane layout matching the legacy drums plugin v3 (HH / Sn / T1 / T2 / T3 /
# Cr / Ri / Ki) so existing sloppaks keep their familiar lane order when the
# rewrite ships.
PRESET_PHASESHIFT8 = [
{"pieces": ["hh_closed", "hh_open", "hh_pedal"], "label": "HH"},
{"pieces": ["snare", "snare_xstick"], "label": "Sn"},
{"pieces": ["tom_hi"], "label": "T1"},
{"pieces": ["tom_mid"], "label": "T2"},
{"pieces": ["tom_low", "tom_floor"], "label": "T3"},
{"pieces": ["crash_l", "crash_r", "splash", "china", "stack"], "label": "Cr"},
{"pieces": ["ride", "ride_bell", "bell"], "label": "Ri"},
{"pieces": ["kick"], "label": "Ki"},
]
# One lane per piece-id — for users with a full e-kit who want every piece on
# its own column. Order roughly mirrors a physical kit left→right.
PRESET_EKIT_FULL = [
{"pieces": ["hh_pedal"], "label": "HH-p"},
{"pieces": ["hh_closed"], "label": "HH-c"},
{"pieces": ["hh_open"], "label": "HH-o"},
{"pieces": ["snare_xstick"], "label": "Sn-x"},
{"pieces": ["snare"], "label": "Sn"},
{"pieces": ["tom_hi"], "label": "T1"},
{"pieces": ["tom_mid"], "label": "T2"},
{"pieces": ["tom_low"], "label": "T3"},
{"pieces": ["tom_floor"], "label": "FT"},
{"pieces": ["stack"], "label": "Stk"},
{"pieces": ["crash_l"], "label": "Cr-L"},
{"pieces": ["splash"], "label": "Sp"},
{"pieces": ["china"], "label": "Ch"},
{"pieces": ["ride"], "label": "Ri"},
{"pieces": ["ride_bell"], "label": "Ri-B"},
{"pieces": ["bell"], "label": "Bl"},
{"pieces": ["crash_r"], "label": "Cr-R"},
{"pieces": ["kick"], "label": "Ki"},
]
PRESETS: dict[str, list[dict]] = {
"rb4": PRESET_RB4,
"phase_shift_8": PRESET_PHASESHIFT8,
"ekit_full": PRESET_EKIT_FULL,
}
# ── drum_tab.json schema helpers ──────────────────────────────────────────────
# Default velocity when a hit omits `v`. Matches spec §5.3 ("v is optional,
# defaults to 100 — keeps simple charts terse").
DEFAULT_VELOCITY = 100
# Current `version` written by importers. Readers MUST accept any version they
# recognise; an unknown version is logged at DEBUG level on every call to
# validate_drum_tab() and the payload is still passed
# through (per Principle IV, additive evolution).
SCHEMA_VERSION = 1
def validate_drum_tab(data: object) -> tuple[bool, str]:
"""Light schema check for a parsed `drum_tab.json` payload.
Returns `(ok, reason)`. Accepts both `version: 1` (current) and absent
`version` (treat as 1) for forward-compat with hand-edited tabs.
`hits[]` is required and must be a list; individual hits are NOT
validated here — per-hit filtering happens in `hit_to_wire()` /
`hits_to_wire()` at WS-stream time, so a single malformed hit cannot
disqualify the whole tab.
"""
if not isinstance(data, dict):
return False, "drum_tab payload must be a JSON object"
hits = data.get("hits")
if not isinstance(hits, list):
return False, "drum_tab.hits must be a list"
kit = data.get("kit", [])
if kit is not None and not isinstance(kit, list):
return False, "drum_tab.kit must be a list (or omitted)"
ver = data.get("version", SCHEMA_VERSION)
if isinstance(ver, bool) or not isinstance(ver, int):
return False, "drum_tab.version must be an integer"
if ver != SCHEMA_VERSION:
log.debug("drum_tab: unknown schema version %r — passing through", ver)
return True, ""
def hit_to_wire(hit: dict) -> dict | None:
"""Normalise one hit dict into the short-key wire form streamed by
`/ws/highway/{filename}`. Returns None on a malformed hit (missing `t`
or `p`) so the loader can drop just that entry without aborting the
whole tab.
Wire keys (all optional except `t`, `p`):
t float seconds required, monotonic
p string piece-id required, free-form (validated against PIECES
by the client; unknown ids render as `"rect"`)
v int 1-127 velocity (omitted when absent; client defaults
to DEFAULT_VELOCITY)
g bool ghost note
f bool flam
k float seconds cymbal-choke tail duration
"""
if not isinstance(hit, dict):
return None
t_raw = hit.get("t")
if isinstance(t_raw, bool):
return None
try:
t = float(t_raw) # type: ignore[arg-type]
except (TypeError, ValueError):
return None
if not math.isfinite(t):
return None
p = hit.get("p")
if not isinstance(p, str) or not p:
return None
out: dict = {"t": round(t, 3), "p": p}
v = hit.get("v")
if not isinstance(v, bool) and isinstance(v, (int, float)) and math.isfinite(v) and 1 <= int(v) <= 127:
out["v"] = int(v)
if bool(hit.get("g")):
out["g"] = True
if bool(hit.get("f")):
out["f"] = True
k = hit.get("k")
if not isinstance(k, bool) and isinstance(k, (int, float)) and math.isfinite(k) and k > 0:
out["k"] = round(float(k), 3)
return out
def hits_to_wire(hits: list[dict]) -> list[dict]:
"""Vectorised `hit_to_wire` — drops malformed entries, sorts by time."""
out: list[dict] = []
for h in hits:
w = hit_to_wire(h)
if w is not None:
out.append(w)
out.sort(key=lambda h: h["t"])
return out
def normalise_kit(kit: list | None) -> list[dict]:
"""Normalise the `kit[]` legend: each entry becomes `{"id": str, "name":
str}`. Unknown piece-ids are kept (forward-compat) with a title-cased
fallback name. Returns an empty list for missing/empty kit (the client
will derive the kit from the union of `hits[].p` in that case)."""
if not isinstance(kit, list):
return []
out: list[dict] = []
seen: set[str] = set()
for entry in kit:
if not isinstance(entry, dict):
continue
pid = entry.get("id")
if not isinstance(pid, str) or not pid or pid in seen:
continue
seen.add(pid)
name = entry.get("name")
if not isinstance(name, str) or not name:
name = pid.replace("_", " ").title()
out.append({"id": pid, "name": name})
return out
+307
View File
@@ -0,0 +1,307 @@
"""Generate MIDI and render audio from a Guitar Pro file."""
import glob
import logging
import os
import subprocess
import sys
import tempfile
from pathlib import Path
log = logging.getLogger("slopsmith.lib.gp2midi")
import guitarpro
from midiutil import MIDIFile
GP_TICKS_PER_QUARTER = 960
# Standard tuning MIDI values (GP string order: 1=high, 6=low)
STANDARD_6 = [64, 59, 55, 50, 45, 40] # e B G D A E
STANDARD_4 = [43, 38, 33, 28] # G D A E (bass)
def gp_to_midi(gp_path: str, output_midi: str, track_indices: list[int] | None = None,
force_standard_tuning: bool = False) -> str:
"""Convert Guitar Pro file to MIDI.
Args:
gp_path: Path to .gp5/.gp4/.gp3 file
output_midi: Output .mid file path
track_indices: Which tracks to include (None = all non-percussion)
force_standard_tuning: If True, use E standard tuning for all instruments
(keeps fret numbers, changes the pitch of open strings)
Returns:
Path to the MIDI file
"""
song = guitarpro.parse(gp_path)
if track_indices is None:
track_indices = list(range(len(song.tracks)))
midi = MIDIFile(
len(track_indices),
ticks_per_quarternote=GP_TICKS_PER_QUARTER,
)
for midi_track_idx, gp_track_idx in enumerate(track_indices):
track = song.tracks[gp_track_idx]
is_perc = track.isPercussionTrack
# MIDI channel: percussion must be 9, others avoid 9
if is_perc:
channel = 9
else:
channel = midi_track_idx if midi_track_idx < 9 else midi_track_idx + 1
channel = min(channel, 15)
midi.addTrackName(midi_track_idx, 0, track.name)
midi.addTempo(midi_track_idx, 0, song.tempo)
# Instrument and volume from GP channel data
gp_ch = track.channel
if gp_ch and not is_perc:
midi.addProgramChange(midi_track_idx, channel, 0, gp_ch.instrument)
elif not is_perc:
midi.addProgramChange(midi_track_idx, channel, 0, 29) # overdriven guitar
# Volume (CC7) and pan (CC10)
if gp_ch:
vol = min(127, gp_ch.volume)
pan = min(127, gp_ch.balance)
midi.addControllerEvent(midi_track_idx, channel, 0, 7, vol)
midi.addControllerEvent(midi_track_idx, channel, 0, 10, pan)
# Tempo changes
tempo_added = set()
for measure in track.measures:
for voice in measure.voices:
for beat in voice.beats:
if beat.effect and beat.effect.mixTableChange:
mtc = beat.effect.mixTableChange
if mtc.tempo and mtc.tempo.value > 0:
tick_time = beat.start / GP_TICKS_PER_QUARTER
if tick_time not in tempo_added:
midi.addTempo(midi_track_idx, tick_time, mtc.tempo.value)
tempo_added.add(tick_time)
# Notes
for measure in track.measures:
for voice in measure.voices:
for beat in voice.beats:
if not beat.notes:
continue
beat_time = beat.start / GP_TICKS_PER_QUARTER
dur_quarters = 4.0 / beat.duration.value
if beat.duration.isDotted:
dur_quarters *= 1.5
if beat.duration.tuplet.enters > 0 and beat.duration.tuplet.times > 0:
dur_quarters *= beat.duration.tuplet.times / beat.duration.tuplet.enters
for note in beat.notes:
if note.type == guitarpro.NoteType.rest:
continue
if force_standard_tuning and not is_perc:
num_strings = len(track.strings)
std = STANDARD_4 if num_strings == 4 else STANDARD_6
string_midi = std[note.string - 1] if note.string - 1 < len(std) else track.strings[note.string - 1].value
else:
string_midi = track.strings[note.string - 1].value
pitch = string_midi + note.value
if note.type == guitarpro.NoteType.dead:
dur_q = 0.05
else:
dur_q = dur_quarters
velocity = note.velocity
if note.effect.ghostNote:
velocity = max(20, velocity // 2)
# Skip invalid notes that would cause midiutil to crash
if dur_q <= 0:
dur_q = 0.05
if pitch < 0 or pitch > 127:
continue
if velocity <= 0:
velocity = 1
midi.addNote(
midi_track_idx, channel,
pitch, beat_time, dur_q, velocity,
)
with open(output_midi, "wb") as f:
try:
midi.writeFile(f)
except IndexError:
# midiutil can crash with "pop from empty list" on malformed note events
# Retry with deinterleave disabled
f.seek(0)
f.truncate()
midi.close()
midi.writeFile(f)
return output_midi
def _find_soundfont() -> str | None:
"""Locate a .sf2 soundfont for MIDI rendering.
Precedence:
1. ``SLOPSMITH_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")
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)
resources = os.environ.get("RESOURCESPATH")
if resources:
matches = sorted(glob.glob(os.path.join(resources, "soundfonts", "*.sf2")))
if matches:
return matches[0]
candidates: list[str] = []
if sys.platform.startswith("linux"):
candidates += [
"/usr/share/soundfonts/FluidR3_GM.sf2",
"/usr/share/soundfonts/FluidR3_GS.sf2",
"/usr/share/soundfonts/default.sf2",
"/usr/share/sounds/sf2/FluidR3_GM.sf2",
"/usr/share/sounds/sf2/default-GM.sf2",
]
elif sys.platform == "darwin":
candidates += [
"/opt/homebrew/share/sounds/sf2/FluidR3_GM.sf2",
"/opt/homebrew/share/soundfonts/FluidR3_GM.sf2",
"/usr/local/share/sounds/sf2/FluidR3_GM.sf2",
"/usr/local/share/soundfonts/FluidR3_GM.sf2",
]
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).
for pattern in (
os.path.join(appdata, "Slopsmith", "soundfonts", "*.sf2"),
os.path.join(appdata, "SoundFonts", "*.sf2"),
):
candidates += sorted(glob.glob(pattern))
for path in candidates:
if os.path.isfile(path):
return path
return None
def _soundfont_install_hint() -> str:
if sys.platform.startswith("linux"):
return (
"Install a soundfont:\n"
" Arch/Manjaro: sudo pacman -S soundfont-fluid\n"
" Debian/Ubuntu: sudo apt install fluid-soundfont-gm\n"
" Fedora: sudo dnf install fluid-soundfont-gm"
)
if sys.platform == "darwin":
# Homebrew's fluid-synth formula doesn't bundle a soundfont; the user
# needs to fetch one separately (confirmed 2026-04 against the
# upstream formula).
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 /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."
)
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 "
"environment variable to its full path."
)
return "Set SLOPSMITH_SOUNDFONT to the full path of a .sf2 file."
def _fluidsynth_install_hint() -> str:
if sys.platform.startswith("linux"):
return (
"Install fluidsynth:\n"
" Arch/Manjaro: sudo pacman -S fluidsynth\n"
" Debian/Ubuntu: sudo apt install fluidsynth\n"
" Fedora: sudo dnf install fluidsynth"
)
if sys.platform == "darwin":
return "Install fluidsynth with Homebrew: brew install fluid-synth"
if sys.platform == "win32":
return (
"Install fluidsynth (https://github.com/FluidSynth/fluidsynth/releases) and "
"ensure fluidsynth.exe is on your PATH."
)
return "Install fluidsynth and ensure it is on PATH."
def render_midi_to_audio(midi_path: str, output_path: str) -> str:
"""Render MIDI to OGG audio using fluidsynth."""
soundfont = _find_soundfont()
if not soundfont:
raise RuntimeError(
"No soundfont found. " + _soundfont_install_hint()
)
wav_path = output_path + ".wav"
ogg_path = output_path + ".ogg"
try:
result = subprocess.run(
["fluidsynth", "-ni", "-T", "wav", "-F", wav_path, "-r", "44100", soundfont, midi_path],
capture_output=True, text=True, timeout=600,
)
except FileNotFoundError as e:
raise RuntimeError("fluidsynth not found. " + _fluidsynth_install_hint()) from e
if result.returncode != 0 or not os.path.exists(wav_path):
raise RuntimeError(f"fluidsynth failed: {result.stderr[-300:]}")
result = subprocess.run(
["ffmpeg", "-y", "-i", wav_path, "-q:a", "6", ogg_path],
capture_output=True, timeout=60,
)
if result.returncode == 0 and os.path.exists(ogg_path):
os.remove(wav_path)
return ogg_path
return wav_path
def gp_to_audio(gp_path: str, output_path: str,
track_indices: list[int] | None = None,
force_standard_tuning: bool = False) -> str:
"""Convert Guitar Pro file directly to audio.
Args:
gp_path: Path to .gp5 file
output_path: Output audio file path (without extension)
track_indices: Which tracks (None = all including drums)
force_standard_tuning: Force E standard tuning (keeps frets, changes pitch)
Returns:
Path to the audio file
"""
tmp_midi = tempfile.mktemp(suffix=".mid", prefix="rs_midi_")
try:
tuning_label = " (E Standard)" if force_standard_tuning else ""
log.info("Generating MIDI from %s%s", Path(gp_path).name, tuning_label)
gp_to_midi(gp_path, tmp_midi, track_indices, force_standard_tuning)
log.info("Rendering audio with FluidSynth...")
return render_midi_to_audio(tmp_midi, output_path)
finally:
if os.path.exists(tmp_midi):
os.remove(tmp_midi)
+529
View File
@@ -0,0 +1,529 @@
"""Guitar Pro → Sloppak Notation Format importer (GPIF: .gpx GP6 / .gp GP7-8).
Builds the per-arrangement ``notation_<id>.json`` payload documented in
``docs/sloppak-spec.md`` §5.3 from a parsed GPIF score, so piano/keys tracks
imported from Guitar Pro carry real engraving data (measures → staves →
voices → beats → notes with absolute MIDI pitch) instead of only the
``midi = string*24 + fret`` guitar wire encoding.
Voice → staff routing (salvaged from the superseded PR #703 ``stf`` wire-field
approach): the GP author's voice position within a bar decides the hand —
voice position 0 lands on the ``rh`` staff (treble, ``G2``), voice positions
≥ 1 land on the ``lh`` staff (bass, ``F4``). A forced-LH track (the merged
``Piano LH`` partner of an LH/RH pair, or a standalone track whose name ends
in ``LH``) routes every voice to ``lh``. This preserves authored hand
crossings instead of inferring hands from pitch.
Timing reuses the same machinery as ``gp2rs_gpx.convert_file`` — the
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
``beat_groups`` emission here exists to avoid re-introducing).
Where this plugs in: ``gp2rs_gpx.convert_file`` calls
``convert_track_to_notation`` for every keys track and writes the payload as
an ``<xml-stem>.notation.json`` sidecar next to the arrangement XML. The
sloppak *assembly* step (which assigns arrangement ids and writes
``manifest.yaml`` — today that lives in the editor plugin's create-mode save)
then renames the sidecar into place via ``attach_notation_to_sloppak``.
Analogous to ``gp2rs.convert_drum_track_to_drumtab`` for the drum tab format.
"""
from __future__ import annotations
import json
import logging
import re
import xml.etree.ElementTree as ET
from pathlib import Path
import notation as notation_mod
log = logging.getLogger("slopsmith.lib.gp2notation")
# GPX NoteValue string → notation duration denominator (sloppak-spec §5.3:
# 1=whole … 32=thirty-second). 64th/128th are below the schema floor; those
# beats are DROPPED with a warning (v1 non-features doctrine: drop, never
# approximate) — clamping the written value to 32 while time advances by the
# true 64th/128th span would emit self-contradictory notation (overlapping
# written durations).
_NOTE_VALUE_DEN: dict[str, int] = {
"Whole": 1, "Half": 2, "Quarter": 4, "Eighth": 8,
"16th": 16, "32nd": 32,
}
_SUB_FLOOR_NOTE_VALUES = frozenset({"64th", "128th"})
_STAFF_DEFS: dict[str, dict] = {
"rh": {"id": "rh", "clef": "G2", "label": "Right Hand"},
"lh": {"id": "lh", "clef": "F4", "label": "Left Hand"},
}
# Track names that force every voice onto the lh staff (e.g. the "Piano LH"
# half of an LH/RH pair imported standalone).
_LH_NAME_RE = re.compile(r"\blh\b\s*$", re.IGNORECASE)
def _children(root: ET.Element, tag: str) -> list[ET.Element]:
"""Children of ``root/<tag>``, or ``[]`` — explicit None check (an empty
Element is falsy, so ``find(...) or []`` would mis-handle it and trips
ElementTree's truth-value DeprecationWarning)."""
el = root.find(tag)
return list(el) if el is not None else []
def beat_groups_for(num: int, den: int) -> list[int] | None:
"""Return the spec ``beat_groups`` list for a time signature, or ``None``.
Simple meters (denominator < 8, e.g. 2/4, 3/4, 4/4) have unambiguous
grouping and omit the field. Compound meters built from dotted beats
group in threes (6/8 → [3, 3]; 9/8 → [3, 3, 3]; 12/8 → [3, 3, 3, 3]);
the common irregular meters get their conventional default (5/8 → [2, 3];
7/8 → [2, 2, 3]). Anything else is omitted — the renderer's default
grouping applies (sloppak-spec §5.3: the field is renderer-agnostic and
optional).
"""
if den < 8:
return None
if num > 3 and num % 3 == 0:
return [3] * (num // 3)
if num == 5:
return [2, 3]
if num == 7:
return [2, 2, 3]
return None
def _rhythm_fields(beat_el: ET.Element, rhythms_dict: dict) -> tuple[int, int, list[int] | None]:
"""Return ``(dur, dot, tu)`` notation fields for a GPIF beat.
- ``dur`` — duration denominator from the referenced Rhythm's NoteValue
(unknown values default to quarter, matching ``_beat_dur_secs``).
- ``dot`` — augmentation dots from ``<AugmentationDot count="N">``,
clamped to the schema's 02 range. Beat *times* advance via
``_beat_secs``, which applies the matching multiplier (×1.5 single,
×1.75 double), so the written dots and the emitted times agree.
- ``tu`` — ``[num, den]`` tuplet from ``<PrimaryTuplet>``, or ``None``.
"""
dur = 4
dot = 0
tu: list[int] | None = None
rref = beat_el.find("Rhythm")
if rref is not None:
rhythm = rhythms_dict.get(rref.get("ref", ""))
if rhythm is not None:
nv = rhythm.findtext("NoteValue", "Quarter")
dur = _NOTE_VALUE_DEN.get(nv, 4)
dot_el = rhythm.find("AugmentationDot")
if dot_el is not None:
try:
dot = max(1, min(2, int(dot_el.get("count", 1))))
except (TypeError, ValueError):
dot = 1
tuplet = rhythm.find("PrimaryTuplet")
if tuplet is not None:
try:
t_num = int(tuplet.get("num", 1))
t_den = int(tuplet.get("den", 1))
if t_num > 0 and t_den > 0 and (t_num, t_den) != (1, 1):
tu = [t_num, t_den]
except (TypeError, ValueError):
pass
return dur, dot, tu
def _beat_secs(beat_el: ET.Element, rhythms_dict: dict, tempo_bpm: float) -> float:
"""Duration of a GPIF beat in seconds, honouring the full dot count.
Mirrors ``gp2rs_gpx._beat_dur_secs`` except for double dots: that helper
applies ×1.5 for any ``<AugmentationDot>`` regardless of its ``count``
attribute, which would make a written ``dot: 2`` disagree with the
emitted absolute beat times (overlapping engraving). Here a single dot
is ×1.5 and a double dot ×1.75, so the notation walk stays
self-consistent; for the rare double-dotted keys beat this intentionally
diverges from the RS-XML walk's single-dot approximation.
"""
from gp2rs_gpx import _NOTE_VALUE_QN
dur_qn = 0.25
rref = beat_el.find("Rhythm")
if rref is not None:
rhythm = rhythms_dict.get(rref.get("ref", ""))
if rhythm is not None:
nv = rhythm.findtext("NoteValue", "Quarter")
dur_qn = _NOTE_VALUE_QN.get(nv, 0.25)
dot_el = rhythm.find("AugmentationDot")
if dot_el is not None:
try:
count = int(dot_el.get("count", 1))
except (TypeError, ValueError):
count = 1
dur_qn *= 1.75 if count >= 2 else 1.5
tuplet = rhythm.find("PrimaryTuplet")
if tuplet is not None:
try:
num = int(tuplet.get("num", 1))
den = int(tuplet.get("den", 1))
if num and den:
dur_qn *= den / num
except (TypeError, ValueError):
pass
return dur_qn * (60.0 / tempo_bpm)
def _masterbar_ks(mb: ET.Element) -> int | None:
"""Key signature (semitones from C, 7…+7) from a MasterBar, or None."""
key_el = mb.find("Key")
if key_el is None:
return None
raw = key_el.findtext("AccidentalCount")
if raw is None:
return None
try:
ks = int(raw.strip())
except (TypeError, ValueError):
return None
return ks if -7 <= ks <= 7 else None
def _walk_track_beats(
root: ET.Element,
raw_idx: int,
string_pitches: list[int],
*,
audio_offset: float,
force_staff: str | None,
) -> list[dict[str, list[list[dict]]]]:
"""Walk one raw track bar-by-bar and bucket its beats per staff.
Returns one entry per masterbar: ``{staff_id: [voice_beats, ...]}`` where
each ``voice_beats`` is the ordered beat list of one GP voice. Timing
mirrors ``gp2rs_gpx.convert_file`` (bar-indexed tempo map applied at bar
starts, ``_beat_secs`` per beat) so notation lines up with the RS XML
(modulo the double-dot fix documented on ``_beat_secs``).
"""
# Local import keeps lib's flat-import convention and avoids a hard cycle
# (gp2rs_gpx imports this module lazily from inside convert_file).
from gp2rs_gpx import (
_build_tempo_map, _gpif_tempo, _note_is_tie, _note_midi, _notes_by_id,
)
masterbars = _children(root, "MasterBars")
bars_by_id = {b.get("id"): b for b in _children(root, "Bars")}
voices_dict = {v.get("id"): v for v in _children(root, "Voices")}
beats_dict = {b.get("id"): b for b in _children(root, "Beats")}
rhythms_dict = {r.get("id"): r for r in _children(root, "Rhythms")}
# Same duplicate-id-tolerant note lookup convert_file uses.
notes_dict = _notes_by_id(root)
tempo_bpm = _gpif_tempo(root)
tempo_iter = iter(_build_tempo_map(root))
next_tempo_bar, next_tempo_bpm = next(tempo_iter, (999999, tempo_bpm))
cur_tempo = tempo_bpm
out: list[dict[str, list[list[dict]]]] = []
current_time = 0.0
for mb_idx, mb in enumerate(masterbars):
while mb_idx >= next_tempo_bar:
cur_tempo = next_tempo_bpm
next_tempo_bar, next_tempo_bpm = next(tempo_iter, (999999, cur_tempo))
time_sig = mb.findtext("Time", "4/4")
try:
num_b, den_b = [int(x) for x in time_sig.split("/")]
except ValueError:
num_b, den_b = 4, 4
# A malformed-but-parseable signature like "4/0" or "-3/4" would
# divide by zero / run time backwards below.
if num_b <= 0 or den_b <= 0:
log.warning("gp2notation: invalid time signature %r — assuming 4/4", time_sig)
num_b, den_b = 4, 4
bar_duration = num_b * (4.0 / den_b) * (60.0 / cur_tempo)
per_staff: dict[str, list[list[dict]]] = {}
bar_ids = mb.findtext("Bars", "").split()
bid = bar_ids[raw_idx] if raw_idx < len(bar_ids) else "-1"
bar = bars_by_id.get(bid) if bid not in ("-1", "") else None
if bar is not None:
for voice_pos, vid in enumerate(bar.findtext("Voices", "").split()):
if vid == "-1":
continue
voice = voices_dict.get(vid)
if voice is None:
continue
# PR #703's voice→staff rule: GP voice position 0 = right
# hand (treble), positions ≥ 1 = left hand (bass); a forced
# staff (merged/standalone LH track) overrides both.
staff = force_staff or ("rh" if voice_pos == 0 else "lh")
voice_beats: list[dict] = []
voice_time = current_time
for beat_id in voice.findtext("Beats", "").split():
beat_el = beats_dict.get(beat_id)
if beat_el is None:
continue
dur_secs = _beat_secs(beat_el, rhythms_dict, cur_tempo)
# Sub-32nd rhythms can't be written in schema v1: drop the
# beat (warning) but advance time by its true span so the
# rest of the bar stays aligned with the RS-XML walk.
rref = beat_el.find("Rhythm")
rhythm = rhythms_dict.get(rref.get("ref", "")) if rref is not None else None
nv = rhythm.findtext("NoteValue", "Quarter") if rhythm is not None else "Quarter"
if nv in _SUB_FLOOR_NOTE_VALUES:
log.warning(
"gp2notation: dropping %s beat at %.3fs — below the "
"schema's 32nd floor (v1 non-feature)",
nv, voice_time + audio_offset,
)
voice_time += dur_secs
continue
dur, dot, tu = _rhythm_fields(beat_el, rhythms_dict)
beat_out: dict = {
"t": round(voice_time + audio_offset, 3),
"dur": dur,
}
if dot:
beat_out["dot"] = dot
if tu:
beat_out["tu"] = tu
notes_out: list[dict] = []
for nid in beat_el.findtext("Notes", "").strip().split():
note_el = notes_dict.get(nid)
if note_el is None:
continue
# GP pitch is absolute for piano-family tracks:
# String+Fret resolves via the track's string-template
# pitches (string_pitches[idx] + fret, concert pitch)
# and Tone+Octave is (octave+1)*12 + step semitone —
# both yield a real MIDI number, no tuning offset.
midi = _note_midi(note_el, string_pitches)
if midi is None or not 0 <= midi <= 127:
continue
note_out: dict = {"midi": midi}
# Unlike the RS-XML walk (which drops tie destinations
# and extends the origin's sustain), notation keeps
# tied continuations as their own beats — engraving
# needs the tied notehead.
if _note_is_tie(note_el):
note_out["tied"] = True
notes_out.append(note_out)
if notes_out:
beat_out["notes"] = notes_out
else:
# Authored rest, or every note failed pitch extraction.
beat_out["rest"] = True
voice_beats.append(beat_out)
voice_time += dur_secs
if voice_beats:
per_staff.setdefault(staff, []).append(voice_beats)
out.append(per_staff)
current_time += bar_duration
return out
def convert_track_to_notation(
root: ET.Element,
raw_idx: int,
string_pitches: list[int],
*,
instrument: str = "piano",
audio_offset: float = 0.0,
track_name: str = "",
lh_raw_idx: int | None = None,
lh_string_pitches: list[int] | None = None,
) -> dict:
"""Convert one GPIF keys/piano track to a notation payload (spec §5.3).
Args:
root: Parsed ``score.gpif`` element (``gp2rs_gpx._load_gpif`` output).
raw_idx: Raw track index into MasterBar ``Bars`` id lists (the same
index ``convert_file`` derives via ``filtered_to_raw``).
string_pitches: The track's string-template tuning (may be empty for
Tone+Octave-encoded tracks).
instrument: Self-describing instrument name for the payload.
audio_offset: Seconds added to every emitted time (audio sync).
track_name: Used only to detect a standalone forced-LH track
(name ending in "LH" → every voice routes to the lh staff).
lh_raw_idx / lh_string_pitches: When a Piano LH/RH pair was merged
(``gp2rs_gpx._find_piano_pairs``), the LH partner's raw index and
tuning — its beats are walked separately and forced onto ``lh``.
Returns the validated notation dict (``version``/``instrument``/
``staves``/``measures``). Raises ``ValueError`` if the built payload
fails ``notation.validate_notation`` (importer bug guard).
"""
from gp2rs_gpx import _build_tempo_map, _gpif_tempo
force_staff = "lh" if (track_name and _LH_NAME_RE.search(track_name)) else None
walked = _walk_track_beats(
root, raw_idx, string_pitches,
audio_offset=audio_offset, force_staff=force_staff,
)
if lh_raw_idx is not None:
lh_walked = _walk_track_beats(
root, lh_raw_idx, lh_string_pitches or [],
audio_offset=audio_offset, force_staff="lh",
)
# Merge the LH partner's voices into each measure's lh staff, after
# any voices the main track already routed there.
for main_bar, lh_bar in zip(walked, lh_walked):
for staff_id, voices in lh_bar.items():
main_bar.setdefault(staff_id, []).extend(voices)
masterbars = _children(root, "MasterBars")
tempo_bpm = _gpif_tempo(root)
tempo_iter = iter(_build_tempo_map(root))
next_tempo_bar, next_tempo_bpm = next(tempo_iter, (999999, tempo_bpm))
cur_tempo = tempo_bpm
measures: list[dict] = []
used_staves: set[str] = set()
current_time = 0.0
last_ts: tuple[int, int] | None = None
last_tempo: float | None = None
last_ks: int | None = None
for mb_idx, mb in enumerate(masterbars):
while mb_idx >= next_tempo_bar:
cur_tempo = next_tempo_bpm
next_tempo_bar, next_tempo_bpm = next(tempo_iter, (999999, cur_tempo))
time_sig = mb.findtext("Time", "4/4")
try:
num_b, den_b = [int(x) for x in time_sig.split("/")]
except ValueError:
num_b, den_b = 4, 4
if num_b <= 0 or den_b <= 0:
log.warning("gp2notation: invalid time signature %r — assuming 4/4", time_sig)
num_b, den_b = 4, 4
measure: dict = {
"idx": mb_idx + 1,
"t": round(current_time + audio_offset, 3),
}
if (num_b, den_b) != last_ts:
measure["ts"] = [num_b, den_b]
groups = beat_groups_for(num_b, den_b)
if groups:
measure["beat_groups"] = groups
last_ts = (num_b, den_b)
if cur_tempo != last_tempo:
measure["tempo"] = cur_tempo
last_tempo = cur_tempo
ks = _masterbar_ks(mb)
if ks is not None and ks != last_ks:
measure["ks"] = ks
last_ks = ks
staves_payload: dict[str, dict] = {}
for staff_id in ("rh", "lh"): # stable staff order
voices = (walked[mb_idx] if mb_idx < len(walked) else {}).get(staff_id)
if not voices:
continue
used_staves.add(staff_id)
staves_payload[staff_id] = {
"voices": [
{"v": v_num, "beats": beats}
for v_num, beats in enumerate(voices, start=1)
],
}
measure["staves"] = staves_payload
measures.append(measure)
current_time += num_b * (4.0 / den_b) * (60.0 / cur_tempo)
staves = [_STAFF_DEFS[s] for s in ("rh", "lh") if s in used_staves]
if not staves:
staves = [_STAFF_DEFS["rh"]] # empty track — still a valid grand-staff stub
payload = {
"version": notation_mod.SCHEMA_VERSION,
"instrument": instrument,
"staves": staves,
"measures": measures,
}
ok, reason = notation_mod.validate_notation(payload)
if not ok:
raise ValueError(f"gp2notation built an invalid payload: {reason}")
return payload
# ── Sidecar + manifest wiring ─────────────────────────────────────────────────
def notation_sidecar_path(xml_path: str | Path) -> Path:
"""The notation sidecar written next to a converted arrangement XML.
``Foo_Keys.xml`` → ``Foo_Keys.notation.json``. Arrangement ids don't
exist yet at convert time (they're assigned when the sloppak manifest is
assembled), so the sidecar pairs with the XML by filename stem; the
assembly step renames it to ``notation_<id>.json`` via
``attach_notation_to_sloppak``.
"""
p = Path(xml_path)
return p.with_name(p.stem + ".notation.json")
def write_notation_sidecar(xml_path: str | Path, payload: dict) -> Path:
"""Validate and write the notation sidecar for a converted XML."""
ok, reason = notation_mod.validate_notation(payload)
if not ok:
raise ValueError(f"refusing to write invalid notation sidecar: {reason}")
side = notation_sidecar_path(xml_path)
side.write_text(json.dumps(payload, separators=(",", ":")), encoding="utf-8")
return side
def attach_notation_to_sloppak(sloppak_dir: str | Path, arr_id: str, payload: dict) -> Path:
"""Write ``notation_<arr_id>.json`` into a directory-form sloppak and add
the ``notation:`` sub-key to that arrangement's manifest entry.
Raises ``ValueError`` on an invalid payload, an unsafe/unknown
arrangement id, or a manifest without a matching arrangement entry.
Note: the manifest is round-tripped through PyYAML (``safe_load`` +
``safe_dump(sort_keys=False)``) — key order is preserved but comments
and custom formatting are lost.
"""
import yaml
ok, reason = notation_mod.validate_notation(payload)
if not ok:
raise ValueError(f"invalid notation payload: {reason}")
if not arr_id or not re.fullmatch(r"[A-Za-z0-9_-]+", arr_id):
raise ValueError(f"unsafe arrangement id for notation filename: {arr_id!r}")
pak = Path(sloppak_dir)
manifest_path = pak / "manifest.yaml"
manifest = yaml.safe_load(manifest_path.read_text(encoding="utf-8"))
if not isinstance(manifest, dict):
raise ValueError(f"{manifest_path} is not a mapping")
entry = next(
(e for e in (manifest.get("arrangements") or [])
if isinstance(e, dict) and e.get("id") == arr_id),
None,
)
if entry is None:
raise ValueError(f"no arrangement with id {arr_id!r} in {manifest_path}")
filename = f"notation_{arr_id}.json"
(pak / filename).write_text(
json.dumps(payload, separators=(",", ":")), encoding="utf-8"
)
entry["notation"] = filename
manifest_path.write_text(
yaml.safe_dump(manifest, sort_keys=False, allow_unicode=True),
encoding="utf-8",
)
return pak / filename
+1865
View File
File diff suppressed because it is too large Load Diff
+2141
View File
File diff suppressed because it is too large Load Diff
+453
View File
@@ -0,0 +1,453 @@
"""
lib/gp8_audio_sync.py — Extract embedded audio and sync data from GP8 (.gp) files.
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:
1. A real backing track audio file (OGG) — no MIDI synthesis needed
2. A precise audio_offset (seconds) from the FramePadding value
3. A bar-indexed tempo map derived from sync point ModifiedTempo values,
which is more accurate than the tab's authored tempo automations for
files that have been manually synced to audio
Public API:
has_embedded_audio(gp_path) -> bool
extract_audio(gp_path, output_dir) -> str | None (path to .ogg file)
extract_sync(gp_path) -> GpSyncData | None
GpSyncData fields:
audio_offset float seconds to add to all RS note times (negative
means audio starts before bar 1)
sync_points list[SyncPoint] bar-indexed audio timestamps
audio_asset_id str filename stem of the OGG in Content/Assets/
SyncPoint fields:
bar int 0-based bar index in the score
time_secs float position in the audio file (seconds from start)
modified_tempo float actual BPM at this point in the recording
original_tempo float tab's authored BPM at this bar
Usage in convert_file():
sync = extract_sync(gp_path)
if sync:
audio_path = extract_audio(gp_path, output_dir)
xml = convert_file(..., audio_offset=sync.audio_offset)
else:
# fall back to gp2midi for GP3-5, or MIDI-less for GPX without audio
pass
"""
import logging
import xml.etree.ElementTree as ET
import zipfile
import io
from dataclasses import dataclass, field
from pathlib import Path
_log = logging.getLogger("slopsmith.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
# from a DAW). Earlier code only matched .ogg, so an MP3-backed file would
# report has_embedded_audio() True (via meta.json) yet extract nothing.
# Match any of these and transcode to OGG on extraction when needed.
_AUDIO_ASSET_EXTS = ('.ogg', '.mp3', '.m4a', '.aac', '.wav', '.flac', '.opus', '.wma')
def _parse_gpif(data: bytes):
"""Parse GPIF XML bytes with defusedxml when available, stdlib otherwise.
Centralised so every caller hardens parsing the same way (no divergent
inline try/except blocks).
"""
try:
import defusedxml.ElementTree as _safe_ET
return _safe_ET.fromstring(data)
except ImportError:
_log.warning(
'gp8_audio_sync: defusedxml not installed; parsing with stdlib '
'xml.etree (install defusedxml for hardened parsing)'
)
return ET.fromstring(data)
def _resolve_audio_asset(zf, root=None) -> tuple[str, str | None]:
"""Resolve the embedded backing-track audio asset inside a .gp ZIP.
Matches ``BackingTrack/AssetId`` against the audio files under
``Content/Assets/`` (OGG, MP3, M4A, …) and falls back to the first
audio asset when the declared id is missing or unmatched. Returns
``(asset_stem, audio_zip_path)``, or ``('', None)`` when the archive
has no audio asset. Shared by ``extract_sync`` and ``extract_audio``
so the matching logic can't drift between them.
"""
audio_files = [
n for n in zf.namelist()
if n.startswith('Content/Assets/')
and n.lower().endswith(_AUDIO_ASSET_EXTS)
]
if not audio_files:
return '', None
# When the SAME backing track is present in several formats (same
# AssetId stem), prefer the OGG: it's copied out losslessly while any
# other format must be transcoded, so this preserves both quality and
# the pre-MP3-support behaviour. This only applies to genuine same-stem
# duplicates — the unmatched fallback below keeps ZIP order so an
# unrelated later OGG can't displace the archive's first asset.
def _prefer_ogg(candidates):
return next(
(n for n in candidates if n.lower().endswith('.ogg')),
candidates[0],
)
declared = ''
if root is None:
try:
root = _parse_gpif(zf.read('Content/score.gpif'))
except Exception:
root = None
if root is not None:
bt = root.find('BackingTrack')
if bt is not None:
aid = bt.find('AssetId')
declared = (aid.text or '').strip() if aid is not None else ''
if declared:
matched = [n for n in audio_files if Path(n).stem == declared]
if matched:
return declared, _prefer_ogg(matched)
_log.warning(
'gp8_audio_sync: declared AssetId %r not found; falling back to first audio asset',
declared,
)
# Fallback: the archive's first audio asset (ZIP order), unchanged from
# the original OGG-only behaviour.
return Path(audio_files[0]).stem, audio_files[0]
# GP8 uses 44100 Hz internally for FrameOffset values regardless of the
# OGG file's own sample rate. The embedded OGG is typically 48000 Hz
# (Rocksmith's preferred rate) and should be passed through as-is —
# do NOT resample it. The 44100 constant is only used here to convert
# FrameOffset integers to seconds for timing math; it never touches audio.
# Verified: 44100 gives <10ms sync error; 48000 gives ~530ms error.
_GP8_FRAME_RATE = 44100
@dataclass
class SyncPoint:
"""One GP8 sync point: a bar-to-audio-timestamp mapping."""
bar: int
time_secs: float # position in audio file (seconds from file start)
modified_tempo: float # actual recording BPM at this bar
original_tempo: float # tab's authored BPM at this bar
@dataclass
class GpSyncData:
"""Sync data extracted from a GP8 file with an embedded backing track."""
audio_offset: float # seconds: negative = audio starts before bar 1
audio_asset_id: str # OGG filename stem in Content/Assets/
sync_points: list[SyncPoint] = field(default_factory=list)
def tempo_at_bar(self, bar: int) -> float:
"""Return the ModifiedTempo for the sync segment containing `bar`.
Uses the last sync point whose bar index is <= the requested bar,
which matches GP8's behaviour of holding each tempo until the next
sync point.
"""
result = self.sync_points[0].modified_tempo if self.sync_points else 120.0
for sp in self.sync_points:
if sp.bar <= bar:
result = sp.modified_tempo
else:
break
return result
def time_at_bar(self, bar: int, beats_per_bar: float = 4.0) -> float:
"""Interpolate the audio timestamp (seconds) for any bar index.
For bars between sync points, interpolates using the ModifiedTempo
of the preceding sync point — matching GP8's linear interpolation.
For bars before the first sync point, extrapolates backward.
"""
if not self.sync_points:
return 0.0
# Find the surrounding sync points
before = self.sync_points[0]
after = None
for sp in self.sync_points:
if sp.bar <= bar:
before = sp
else:
after = sp
break
# Between two sync points, interpolate linearly by bar index between
# the two known audio timestamps. This is exact regardless of the
# time signature (no beats_per_bar assumption) and matches GP8's
# straight-line interpolation between sync points.
if after is not None and after.bar > before.bar:
frac = (bar - before.bar) / (after.bar - before.bar)
return before.time_secs + frac * (after.time_secs - before.time_secs)
# Past the last sync point (or before the first): no second anchor, so
# extrapolate from `before` using its ModifiedTempo. beats_per_bar
# defaults to 4.0 — callers should pass the actual time-signature
# numerator for correct non-4/4 extrapolation here.
bars_since = bar - before.bar
seconds_per_bar = beats_per_bar * 60.0 / before.modified_tempo
return before.time_secs + bars_since * seconds_per_bar
def _open_gp_zip(gp_path: str):
"""Open a .gp ZIP container and return (raw_bytes, ZipFile)."""
with open(gp_path, 'rb') as fh:
raw = fh.read()
if raw[:2] != b'PK':
raise ValueError(f"{gp_path!r} is not a GP7/GP8 ZIP file (magic: {raw[:4]!r})")
return raw, zipfile.ZipFile(io.BytesIO(raw))
def has_embedded_audio(gp_path: str) -> bool:
"""Return True if the .gp file has an embedded backing track.
This is the canonical gate: it returns False for anything that isn't a
GP7/GP8 ZIP container with embedded audio (including GP3/4/5 files and
malformed inputs). Callers should check this first — `extract_sync` and
`extract_audio` return None for both "no embedded audio" and "not a
GP7/8 container", so they don't distinguish the two on their own.
"""
try:
raw, zf = _open_gp_zip(gp_path)
with zf:
names = zf.namelist()
# meta.json has {"hasAudio": true} when audio is embedded
if 'meta.json' in names:
import json
meta = json.loads(zf.read('meta.json'))
if meta.get('hasAudio'):
return True
# Also check directly for any embedded audio asset (OGG, MP3, …)
return any(
n.startswith('Content/Assets/')
and n.lower().endswith(_AUDIO_ASSET_EXTS)
for n in names
)
except Exception:
return False
def extract_sync(gp_path: str) -> GpSyncData | None:
"""Extract sync data from a GP8 file.
Returns None if the file has no embedded audio or no sync points.
"""
try:
raw, zf = _open_gp_zip(gp_path)
with zf:
if 'Content/score.gpif' not in zf.namelist():
return None
root = _parse_gpif(zf.read('Content/score.gpif'))
# Find the BackingTrack element for FramePadding and asset ID
bt = root.find('BackingTrack')
if bt is None:
return None
# FramePadding: negative = audio starts before bar 1
frame_padding = 0
fp_el = bt.find('FramePadding')
if fp_el is not None and fp_el.text:
try:
frame_padding = int(fp_el.text.strip())
except (ValueError, TypeError):
pass
audio_offset = frame_padding / _GP8_FRAME_RATE
# Resolve the audio asset (AssetId match, first-asset fallback).
asset_name, ogg_match = _resolve_audio_asset(zf, root)
ogg_files = [ogg_match] if ogg_match else []
if not asset_name and not ogg_files:
return None
# Extract SyncPoint automations from MasterTrack
mt = root.find('MasterTrack')
sync_points: list[SyncPoint] = []
if mt is not None:
for auto in mt.findall('.//Automations/*'):
if auto.findtext('Type') != 'SyncPoint':
continue
val = auto.find('Value')
if val is None:
continue
try:
bar = int(val.findtext('BarIndex') or 0)
# FrameOffset (inside Value) is the audio frame for this
# sync point. Default to 0 when absent — do NOT fall back
# to the automation's `Position`, which is an in-bar
# musical position (1/16384-note units), not a frame
# count, and would yield a nonsensical time_secs.
frame_offset = 0
fo_el = val.find('FrameOffset')
if fo_el is not None and fo_el.text:
frame_offset = int(fo_el.text.strip())
modified_tempo = float(val.findtext('ModifiedTempo') or 120)
original_tempo = float(val.findtext('OriginalTempo') or 120)
time_secs = frame_offset / _GP8_FRAME_RATE
sync_points.append(SyncPoint(
bar=bar,
time_secs=time_secs,
modified_tempo=modified_tempo,
original_tempo=original_tempo,
))
except (ValueError, TypeError):
continue
sync_points.sort(key=lambda sp: sp.bar)
if not sync_points and not ogg_files:
return None
return GpSyncData(
audio_offset=audio_offset,
audio_asset_id=asset_name,
sync_points=sync_points,
)
except Exception as e:
_log.warning("gp8_audio_sync: failed to extract sync from %r: %s", gp_path, e)
return None
def extract_audio(gp_path: str, output_dir: str) -> str | None:
"""Extract the embedded backing-track audio to output_dir as OGG.
Returns the path to the extracted `.ogg` file, or None if no audio is
found (or a non-OGG asset couldn't be transcoded). The filename is
derived from the GP file stem with an `_audio` suffix:
e.g. my_song.gp -> my_song_audio.ogg.
GP8 embeds the backing track as OGG or another format (MP3 is common).
An OGG asset is copied out verbatim; any other format is transcoded to
OGG via ffmpeg so the editor/web pipeline — which expects OGG stems —
can use it unchanged.
"""
try:
raw, zf = _open_gp_zip(gp_path)
with zf:
# Resolve the asset via the same AssetId logic extract_sync uses.
_asset_name, chosen = _resolve_audio_asset(zf)
if not chosen:
return None
out = Path(output_dir)
out.mkdir(parents=True, exist_ok=True)
# Name the output after the GP file, not the UUID asset name.
stem = Path(gp_path).stem
out_path = out / f"{stem}_audio.ogg"
src_ext = Path(chosen).suffix.lower()
# Already OGG — copy the bytes out verbatim (lossless, fast).
if src_ext == '.ogg':
out_path.write_bytes(zf.read(chosen))
_log.info("gp8_audio_sync: extracted audio to %s", out_path)
return str(out_path)
# Non-OGG (e.g. MP3): stage + transcode inside a private temp dir,
# then atomically move the result into place. A private work dir
# keeps the function idempotent — a failed run can't clobber or
# delete a good *_audio.ogg from a previous successful run, and the
# staged source can't collide with a file the caller already put in
# output_dir.
import os
import shutil
import tempfile
work = Path(tempfile.mkdtemp(prefix="gp8_audio_", dir=str(out)))
try:
src_path = work / f"src{src_ext or '.bin'}"
src_path.write_bytes(zf.read(chosen))
try:
from audio import _ffmpeg_cmd, _ffmpeg_wav_to_ogg
ffmpeg = _ffmpeg_cmd()
except Exception:
ffmpeg = None
if not ffmpeg:
_log.warning(
"gp8_audio_sync: embedded audio is %s but ffmpeg is "
"unavailable to transcode it to OGG", src_ext,
)
return None
# `_ffmpeg_wav_to_ogg` runs `ffmpeg -i <in> ... <out.ogg>`; the
# input may be any format ffmpeg can decode despite the name.
tmp_ogg = work / "out.ogg"
r = _ffmpeg_wav_to_ogg(ffmpeg, src_path, tmp_ogg)
if (r.returncode == 0 and tmp_ogg.exists()
and tmp_ogg.stat().st_size >= 100):
# Atomic move into place — out_path is only ever touched on
# success (work dir is under output_dir, so same filesystem).
os.replace(tmp_ogg, out_path)
_log.info(
"gp8_audio_sync: transcoded embedded %s audio to %s",
src_ext, out_path,
)
return str(out_path)
_log.warning(
"gp8_audio_sync: ffmpeg failed to transcode embedded %s "
"audio to OGG (rc=%s)", src_ext, r.returncode,
)
return None
finally:
shutil.rmtree(work, ignore_errors=True)
except Exception as e:
_log.warning("gp8_audio_sync: failed to extract audio from %r: %s", gp_path, e)
return None
def build_tempo_map_from_sync(sync: GpSyncData) -> list[tuple[int, float]]:
"""
Build a bar-indexed tempo map from GP8 sync points.
Returns list of (bar_index, bpm) pairs sorted by bar_index, in the same
format as gp2rs_gpx._build_tempo_map(). This can be passed directly to
the bar iteration loop in convert_file() for accurate timing.
For GP8 files with audio sync, ModifiedTempo values are more accurate
than the tab's authored Tempo automations — they reflect the actual
recording tempo rather than the transcriber's approximation.
"""
if not sync.sync_points:
return [(0, 120.0)]
return [(sp.bar, sp.modified_tempo) for sp in sync.sync_points]
if __name__ == '__main__':
import sys
import logging as _logging
_logging.basicConfig(level=_logging.INFO)
path = sys.argv[1] if len(sys.argv) > 1 else None
if not path:
_log.error('Usage: python gp8_audio_sync.py <file.gp>')
sys.exit(1)
_log.info('has_embedded_audio: %s', has_embedded_audio(path))
sync = extract_sync(path)
if sync:
_log.info('audio_offset: %.4fs', sync.audio_offset)
_log.info('audio_asset: %s', sync.audio_asset_id)
_log.info('sync_points: %d', len(sync.sync_points))
for sp in sync.sync_points:
_log.info(
'bar=%-4d t=%.3fs modified_bpm=%.3f original_bpm=%.1f',
sp.bar, sp.time_secs, sp.modified_tempo, sp.original_tempo,
)
else:
_log.info('No sync data found')
+1077
View File
File diff suppressed because it is too large Load Diff
+175
View File
@@ -0,0 +1,175 @@
"""Logging configuration for Slopsmith.
Call ``configure_logging()`` once at server startup, before any slopsmith
module imports that might emit log records.
Environment variables:
LOG_LEVEL severity threshold for the ``slopsmith.*`` 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.
LOG_FILE optional path; when set, a RotatingFileHandler is added
alongside the console handler (max 10 MB, 5 backups).
The parent directory is created automatically if it does not
exist. If the file cannot be opened, a warning is printed
and the server continues with console-only logging.
Useful for persistent NAS deployments.
"""
from __future__ import annotations
import logging
import logging.handlers
import os
import sys
from pathlib import Path
import structlog
def _add_correlation_id(
logger: object, method_name: str, event_dict: dict
) -> dict:
"""Inject the current request correlation ID into the event dict."""
try:
from asgi_correlation_id import correlation_id
cid = correlation_id.get(None)
if cid:
event_dict["request_id"] = cid
except ImportError:
pass
return event_dict
def configure_logging() -> None:
"""Wire up the slopsmith logger hierarchy.
Safe to call multiple times; always reflects the current LOG_LEVEL,
LOG_FORMAT, and LOG_FILE environment variables.
"""
raw_level = os.environ.get("LOG_LEVEL", "INFO").upper()
level = getattr(logging, raw_level, None)
if not isinstance(level, int):
sys.stderr.write(
f"[slopsmith] WARNING: unrecognised LOG_LEVEL={raw_level!r};"
" falling back to INFO.\n"
)
level = logging.INFO
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};"
" falling back to 'text'.\n"
)
raw_fmt = "text"
fmt = raw_fmt
log_file = os.environ.get("LOG_FILE", "").strip()
# Console renderer: coloured when text mode, JSON otherwise.
console_renderer = (
structlog.processors.JSONRenderer()
if fmt == "json"
else structlog.dev.ConsoleRenderer()
)
# File renderer: always plain (no ANSI escape sequences) so rotated log
# files are human-readable without a terminal. JSON mode reuses the same
# renderer because JSON output is already colour-free.
file_renderer = (
structlog.processors.JSONRenderer()
if fmt == "json"
else structlog.dev.ConsoleRenderer(colors=False)
)
# Applied to all records — both structlog-native and stdlib (foreign) calls.
# Stdlib logging handles %-style format strings itself, so no
# PositionalArgumentsFormatter is needed here.
pre_chain: list = [
structlog.contextvars.merge_contextvars,
_add_correlation_id,
structlog.stdlib.add_log_level,
structlog.stdlib.add_logger_name,
structlog.processors.TimeStamper(fmt="iso"),
structlog.processors.StackInfoRenderer(),
]
structlog.configure(
processors=pre_chain + [structlog.stdlib.ProcessorFormatter.wrap_for_formatter],
logger_factory=structlog.stdlib.LoggerFactory(),
wrapper_class=structlog.stdlib.BoundLogger,
# Keep False so that every reconfigure() call takes effect immediately
# for any code that holds a structlog.get_logger() proxy. The small
# per-call overhead is acceptable given that logging is not on the hot
# path.
cache_logger_on_first_use=False,
)
def _make_formatter(renderer: object) -> structlog.stdlib.ProcessorFormatter:
return structlog.stdlib.ProcessorFormatter(
processors=[
structlog.stdlib.ProcessorFormatter.remove_processors_meta,
# Format exc_info tuples to strings before the renderer so that
# JSONRenderer never encounters a non-serializable traceback object.
structlog.processors.ExceptionRenderer(),
renderer,
],
foreign_pre_chain=pre_chain,
)
console_formatter = _make_formatter(console_renderer)
console = logging.StreamHandler(sys.stdout)
console.setFormatter(console_formatter)
handlers: list[logging.Handler] = [console]
if log_file:
file_formatter = _make_formatter(file_renderer)
try:
Path(log_file).parent.mkdir(parents=True, exist_ok=True)
fh = logging.handlers.RotatingFileHandler(
log_file,
maxBytes=10 * 1024 * 1024, # 10 MB
backupCount=5,
encoding="utf-8",
)
fh.setFormatter(file_formatter)
handlers.append(fh)
except OSError as exc:
sys.stderr.write(
f"[slopsmith] 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")] + [
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
# same objects) isn't closed while still attached to another logger tree.
old_handlers: set[logging.Handler] = set()
for lg in all_loggers:
old_handlers.update(lg.handlers)
# Detach first, then close each unique handler exactly once.
for lg in all_loggers:
for h in list(lg.handlers):
lg.removeHandler(h)
for h in old_handlers:
h.close()
# Install fresh handlers on the slopsmith root.
root = logging.getLogger("slopsmith")
for h in handlers:
root.addHandler(h)
root.setLevel(level)
root.propagate = False
# Route uvicorn output through the same pipeline so everything is uniform.
for name in _uvicorn_names:
lg = logging.getLogger(name)
lg.handlers = list(handlers)
lg.propagate = False
lg.setLevel(level)
+434
View File
@@ -0,0 +1,434 @@
"""loosefolder.py — treat a directory of raw RS2014 assets as a playable song.
Expected layout (artist/album/song_dir structure is optional but used for
metadata inference when manifest.json is absent):
song_dir/
audio.wem (required)
lead.xml (at least one arrangement XML required)
rhythm.xml
bass.xml
manifest.json (optional)
album_art.jpg / cover.jpg / album.png (optional)
manifest.json fields (all optional XML metadata fills in gaps):
{
"title": "Song Name",
"artist": "Artist Name",
"album": "Album Name",
"year": "2024",
"tuning_offsets": [0, 0, 0, 0, 0, 0]
}
"""
import json
import math
import xml.etree.ElementTree as ET
from pathlib import Path
AUDIO_NAMES = ["audio.wem", "song.wem"]
# Match every extension server.get_song_art is prepared to serve
# (jpeg/png/webp). Without `.jpeg`/`.webp`, loose folders shipping
# `cover.jpeg` or `album_art.webp` would never have their art surfaced.
_ART_STEMS = ["album_art", "cover", "album", "art", "folder"]
_ART_EXTS = [".jpg", ".jpeg", ".png", ".webp"]
ART_NAMES = [f"{stem}{ext}" for stem in _ART_STEMS for ext in _ART_EXTS]
# Arrangement type detection from filename keywords or <arrangement> tag
# Format: keyword -> (type, display_name, sort_priority)
ARR_TYPE_MAP = {
"lead": ("lead", "Lead", 0),
"rhythm": ("rhythm", "Rhythm", 2),
"bass": ("bass", "Bass", 3),
"combo": ("combo", "Combo", 1),
"chord": ("combo", "Combo", 1),
"humstrum": ("combo", "Combo", 1),
}
def _iter_local(path: Path, pattern: str):
"""Yield regular files matching `pattern` in `path` that resolve
inside `path`.
Two guards in one helper:
* Reject directories (a folder named `audio.wem` or `lead.xml`
would otherwise be matched by glob and break downstream
readers / converters).
* Reject symlinks escaping the folder so a crafted CDLC can't
smuggle external content into the scan.
"""
root = path.resolve()
for match in path.glob(pattern):
if not match.is_file():
continue
try:
resolved = match.resolve()
resolved.relative_to(root)
except (OSError, ValueError):
continue
yield resolved
def _iter_local_xmls(path: Path):
"""Backwards-compatible wrapper for `_iter_local(path, '*.xml')`."""
yield from _iter_local(path, "*.xml")
def is_loose_song(path: Path) -> bool:
"""True if this directory looks like a playable loose song folder.
Requires both a non-preview WEM and at least one arrangement XML
that isn't a vocals or showlights track — otherwise highway_ws would
later fail when it tries to pick an arrangement from an empty list.
Classification looks at the XML root element rather than the
filename so a custom named `lead_vocals_fix.xml` (root `<song>`)
still counts as a playable arrangement.
"""
if not path.is_dir():
return False
# Require an actual file that resolves inside `path` — both
# rejects directories named `audio.wem` and refuses symlinks
# escaping the song folder.
def _named_audio_ok(name: str) -> bool:
p = path / name
if not p.is_file():
return False
try:
p.resolve().relative_to(path.resolve())
except (OSError, ValueError):
return False
return True
has_audio = (
any(_named_audio_ok(a) for a in AUDIO_NAMES)
or any("preview" not in f.stem.lower()
for f in _iter_local(path, "*.wem"))
)
if not has_audio:
return False
for xml in _iter_local_xmls(path):
try:
root_tag = ET.parse(str(xml)).getroot().tag
except Exception:
continue
if root_tag == "song":
return True
return False
def find_audio(path: Path) -> Path | None:
"""Return the path to the best audio file in the folder.
Prefers known names, then falls back to any WEM that isn't a preview clip.
Only returns regular files that resolve inside `path` a directory,
broken symlink, or symlink escaping the folder would otherwise be
returned and either break convert_wem or read external content.
"""
root = path.resolve()
def _in_folder(p: Path) -> bool:
try:
p.resolve().relative_to(root)
except (OSError, ValueError):
return False
return True
# Check known names first
for a in AUDIO_NAMES:
cand = path / a
if cand.is_file() and _in_folder(cand):
return cand
def _safe_size(f: Path) -> int:
# Treat unreadable files (broken symlinks, permission errors)
# as zero-byte so they sort last and never get picked.
try:
return f.stat().st_size
except OSError:
return 0
candidates = sorted(
[f for f in _iter_local(path, "*.wem")
if "preview" not in f.stem.lower()],
key=_safe_size,
reverse=True,
)
return candidates[0] if candidates else None
def find_art(path: Path) -> Path | None:
"""Return the path to the first recognised album art file in the folder.
Only matches regular files a directory named `cover.jpg` would
otherwise be returned and trip the FileResponse / containment
checks downstream.
"""
return next((path / a for a in ART_NAMES if (path / a).is_file()), None)
def _arr_type_from_filename(stem: str) -> tuple:
"""Infer arrangement type from filename keywords."""
s = stem.lower()
for key, val in ARR_TYPE_MAP.items():
if key in s:
return val
return ("lead", "Lead", 0) # fallback
def _parse_xml_meta(xml_path: Path) -> dict:
"""Parse a Rocksmith arrangement XML and return song-level metadata."""
try:
root = ET.parse(str(xml_path)).getroot()
if root.tag != "song":
return {}
def txt(tag, default=""):
el = root.find(tag)
return el.text.strip() if el is not None and el.text else default
# Tuning from attributes
tuning_el = root.find("tuning")
if tuning_el is not None:
offsets = [int(tuning_el.get(f"string{i}", 0)) for i in range(6)]
else:
offsets = [0] * 6
# Arrangement type — filename is more reliable than the XML tag
# because some authoring tools write "Lead" for all arrangements.
# We read the XML tag here and let _detect_arrangements decide
# which source to trust.
arr_tag = txt("arrangement", "").lower()
arr_from_tag = ARR_TYPE_MAP.get(arr_tag, None)
duration = 0.0
try:
duration = float(txt("songLength", "0"))
except (ValueError, TypeError):
pass
return {
"title": txt("title"),
"artist": txt("artistName"),
"album": txt("albumName"),
"year": txt("albumYear", ""),
"duration": duration,
"tuning_offsets": offsets,
"arr_from_tag": arr_from_tag, # may be None
}
except Exception:
return {}
def _detect_arrangements(path: Path) -> tuple[list[dict], dict]:
"""
Parse all arrangement XMLs.
Returns (arrangements_list, shared_meta).
shared_meta contains title/artist/album/year/duration/tuning_offsets
sourced from the highest-priority arrangement (lead > combo > rhythm >
bass) picking the guitar tuning when both bass and lead are present.
"""
arrangements = []
# Track which arrangement priority sourced shared_meta so a later,
# higher-priority arrangement (lead < bass in sort order) overrides.
shared_meta = {}
shared_priority = None
for xml in sorted(_iter_local_xmls(path)):
# Trust the XML root over the filename — a custom named
# `lead_vocals_fix.xml` is still a real arrangement.
# `_parse_xml_meta` returns {} for any root other than <song>,
# which is how vocals/showlights tracks get filtered out.
stem = xml.stem.lower()
meta = _parse_xml_meta(xml)
if not meta:
continue
# Arrangement type: prefer filename keywords over the XML tag
# because some tools write "Lead" for all arrangements regardless
# of actual type. Only fall back to the XML tag when the filename
# gives no useful signal (i.e. no recognisable keyword found).
filename_type = _arr_type_from_filename(stem)
if filename_type[0] != "lead" or "lead" in stem:
# Filename gave a confident answer
arr_type, arr_name, priority = filename_type
else:
# Filename wasn't specific — try the XML tag
arr_from_tag = meta.get("arr_from_tag")
if arr_from_tag:
arr_type, arr_name, priority = arr_from_tag
else:
arr_type, arr_name, priority = filename_type
# Take song-level fields from the highest-priority arrangement
# (lowest `priority` number) so tuning reflects the main guitar
# instead of whatever sorted first alphabetically.
if meta.get("title") and (shared_priority is None or priority < shared_priority):
shared_meta = {k: meta[k] for k in
("title", "artist", "album", "year",
"duration", "tuning_offsets")}
shared_priority = priority
arrangements.append({
"type": arr_type,
"name": arr_name,
"file": xml.name,
"priority": priority,
})
arrangements.sort(key=lambda a: a["priority"])
for i, a in enumerate(arrangements):
a["index"] = i
del a["priority"]
return arrangements, shared_meta
def _has_lyrics(path: Path) -> bool:
"""Return True if any XML in the folder is a vocals track."""
for xml in _iter_local_xmls(path):
try:
if ET.parse(str(xml)).getroot().tag == "vocals":
return True
except Exception:
pass
return False
def _coerce_duration(raw, fallback) -> float:
"""Coerce a manifest duration to a finite float, falling back on bad input.
Rejects NaN / Infinity so a manifest like `{"duration": "Infinity"}`
can't poison `meta_db` and then crash Starlette's JSON encoder when
the row is served back through `/api/song/...`.
"""
try:
v = float(raw)
if math.isfinite(v):
return v
except (TypeError, ValueError):
pass
try:
v = float(fallback or 0.0)
return v if math.isfinite(v) else 0.0
except (TypeError, ValueError):
return 0.0
def _coerce_text(raw) -> str | None:
"""Return raw if it's a non-empty string, else None.
Manifest fields like `title` / `artist` / `album` can arrive as
JSON nulls, lists, or numbers (e.g. someone setting album to a
year by mistake). Returning None lets the caller fall back to
XML / folder inference instead of crashing the DB row write.
"""
if isinstance(raw, str) and raw:
return raw
return None
def _coerce_tuning_offsets(raw, fallback) -> list[int]:
"""Validate manifest tuning_offsets: must be a list of 6 numeric values."""
if isinstance(raw, list) and len(raw) == 6:
try:
return [int(v) for v in raw]
except (TypeError, ValueError):
pass
if isinstance(fallback, list) and len(fallback) == 6:
return list(fallback)
return [0] * 6
def _validate_manifest_arrangements(raw) -> list[dict] | None:
"""Return raw if it's a well-formed arrangement list, else None.
Each entry must be a dict carrying at least `type`, `name`, `file`
(string-typed). Bad shapes get dropped so the parsed XML list wins.
"""
if not isinstance(raw, list) or not raw:
return None
out = []
for entry in raw:
if not isinstance(entry, dict):
return None
if not all(isinstance(entry.get(k), str) and entry.get(k)
for k in ("type", "name", "file")):
return None
out.append(entry)
return out
def extract_meta(path: Path, dlc_root: Path | None = None) -> dict:
"""Return metadata dict for a loose song folder.
Priority chain:
1. manifest.json (explicit user-supplied data)
2. XML metadata (parsed from arrangement XMLs)
3. Folder name (last resort inference from directory structure)
`dlc_root` is used to bound the folder-name inference: artist/album
are only inferred from `path.relative_to(dlc_root)` components, so
a loose folder placed at `<DLC>/song/` doesn't accidentally surface
the user's home-directory name as the artist.
"""
# 1. Try manifest.json
manifest_path = path / "manifest.json"
manifest = {}
if manifest_path.exists():
try:
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
except Exception:
pass
if not isinstance(manifest, dict):
manifest = {}
# 2. Parse XMLs for song metadata + arrangements
arrangements, xml_meta = _detect_arrangements(path)
# 3. Folder inference — only from path components under DLC_DIR so
# absolute-path parts (`/home/<user>/...`) can never leak as artist.
rel_parts: tuple[str, ...] = ()
if dlc_root is not None:
try:
rel_parts = path.resolve().relative_to(dlc_root.resolve()).parts
except (ValueError, OSError):
rel_parts = ()
# Coerce manifest text fields — non-strings (lists, numbers, null)
# would otherwise propagate into meta_db rows and break DB writes.
title = _coerce_text(manifest.get("title")) or xml_meta.get("title") or path.name
artist = (_coerce_text(manifest.get("artist")) or xml_meta.get("artist")
or (rel_parts[-3] if len(rel_parts) >= 3 else ""))
album = (_coerce_text(manifest.get("album")) or xml_meta.get("album")
or (rel_parts[-2] if len(rel_parts) >= 2 else ""))
raw_year = manifest.get("year")
if isinstance(raw_year, (int, float)) and not isinstance(raw_year, bool):
manifest_year = str(int(raw_year))
else:
manifest_year = _coerce_text(raw_year) or ""
year = manifest_year or str(xml_meta.get("year", ""))
duration = _coerce_duration(manifest.get("duration"),
xml_meta.get("duration", 0))
tuning_offsets = _coerce_tuning_offsets(manifest.get("tuning_offsets"),
xml_meta.get("tuning_offsets"))
manifest_arr = _validate_manifest_arrangements(manifest.get("arrangements"))
if manifest_arr is not None:
arrangements = manifest_arr
audio = find_audio(path)
art = find_art(path)
return {
"title": title,
"artist": artist,
"album": album,
"year": year,
"duration": duration,
"tuning_offsets": tuning_offsets,
"arrangements": arrangements,
"audio_path": str(audio) if audio else None,
"art_path": str(art) if art else None,
"has_lyrics": _has_lyrics(path),
}
+512
View File
@@ -0,0 +1,512 @@
"""WhisperX-based lyric transcription for vocal stems.
Acts as a fallback path when a sloppak lacks `lyrics.json`. Operates on an
already-isolated vocal stem (a Demucs `vocals.ogg`) does NOT separate
vocals from a mixed track; that's the caller's responsibility.
Output shape matches the on-disk `lyrics.json` shape documented at
`docs/sloppak-spec.md` §2.3:
[{"t": float, "d": float, "w": str}, ...]
`t` and `d` are seconds. `w` carries a `-` suffix when it joins to the
following syllable, and a `+` suffix when it's the last syllable on a
line (the frontend renderer in `static/highway.js` keys off
`raw.endsWith('+')` and strips the suffix before drawing see
`docs/sloppak-spec.md` §2.3). Both markers are suffixes on real
syllables, never standalone tokens. WhisperX emits words, not
syllables; the mapper appends `+` to the previous word on segment-gap
heuristics and otherwise lets each word stand as its own syllable.
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
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.
Callers pick between them based on a `whisperx.server_url` config and
fall back as appropriate. This module does not read config both
entry points are pure functions of their arguments.
Hallucination mitigation
Whisper invents plausible-sounding lyrics on near-silent or purely
instrumental input. Two gates guard against that:
1. `vocals_has_signal(path, threshold)` cheap RMS check before
inference. Skips songs where the vocal stem is below threshold
(Demucs returns near-silent vocals for instrumentals).
2. `min_word_score` post-filter WhisperX's word alignment emits a
per-word confidence score; words below the threshold are dropped
from the output. Default 0.35 matches the value the reference
TabGrabber prototype settled on.
"""
from __future__ import annotations
import gc
import logging
from pathlib import Path
from typing import Callable, Optional
log = logging.getLogger("slopsmith.lib.lyrics_transcribe")
ProgressCB = Optional[Callable[[float, str, str], None]]
# ── Availability probes ──────────────────────────────────────────────────────
def whisperx_available() -> bool:
"""Cheap probe — does this interpreter have whisperx importable?
The local transcription path imports whisperx lazily, so this probe lets
callers gate on availability without paying the full import cost
(which transitively pulls torch and may try to initialize CUDA).
Catches a broader exception set than just ImportError because
importing whisperx can fail with OSError (libsndfile or other
native libs missing), RuntimeError (torch CUDA init failure,
BLAS/LAPACK load problems), or essentially any exception the
deep transitive stack chooses to raise. The tests assert this
probe NEVER raises; falling back to False for any failure mode
keeps that contract while still surfacing the real error if a
later actual transcription tries to use the helper."""
try:
import whisperx # noqa: F401
return True
except (ImportError, OSError, RuntimeError) as e:
log.debug("whisperx_available: import failed (%s)", e)
return False
except Exception as e:
# Last-resort catch so a transient/unexpected failure can't
# crash the caller. Logged at WARNING so it's visible in normal
# operation (vs the expected ImportError on installs without
# whisperx, which stays at DEBUG).
log.warning("whisperx_available: unexpected probe failure (%s)", e)
return False
# ── Silence gate ─────────────────────────────────────────────────────────────
def vocals_has_signal(vocals_path: Path, threshold: float = 0.005) -> bool:
"""Return True if the vocal stem has RMS energy above `threshold`.
Cheap pre-check intended to short-circuit transcription on
instrumentals Demucs separates instrumental tracks into a
near-silent vocals stem, and running Whisper on silence produces
hallucinated lyrics. The default threshold is conservative; a
truly silent stem reads ~1e-6, normal vocals well above 0.01.
Returns True when soundfile or numpy is missing OR fails to load
its native lib (best-effort gate, not a hard requirement). The
transcription itself will surface the real failure if those deps
are actually needed downstream.
Catching OSError matters because `import soundfile` performs a
ctypes load of `libsndfile` at import time on a host without the
native lib installed, that raises `OSError` (not ImportError) and
would otherwise propagate up and break the surrounding
transcription run instead of just skipping the gate."""
try:
import numpy as np
import soundfile as sf
except (ImportError, OSError) as e:
log.debug("vocals_has_signal: soundfile/numpy unavailable (%s) — skipping gate", e)
return True
# Stream the file in blocks instead of loading the whole stem into
# memory. A 4-minute stereo vocal stem at 44.1kHz is ~84 MB as
# float32; multiply that across a batch of conversions and the
# allocations get noticeable. SoundFile.blocks() yields chunks
# without ever holding the full buffer, and we only need a
# running sum-of-squares + frame count to compute RMS at the end.
# Short-circuit threshold check inside the loop: once we've
# accumulated enough signal to clear the gate, no need to keep
# scanning the rest of the file.
sumsq = 0.0
nframes = 0
try:
with sf.SoundFile(str(vocals_path)) as fh:
for block in fh.blocks(blocksize=65536, dtype="float32", always_2d=False):
if block.size == 0:
continue
if block.ndim > 1:
block = block.mean(axis=1)
sumsq += float(np.sum(np.square(block)))
nframes += int(block.shape[0])
# Early exit once we know the gate will pass — no point
# reading the rest of a 4-minute file to confirm.
if nframes > 0 and (sumsq / nframes) >= (threshold * threshold):
log.debug("vocals_has_signal: %s passed early at %d frames",
vocals_path.name, nframes)
return True
except Exception as e:
log.warning("vocals_has_signal: read of %s failed: %s", vocals_path, e)
return True
if nframes == 0:
return False
rms = float(np.sqrt(sumsq / nframes))
log.debug("vocals_has_signal: %s rms=%.6f threshold=%.6f", vocals_path.name, rms, threshold)
return rms >= threshold
# ── Output mapping ───────────────────────────────────────────────────────────
# Gap (in seconds) between WhisperX segments that triggers a `+` line break
# syllable in the sloppak output. Bumped from 1.5s (TabGrabber's value) to
# 3.0s after seeing the lower threshold produce short-burst phrasing on
# sung material — singers breathe at ~0.5-1.5s between phrases of the
# same verse, so the tighter cutoff fragmented every line into a few
# words. 3.0s captures stanza-level pauses (verse→chorus, end-of-bridge)
# while keeping intra-line breaths grouped on one rendered line. The
# highway renderer still has its own 4.0s safety fallback (see
# static/highway.js) that forces a wrap regardless, so this only
# controls when WE author breaks vs delegating to the renderer.
_LINE_BREAK_GAP_SECONDS = 3.0
# Floor on per-word duration in the sloppak output. WhisperX occasionally
# emits zero-length words for very short syllables; the highway overlay's
# fade timing expects a non-zero `d`, so clamp here.
_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
# parent `stem_separation` RFC):
# * patch — metadata-only or implementation fixes; no regeneration
# * minor — backward-compatible additions
# * major — output shape / semantics changed; existing transcriptions
# should be regenerated
# Independent from any upstream WhisperX / Whisper / wav2vec2 version.
LYRIC_TRANSCRIPTION_SCHEMA_VERSION = "1.0.0"
LYRIC_TRANSCRIPTION_ENGINE = "whisperx"
def _whisperx_to_sloppak(aligned: dict, min_score: float) -> list[dict]:
"""Map WhisperX `aligned` output to sloppak `lyrics.json` shape.
`aligned` is the dict returned by `whisperx.align()`: a `segments`
list, each segment carrying a `words` list of `{word, start, end,
score}` dicts. Drops words below `min_score` (hallucination filter)
and marks line breaks on segment gaps that exceed
`_LINE_BREAK_GAP_SECONDS`.
Line-break encoding follows the frontend lyric renderer's convention
in `static/highway.js`: `+` is a SUFFIX on the last word of a line,
not a standalone token. A bare `{"w": "+"}` token would be parsed
as an empty syllable that ends a line visible as a blank slot in
the overlay. Emitting `"world+"` instead keeps the syllable count
correct and the renderer strips the suffix when drawing.
Times are rounded to 3 decimals to match the on-disk `lyrics.json`
convention (docs/sloppak-spec.md §2.3)."""
out: list[dict] = []
# `prev_end` tracks the actual end of the last processed segment
# (NOT the last surviving word), so the gap heuristic measures
# against real audio timing. Segments whose only words get filtered
# out still advance the cursor — otherwise the next segment's gap
# would falsely measure all the way back to whatever survived
# several segments ago.
prev_end: float | None = None
for segment in aligned.get("segments", []) or []:
words = segment.get("words") or []
# Walk every word, regardless of whether it survives the
# confidence filter, so we can apply the line-break heuristic
# at the moment we actually emit a syllable. Doing the gap
# check at emit time (vs. once per segment) means a segment
# whose entire word list gets filtered can't strand a "pending"
# break that fires against an unrelated syllable in a later
# segment.
for w in words:
text = (w.get("word") or "").strip()
if not text:
continue
start = w.get("start")
end = w.get("end")
score = w.get("score")
# Drop words that fail confidence threshold. WhisperX
# occasionally emits words without a score (e.g. when
# alignment couldn't localize them); treat those as
# untrustworthy and drop too.
if not isinstance(score, (int, float)) or score < min_score:
continue
if not isinstance(start, (int, float)) or not isinstance(end, (int, float)):
continue
# Line break: suffix `+` on the previous emitted syllable
# if there's a comfortably large silence between the last
# processed-segment cursor and the current surviving word.
# Anchoring on `prev_end` (segment end), not `out[-1]`'s
# actual end, keeps the heuristic aligned with real audio
# timing — a long mid-segment pause within one phrase
# shouldn't force a break, and a trailing-words-filtered
# segment shouldn't falsely inflate the gap to the next one.
if (
prev_end is not None
and (float(start) - prev_end) > _LINE_BREAK_GAP_SECONDS
and out
and not out[-1]["w"].endswith("+")
):
out[-1]["w"] = out[-1]["w"] + "+"
duration = max(_MIN_WORD_DURATION, float(end) - float(start))
out.append({
"t": round(float(start), 3),
"d": round(duration, 3),
"w": text,
})
# Advance `prev_end` to the segment's actual end (or the latest
# numeric word end if the segment lacks an `end`). This runs
# for every segment — even empty / fully-filtered ones — so
# the next segment's gap measurement reflects real audio
# timing regardless of survivorship.
seg_end = segment.get("end")
if isinstance(seg_end, (int, float)):
prev_end = float(seg_end)
else:
word_ends = [
float(w["end"]) for w in words
if isinstance(w.get("end"), (int, float))
]
if word_ends:
prev_end = max(word_ends)
return out
# ── Local transcription ─────────────────────────────────────────────────────
def _pick_compute_type(device: str) -> str:
"""Match TabGrabber's compute-type defaults: float16 on CUDA, int8 on CPU.
WhisperX accepts float16/float32/int8 on CUDA and int8/float32 on CPU.
int8 is the only viable choice for CPU inference at usable speeds."""
return "float16" if device == "cuda" else "int8"
def _resolve_device(device: str | None) -> str:
if device and device != "auto":
return device
try:
import torch
return "cuda" if torch.cuda.is_available() else "cpu"
except ImportError:
return "cpu"
def _free_gpu_memory() -> None:
"""Force a GC + CUDA cache flush.
Note that `del`-ing a local in a helper function only deletes the
helper's parameter binding, not the caller's reference to actually
drop the model the caller must null its own variables (see the
finally block in `transcribe_vocals_local`). This helper only handles
the GC + CUDA side, which is the same regardless of who held the
references. Safe to call regardless of CUDA availability or whether
torch is even installed."""
gc.collect()
try:
import torch
if torch.cuda.is_available():
torch.cuda.empty_cache()
except ImportError:
pass
def transcribe_vocals_local(
vocals_path: Path,
*,
model_size: str = "medium",
language: str | None = None,
device: str | None = None,
compute_type: str | None = None,
min_word_score: float = 0.35,
progress_cb: ProgressCB = None,
) -> list[dict]:
"""Run WhisperX in-process against a vocal stem.
Deferred whisperx import callers gate on `whisperx_available()`
first to avoid the ImportError surfacing here. Heavy: first call
downloads ~1.5 GB of model weights for `medium` (~3 GB for
`large-v2`) into the WhisperX cache.
`model_size` is one of WhisperX's accepted sizes: tiny, base, small,
medium, large-v2, large-v3. Default `medium` balances accuracy and
first-run download size; bump to `large-v2` for production quality.
`language` is an ISO code (e.g. `"en"`); `None` lets WhisperX
autodetect from the audio.
`device` is `"cuda"` / `"cpu"` / `None` (auto-detect). `compute_type`
follows TabGrabber's defaults when `None`."""
try:
import whisperx
except ImportError as e:
raise RuntimeError(
"whisperx not installed. Install via `pip install whisperx`."
) from e
resolved_device = _resolve_device(device)
resolved_compute = compute_type or _pick_compute_type(resolved_device)
if progress_cb:
try:
progress_cb(0.05, "transcribing", f"Loading WhisperX ({model_size}, {resolved_device})")
except Exception:
pass
# Wrap every model lifecycle call in a single try/finally so a failure in
# load_audio / transcribe / load_align_model still frees the ASR model —
# otherwise a bad stem in the middle of a batch run strands GPU memory and
# the next song's load_model OOMs.
#
# Caller-side `= None` reassignment is the only way to actually drop the
# references here; a helper's `del m` only releases the helper's binding,
# leaving the caller's reference live and the GPU memory pinned until
# this function returns. That defeats the purpose of running gc + empty
# cache mid-batch — by the time the next song's transcribe_vocals_local
# fires, we want the previous model GONE, not held until the caller
# frame unwinds.
asr_model = align_model = align_metadata = None
try:
asr_model = whisperx.load_model(model_size, resolved_device, compute_type=resolved_compute)
audio = whisperx.load_audio(str(vocals_path))
if progress_cb:
try:
progress_cb(0.30, "transcribing", "Transcribing vocals")
except Exception:
pass
result = asr_model.transcribe(audio, language=language)
detected_lang = result.get("language") or language or "en"
if progress_cb:
try:
progress_cb(0.60, "transcribing", f"Aligning words ({detected_lang})")
except Exception:
pass
align_model, align_metadata = whisperx.load_align_model(
language_code=detected_lang, device=resolved_device
)
aligned = whisperx.align(
result["segments"], align_model, align_metadata, audio,
resolved_device, return_char_alignments=False,
)
finally:
asr_model = None
align_model = None
align_metadata = None
_free_gpu_memory()
if progress_cb:
try:
progress_cb(0.90, "transcribing", "Building lyric tokens")
except Exception:
pass
return _whisperx_to_sloppak(aligned, min_word_score)
# ── Remote transcription ────────────────────────────────────────────────────
def transcribe_vocals_remote(
vocals_path: Path,
server_url: str,
*,
language: str | None = None,
api_key: str | None = None,
timeout: int = 300,
min_word_score: float = 0.35,
progress_cb: ProgressCB = None,
) -> list[dict]:
"""POST the vocal stem to `{server_url}/align` and parse the response.
Expects the server to respond with a JSON object carrying a `words` (or
`segments`) field in WhisperX's native shape; `_whisperx_to_sloppak`
consumes that directly.
`min_word_score` is applied to native `segments` responses the same
way the local path applies it, so the hallucination guard doesn't
weaken when routing to a remote server. Pre-flattened `{"words": [...]}`
responses are passed through unfiltered (the server is assumed to
have applied its own gating before flattening).
Errors raise `RuntimeError` with a truncated server response, same
idiom Demucs uses, so the caller can log+continue without bringing
down the surrounding split job."""
import requests
server_url = server_url.rstrip("/")
if progress_cb:
try:
progress_cb(0.10, "transcribing", f"Uploading to WhisperX server ({server_url})")
except Exception:
pass
headers: dict[str, str] = {}
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
params: dict[str, str] = {}
if language:
params["language"] = language
with open(vocals_path, "rb") as f:
resp = requests.post(
f"{server_url}/align",
files={"file": (vocals_path.name, f, "audio/ogg")},
params=params,
headers=headers or None,
timeout=timeout,
)
if resp.status_code != 200:
raise RuntimeError(f"WhisperX server error ({resp.status_code}): {resp.text[:300]}")
data = resp.json()
# Two response shapes are accepted, in this order of preference:
#
# 1. Native WhisperX `{"segments": [...]}` — let the standard
# mapper handle it (line breaks + score filter + clamps).
# 2. Pre-flattened sloppak shape `{"words": [{"t","d","w"}, ...]}`
# — pass through with rounding for parity with local path.
#
# Anything else is an error: surface enough of the response that
# `_maybe_transcribe_lyrics` can log it and move on.
if "segments" in data:
return _whisperx_to_sloppak(data, min_score=min_word_score)
if "words" in data:
raw_words = data["words"]
if not isinstance(raw_words, list):
raise RuntimeError(
f"WhisperX server returned non-list `words`: {type(raw_words).__name__}"
)
out: list[dict] = []
for w in raw_words:
# Defensive: a malformed server could ship strings, numbers,
# or partial dicts. Skip anything that isn't a dict with all
# three required keys so the loop doesn't crash on bad data —
# the worst case is a partial transcription, not a wedged job.
if not isinstance(w, dict):
continue
if "t" not in w or "d" not in w or "w" not in w:
continue
try:
out.append({
"t": round(float(w["t"]), 3),
"d": round(float(w["d"]), 3),
"w": str(w["w"]),
})
except (TypeError, ValueError):
# Bad numeric types on this entry; skip and continue.
continue
return out
raise RuntimeError(f"WhisperX server returned unrecognized shape: {str(data)[:300]}")
+626
View File
@@ -0,0 +1,626 @@
"""MIDI file import — list tracks and convert tracks to sloppak payloads.
Two parallel flows live here:
- **Keys path** (`list_midi_tracks` + `convert_midi_track_to_keys_wire`):
filters channel-9 out and emits a standard guitar-style arrangement that
the piano plugin decodes via `midi = string * 24 + fret`.
- **Drums path** (`list_drum_tracks` + `convert_drum_track_from_midi`):
keeps channel-9 only and emits the `drum_tab.json` shape documented in
`docs/sloppak-spec.md` §5.3, ready to drop alongside the sloppak
manifest's `drum_tab:` key.
The editor's track picker uses both for the +Drums and +Keys modals.
"""
from __future__ import annotations
import math
from bisect import bisect_right
from collections import deque
from typing import Callable
import mido
import drums as drums_mod
# General MIDI piano-family programs (0-7) plus chromatic percussion + organ.
# Used to flag obvious keyboard tracks for the picker UI.
_KEY_PROGRAMS = set(range(0, 24))
_KEYBOARD_NAME_HINTS = (
"piano", "keys", "keyboard", "synth", "organ", "rhodes",
"harpsichord", "clavinet", "wurlitzer", "ep ", "epiano",
)
def list_midi_tracks(midi_path: str) -> list[dict]:
"""Return a list of track descriptors suitable for the picker UI.
Format-0 MIDI files store every channel in a single track; if we just
enumerated `midi.tracks` we'd produce one picker entry that merged
every part into a single Keys arrangement. For format-0 only, split
that single track into one virtual entry per non-drum channel so the
user can isolate the piano part.
Type-1 (parallel tracks) and type-2 (independent sequences) keep their
one-entry-per-track shape: their tracks already represent the parts
the author intended, and a track that uses e.g. LH/RH on separate
channels would otherwise lose half its notes when the user picked
just one of the split entries with no way to recover the merged form.
Drum (channel-9) channels are dropped from the listing here the
keys-import converter unconditionally skips channel-9 events, so a
drums entry would yield an empty arrangement. Use `list_drum_tracks`
+ `convert_drum_track_from_midi` for the MIDI drum-import flow.
Each item: {index, name, instrument, notes, channel, is_piano, is_drums,
channel_filter}. For split entries `channel_filter` is set; for
unsplit entries it's None.
"""
midi = mido.MidiFile(midi_path)
tracks: list[dict] = []
midi_type = getattr(midi, "type", 1)
# Only format-0 collapses every part into one track and therefore
# benefits from per-channel splitting. Type-1/2 tracks already
# represent author-defined parts.
split_format = (midi_type == 0)
for i, track in enumerate(midi.tracks):
name = ""
# Per-channel stats, populated by walking the track once.
per_channel: dict[int, dict] = {}
for msg in track:
if msg.type == "track_name" and not name:
name = msg.name or ""
elif msg.type == "program_change":
ch = int(getattr(msg, "channel", -1))
slot = per_channel.setdefault(ch, {"program": -1, "notes": 0})
if slot["program"] < 0:
slot["program"] = int(msg.program)
elif msg.type == "note_on" and int(getattr(msg, "velocity", 0)) > 0:
ch = int(getattr(msg, "channel", -1))
slot = per_channel.setdefault(ch, {"program": -1, "notes": 0})
slot["notes"] += 1
# Drop channels that never produced a note (tempo/meta-only entries
# would just clutter the picker) AND drop drum channels — the
# keys-import converter skips channel-9 events unconditionally,
# so a drums entry would always yield an empty arrangement.
active_channels = sorted(
ch for ch, info in per_channel.items()
if info["notes"] > 0 and ch != 9
)
if not active_channels:
# Track with no melodic notes (silent or drums-only). Skip.
continue
# Format-0 with multiple non-drum channels is the only case where
# we split. Type-1/2 keep one-entry-per-track so the user can
# always import the whole part.
split = split_format and len(active_channels) > 1
if split:
iter_channels = active_channels
else:
# One merged entry; channel comes from the first active one
# for display purposes (and in case the converter ever needs
# a hint, though channel_filter=None means "merge all").
iter_channels = [active_channels[0]]
for ch in iter_channels:
info = per_channel[ch]
program = info["program"]
note_count = (
info["notes"] if split
else sum(per_channel[c]["notes"] for c in active_channels)
)
if split:
channel_label = f"Ch{ch + 1}"
base = name or f"Track {i}"
entry_name = f"{base}{channel_label}"
else:
entry_name = name or f"Track {i}"
# Classify on the per-channel program first. The track-level
# name hint is a tiebreaker only when no program_change was
# seen for this channel — otherwise a track named "Piano"
# that hosts bass on ch2 would wrongly flag ch2 as piano in
# the format-0 split case. For non-split tracks the name
# hint still carries weight (single-channel tracks usually
# share track name + program intent).
if program in _KEY_PROGRAMS:
is_piano = True
elif program < 0 and not split:
# Program unknown for this single-channel track — fall
# back to the track-name heuristic.
name_lower = entry_name.lower()
is_piano = any(hint in name_lower for hint in _KEYBOARD_NAME_HINTS)
else:
is_piano = False
tracks.append({
"index": i,
# When set, the converter filters the track's events to this
# channel only. None means "use every non-drum channel".
"channel_filter": ch if split else None,
"name": entry_name,
"instrument": program,
"notes": note_count,
"channel": ch,
"is_piano": bool(is_piano),
# `is_drums` is always False on emitted entries because we
# filter channel 9 above. Kept for shape compatibility
# with the GP picker entries the frontend also reads.
"is_drums": False,
"strings": 0,
"is_percussion": False,
})
return tracks
def convert_midi_track_to_keys_wire(
midi_path: str,
track_index: int,
audio_offset: float = 0.0,
name: str = "Keys",
channel_filter: int | None = None,
) -> dict:
"""Convert a single MIDI track into a sloppak-format keys arrangement.
Encodes each MIDI note as the piano plugin expects: string = pitch // 24,
fret = pitch % 24 (so noteToMidi(s, f) = s * 24 + f recovers the pitch).
Returns a wire-format arrangement dict ready to be written to
arrangements/<id>.json.
audio_offset (seconds) is added to every note's start time. Useful as a
coarse pre-sync handle; finer alignment happens in the editor.
channel_filter (optional): when set, only events on this channel are
processed. Used by the picker to isolate one channel out of a format-0
track that mixes multiple instruments.
CC64 (sustain pedal) is honored: when a key is released while the pedal
is held, the note's end time is extended to the pedal-up event on the
same channel. Pedal-down/up transitions are tracked per channel.
"""
midi = mido.MidiFile(midi_path)
if track_index < 0 or track_index >= len(midi.tracks):
raise ValueError(f"track_index {track_index} out of range")
# Build a tempo map. The right scope depends on the SMF format:
# - type 0 (single track holding everything): the lone track is also
# the source of tempo events. Walking it (and only it) is correct.
# - type 1 (parallel tracks, shared timeline): tempo events live on
# the conductor track (usually track 0) but the spec allows them
# anywhere. Merge across all tracks so we don't miss any.
# - type 2 (independent sequential tracks, each its own timeline):
# a foreign track's tempo events do NOT apply to the chosen
# track. Merging would mis-time the notes — restrict the tempo
# scan to the selected track only.
ticks_per_beat = midi.ticks_per_beat
raw_events: list[tuple[int, int]] = [(0, 500000)] # default 120 BPM
midi_type = getattr(midi, "type", 1)
tempo_source = (
[midi.tracks[track_index]] if midi_type == 2 else midi.tracks
)
for track in tempo_source:
abs_tick = 0
for msg in track:
abs_tick += msg.time
if msg.type == "set_tempo":
raw_events.append((abs_tick, int(msg.tempo)))
raw_events.sort(key=lambda e: e[0])
# Deduplicate at same tick (keep the last one written).
deduped: list[tuple[int, int]] = []
for ev in raw_events:
if deduped and deduped[-1][0] == ev[0]:
deduped[-1] = ev
else:
deduped.append(ev)
# Precompute (tick, seconds_at_tick, microseconds_per_beat). seconds_at_tick
# is the cumulative time up to that tempo-change event.
tempo_table: list[tuple[int, float, int]] = []
cum_seconds = 0.0
prev_tick = 0
prev_tempo = deduped[0][1]
for ev_tick, ev_tempo in deduped:
cum_seconds += (ev_tick - prev_tick) * (prev_tempo / 1_000_000.0) / ticks_per_beat
tempo_table.append((ev_tick, cum_seconds, ev_tempo))
prev_tick = ev_tick
prev_tempo = ev_tempo
tempo_ticks = [row[0] for row in tempo_table]
def tick_to_seconds(tick: int) -> float:
"""O(log N) tempo-aware tick→seconds via cumulative table + bisect."""
i = bisect_right(tempo_ticks, tick) - 1
if i < 0:
i = 0
base_tick, base_seconds, tempo = tempo_table[i]
return base_seconds + (tick - base_tick) * (tempo / 1_000_000.0) / ticks_per_beat
# Walk the requested track, collect note_on/note_off pairs. To handle
# rapid retriggers (same pitch starting again before the previous
# note_off), keep a stack of start ticks per (channel, pitch).
#
# Sustain pedal (CC64): when value >= 64, the channel is "pedal down"
# and key-release events don't truncate the note — they move the
# pending start onto `pedal_pending`, where it waits for the pedal-up
# transition. Pedal-up finalises every pending note on that channel
# using the pedal-up tick as the end.
track = midi.tracks[track_index]
abs_tick = 0
active: dict[tuple[int, int], deque[int]] = {}
pedal_pending: dict[int, list[tuple[int, int]]] = {} # ch -> [(pitch, start_tick)]
pedal_down: dict[int, bool] = {}
notes_out: list[dict] = []
def _emit(pitch: int, start_tick: int, end_tick: int) -> None:
t = tick_to_seconds(start_tick) + float(audio_offset)
end = tick_to_seconds(end_tick) + float(audio_offset)
notes_out.append({
"t": round(t, 3),
"s": int(pitch // 24),
"f": int(pitch % 24),
"sus": round(max(0.0, end - t), 3),
"sl": -1, "slu": -1, "bn": 0,
"ho": False, "po": False, "hm": False, "hp": False,
"pm": False, "mt": False, "tr": False, "ac": False, "tp": False,
})
for msg in track:
abs_tick += msg.time
msg_ch = int(getattr(msg, "channel", -1))
# Channel filter: when the picker entry was a format-0 split, only
# process events on the chosen channel. Channel-less meta events
# (set_tempo, etc.) have channel == -1 and pass through unaffected
# because the message types we act on below all have a channel.
if channel_filter is not None and msg_ch != -1 and msg_ch != channel_filter:
continue
if msg.type == "note_on" and int(getattr(msg, "velocity", 0)) > 0:
if msg_ch == 9:
continue # skip percussion
pitch = int(msg.note)
active.setdefault((msg_ch, pitch), deque()).append(abs_tick)
elif msg.type == "note_off" or (
msg.type == "note_on" and int(getattr(msg, "velocity", 0)) == 0
):
pitch = int(msg.note)
stack = active.get((msg_ch, pitch))
if not stack:
continue
# FIFO match against the oldest still-active start so overlapping
# retriggers each get a sensible end time.
start_tick = stack.popleft()
if not stack:
active.pop((msg_ch, pitch), None)
if pedal_down.get(msg_ch, False):
# Defer: extend the note until pedal-up.
pedal_pending.setdefault(msg_ch, []).append((pitch, start_tick))
else:
_emit(pitch, start_tick, abs_tick)
elif msg.type == "control_change" and int(getattr(msg, "control", -1)) == 64:
was_down = pedal_down.get(msg_ch, False)
now_down = int(getattr(msg, "value", 0)) >= 64
pedal_down[msg_ch] = now_down
if was_down and not now_down:
# Pedal-up: finalise every pending note on this channel.
pending = pedal_pending.pop(msg_ch, [])
for pitch, start_tick in pending:
_emit(pitch, start_tick, abs_tick)
# End-of-track: close anything still active or held by the pedal,
# using abs_tick as the end. Pedaled notes that never saw a pedal-up
# land here too.
for (_ch, pitch), starts in active.items():
for start_tick in starts:
_emit(pitch, start_tick, abs_tick)
active.clear()
for _ch, pending in pedal_pending.items():
for pitch, start_tick in pending:
_emit(pitch, start_tick, abs_tick)
pedal_pending.clear()
notes_out.sort(key=lambda n: n["t"])
return {
"name": name,
"tuning": [0, 0, 0, 0, 0, 0],
"capo": 0,
"notes": notes_out,
"chords": [],
"anchors": [],
"handshapes": [],
"templates": [],
}
# ── Tempo + drum-import shared helpers ───────────────────────────────────────
def _build_tick_to_seconds(midi: mido.MidiFile, track_index: int) -> Callable[[int], float]:
"""Return an `(abs_tick) -> seconds` function for the chosen track.
Tempo-event scope depends on the SMF format (mirrors the keys converter):
- type 0: single track holds tempo + notes; walk it alone.
- type 1: parallel tracks share the timeline; merge tempo events.
- type 2: independent timelines; tempo only from the chosen track.
"""
ticks_per_beat = midi.ticks_per_beat
raw_events: list[tuple[int, int]] = [(0, 500000)] # default 120 BPM
midi_type = getattr(midi, "type", 1)
tempo_source = (
[midi.tracks[track_index]] if midi_type == 2 else midi.tracks
)
for tr in tempo_source:
abs_tick = 0
for msg in tr:
abs_tick += msg.time
if msg.type == "set_tempo":
raw_events.append((abs_tick, int(msg.tempo)))
raw_events.sort(key=lambda e: e[0])
deduped: list[tuple[int, int]] = []
for ev in raw_events:
if deduped and deduped[-1][0] == ev[0]:
deduped[-1] = ev
else:
deduped.append(ev)
tempo_table: list[tuple[int, float, int]] = []
cum_seconds = 0.0
prev_tick = 0
prev_tempo = deduped[0][1]
for ev_tick, ev_tempo in deduped:
cum_seconds += (ev_tick - prev_tick) * (prev_tempo / 1_000_000.0) / ticks_per_beat
tempo_table.append((ev_tick, cum_seconds, ev_tempo))
prev_tick = ev_tick
prev_tempo = ev_tempo
tempo_ticks = [row[0] for row in tempo_table]
def tick_to_seconds(tick: int) -> float:
i = bisect_right(tempo_ticks, tick) - 1
if i < 0:
i = 0
base_tick, base_seconds, tempo = tempo_table[i]
return base_seconds + (tick - base_tick) * (tempo / 1_000_000.0) / ticks_per_beat
return tick_to_seconds
# ── Drum track listing (channel-9 only) ──────────────────────────────────────
# Velocity below this is treated as a ghost note. GM doesn't have an explicit
# ghost flag; chartists encode dynamics through velocity. 40 is the same
# threshold the drums plugin uses for ghost-note styling.
_GHOST_VELOCITY = 40
# Two hits on the same piece closer together than this are interpreted as a
# flam (the later one carries `f: true`). 30 ms matches the drums plugin's
# leading-glyph offset for flam rendering.
_FLAM_WINDOW_S = 0.030
# A cymbal note whose explicit note-off arrives within this window after the
# note-on is treated as a choke (the chartist clamped the cymbal). 120 ms
# matches the spec's mention of choke tail durations.
_CHOKE_MAX_S = 0.120
def list_drum_tracks(midi_path: str) -> list[dict]:
"""List tracks that contain GM channel-9 (percussion) note_on events.
For format-0 files (everything in one track, channels intermixed), this
surfaces the lone track once when it has channel-9 hits. For format-1/2
files, each track that fires channel-9 notes shows up. Mirrors
`list_midi_tracks` so the editor's +Drums modal can show the same shape
of picker entry the +Keys modal does.
Each item: {index, name, instrument, notes, channel, is_piano,
is_drums, strings, is_percussion, channel_filter} the same
picker-entry shape `list_midi_tracks` emits, so the editor frontend
can consume either list uniformly. For drum tracks the classification
fields are fixed: `is_drums`/`is_percussion` True, `is_piano` False,
`instrument` -1, `strings` 0. `channel_filter` is always 9 the
converter uses it to skip non-drum events on a mixed-channel track.
"""
midi = mido.MidiFile(midi_path)
out: list[dict] = []
for i, track in enumerate(midi.tracks):
name = ""
note_count = 0
for msg in track:
if msg.type == "track_name" and not name:
name = msg.name or ""
elif (
msg.type == "note_on"
and int(getattr(msg, "velocity", 0)) > 0
and int(getattr(msg, "channel", -1)) == 9
):
note_count += 1
if note_count == 0:
continue
out.append({
"index": i,
"channel_filter": 9,
"name": name or f"Track {i} (drums)",
"instrument": -1,
"notes": note_count,
"channel": 9,
"is_piano": False,
"is_drums": True,
"strings": 0,
"is_percussion": True,
})
return out
def convert_drum_track_from_midi(
midi_path: str,
track_index: int,
audio_offset: float = 0.0,
name: str = "Drums",
*,
out_unmapped: dict[int, dict] | None = None,
) -> dict:
"""Convert a MIDI drum track to a `drum_tab.json` dict.
Reads channel-9 note_on events on the chosen track, maps each MIDI note
to a piece-id via `lib.drums.midi_to_piece`, and emits hits with
velocity preserved verbatim. Three heuristics encode articulations that
GM MIDI doesn't have explicit flags for:
- **Ghost**: velocity < 40 `g: true`.
- **Flam**: two hits on the same piece within 30 ms the louder one
(the main strike) carries `f: true` and the quieter grace note is
dropped (the renderer draws the leading glyph itself from the `f`
flag). Typically the grace note arrives first in time, but the
heuristic is velocity-based to handle MIDI files where encoding
order differs from chronological order.
- **Choke**: a cymbal note-off arriving within 120 ms of its note-on
sets `k` to the actual onoff duration.
Callers can pass an empty dict as ``out_unmapped`` to receive a
per-MIDI record of every channel-9 note_on that didn't resolve to a
piece-id (``{midi: {"count": int, "times": [float, ...]}}``, times
capped at 100 samples per note). The default path skips this
capture entirely so MIDIs heavy with cowbell/tambourine/etc. take
no extra work.
"""
offset = float(audio_offset)
if not math.isfinite(offset):
raise ValueError(f"audio_offset must be a finite number, got {audio_offset!r}")
midi = mido.MidiFile(midi_path)
if track_index < 0 or track_index >= len(midi.tracks):
raise ValueError(f"track_index {track_index} out of range")
tick_to_seconds = _build_tick_to_seconds(midi, track_index)
track = midi.tracks[track_index]
# Two passes: collect raw note_on/note_off pairs in pass 1 (so we know
# each on's actual off time for choke detection), then apply the
# flam-collapse + serialisation in pass 2.
raw: list[dict] = [] # one entry per note_on
# Use list[int] per MIDI note so overlapping/retriggered hits (note_on
# before note_off for the same note) are each tracked independently
# rather than the later one overwriting the earlier one's index.
open_hits: dict[int, deque[int]] = {} # midi note -> FIFO queue of indices in `raw`
abs_tick = 0
for msg in track:
abs_tick += msg.time
if int(getattr(msg, "channel", -1)) != 9:
continue
if msg.type == "note_on" and int(getattr(msg, "velocity", 0)) > 0:
midi_note = int(msg.note)
piece = drums_mod.midi_to_piece(midi_note)
if piece is None:
# Default path: drop silently. Only pay the tick->seconds
# cost on the opt-in capture path so MIDIs full of unmapped
# percussion don't take a perf hit when the caller didn't
# ask for unmapped reporting.
if out_unmapped is None:
continue
t = tick_to_seconds(abs_tick) + offset
entry = out_unmapped.setdefault(
midi_note, {"count": 0, "times": []})
entry["count"] += 1
if len(entry["times"]) < 100:
entry["times"].append(round(t, 3))
continue
# Mapped note: compute t once for the raw entry.
t = tick_to_seconds(abs_tick) + offset
raw.append({
"t": t,
"p": piece,
"v": int(msg.velocity),
"_midi": midi_note,
"_on_tick": abs_tick,
})
open_hits.setdefault(midi_note, deque()).append(len(raw) - 1)
elif msg.type == "note_off" or (
msg.type == "note_on" and int(getattr(msg, "velocity", 0)) == 0
):
midi_note = int(msg.note)
stack = open_hits.get(midi_note)
if not stack:
continue
idx = stack.popleft() # FIFO: oldest note_on matches this note_off
if not stack:
del open_hits[midi_note]
hit = raw[idx]
if drums_mod.piece_category(hit["p"]) != "cymbal":
continue
on_secs = tick_to_seconds(hit["_on_tick"])
off_secs = tick_to_seconds(abs_tick)
dur = off_secs - on_secs
if 0.0 < dur <= _CHOKE_MAX_S:
hit["k"] = round(dur, 3)
raw.sort(key=lambda h: (h["t"], h["p"]))
# Flam collapse: for each hit, compare it against the most-recent
# previous hit of the SAME piece (not just the globally adjacent entry),
# so an intervening hit from a different piece does not break flam
# detection for densely-played patterns (e.g. kick + snare flam).
flam_indices: set[int] = set()
drop_indices: set[int] = set()
last_by_piece: dict[str, int] = {} # piece-id -> index in raw[]
for i, curr in enumerate(raw):
piece = curr["p"]
prev_i = last_by_piece.get(piece)
if prev_i is not None and prev_i not in drop_indices:
prev = raw[prev_i]
if (curr["t"] - prev["t"]) <= _FLAM_WINDOW_S:
# Prefer the louder hit as the "main"; the quieter is the
# leading grace. The main hit receives `f: true` so the
# renderer draws a small grace glyph slightly ahead of it.
if prev["v"] <= curr["v"]:
drop_indices.add(prev_i)
flam_indices.add(i)
else:
drop_indices.add(i)
flam_indices.add(prev_i)
# Don't advance last_by_piece — keep prev_i as anchor so a
# triple flam doesn't chain two drops.
continue
last_by_piece[piece] = i
out_hits: list[dict] = []
for i, hit in enumerate(raw):
if i in drop_indices:
continue
# Round here (not at append time) so flam comparisons above used full precision.
new_hit: dict = {"t": round(hit["t"], 3), "p": hit["p"]}
vel = hit["v"]
if 1 <= vel <= 127:
new_hit["v"] = vel
if vel < _GHOST_VELOCITY:
new_hit["g"] = True
if i in flam_indices:
new_hit["f"] = True
if "k" in hit:
new_hit["k"] = hit["k"]
out_hits.append(new_hit)
# Build kit legend from the union of piece-ids that survived.
seen_pieces: list[str] = []
seen_set: set[str] = set()
for h in out_hits:
if h["p"] not in seen_set:
seen_set.add(h["p"])
seen_pieces.append(h["p"])
return {
"version": drums_mod.SCHEMA_VERSION,
"name": name,
"kit": [
{"id": pid, "name": pid.replace("_", " ").title()}
for pid in seen_pieces
],
"hits": out_hits,
}
+340
View File
@@ -0,0 +1,340 @@
"""Notation format vocabulary and wire helpers.
The canonical notation payload for a sloppak arrangement is a per-arrangement
``notation_<id>.json`` file referenced from the arrangement entry in
``manifest.yaml`` via the ``notation:`` sub-key (see
``docs/sloppak-spec.md`` §5).
This module is the source of truth for:
- the closed vocabulary of valid clef identifiers and note durations,
- a permissive validator used by both the sloppak loader and importers,
- wire helpers that normalise measure data for WS streaming.
The schema is intentionally extensible: unknown fields round-trip through
the loader so a newer sloppak can still render on an older client that just
doesn't have visuals for the new field. Validation is strict only on the
required top-level shape (``version``, ``staves``, ``measures`` types).
Analogous to ``lib/drums.py`` for the drum tab format.
"""
from __future__ import annotations
import logging
import math
log = logging.getLogger("slopsmith.lib.notation")
# ── Vocabulary ────────────────────────────────────────────────────────────────
# Current ``version`` written by importers. Readers MUST accept any version
# they recognise; an unknown version is logged at DEBUG and passed through
# (forward-compat per sloppak-spec Principle IV).
SCHEMA_VERSION: int = 1
# Closed set of alphaTab clef identifiers.
# G2 — treble clef (standard guitar, violin, flute, …)
# F4 — bass clef (piano left hand, bass, cello, …)
# C3 — alto clef (viola)
# C4 — tenor clef (cello upper register, trombone)
# neutral — unpitched / percussion staff
CLEFS: set[str] = {"G2", "F4", "C3", "C4", "neutral"}
# Valid note duration denominators (integer powers of 2, up to 32nd note).
# 1=whole, 2=half, 4=quarter, 8=eighth, 16=sixteenth, 32=thirty-second.
DURATIONS: set[int] = {1, 2, 4, 8, 16, 32}
# Typed grace-note vocabulary (beat field ``grace``).
# "a" — acciaccatura: slashed grace, steals time from the PREVIOUS note
# (MusicXML <grace slash="yes">).
# "p" — appoggiatura: unslashed grace, steals time from the FOLLOWING note
# (MusicXML <grace>).
GRACE_TYPES: set[str] = {"a", "p"}
# Forced stem directions (note field ``stem``). Omit to let the renderer
# decide (MusicXML <stem>).
STEM_DIRECTIONS: set[str] = {"up", "down"}
# Dynamics vocabulary (beat field ``dyn``).
DYNAMICS: set[str] = {"ppp", "pp", "p", "mp", "mf", "f", "ff", "fff"}
# ── Schema validation ─────────────────────────────────────────────────────────
def validate_notation(data: object) -> tuple[bool, str]:
"""Light schema check for a parsed ``notation_<id>.json`` payload.
Returns ``(ok, reason)``. Permissive: unknown top-level fields pass
through unchanged. Strict only on the required top-level shape:
- ``data`` must be a JSON object (dict).
- ``data["version"]`` must be an int when present. A missing ``version``
key is accepted as ``SCHEMA_VERSION``. An unknown version value is
logged at DEBUG level and still accepted forward-compat.
- ``data["staves"]`` must be a list.
- ``data["measures"]`` must be a list.
"""
if not isinstance(data, dict):
return False, "notation payload must be a JSON object"
# version — optional, must be int when present (bool subclasses int, reject)
ver = data.get("version", SCHEMA_VERSION)
if isinstance(ver, bool) or not isinstance(ver, int):
return False, "notation.version must be an integer"
if ver != SCHEMA_VERSION:
log.debug("notation: unknown schema version %r — passing through", ver)
# staves — required list
if "staves" not in data:
return False, "notation.staves is required"
if not isinstance(data["staves"], list):
return False, "notation.staves must be a list"
# measures — required list
if "measures" not in data:
return False, "notation.measures is required"
if not isinstance(data["measures"], list):
return False, "notation.measures must be a list"
return True, ""
# ── Wire helpers ──────────────────────────────────────────────────────────────
def _finite_float_wire(v: object, fallback: float = 0.0) -> float:
"""Return ``float(v)`` if finite, else ``fallback``.
Prevents NaN/Infinity from reaching the WS JSON encoder where they would
produce non-standard tokens that break browser ``JSON.parse`` calls.
"""
try:
f = float(v) # type: ignore[arg-type]
except (TypeError, ValueError):
return fallback
return f if math.isfinite(f) else fallback
def measure_to_wire(measure: object) -> dict:
"""Normalise one measure dict for WS streaming.
- Returns a shallow copy of the measure; the input is not mutated.
- The measure-level ``t`` (start time) and ``tempo`` fields are guarded
against NaN/Infinity non-finite values fall back to 0.0 so the
highway WS frame always carries valid JSON.
- Beat times (``beats[].t`` inside each voice of each staff) are rounded
to 3 decimal places and carry the same finite guard, matching the
precision used by ``drums.hit_to_wire``.
- Fields equal to their default value are NOT stripped here that is the
file author's responsibility. The wire helper is pass-through with time
normalisation only.
- Malformed input (non-dict) returns an empty dict so callers can skip it.
"""
if not isinstance(measure, dict):
return {}
# Shallow-copy the top level so callers can't mutate our data.
out = dict(measure)
# Guard measure-level numeric fields against NaN/Infinity.
# ``t`` — measure start time in seconds; ``tempo`` — BPM at this measure.
for _field in ("t", "tempo"):
if _field in out and isinstance(out[_field], (int, float)) and not isinstance(out[_field], bool):
out[_field] = _finite_float_wire(out[_field])
# Deep-round beat times inside staves → voices → beats → t.
# We copy each level we touch to avoid mutating the source.
staves_raw = out.get("staves")
if isinstance(staves_raw, dict):
staves_out: dict = {}
for staff_id, staff_data in staves_raw.items():
if not isinstance(staff_data, dict):
staves_out[staff_id] = staff_data
continue
staff_out = dict(staff_data)
voices_raw = staff_out.get("voices")
if isinstance(voices_raw, list):
voices_out = []
for voice in voices_raw:
if not isinstance(voice, dict):
voices_out.append(voice)
continue
voice_out = dict(voice)
beats_raw = voice_out.get("beats")
if isinstance(beats_raw, list):
beats_out = []
for beat in beats_raw:
if not isinstance(beat, dict):
beats_out.append(beat)
continue
beat_out = dict(beat)
t = beat_out.get("t")
if isinstance(t, (int, float)) and not isinstance(t, bool):
# Guard against NaN/Infinity: json.loads accepts
# them but they serialize to invalid JSON tokens
# over the highway WS, breaking clients.
beat_out["t"] = round(_finite_float_wire(t), 3)
beats_out.append(beat_out)
voice_out["beats"] = beats_out
voices_out.append(voice_out)
staff_out["voices"] = voices_out
staves_out[staff_id] = staff_out
out["staves"] = staves_out
return out
def measures_to_wire(measures: list[dict]) -> list[dict]:
"""Vectorised ``measure_to_wire``.
Drops entries that round-trip as empty dicts (i.e. malformed non-dict
entries). Preserves source order does NOT sort by time.
"""
out: list[dict] = []
for m in measures:
w = measure_to_wire(m)
if w:
out.append(w)
return out
# ── Notation → flat notes (for editors / falling-note renderers) ──────────────
def _beat_written_seconds(beat: dict, qn: float) -> float:
"""Written duration of one beat in seconds at quarter-note length ``qn``,
honouring dots (``n`` dots ``2 1/2`` × base) and tuplets (``tu`` =
``[actual, normal]`` scale by ``normal/actual``, e.g. a ``[3, 2]`` triplet
sounds as long)."""
dur = beat.get("dur") or 4
dot = beat.get("dot") or 0
dot_mult = (2.0 - 0.5 ** dot) if isinstance(dot, int) and dot > 0 else 1.0
tu_mult = 1.0
tu = beat.get("tu")
if isinstance(tu, (list, tuple)) and len(tu) == 2:
try:
actual, normal = float(tu[0]), float(tu[1])
if actual > 0 and normal > 0:
tu_mult = normal / actual
except (TypeError, ValueError):
pass
try:
return qn * (4.0 / float(dur)) * dot_mult * tu_mult
except (TypeError, ValueError, ZeroDivisionError):
return qn
def notation_to_notes(notation: object) -> list[dict]:
"""Flatten a notation payload to ``[{"t", "midi", "sus"}, ...]`` sorted by
``(t, midi)`` absolute onset seconds, absolute MIDI pitch, and a sounding
duration in seconds.
The inverse direction of the lifter: turns the staves/measures/voices/beats
structure back into a flat note list a piano-roll editor or a falling-note
highway can consume. Semantics:
- **Sustain = the beat's written duration** at the local tempo (honouring
dots and tuplet ``tu`` ratios). This is the note's notated length, so it
neither stretches a short note across a following gap nor truncates a note
that rings past a later onset. Exact when the source emits tuplet ``tu``
(e.g. gp2notation) or quantised durations (the wire-lifter); a source that
omits ``tu`` for tuplets the current MusicXML importer yields the
printed note length, with onsets still exact (upstream follow-up).
- **Tempo carries forward** across measures (a measure without a ``tempo``
field inherits the last seen one; default 120 BPM).
- **Tied continuations fold** into the originating note's sustain (summing
written durations across beats/barlines).
- **Grace** beats and **rests** are skipped (rests also end any held notes).
"""
if not isinstance(notation, dict):
return []
# Pass 1 — gather each voice's beats in order across measures, carrying the
# written-duration fallback (seconds at that measure's tempo) per beat.
voice_timelines: dict[tuple, list[dict]] = {}
last_tempo = 120.0
for measure in notation.get("measures") or []:
if not isinstance(measure, dict):
continue
tempo = measure.get("tempo")
if tempo:
try:
v = float(tempo)
if v > 0:
last_tempo = v
except (TypeError, ValueError):
pass
qn = 60.0 / last_tempo if last_tempo > 0 else 0.5
staves = measure.get("staves")
if not isinstance(staves, dict):
continue # permissive: skip a malformed-but-loadable measure
for staff_id, staff in staves.items():
if not isinstance(staff, dict):
continue
voices = staff.get("voices")
if not isinstance(voices, list):
continue
for vi, voice in enumerate(voices):
if not isinstance(voice, dict):
continue
voice_id = voice.get("v", vi)
if not isinstance(voice_id, (int, str)):
voice_id = vi # unhashable/odd id → fall back to position
tl = voice_timelines.setdefault((staff_id, voice_id), [])
beats = voice.get("beats")
if not isinstance(beats, list):
continue
for beat in beats:
if not isinstance(beat, dict):
continue
try:
t = float(beat.get("t", 0.0))
except (TypeError, ValueError):
t = 0.0
bn = beat.get("notes")
tl.append({
"t": t,
"written": _beat_written_seconds(beat, qn),
"grace": bool(beat.get("grace")),
"rest": bool(beat.get("rest")),
"notes": bn if isinstance(bn, list) else [],
})
# Pass 2 — per voice, sustain = the beat's written duration; fold tied
# continuations into the open note for that pitch.
out: list[dict] = []
for tl in voice_timelines.values():
# Tie folding assumes chronological beats; a hand-written file (or a
# future editor reorder) may not be ordered. Stable sort keeps
# same-onset (chord) beats in source order.
tl.sort(key=lambda b: b["t"])
opens: dict[int, dict] = {}
for b in tl:
if b["grace"]:
continue
dur_secs = b["written"]
if b["rest"]:
opens.clear()
continue
present: set[int] = set()
for n in b["notes"]:
if not isinstance(n, dict):
continue
try:
midi = int(n.get("midi"))
except (TypeError, ValueError):
continue
if not 0 <= midi <= 127:
continue
present.add(midi)
if n.get("tied") and midi in opens:
opens[midi]["sus"] = round(opens[midi]["sus"] + dur_secs, 4)
else:
note = {"t": round(b["t"], 4), "midi": midi, "sus": round(dur_secs, 4)}
out.append(note)
opens[midi] = note
for p in [p for p in opens if p not in present]:
del opens[p]
out.sort(key=lambda n: (n["t"], n["midi"]))
return out
+324
View File
@@ -0,0 +1,324 @@
"""Lift legacy guitar-wire keys notes into the Sloppak Notation Format.
The reusable heuristic core shared by the one-time
``scripts/lift_keys_notation.py`` converter and any in-process caller (e.g.
the Arrangement Editor's notation save path). It takes decoded wire notes
plus the song-level ``beats`` array and infers measures, written durations,
and a right/left-hand split, producing a validated ``notation`` payload.
The heuristics (see ``build_notation`` and friends):
1. **Wire decode** ``decode_wire_notes`` unpacks ``midi = s*24 + f`` (the
Clone Hero / GP-import legacy encoding, sloppak-spec §5.3 legacy fallback),
including chord notes, to ``[{"t", "midi", "sus"}, ...]`` sorted by time.
2. **Measures / tempo** ``downbeat_times`` reads the song-level downbeats
(``measure >= 0`` entries); ``measure_tempos`` derives a per-measure BPM
from their spacing at the given time signature.
3. **Durations** wire sustain when > 0, else the gap to the next onset in
the same hand, quantized to the nearest plain or single-dotted
``{1,2,4,8,16,32}`` at the local tempo, floored at a 32nd
(``quantize_duration``).
4. **Hand split** ``split_hands`` groups simultaneous onsets (within 10 ms);
a group spanning more than 12 semitones is split at its largest internal
interval gap (low side ``lh``); otherwise the whole group goes by mean
pitch vs middle C ( 60 ``rh``). Output is single-staff when everything
lands on one side.
``build_notation`` assembles these into the schema payload, splitting notes
that cross a barline into tied continuations, and validates via
``notation.validate_notation`` before returning (raising on an invalid build
so a caller never persists a payload the loader would drop). Returns ``None``
when there is nothing to lift (no notes or no downbeats).
"""
from __future__ import annotations
from bisect import bisect_right
import re
import notation as notation_mod
# Arrangement names that identify a piano-family arrangement.
KEYS_NAME_RE = re.compile(r"\b(keys|piano|keyboard|synth)\b", re.IGNORECASE)
# Onsets within this window are treated as one simultaneous group/beat.
SIMULTANEITY_WINDOW_S = 0.010
# A simultaneous group spanning more than this is split between two hands.
HAND_SPLIT_SPAN_SEMITONES = 12
MIDDLE_C = 60
# ── Wire decoding ─────────────────────────────────────────────────────────────
def decode_wire_notes(arr_data: dict) -> list[dict]:
"""Decode an arrangement JSON's notes + chord notes to
``[{"t": float, "midi": int, "sus": float}, ...]`` sorted by time.
Keys content packs absolute MIDI as ``midi = s*24 + f`` (sloppak-spec
§5.3 legacy fallback). Sustain is the ``sus`` field (``l`` accepted as a
legacy alias). Entries with malformed fields are skipped.
"""
out: list[dict] = []
def _push(t, s, f, sus):
try:
t = float(t)
midi = int(s) * 24 + int(f)
sus = float(sus or 0.0)
except (TypeError, ValueError):
return
if 0 <= midi <= 127:
out.append({"t": t, "midi": midi, "sus": max(0.0, sus)})
for n in arr_data.get("notes") or []:
if isinstance(n, dict):
_push(n.get("t"), n.get("s"), n.get("f"), n.get("sus", n.get("l")))
for ch in arr_data.get("chords") or []:
if not isinstance(ch, dict):
continue
ch_t = ch.get("t")
for cn in ch.get("notes") or []:
if isinstance(cn, dict):
# Chord notes carry no own time — they sound at the chord's t.
_push(cn.get("t", ch_t), cn.get("s"), cn.get("f"),
cn.get("sus", cn.get("l")))
out.sort(key=lambda n: (n["t"], n["midi"]))
return out
# ── Hand split ────────────────────────────────────────────────────────────────
def group_simultaneous(notes: list[dict]) -> list[list[dict]]:
"""Group time-sorted notes whose onsets fall within 10 ms of the group start."""
groups: list[list[dict]] = []
for n in notes:
if groups and n["t"] - groups[-1][0]["t"] <= SIMULTANEITY_WINDOW_S:
groups[-1].append(n)
else:
groups.append([n])
return groups
def split_hands(notes: list[dict]) -> dict[str, list[dict]]:
"""Assign every note to ``rh`` or ``lh`` per the heuristic.
Per simultaneous group: a span > 12 semitones splits at the largest
internal interval gap (low side lh); otherwise the whole group goes by
mean pitch vs middle C ( 60 rh).
"""
hands: dict[str, list[dict]] = {"rh": [], "lh": []}
for group in group_simultaneous(notes):
pitches = sorted(n["midi"] for n in group)
span = pitches[-1] - pitches[0]
if len(pitches) > 1 and span > HAND_SPLIT_SPAN_SEMITONES:
# Largest internal gap; ties resolve to the lowest such gap so the
# left hand keeps the tight low cluster.
gaps = [pitches[i + 1] - pitches[i] for i in range(len(pitches) - 1)]
split_after = gaps.index(max(gaps))
threshold = pitches[split_after] # lh: midi <= threshold
for n in group:
hands["lh" if n["midi"] <= threshold else "rh"].append(n)
else:
mean = sum(pitches) / len(pitches)
hand = "rh" if mean >= MIDDLE_C else "lh"
hands[hand].extend(group)
return {h: ns for h, ns in hands.items() if ns}
# ── Timing ────────────────────────────────────────────────────────────────────
def downbeat_times(beats: list[dict]) -> list[float]:
"""Times of the song-level downbeats (entries with ``measure >= 0``)."""
out: list[float] = []
for b in beats or []:
if not isinstance(b, dict):
continue
try:
measure = int(b.get("measure", -1))
t = float(b.get("time", 0.0))
except (TypeError, ValueError):
continue
if measure >= 0:
out.append(t)
out.sort()
return out
def measure_tempos(downbeats: list[float], ts: tuple[int, int]) -> list[float]:
"""Per-measure BPM from downbeat spacing at the given time signature.
BPM is quarter-note based: a measure holds ``num * 4/den`` quarter notes,
so ``bpm = qn_per_measure * 60 / measure_duration``. The last measure has
no next downbeat and inherits the previous measure's tempo (120 BPM for
a single-measure song).
"""
num, den = ts
qn_per_measure = num * (4.0 / den)
tempos: list[float] = []
for i in range(len(downbeats)):
if i + 1 < len(downbeats) and downbeats[i + 1] > downbeats[i]:
dur = downbeats[i + 1] - downbeats[i]
tempos.append(qn_per_measure * 60.0 / dur)
else:
tempos.append(tempos[-1] if tempos else 120.0)
return tempos
def quantize_duration(dur_secs: float, bpm: float) -> tuple[int, int]:
"""Quantize a duration in seconds to ``(dur, dot)`` at the local tempo.
Candidates are the plain and single-dotted schema denominators
``{1,2,4,8,16,32}``; the closest in absolute seconds wins. Anything at or
below the 32nd floor returns ``(32, 0)``.
"""
qn = 60.0 / bpm
best: tuple[int, int] = (32, 0)
best_err = float("inf")
for den in (1, 2, 4, 8, 16, 32):
for dot in (0, 1):
cand = qn * (4.0 / den) * (1.5 if dot else 1.0)
err = abs(dur_secs - cand)
if err < best_err:
best_err = err
best = (den, dot)
# Floor: never quantize below a plain 32nd.
if dur_secs <= qn * (4.0 / 32):
return (32, 0)
return best
# ── Notation assembly ─────────────────────────────────────────────────────────
_STAFF_DEFS = {
"rh": {"id": "rh", "clef": "G2", "label": "Right Hand"},
"lh": {"id": "lh", "clef": "F4", "label": "Left Hand"},
}
def build_notation(
wire_notes: list[dict],
beats: list[dict],
ts: tuple[int, int] = (4, 4),
instrument: str = "piano",
) -> dict | None:
"""Build a notation payload from decoded wire notes + song beats.
Returns ``None`` when there is nothing to lift (no notes or no downbeats).
"""
if not wire_notes:
return None
downbeats = downbeat_times(beats)
if not downbeats:
return None
tempos = measure_tempos(downbeats, ts)
hands = split_hands(wire_notes)
# Anacrusis: onsets before the first downbeat get their own pickup
# measure (sloppak-spec §5.3 `pickup: true`) starting at the earliest
# such onset, rather than being clamped into measure 1 with beats that
# precede the measure's own `t`.
earliest_onset = min(n["t"] for ns in hands.values() for n in ns)
measure_starts = list(downbeats)
has_pickup = earliest_onset < downbeats[0] - 1e-6
if has_pickup:
measure_starts.insert(0, earliest_onset)
tempos.insert(0, tempos[0])
def _measure_index(t: float) -> int:
return max(0, min(len(measure_starts) - 1, bisect_right(measure_starts, t + 1e-9) - 1))
def _measure_end(mi: int) -> float | None:
return measure_starts[mi + 1] if mi + 1 < len(measure_starts) else None
# Per-hand: resolve each onset group into one beat with a duration,
# splitting notes that cross a barline into tied continuations — a beat
# longer than the space left in its measure is unrepresentable in
# standard notation (e.g. a half note starting on beat 4 of 4/4).
beats_by_measure: dict[int, dict[str, list[dict]]] = {}
def _emit(hand: str, mi: int, t: float, dur_secs: float, midis: list[int], tied: bool) -> None:
bpm = tempos[mi]
dur, dot = quantize_duration(dur_secs, bpm)
beat_out: dict = {"t": round(t, 3), "dur": dur}
if dot:
beat_out["dot"] = dot
beat_out["notes"] = [
{"midi": m, **({"tied": True} if tied else {})} for m in midis
]
beats_by_measure.setdefault(mi, {}).setdefault(hand, []).append(beat_out)
for hand, notes in hands.items():
groups = group_simultaneous(notes)
for gi, group in enumerate(groups):
t = group[0]["t"]
mi = _measure_index(t)
bpm = tempos[mi]
# Raw duration: longest wire sustain in the group when any is
# > 0, else the gap to the next onset group in the same hand
# (last group falls back to one quarter at the local tempo).
sus = max(n["sus"] for n in group)
if sus > 0:
raw = sus
elif gi + 1 < len(groups):
raw = groups[gi + 1][0]["t"] - t
else:
raw = 60.0 / bpm
# Sorted, deduplicated pitches (both hands striking the same key
# at the same instant collapses to one notehead).
midis = sorted({n["midi"] for n in group})
# Walk the span across barlines, emitting a tied continuation in
# each subsequent measure. Tolerance: half a 32nd at the local
# tempo, so quantization jitter doesn't split clean durations.
seg_t, remaining, tied = t, raw, False
while True:
seg_mi = _measure_index(seg_t)
end = _measure_end(seg_mi)
tol = (60.0 / tempos[seg_mi]) * (4.0 / 32) / 2
if end is None or seg_t + remaining <= end + tol:
_emit(hand, seg_mi, seg_t, remaining, midis, tied)
break
_emit(hand, seg_mi, seg_t, end - seg_t, midis, tied)
remaining -= end - seg_t
seg_t = end
tied = True
used_staves = [s for s in ("rh", "lh") if s in hands]
measures: list[dict] = []
num, den = ts
last_emitted_tempo: float | None = None
for mi, start in enumerate(measure_starts):
measure: dict = {"idx": mi + 1, "t": round(start, 3)}
if mi == 0:
measure["ts"] = [num, den]
if has_pickup:
measure["pickup"] = True
bpm = tempos[mi]
if last_emitted_tempo is None or abs(bpm - last_emitted_tempo) > 1.0:
measure["tempo"] = round(bpm, 2)
last_emitted_tempo = bpm
staves_payload: dict[str, dict] = {}
for staff_id in used_staves:
staff_beats = beats_by_measure.get(mi, {}).get(staff_id)
if staff_beats:
staff_beats.sort(key=lambda b: b["t"])
staves_payload[staff_id] = {"voices": [{"v": 1, "beats": staff_beats}]}
measure["staves"] = staves_payload
measures.append(measure)
payload = {
"version": notation_mod.SCHEMA_VERSION,
"instrument": instrument,
"staves": [_STAFF_DEFS[s] for s in used_staves],
"measures": measures,
}
ok, reason = notation_mod.validate_notation(payload)
if not ok: # importer bug guard — never write a payload the loader drops
raise ValueError(f"build_notation produced an invalid payload: {reason}")
return payload
+661
View File
@@ -0,0 +1,661 @@
"""Player progression engine: instrument paths, challenges, quests, wallet.
Pure evaluation logic for the progression system (spec 010). The only IO in
this module is ``load_content()`` reading the bundled JSON content files under
``data/progression/`` everything else is deterministic functions over plain
dicts so the whole engine is unit-testable without a database.
Vocabulary
----------
content The validated bundle of path/quest/shop definitions (see
``load_content``). Definitions are data: adding a path, level,
challenge, quest or shop item is a JSON edit, never a code change.
snapshot The caller-built view of current player state fed to
``evaluate_event``::
{
"calibration_status": "pending" | "completed" | "skipped",
"paths": {path_id: level, ...}, # selected paths only
"challenges": {challenge_id: {"count", "completed", "detail"}},
"quests": [{"period_type", "quest_id", "count", "completed",
"reward_db", "detail"}, ...], # current periods only
"streak": int, # current day streak
"xp_total": int, # lifetime dB earned
}
event ``{"type": <goal event type>, "payload": {...}}`` the single
choke point unit. ``song_completed`` payloads carry
``{filename, instrument, accuracy, score, is_diagnostic}``;
``minigame_run`` payloads carry ``{game_id, score}``;
``quest_completed`` payloads carry ``{period_type, quest_id}``.
Flat-importable (``from progression import ...``) per constitution
Principle V. Covered by tests/test_progression.py.
"""
from __future__ import annotations
import json
import random
import re
from datetime import date, datetime, timedelta
from pathlib import Path
__all__ = [
"COUNT_GOAL_TYPES",
"GOAL_TYPES",
"SHOP_SLOTS",
"THRESHOLD_GOAL_TYPES",
"active_challenges",
"evaluate_event",
"goal_matches_event",
"instrument_for_arrangement",
"load_content",
"mastery_rank",
"path_max_level",
"period_keys",
"period_resets_at",
"select_quests",
"threshold_goal_met",
"wallet_balance",
]
# Count goals increment per matching event; threshold goals complete the
# moment a snapshot value crosses the line (checked on every event).
COUNT_GOAL_TYPES = frozenset(
{"song_completed", "songs_played_total", "minigame_run", "quest_completed"}
)
THRESHOLD_GOAL_TYPES = frozenset({"streak_reached", "db_earned"})
GOAL_TYPES = COUNT_GOAL_TYPES | THRESHOLD_GOAL_TYPES
SHOP_SLOTS = frozenset({"theme", "avatar_frame"})
CALIBRATION_ACCURACY = 0.9999 # >= this counts as the 100% calibration run
# ---------------------------------------------------------------------------
# Content loading / validation
# ---------------------------------------------------------------------------
def _valid_goal(goal, warnings: list, where: str) -> bool:
"""Validate one goal dict, appending human-readable warnings."""
if not isinstance(goal, dict):
warnings.append(f"{where}: goal is not an object")
return False
gtype = goal.get("type")
if gtype not in GOAL_TYPES:
warnings.append(f"{where}: unknown goal type {gtype!r}")
return False
if gtype in COUNT_GOAL_TYPES:
target = goal.get("target")
if not isinstance(target, int) or isinstance(target, bool) or target < 1:
warnings.append(f"{where}: goal target must be a positive integer")
return False
elif gtype == "streak_reached":
days = goal.get("days")
if not isinstance(days, int) or isinstance(days, bool) or days < 1:
warnings.append(f"{where}: streak_reached needs positive integer 'days'")
return False
elif gtype == "db_earned":
amount = goal.get("amount")
if not isinstance(amount, int) or isinstance(amount, bool) or amount < 1:
warnings.append(f"{where}: db_earned needs positive integer 'amount'")
return False
for frac_key in ("min_accuracy",):
if frac_key in goal:
v = goal[frac_key]
if not isinstance(v, (int, float)) or isinstance(v, bool) or not (0 < v <= 1):
warnings.append(f"{where}: {frac_key} must be a fraction in (0, 1]")
return False
return True
def _load_json(path: Path, warnings: list):
try:
with open(path, "r", encoding="utf-8") as fh:
return json.load(fh)
except (OSError, ValueError) as exc:
warnings.append(f"{path.name}: unreadable content file ({exc})")
return None
def _load_path_file(path: Path, seen_challenge_ids: set, warnings: list):
raw = _load_json(path, warnings)
if not isinstance(raw, dict):
if raw is not None:
warnings.append(f"{path.name}: path file is not an object")
return None
pid = raw.get("id")
if not isinstance(pid, str) or not pid:
warnings.append(f"{path.name}: missing path id")
return None
levels_raw = raw.get("levels")
if not isinstance(levels_raw, list) or not levels_raw:
warnings.append(f"{path.name}: path {pid!r} has no levels")
return None
levels = []
expected = 1
for entry in levels_raw:
where = f"{path.name}:{pid}"
if not isinstance(entry, dict):
warnings.append(f"{where}: level entry is not an object")
continue
lvl = entry.get("level")
if not isinstance(lvl, int) or isinstance(lvl, bool) or lvl < 1:
warnings.append(f"{where}: level number must be a positive integer")
continue
if lvl != expected:
warnings.append(
f"{where}: level numbering gap (expected {expected}, got {lvl})"
)
expected = lvl + 1
challenges = []
for ch in entry.get("challenges") or []:
cid = isinstance(ch, dict) and ch.get("id")
cwhere = f"{where} L{lvl} challenge {cid or '?'}"
if not isinstance(cid, str) or not cid:
warnings.append(f"{cwhere}: missing challenge id")
continue
if cid in seen_challenge_ids:
warnings.append(f"{cwhere}: duplicate challenge id")
continue
if not _valid_goal(ch.get("goal"), warnings, cwhere):
continue
seen_challenge_ids.add(cid)
challenges.append(
{
"id": cid,
"title": str(ch.get("title") or cid),
"description": str(ch.get("description") or ""),
"goal": ch["goal"],
}
)
required = entry.get("required")
if not isinstance(required, int) or isinstance(required, bool) or required < 1:
warnings.append(f"{where} L{lvl}: 'required' must be a positive integer")
continue
if not challenges:
warnings.append(f"{where} L{lvl}: no valid challenges, level skipped")
continue
if required > len(challenges):
warnings.append(
f"{where} L{lvl}: required {required} > {len(challenges)} challenges; clamped"
)
required = len(challenges)
levels.append({"level": lvl, "required": required, "challenges": challenges})
if not levels:
warnings.append(f"{path.name}: path {pid!r} has no valid levels")
return None
return {
"id": pid,
"name": str(raw.get("name") or pid),
"icon": str(raw.get("icon") or ""),
"order": raw.get("order") if isinstance(raw.get("order"), int) else 0,
"levels": levels,
}
def _load_quest_pool(raw, period_type: str, warnings: list):
if not isinstance(raw, dict):
warnings.append(f"quests.json: missing {period_type!r} section")
return {"count": 0, "pool": {}}
count = raw.get("count")
if not isinstance(count, int) or isinstance(count, bool) or count < 1:
warnings.append(f"quests.json: {period_type} count must be a positive integer")
count = 0
pool = {}
for q in raw.get("pool") or []:
qid = isinstance(q, dict) and q.get("id")
where = f"quests.json {period_type} quest {qid or '?'}"
if not isinstance(qid, str) or not qid:
warnings.append(f"{where}: missing quest id")
continue
if qid in pool:
warnings.append(f"{where}: duplicate quest id")
continue
reward = q.get("reward_db")
if not isinstance(reward, int) or isinstance(reward, bool) or reward < 0:
warnings.append(f"{where}: reward_db must be a non-negative integer")
continue
if not _valid_goal(q.get("goal"), warnings, where):
continue
pool[qid] = {
"id": qid,
"title": str(q.get("title") or qid),
"description": str(q.get("description") or ""),
"reward_db": reward,
"goal": q["goal"],
}
return {"count": count, "pool": pool}
def _load_shop(raw, warnings: list):
items = {}
if not isinstance(raw, dict):
return items
for item in raw.get("items") or []:
iid = isinstance(item, dict) and item.get("id")
where = f"shop.json item {iid or '?'}"
if not isinstance(iid, str) or not iid:
warnings.append(f"{where}: missing item id")
continue
if iid in items:
warnings.append(f"{where}: duplicate item id")
continue
slot = item.get("slot")
if slot not in SHOP_SLOTS:
warnings.append(f"{where}: unknown slot {slot!r}")
continue
cost = item.get("cost")
if not isinstance(cost, int) or isinstance(cost, bool) or cost < 0:
warnings.append(f"{where}: cost must be a non-negative integer")
continue
payload = item.get("payload")
if not isinstance(payload, dict):
warnings.append(f"{where}: payload must be an object")
continue
items[iid] = {
"id": iid,
"slot": slot,
"name": str(item.get("name") or iid),
"description": str(item.get("description") or ""),
"cost": cost,
"payload": payload,
}
return items
def load_content(root) -> tuple[dict, list]:
"""Load and validate the progression content bundle under ``root``.
Returns ``(content, warnings)``. Invalid entries are skipped with a
warning bad content must never be fatal. ``content``::
{
"paths": {path_id: path}, # sorted by (order, id) when listed
"challenge_index": {challenge_id: {"path_id", "level", "challenge"}},
"quests": {"daily": {"count", "pool": {qid: quest}}, "weekly": {...}},
"shop": {item_id: item},
}
"""
root = Path(root)
warnings: list = []
paths: dict = {}
challenge_index: dict = {}
seen_challenge_ids: set = set()
paths_dir = root / "paths"
if paths_dir.is_dir():
for path_file in sorted(paths_dir.glob("*.json")):
loaded = _load_path_file(path_file, seen_challenge_ids, warnings)
if loaded is None:
continue
if loaded["id"] in paths:
warnings.append(f"{path_file.name}: duplicate path id {loaded['id']!r}")
continue
paths[loaded["id"]] = loaded
for level in loaded["levels"]:
for ch in level["challenges"]:
challenge_index[ch["id"]] = {
"path_id": loaded["id"],
"level": level["level"],
"challenge": ch,
}
else:
warnings.append(f"missing content directory {paths_dir}")
quests_raw = _load_json(root / "quests.json", warnings) or {}
quests = {
"daily": _load_quest_pool(quests_raw.get("daily"), "daily", warnings),
"weekly": _load_quest_pool(quests_raw.get("weekly"), "weekly", warnings),
}
shop = _load_shop(_load_json(root / "shop.json", warnings) or {}, warnings)
content = {
"paths": paths,
"challenge_index": challenge_index,
"quests": quests,
"shop": shop,
}
return content, warnings
# ---------------------------------------------------------------------------
# Instrument attribution
# ---------------------------------------------------------------------------
def instrument_for_arrangement(arr_entry) -> str:
"""Map a library arrangement entry to a progression instrument.
PSARC/loose entries carry ``type`` (lead/rhythm/bass/combo); sloppaks may
only carry ``name``. Vocals are recognised so they never count toward
guitar challenges; everything else defaults to guitar.
"""
if not isinstance(arr_entry, dict):
return "guitar"
arr_type = str(arr_entry.get("type") or "").strip().lower()
name = str(arr_entry.get("name") or "").strip().lower()
if arr_type == "bass":
return "bass"
if arr_type == "drums":
return "drums"
if arr_type in ("piano", "keys"):
return "keys"
# Check name before committing to a guitar type — legacy PSARC keys
# arrangements often carry a generic type (lead/rhythm/combo) but have a
# name like "Keys" or "Piano". Name overrides the generic type for all
# well-known non-guitar instruments so that scored keys runs advance the
# keys path and quests even when the Rocksmith XML type was not updated.
if "bass" in name:
return "bass"
if "drum" in name or "percussion" in name:
return "drums"
if "vocal" in name:
return "vocals"
# Word-boundary match so e.g. "Monkeys Medley" doesn't read as keys.
if re.search(r"\b(?:keys|piano|keyboard|synth)\b", name):
return "keys"
if arr_type in ("lead", "rhythm", "combo"):
return "guitar"
return "guitar"
# ---------------------------------------------------------------------------
# Quest periods
# ---------------------------------------------------------------------------
def period_keys(now: datetime) -> dict:
"""Period keys for ``now`` (local time): daily ``YYYY-MM-DD``, weekly
ISO-week ``YYYY-Www`` (Monday-started)."""
iso_year, iso_week, _ = now.date().isocalendar()
return {
"daily": now.date().isoformat(),
"weekly": f"{iso_year}-W{iso_week:02d}",
}
def period_resets_at(period_type: str, now: datetime) -> datetime:
"""When the current period rolls over: next local midnight (daily) or next
Monday 00:00 local (weekly)."""
midnight = datetime.combine(now.date() + timedelta(days=1), datetime.min.time())
if period_type == "daily":
return midnight
if period_type == "weekly":
days_to_monday = 7 - now.date().weekday()
return datetime.combine(
now.date() + timedelta(days=days_to_monday), datetime.min.time()
)
raise ValueError(f"unknown period type {period_type!r}")
def select_quests(pool_ids, period_type: str, period_key: str, count: int) -> list:
"""Deterministically pick ``count`` quest ids for a period.
Same (period_type, period_key, pool) always yields the same selection, so
quest rotation survives restarts without any persisted scheduler state.
"""
ordered = sorted(pool_ids)
if count >= len(ordered):
return ordered
rng = random.Random(f"{period_type}:{period_key}") # noqa: S311 — deterministic, non-cryptographic sampling for quest rotation
return sorted(rng.sample(ordered, count))
# ---------------------------------------------------------------------------
# Goal evaluation
# ---------------------------------------------------------------------------
def goal_matches_event(goal: dict, event: dict) -> bool:
"""Whether a count-based goal is advanced by this event (threshold goals
never match events see ``threshold_goal_met``).
Diagnostic (calibration) plays are deliberately NOT regular songs: they
only count toward a song goal that explicitly targets them by filename,
so finishing onboarding at 100% yields exactly Mastery Rank 1 instead of
also completing the first guitar challenges."""
gtype = goal.get("type")
etype = event.get("type")
payload = event.get("payload") or {}
if gtype == "songs_played_total":
return etype == "song_completed" and not payload.get("is_diagnostic")
if gtype == "song_completed":
if etype != "song_completed":
return False
if payload.get("is_diagnostic") and goal.get("filename") != payload.get("filename"):
return False
instrument = goal.get("instrument")
if instrument and payload.get("instrument") != instrument:
return False
if goal.get("filename") and payload.get("filename") != goal["filename"]:
return False
min_accuracy = goal.get("min_accuracy")
if min_accuracy is not None:
accuracy = payload.get("accuracy")
if not isinstance(accuracy, (int, float)) or accuracy < min_accuracy:
return False
min_score = goal.get("min_score")
if min_score is not None:
score = payload.get("score")
if not isinstance(score, (int, float)) or score < min_score:
return False
return True
if gtype == "minigame_run":
if etype != "minigame_run":
return False
if goal.get("game_id") and payload.get("game_id") != goal["game_id"]:
return False
min_score = goal.get("min_score")
if min_score is not None:
score = payload.get("score")
if not isinstance(score, (int, float)) or score < min_score:
return False
return True
if gtype == "quest_completed":
if etype != "quest_completed":
return False
period = goal.get("period")
if period and payload.get("period_type") != period:
return False
return True
return False
def threshold_goal_met(goal: dict, snapshot: dict) -> bool:
"""Whether a threshold goal is satisfied by current snapshot values."""
gtype = goal.get("type")
if gtype == "streak_reached":
return int(snapshot.get("streak") or 0) >= int(goal.get("days") or 0)
if gtype == "db_earned":
return int(snapshot.get("xp_total") or 0) >= int(goal.get("amount") or 0)
return False
def _advance_counter(goal: dict, event: dict, count: int, detail):
"""Apply one matching event to a count-based goal.
Returns ``(new_count, new_detail, advanced)``. ``distinct`` song goals
keep the set of seen filenames in ``detail["seen"]`` so replays of the
same song don't advance the counter.
"""
detail = detail if isinstance(detail, dict) else {}
if goal.get("type") == "song_completed" and goal.get("distinct"):
filename = (event.get("payload") or {}).get("filename")
if not filename:
return count, detail, False
seen = detail.get("seen")
seen = list(seen) if isinstance(seen, list) else []
if filename in seen:
return count, detail, False
seen.append(filename)
return count + 1, {**detail, "seen": seen}, True
return count + 1, detail, True
def evaluate_event(event: dict, content: dict, snapshot: dict) -> dict:
"""Evaluate one progression event against current state.
Pure: returns the deltas for the caller to persist, never mutates inputs.
Output::
{
"challenges": [{"challenge_id", "path_id", "level", "count",
"target", "detail", "completed"}],
"quests": [{"period_type", "quest_id", "count", "target",
"detail", "completed", "reward_db"}],
"level_ups": [{"path_id", "new_level"}],
"calibration_completed": bool,
}
Only listed (i.e. changed) challenges/quests appear. Threshold goals
(streak_reached, db_earned) are re-checked on every event since the
snapshot values they read can move with any award.
"""
outcome = {
"challenges": [],
"quests": [],
"level_ups": [],
"calibration_completed": False,
}
payload = event.get("payload") or {}
if (
event.get("type") == "song_completed"
and payload.get("is_diagnostic")
and isinstance(payload.get("accuracy"), (int, float))
and payload["accuracy"] >= CALIBRATION_ACCURACY
and snapshot.get("calibration_status") in ("pending", "skipped", None)
):
outcome["calibration_completed"] = True
challenge_state = snapshot.get("challenges") or {}
path_levels = snapshot.get("paths") or {}
# Completed counts per (path, level) so we can detect level-ups after
# applying this event's challenge completions.
completed_now: dict = {}
for path_id, level in path_levels.items():
for ch in active_challenges(content, path_id, level):
goal = ch["goal"]
state = challenge_state.get(ch["id"]) or {}
if state.get("completed"):
completed_now[path_id] = completed_now.get(path_id, 0) + 1
continue
count = int(state.get("count") or 0)
detail = state.get("detail")
gtype = goal.get("type")
changed = False
completed = False
if gtype in THRESHOLD_GOAL_TYPES:
if threshold_goal_met(goal, snapshot):
changed = completed = True
elif goal_matches_event(goal, event):
count, detail, advanced = _advance_counter(goal, event, count, detail)
if advanced:
changed = True
completed = count >= int(goal.get("target") or 1)
if changed:
target = int(goal.get("target") or 1) if gtype in COUNT_GOAL_TYPES else 1
outcome["challenges"].append(
{
"challenge_id": ch["id"],
"path_id": path_id,
"level": level + 1,
"count": count if gtype in COUNT_GOAL_TYPES else target,
"target": target,
"detail": detail if isinstance(detail, dict) and detail else None,
"completed": completed,
}
)
if completed:
completed_now[path_id] = completed_now.get(path_id, 0) + 1
for path_id, level in path_levels.items():
nxt = _level_entry(content, path_id, level + 1)
if nxt and completed_now.get(path_id, 0) >= nxt["required"]:
outcome["level_ups"].append({"path_id": path_id, "new_level": level + 1})
for quest in snapshot.get("quests") or []:
if quest.get("completed"):
continue
period_type = quest.get("period_type")
pool = (content.get("quests") or {}).get(period_type, {}).get("pool", {})
qdef = pool.get(quest.get("quest_id"))
if not qdef:
continue
goal = qdef["goal"]
count = int(quest.get("count") or 0)
detail = quest.get("detail")
gtype = goal.get("type")
changed = False
completed = False
if gtype in THRESHOLD_GOAL_TYPES:
if threshold_goal_met(goal, snapshot):
changed = completed = True
elif goal_matches_event(goal, event):
count, detail, advanced = _advance_counter(goal, event, count, detail)
if advanced:
changed = True
completed = count >= int(goal.get("target") or 1)
if changed:
target = int(goal.get("target") or 1) if gtype in COUNT_GOAL_TYPES else 1
outcome["quests"].append(
{
"period_type": period_type,
"quest_id": quest["quest_id"],
"count": count if gtype in COUNT_GOAL_TYPES else target,
"target": target,
"detail": detail if isinstance(detail, dict) and detail else None,
"completed": completed,
"reward_db": int(quest.get("reward_db") or 0),
}
)
return outcome
# ---------------------------------------------------------------------------
# Rank / paths / wallet math
# ---------------------------------------------------------------------------
def _level_entry(content: dict, path_id: str, level: int):
path = (content.get("paths") or {}).get(path_id)
if not path:
return None
for entry in path["levels"]:
if entry["level"] == level:
return entry
return None
def active_challenges(content: dict, path_id: str, level: int) -> list:
"""The challenge set a path at ``level`` is working on (level+1's set).
Empty at max level or for unknown paths."""
nxt = _level_entry(content, path_id, level + 1)
return list(nxt["challenges"]) if nxt else []
def path_max_level(content: dict, path_id: str) -> int:
path = (content.get("paths") or {}).get(path_id)
if not path or not path["levels"]:
return 0
return max(entry["level"] for entry in path["levels"])
def mastery_rank(calibration_status, path_levels) -> int:
"""Mastery Rank = onboarding rank (1 once calibration is completed or
skipped) + the sum of all selected path levels."""
onboarding = 0 if (calibration_status or "pending") == "pending" else 1
return onboarding + sum(int(v or 0) for v in (path_levels or {}).values())
def wallet_balance(xp_total, spent) -> int:
"""Spendable dB. Clamped at 0: a per-source XP reset can lower lifetime
earnings below the amount already spent."""
return max(0, int(xp_total or 0) - int(spent or 0))
+37
View File
@@ -0,0 +1,37 @@
"""Path-containment helper for code that joins attacker-controlled names
under a server-owned root.
"""
from __future__ import annotations
from pathlib import Path
def safe_join(root: Path, name: str) -> Path | None:
"""Resolve ``name`` under ``root`` and return the resolved Path, or
``None`` if it would escape ``root`` or is unrepresentable.
Rejects:
* empty names
* paths that resolve outside ``root`` (``..`` traversal, absolute paths)
* paths the OS can't resolve (embedded NULs, OSError on stat)
Normalizes:
* backslash separators to forward slash so a Windows-style entry
name inside a user-supplied archive can't bypass containment on
POSIX hosts (``..\\foo`` would otherwise be treated as a literal
single filename on Linux and resolve inside ``root`` but on
Windows the same string IS a traversal; normalising means both
platforms reject it identically).
"""
if not name:
return None
safe = name.replace("\\", "/")
try:
root_resolved = root.resolve()
candidate = (root_resolved / safe).resolve()
if not candidate.is_relative_to(root_resolved):
return None
except (ValueError, OSError):
return None
return candidate
+148
View File
@@ -0,0 +1,148 @@
"""Side-effect-free metadata extraction worker for the library scan.
This module is deliberately kept apart from ``server.py`` so that
``ProcessPoolExecutor`` workers can import and unpickle ``_scan_one``
without dragging in ``server.py``'s import-time side effects
(``configure_logging()``, ``meta_db = MetadataDB()`` opening/migrating
SQLite, and ``register_plugin_api(app)`` registering routes).
The background scan spawns its pool with the ``spawn`` start method (see
``server._background_scan``), so each worker is a fresh interpreter that
imports only this module plus the pure ``lib`` helpers below never the
whole server. That avoids two problems flagged in review:
* forking a ``ProcessPoolExecutor`` from the non-main scan thread (the
default on Linux), which can deadlock on locks held by other threads at
fork time; and
* re-running ``server.py``'s side effects in every worker on ``spawn``
platforms (macOS/Windows), which would reopen SQLite per worker and let
a multi-process ``RotatingFileHandler`` corrupt the log file.
It also means the per-file ``log.debug`` below simply no-ops inside
workers (logging is unconfigured there), which is the desired behaviour
worker log records never reach the shared log file.
"""
import logging
from pathlib import Path
from song import compute_smart_names
from tunings import tuning_name
import sloppak as sloppak_mod
import loosefolder as loosefolder_mod
log = logging.getLogger("slopsmith.scan_worker")
def _relpath(f: Path, dlc: Path) -> str:
# Store the path relative to the DLC root so sub-folders (e.g.
# dlc/sloppak/foo.sloppak) resolve back correctly later.
try:
return f.relative_to(dlc).as_posix()
except ValueError:
return f.name
def _extract_meta_sloppak(path: Path) -> dict:
"""Extract metadata for a sloppak (file or directory)."""
meta = sloppak_mod.extract_meta(path)
offsets = meta.pop("tuning_offsets", None) or [0] * 6
name = tuning_name(offsets)
meta["tuning"] = name
meta["tuning_name"] = name
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);
# default to empty for older callers / mocks.
meta.setdefault("stem_ids", [])
# Compute smart names for sloppak arrangements using name-based fallback
# (sloppak manifests use display names like "Lead"/"Rhythm"/"Bass" directly).
arrs = meta.get("arrangements") or []
if arrs:
from song import Arrangement as _ArrCls
_arr_objs = [_ArrCls(name=a.get("name", "")) for a in arrs]
_smart = compute_smart_names(_arr_objs)
for a, sn in zip(arrs, _smart):
a["smart_name"] = sn
return meta
def _extract_meta_loosefolder(path: Path, dlc_root: Path | None) -> dict:
"""Extract metadata for a loose song folder (raw XMLs + WEM audio).
`dlc_root` is passed in (rather than resolved here via the server's
`_get_dlc_dir()`) so this module stays free of server.py state and is
safe to import in spawned ProcessPool workers.
"""
# Pass the DLC root so artist/album folder inference operates on the
# dlc-relative path; otherwise absolute-path parts (e.g. the user's
# home dir name) would leak into metadata for songs placed shallow
# inside DLC_DIR.
meta = loosefolder_mod.extract_meta(path, dlc_root=dlc_root)
offsets = meta.pop("tuning_offsets", None) or [0] * 6
name = tuning_name(offsets)
meta["tuning"] = name
meta["tuning_name"] = name
meta["tuning_sort_key"] = sum(offsets)
meta["tuning_offsets"] = " ".join(str(o) for o in offsets)
meta["format"] = "loose"
meta.setdefault("stem_ids", [])
# The library helper exposes absolute filesystem paths for audio/art
# so callers inside the server can resolve them. Strip these before
# the meta enters the API/DB cache — `/api/song/{filename}` returns
# the dict directly on a cache miss, which would otherwise leak
# `/home/<user>/...` paths to the frontend.
meta.pop("audio_path", None)
meta.pop("art_path", None)
return meta
def _extract_meta_for_file(path: Path, dlc_root=None) -> dict:
"""Extract metadata — dispatches on shape: sloppak or loose-folder song.
`dlc_root` is only consulted for loose-folder songs (for dlc-relative
artist/album inference). It may be a `Path`, `None`, or a zero-arg
callable returning `Path | None`; the callable is invoked lazily, only
on the loose-folder branch, so sloppak extraction never triggers a
(potentially disk-reading) DLC-root lookup. The background scan passes
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
songs. Encrypted/proprietary archive formats are not supported and are
silently ignored (empty metadata) rather than decrypted.
"""
# Sloppak is detected by `.sloppak` suffix only (cheap), so check it
# first — that way a user's loose folder named `foo.sloppak` still wins
# the sloppak branch instead of being misclassified.
if sloppak_mod.is_sloppak(path):
return _extract_meta_sloppak(path)
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
# reads encrypted archive formats.
return {
"title": "", "artist": "", "album": "", "year": "",
"duration": 0.0, "tuning": "E Standard",
"arrangements": [], "has_lyrics": False,
"stem_ids": [],
"tuning_name": "E Standard",
"tuning_sort_key": 0,
"tuning_offsets": "0 0 0 0 0 0",
}
def _scan_one(item):
"""Process-pool worker: extract metadata for one library item.
Top-level (and in this side-effect-free module) so ProcessPoolExecutor
can pickle it by reference and the spawned worker can import it without
pulling in server.py. `dlc` travels through the tuple rather than being
captured from a closure so it survives pickling.
"""
f, mtime, size, dlc = item
log.debug("scanning %s", f.name)
meta = _extract_meta_for_file(f, dlc)
return _relpath(f, dlc), mtime, size, meta
+657
View File
@@ -0,0 +1,657 @@
"""Sloppak — open song format loader.
A `.sloppak` is an open, hand-editable song package. It exists in two
interchangeable forms:
1. **Zip archive** a `.sloppak` file containing a `manifest.yaml`,
arrangement JSONs, stem OGGs, optional cover/lyrics. Distribution form.
2. **Directory** a directory whose name ends in `.sloppak/` containing the
same files. Authoring form.
See the format spec in the project's sloppak plan for the full layout.
"""
from __future__ import annotations
import json
import logging
import shutil
import threading
import zipfile
from dataclasses import dataclass, field
from pathlib import Path
log = logging.getLogger("slopsmith.lib.sloppak")
import yaml
from safepath import safe_join
from song import (
Song,
Beat,
Section,
Arrangement,
arrangement_from_wire,
_finite_float,
)
import drums as drums_mod
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")
# ── Source resolution (zip unpack cache + directory passthrough) ──────────────
# Maps sloppak filename (relative to DLC_DIR) → (source_dir, mtime, size).
# For directory-form sloppaks, source_dir is the original path and we only
# track it so serving can locate it by filename.
# For zipped sloppaks, source_dir is a cache dir under the unpack root.
_source_cache: dict[str, tuple[Path, float, int]] = {}
_source_lock = threading.Lock()
def _unpack_zip(zip_path: Path, dest: Path) -> None:
"""Extract a sloppak zip archive into dest, replacing any previous contents.
Members whose names escape ``dest`` via ``..`` segments, absolute paths, or
Windows-style separators are skipped with a warning so a crafted sloppak
can't write outside the unpack cache (zip-slip).
"""
if dest.exists():
shutil.rmtree(dest, ignore_errors=True)
dest.mkdir(parents=True, exist_ok=True)
dest_resolved = dest.resolve()
with zipfile.ZipFile(str(zip_path), "r") as zf:
for member in zf.infolist():
target = safe_join(dest_resolved, member.filename)
if target is None:
log.warning("sloppak: rejected unsafe zip member %r", member.filename)
continue
# A contained-but-degenerate name (e.g. "." or "subdir/..") would
# resolve back to the unpack root itself; opening that path for
# write is meaningless and would mask a real bug, so skip it.
if target == dest_resolved:
log.warning("sloppak: rejected zip member resolving to unpack root %r", member.filename)
continue
try:
if member.is_dir():
target.mkdir(parents=True, exist_ok=True)
continue
target.parent.mkdir(parents=True, exist_ok=True)
with zf.open(member) as src, open(target, "wb") as dst:
shutil.copyfileobj(src, dst)
except (OSError, zipfile.BadZipFile, RuntimeError, NotImplementedError) as e:
log.warning("sloppak: failed to extract zip member %r: %s", member.filename, e)
continue
def _safe_id(filename: str) -> str:
"""Turn a filename into a filesystem-safe cache key (no path separators)."""
return filename.replace("/", "__").replace("\\", "__").replace(" ", "_")
def resolve_source_dir(
filename: str,
dlc_root: Path,
unpack_cache_root: Path,
) -> Path:
"""Return the on-disk directory containing a sloppak's files.
- Directory-form: returns the sloppak dir itself (no copy).
- Zip-form: unpacks to ``unpack_cache_root/{id}/`` on first use,
re-unpacks if mtime/size changed, then returns that dir.
Caches the resolution so subsequent calls are ~free.
"""
path = dlc_root / filename
stat = path.stat()
mtime, size = stat.st_mtime, stat.st_size
with _source_lock:
cached = _source_cache.get(filename)
if cached:
cached_dir, cached_mtime, cached_size = cached
if (
cached_mtime == mtime
and cached_size == size
and cached_dir.exists()
):
return cached_dir
if path.is_dir():
resolved = path
else:
# Zip form — unpack to the cache.
dest = unpack_cache_root / _safe_id(filename)
_unpack_zip(path, dest)
resolved = dest
with _source_lock:
_source_cache[filename] = (resolved, mtime, size)
return resolved
def get_cached_source_dir(filename: str) -> Path | None:
"""Return the cached source dir for a sloppak if one is known."""
with _source_lock:
cached = _source_cache.get(filename)
return cached[0] if cached else None
# ── Manifest + song loading ───────────────────────────────────────────────────
def _read_manifest(source_dir: Path) -> dict:
mf = source_dir / "manifest.yaml"
if not mf.exists():
mf = source_dir / "manifest.yml"
if not mf.exists():
raise FileNotFoundError(f"manifest.yaml not found in {source_dir}")
with mf.open("r", encoding="utf-8") as fh:
data = yaml.safe_load(fh)
if not isinstance(data, dict):
raise ValueError("manifest.yaml must contain a mapping at the top level")
return data
def _read_manifest_from_zip(zip_path: Path) -> dict:
"""Read just manifest.yaml from a zipped sloppak without unpacking stems."""
with zipfile.ZipFile(str(zip_path), "r") as zf:
for name in ("manifest.yaml", "manifest.yml"):
try:
with zf.open(name) as fh:
data = yaml.safe_load(fh.read().decode("utf-8"))
if isinstance(data, dict):
return data
except KeyError:
continue
raise FileNotFoundError(f"manifest.yaml not found in zip {zip_path}")
def load_manifest(path: Path) -> dict:
"""Return the parsed manifest dict for a sloppak (dir or zip)."""
if path.is_dir():
return _read_manifest(path)
return _read_manifest_from_zip(path)
@dataclass
class LoadedSloppak:
"""Result of loading a sloppak: the Song object plus stem descriptors."""
song: Song
stems: list[dict] # [{"id": str, "file": str, "default": bool}]
source_dir: Path
manifest: dict
# Parsed `drum_tab.json` payload when the manifest carries a `drum_tab:`
# key pointing at a readable, schema-valid file. None otherwise (older
# sloppaks, sloppaks without drums, sloppaks whose drum tab failed to
# parse). The drums plugin reads this through the highway WS rather than
# the file directly — see server.py highway_ws for the wire shape.
drum_tab: dict | None = None
# Parsed `song_timeline.json` payload when the manifest carries a
# `song_timeline:` key pointing at a readable, schema-valid file.
# When present, its beats/sections take priority over any beats/sections
# embedded in the arrangement JSONs.
song_timeline: dict | None = None
# Maps arrangement id → validated notation payload. None when no
# arrangement passed schema validation; a non-empty dict only when at least
# one arrangement carried a `notation:` sub-key whose file loaded and passed
# schema validation. The dict is never an empty mapping at runtime.
notation_by_id: dict[str, dict] | None = None
# Manifest arrangement id for each entry in song.arrangements, in the same
# order. None where the manifest entry had no id field. Parallel to
# 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)
def load_song(
filename: str,
dlc_root: Path,
unpack_cache_root: Path,
) -> LoadedSloppak:
"""Fully load a sloppak: resolve its source dir, parse manifest + all
arrangements + optional lyrics, and return a ready-to-stream Song."""
source_dir = resolve_source_dir(filename, dlc_root, unpack_cache_root)
manifest = _read_manifest(source_dir)
song = Song(
title=str(manifest.get("title", "")),
artist=str(manifest.get("artist", "")),
album=str(manifest.get("album", "")),
year=int(manifest.get("year", 0) or 0),
song_length=float(manifest.get("duration", 0.0) or 0.0),
)
# Load each arrangement from its JSON file.
notation_acc: dict[str, dict] = {}
any_notation = False
arrangement_ids_acc: list[str | None] = [] # parallel to song.arrangements
for entry in manifest.get("arrangements", []) or []:
if not isinstance(entry, dict):
log.warning("sloppak: non-dict arrangement entry skipped (%r)", type(entry).__name__)
continue
rel_raw = entry.get("file")
rel = rel_raw.strip() if isinstance(rel_raw, str) else ""
notation_raw = entry.get("notation")
has_notation_key = isinstance(notation_raw, str) and bool(notation_raw.strip())
if not rel and not has_notation_key:
continue
data = None
if rel:
try:
arr_path = (source_dir / rel).resolve()
arr_path.relative_to(source_dir.resolve())
except ValueError:
log.warning("sloppak: arrangement path %r escapes source_dir — skipped", rel)
continue
except OSError as e:
log.warning("sloppak: arrangement path resolution failed (%s) — skipped", e)
continue
if not arr_path.exists():
continue
try:
data = json.loads(arr_path.read_text(encoding="utf-8"))
except Exception as e:
log.debug("sloppak: failed to parse arrangement %r: %s", rel, e)
continue
arr = arrangement_from_wire(data)
else:
arr = arrangement_from_wire({
"notes": [], "chords": [], "anchors": [],
"handshapes": [], "templates": [],
})
# Manifest-level overrides take precedence over anything embedded in
# the arrangement JSON (name, tuning, capo, centOffset).
if entry.get("name"):
arr.name = str(entry["name"])
if "tuning" in entry:
arr.tuning = list(entry["tuning"])
if "capo" in entry:
arr.capo = int(entry["capo"])
if "centOffset" in entry:
# _finite_float keeps a malformed manifest NaN/Infinity from
# poisoning the song_info JSON (same guard as the wire path).
arr.cent_offset = _finite_float(entry["centOffset"])
# Beats/sections can live on the arrangement itself in the wire format.
# If the manifest-level arrangement JSON carries them, pull them onto
# the song object the first time we see them.
if data is not None:
if not song.beats:
for b in data.get("beats", []) or []:
song.beats.append(
Beat(time=float(b.get("time", 0)), measure=int(b.get("measure", -1)))
)
if not song.sections:
for s in data.get("sections", []) or []:
song.sections.append(
Section(
name=str(s.get("name", "")),
number=int(s.get("number", 0)),
start_time=float(s.get("time", s.get("start_time", 0))),
)
)
song.arrangements.append(arr)
arr_id = str(entry.get("id", "")).strip()
arrangement_ids_acc.append(arr_id or None)
if not arr_id:
log.warning("sloppak: arrangement entry has no id — notation skipped")
continue
notation_rel = entry.get("notation")
if not isinstance(notation_rel, str):
continue
notation_rel = notation_rel.strip()
if not notation_rel:
continue
try:
nt_path = (source_dir / notation_rel).resolve()
nt_path.relative_to(source_dir.resolve())
except ValueError:
log.warning("sloppak: notation path %r escapes source_dir — skipped", notation_rel)
nt_path = None
except OSError as e:
log.warning("sloppak: notation path resolution failed (%s) — skipped", e)
nt_path = None
raw_nt = None
if nt_path is not None and nt_path.exists():
try:
raw_nt = json.loads(nt_path.read_text(encoding="utf-8"))
except Exception as e:
log.warning("sloppak: failed to parse notation %r: %s", notation_rel, e)
if raw_nt is not None:
ok, reason = notation_mod.validate_notation(raw_nt)
if ok:
if arr_id in notation_acc:
log.warning(
"sloppak: duplicate arrangement id %r — notation overwritten", arr_id
)
notation_acc[arr_id] = raw_nt
any_notation = True
else:
log.warning("sloppak: notation %r failed validation: %s", notation_rel, reason)
notation_by_id_data = notation_acc if any_notation else None
# Optional drum_tab.json — top-level manifest key per sloppak-spec §5.3.
# The file lives off to the side (its own JSON), and the manifest opts in
# via `drum_tab: drum_tab.json`. The loader stays permissive: a missing file
# silently disables drum playback; a malformed or invalid tab disables it
# with a warning.
drum_tab_data: dict | None = None
drum_tab_rel = manifest.get("drum_tab")
if isinstance(drum_tab_rel, str) and drum_tab_rel:
# Constrain to source_dir to prevent a crafted manifest from reading
# files outside the sloppak directory via path traversal (e.g. ../../etc).
# Wrap both resolve() calls in a broad handler: symlink loops and
# permission errors on .resolve() should disable drums, not abort load.
try:
dt_path = (source_dir / drum_tab_rel).resolve()
dt_path.relative_to(source_dir.resolve())
except ValueError:
log.warning("sloppak: drum_tab path %r escapes source_dir — skipped", drum_tab_rel)
dt_path = None
except OSError as e:
log.warning("sloppak: drum_tab path resolution failed (%s) — skipped", e)
dt_path = None
if dt_path is not None and dt_path.exists():
try:
raw = json.loads(dt_path.read_text(encoding="utf-8"))
except Exception as e:
log.warning("sloppak: failed to parse drum_tab %r: %s", drum_tab_rel, e)
raw = None
if raw is not None:
ok, reason = drums_mod.validate_drum_tab(raw)
if ok:
drum_tab_data = raw
else:
log.warning("sloppak: drum_tab %r failed validation: %s",
drum_tab_rel, reason)
# Drum-only sloppak: every GP track was percussion, so it ships a
# drum_tab but no pitched arrangements. The highway WS rejects an empty
# arrangements list with "No arrangements found" *before* it serves the
# drum_tab, leaving the drums unplayable even in the drum highway.
# Synthesize a minimal placeholder arrangement so the stream proceeds and
# the drum_tab reaches the drum highway. It carries no notes (the guitar
# highway just shows an empty board) and, when the manifest omits a
# duration, derives a song length from the last drum hit so the timeline
# isn't zero-length.
if not song.arrangements and drum_tab_data is not None:
if song.song_length <= 0:
# validate_drum_tab() intentionally does NOT type-check individual
# hits (they're sanitized at WS-stream time), so a hit may carry a
# non-numeric "t". Skip anything that won't convert rather than let
# one malformed hit abort the whole load.
_max_t = 0.0
for _h in drum_tab_data.get("hits") or []:
if not isinstance(_h, dict):
continue
try:
_max_t = max(_max_t, float(_h.get("t", 0) or 0))
except (TypeError, ValueError):
continue
if _max_t > 0:
song.song_length = _max_t + 2.0
song.arrangements.append(Arrangement(name="Drums"))
arrangement_ids_acc.append(None)
# Optional song_timeline.json — top-level manifest key per sloppak-spec §5.3.
# When present, its beats and sections override whatever the arrangement JSONs
# already loaded onto the song object — song_timeline is the authoritative
# source for timeline data in sloppaks that carry it.
song_timeline_data: dict | None = None
song_timeline_rel = manifest.get("song_timeline")
if isinstance(song_timeline_rel, str) and song_timeline_rel:
try:
st_path = (source_dir / song_timeline_rel).resolve()
st_path.relative_to(source_dir.resolve())
except ValueError:
log.warning("sloppak: song_timeline path %r escapes source_dir — skipped", song_timeline_rel)
st_path = None
except OSError as e:
log.warning("sloppak: song_timeline path resolution failed (%s) — skipped", e)
st_path = None
if st_path is not None and st_path.exists():
try:
raw = json.loads(st_path.read_text(encoding="utf-8"))
except Exception as e:
log.warning("sloppak: failed to parse song_timeline %r: %s", song_timeline_rel, e)
raw = None
if raw is not None:
if not isinstance(raw, dict):
log.warning("sloppak: song_timeline %r ignored — expected dict, got %s",
song_timeline_rel, type(raw).__name__)
elif not isinstance(raw.get("beats"), list):
log.warning("sloppak: song_timeline %r ignored — 'beats' must be a list",
song_timeline_rel)
elif not isinstance(raw.get("sections"), list):
log.warning("sloppak: song_timeline %r ignored — 'sections' must be a list",
song_timeline_rel)
else:
song.beats = []
song.sections = []
for b in raw["beats"]:
if not isinstance(b, dict):
log.warning(
"sloppak: song_timeline %r — non-dict beat entry skipped (%r)",
song_timeline_rel, type(b).__name__,
)
continue
try:
song.beats.append(
Beat(
# _finite_float prevents NaN/Infinity from
# slipping through json.loads and poisoning
# the highway WS JSON with invalid tokens.
time=_finite_float(b.get("time", 0)),
measure=int(b.get("measure", -1)),
)
)
except (TypeError, ValueError):
log.warning(
"sloppak: song_timeline %r — invalid beat entry skipped (%r)",
song_timeline_rel, b,
)
continue
for s in raw["sections"]:
if not isinstance(s, dict):
log.warning(
"sloppak: song_timeline %r — non-dict section entry skipped (%r)",
song_timeline_rel, type(s).__name__,
)
continue
try:
song.sections.append(
Section(
name=str(s.get("name", "")),
number=int(s.get("number", 0)),
# Same key fallback as the arrangement-JSON
# section parser: `time` with `start_time`
# as the legacy alias.
# _finite_float: same NaN/Infinity guard as
# beat timestamps above.
start_time=_finite_float(
s.get("time", s.get("start_time", 0))
),
)
)
except (TypeError, ValueError):
log.warning(
"sloppak: song_timeline %r — invalid section entry skipped (%r)",
song_timeline_rel, s,
)
continue
song_timeline_data = raw
# Optional shared lyrics file. Same safety posture as the drum_tab
# loader above: constrain the manifest-declared path to source_dir
# (a crafted sloppak with `lyrics: ../../etc/passwd.json` would
# otherwise read arbitrary files), and ignore the payload unless
# it's the documented shape — a flat list of syllable dicts.
# Anything else (a dict at the root, a string, malformed entries)
# leaves `song.lyrics` empty rather than streaming surprise data
# downstream through the WS path.
lyrics_rel = manifest.get("lyrics")
if isinstance(lyrics_rel, str) and lyrics_rel:
try:
lyr_path = (source_dir / lyrics_rel).resolve()
lyr_path.relative_to(source_dir.resolve())
except ValueError:
log.warning("sloppak: lyrics path %r escapes source_dir — skipped", lyrics_rel)
lyr_path = None
except OSError as e:
log.warning("sloppak: lyrics path resolution failed (%s) — skipped", e)
lyr_path = None
if lyr_path is not None and lyr_path.exists():
try:
raw = json.loads(lyr_path.read_text(encoding="utf-8"))
except Exception as e:
log.debug("sloppak: failed to parse lyrics %r: %s", lyrics_rel, e)
raw = None
if isinstance(raw, list):
# Filter to entries that at least look like syllables —
# presence of all three required keys with the right
# primitive types. Drops anything weird without poisoning
# the whole list.
song.lyrics = [
e for e in raw
if isinstance(e, dict)
and isinstance(e.get("w"), str)
and isinstance(e.get("t"), (int, float))
and isinstance(e.get("d"), (int, float))
]
if song.lyrics:
# Provenance — populated by the converter (xml/sng),
# the WhisperX fallback (whisperx), or hand-edits
# (user). Validate against the closed enum so a
# hand-edited (or otherwise malformed) manifest can't
# propagate a YAML dict / list / arbitrary string
# into the highway WS `lyrics.source` field and out
# to plugin badges. Anything outside the enum (or
# the wrong type) falls back to "xml" — the spec's
# back-compat default — instead of being stringified
# and trusted.
_ALLOWED_LYRICS_SOURCES = {"xml", "sng", "whisperx", "user"}
raw_source = manifest.get("lyrics_source")
if isinstance(raw_source, str) and raw_source in _ALLOWED_LYRICS_SOURCES:
song.lyrics_source = raw_source
else:
if raw_source is not None and (
not isinstance(raw_source, str)
or raw_source not in _ALLOWED_LYRICS_SOURCES
):
log.warning(
"sloppak: ignoring invalid lyrics_source %r"
"must be one of %s; falling back to 'xml'",
raw_source, sorted(_ALLOWED_LYRICS_SOURCES),
)
song.lyrics_source = "xml"
elif raw is not None:
log.warning("sloppak: lyrics %r ignored — expected list, got %s",
lyrics_rel, type(raw).__name__)
# Stem descriptors — normalized for callers. File paths are resolved but
# returned as ``file`` relative strings so URL construction stays caller-side.
stems: list[dict] = []
for s in manifest.get("stems", []) or []:
if not isinstance(s, dict):
continue
sid = str(s.get("id", ""))
sfile = str(s.get("file", ""))
if not sid or not sfile:
continue
default_val = s.get("default", True)
if isinstance(default_val, str):
default_on = default_val.lower() not in ("off", "false", "0", "no")
else:
default_on = bool(default_val)
stems.append({"id": sid, "file": sfile, "default": default_on})
return LoadedSloppak(
song=song,
stems=stems,
source_dir=source_dir,
manifest=manifest,
drum_tab=drum_tab_data,
song_timeline=song_timeline_data,
notation_by_id=notation_by_id_data,
arrangement_ids=arrangement_ids_acc,
)
# ── Fast metadata extractor (scanner path) ────────────────────────────────────
def _tuning_for_meta(arrangements_manifest: list[dict]) -> list[int]:
"""Best-effort guitar-first tuning for the library index."""
for entry in arrangements_manifest:
name = str(entry.get("name", "")).lower()
tun = entry.get("tuning")
if tun and isinstance(tun, list) and name in ("lead", "rhythm", "combo"):
return list(tun)
# Fallback: first arrangement with a tuning
for entry in arrangements_manifest:
tun = entry.get("tuning")
if tun and isinstance(tun, list):
return list(tun)
return [0] * 6
def extract_meta(path: Path) -> dict:
"""Fast metadata for the library scanner. Reads only the manifest."""
manifest = load_manifest(path)
arr_list = manifest.get("arrangements", []) or []
arrangements = []
for i, entry in enumerate(arr_list):
arrangements.append(
{
"index": i,
"name": str(entry.get("name", entry.get("id", f"Arr{i}"))),
"notes": 0, # unknown without loading; fine for the index
}
)
# Sort like PSARC path: Lead > Combo > Rhythm > Bass
priority = {"Lead": 0, "Combo": 1, "Rhythm": 2, "Bass": 3}
arrangements.sort(key=lambda a: priority.get(a["name"], 99))
for i, a in enumerate(arrangements):
a["index"] = i
has_lyrics = bool(manifest.get("lyrics"))
tuning_offsets = _tuning_for_meta(arr_list)
stems_list = manifest.get("stems", []) or []
stem_ids: list[str] = []
for s in stems_list:
if not isinstance(s, dict):
continue
sid = s.get("id")
sfile = s.get("file")
# Match `load_song()`'s validation: a stem entry needs BOTH a
# non-empty id AND a non-empty file to be playable. Indexing a
# half-formed entry would advertise a stem that load_song will
# later refuse to surface, so the library filter would lie.
if (
isinstance(sid, str) and sid
and isinstance(sfile, str) and sfile
):
stem_ids.append(sid)
stem_count = len(stem_ids)
return {
"title": str(manifest.get("title", "")),
"artist": str(manifest.get("artist", "")),
"album": str(manifest.get("album", "")),
"year": str(manifest.get("year", "") or ""),
"duration": float(manifest.get("duration", 0) or 0),
"tuning_offsets": tuning_offsets, # caller maps to a name via tunings.tuning_name
"arrangements": arrangements,
"has_lyrics": has_lyrics,
"stem_count": stem_count,
# slopsmith#129: per-stem filter needs the id list, not just count.
"stem_ids": stem_ids,
}
+1375
View File
File diff suppressed because it is too large Load Diff
+70
View File
@@ -0,0 +1,70 @@
"""Pure helpers for fee[dB]ack v0.3.0 song-stats scoring + upsert logic.
The score/accuracy formulas mirror the frontend recorder (static/v3/
stats-recorder.js) so the value the badge shows and the value the server
stores agree. Kept pure + flat-importable (constitution Principle V); tested
in tests/test_song_score.py.
accuracy(hits, misses) = hits / max(1, hits + misses) # 0..1
score(hits, misses) = round(hits * 100 * accuracy) # monotonic in accuracy
"""
from __future__ import annotations
import math
__all__ = ["accuracy", "score", "merge_stats"]
def accuracy(hits: int, misses: int) -> float:
hits = max(0, int(hits or 0))
misses = max(0, int(misses or 0))
return hits / max(1, hits + misses)
def score(hits: int, misses: int) -> int:
"""Deterministic, monotonic-in-accuracy integer score.
Rounds half-AWAY-from-zero to match the frontend recorder's JS
Math.round() (e.g. hits=3, misses=5 112.5 113), not Python's
banker's rounding which would give 112 and disagree with the client."""
hits = max(0, int(hits or 0))
return int(math.floor(hits * 100 * accuracy(hits, misses) + 0.5))
def merge_stats(existing: dict | None, session: dict) -> dict:
"""Upsert/max merge of a scored session into the existing row.
`plays` increments; `best_*` take the max of old/new; `last_*` take the
new session; `last_position` falls back to the existing value when the
session doesn't carry one. Returns the merged field dict (no IO).
"""
e = existing or {}
def _i(v, d=0):
try:
return int(v)
except (TypeError, ValueError, OverflowError):
return d
def _f(v, d=0.0):
# Reject NaN/Inf as well as unparseable values: a stored non-finite
# would later break JSON serialization of /api/stats reads.
try:
f = float(v)
return f if math.isfinite(f) else d
except (TypeError, ValueError, OverflowError):
return d
new_score = _i(session.get("score"))
new_acc = _f(session.get("accuracy"))
sess_pos = session.get("last_position")
last_position = _f(sess_pos) if sess_pos is not None else _f(e.get("last_position"))
return {
"plays": _i(e.get("plays")) + 1,
"best_score": max(_i(e.get("best_score")), new_score),
"best_accuracy": max(_f(e.get("best_accuracy")), new_acc),
"last_score": new_score,
"last_accuracy": new_acc,
"last_position": last_position,
}
+116
View File
@@ -0,0 +1,116 @@
"""Persist user-edited song metadata back into the underlying song file.
The library scanner (`lib/scan_worker.py`) re-derives title/artist/album/year
from the file on every full rescan from the sloppak ``manifest.yaml``
top-level keys. A DB-only edit therefore reverts the moment a full rescan
re-reads the file. Writing the edit into the file makes the file the single
source of truth, so the change survives both incremental and full rescans.
``fields`` is a partial dict of any of ``title``/``artist``/``album``/``year``;
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.
Unknown / unsupported shapes return False and the caller keeps the DB-only
update.
"""
from __future__ import annotations
import shutil
import zipfile
from pathlib import Path
import yaml
def _coerce_year(value):
"""Convert *value* to an int year, or 0 for empty/invalid (clear intent).
The scanner reads ``SongYear`` as ``str(manifest.get("year", "") or "")``
for sloppaks so 0 round-trips back to an empty string, which is the
correct DB representation of "no year". Callers must gate on
``"year" in fields`` before calling this; they must NOT gate on the return
value being non-None/non-zero (that would silently drop a year-clear edit,
which is the bug this function fixes).
"""
try:
return int(value)
except (TypeError, ValueError):
return 0 # empty string / non-numeric → clear the year
def _apply_to_sloppak_manifest(manifest: dict, fields: dict) -> bool:
"""Update a parsed sloppak manifest in place. Returns True if changed."""
dirty = False
for key in ("title", "artist", "album"):
if fields.get(key) is not None:
manifest[key] = str(fields[key])
dirty = True
if "year" in fields:
manifest["year"] = _coerce_year(fields["year"])
dirty = True
return dirty
def _rewrite_zip_manifest(zip_path: Path, dumped: str) -> bool:
"""Rewrite manifest.yaml inside a zip-form sloppak, preserving every other
entry (and its original compression). Backup + temp + atomic replace."""
zip_path = Path(zip_path)
backup = zip_path.with_name(zip_path.name + ".bak")
if not backup.exists():
shutil.copy2(zip_path, backup)
out_tmp = zip_path.with_name(zip_path.name + ".tmp")
with zipfile.ZipFile(str(zip_path), "r") as zin:
names = zin.namelist()
manifest_name = "manifest.yaml"
for cand in ("manifest.yaml", "manifest.yml"):
if cand in names:
manifest_name = cand
break
with zipfile.ZipFile(str(out_tmp), "w", zipfile.ZIP_DEFLATED) as zout:
for item in zin.infolist():
if item.filename in ("manifest.yaml", "manifest.yml"):
continue
# Passing the original ZipInfo preserves each entry's
# compress_type — important for already-compressed ogg stems.
zout.writestr(item, zin.read(item.filename))
zout.writestr(manifest_name, dumped)
out_tmp.replace(zip_path)
return True
def write_sloppak_metadata(path: Path, fields: dict) -> bool:
"""Write metadata into a sloppak (directory or zip form). Returns True if
anything was written."""
import sloppak as sloppak_mod
path = Path(path)
manifest = sloppak_mod.load_manifest(path)
if not _apply_to_sloppak_manifest(manifest, fields):
return False
dumped = yaml.safe_dump(manifest, sort_keys=False, allow_unicode=True)
if path.is_dir():
mf = path / "manifest.yaml"
if not mf.exists() and (path / "manifest.yml").exists():
mf = path / "manifest.yml"
mf.write_text(dumped, encoding="utf-8")
return True
return _rewrite_zip_manifest(path, dumped)
def write_song_metadata(path: Path, fields: dict) -> bool:
"""Persist edited title/artist/album/year into the song's file.
Dispatches by shape: ``.sloppak`` files and sloppak directories
(manifest.yaml present). Loose-folder and unknown shapes return False
(caller keeps the DB-only update). Returns True if the file was modified.
"""
path = Path(path)
suffix = path.suffix.lower()
if path.is_dir():
if (path / "manifest.yaml").exists() or (path / "manifest.yml").exists():
return write_sloppak_metadata(path, fields)
return False
if suffix == ".sloppak":
return write_sloppak_metadata(path, fields)
return False
+199
View File
@@ -0,0 +1,199 @@
"""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``
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),
so we rebuild the sheet ourselves with node + the pinned ``tailwindcss``,
scanning the baked-in plugins *and* the user plugins dir.
Best-effort: a logged no-op (returns ``False``) when the toolchain or inputs
are absent e.g. a native dev run with no node, or a desktop bundle that
already baked a complete sheet so plugin install / startup never hard-fails
on a missing optional engine.
"""
from __future__ import annotations
import json
import logging
import os
import shutil
import subprocess
import tempfile
import threading
from pathlib import Path
log = logging.getLogger("slopsmith.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
# Tailwind 3.x.
_TAILWIND_VERSION = "3.4.19"
# Serialize rebuilds: concurrent installs (or install racing the startup scan)
# must not run the CLI against the same output file at once.
_lock = threading.Lock()
# Set by a trigger that arrives while a rebuild is already running, so the
# in-flight build re-runs once more to pick up the newer plugin set instead of
# every concurrent trigger stacking its own redundant build.
_rerun = threading.Event()
# lib/ lives at ``<app>/lib``; the app root (static/, tailwind.config.js) is its
# grandparent.
APP_DIR = Path(__file__).resolve().parent.parent
def _user_plugins_dir() -> Path | None:
raw = os.environ.get("SLOPSMITH_PLUGINS_DIR", "").strip()
if not raw:
return None
p = Path(raw)
return p if p.is_dir() else None
def user_plugin_count() -> int:
"""Number of installed plugins in the runtime user plugins dir (0 if unset).
Counts only directories that contain a ``plugin.json`` so stray caches/tmp
dirs (which have none) don't trigger rebuilds.
"""
d = _user_plugins_dir()
if not d:
return 0
return sum(1 for p in d.iterdir() if p.is_dir() and (p / "plugin.json").is_file())
def _tailwind_cmd() -> list[str] | None:
"""Prefer a globally-installed ``tailwindcss`` (offline, no fetch); fall back
to ``npx`` which resolves/fetches the pinned version on demand."""
exe = shutil.which("tailwindcss")
if exe:
return [exe]
npx = shutil.which("npx")
if npx:
return [npx, "-y", f"tailwindcss@{_TAILWIND_VERSION}"]
return None
def can_rebuild() -> bool:
return (
_tailwind_cmd() is not None
and (APP_DIR / "tailwind.config.js").is_file()
and (APP_DIR / "static" / "_tailwind.src.css").is_file()
)
def _write_runtime_config(tmpdir: Path) -> Path:
"""Wrapper config that reuses the base theme/safelist/exclusions but widens
``content`` to absolute paths covering the user plugins dir as well."""
base_cfg = APP_DIR / "tailwind.config.js"
# Use forward-slash (POSIX) globs/paths: Tailwind's fast-glob matcher needs
# forward slashes even on Windows, and node `require()` accepts them too.
content = [
(APP_DIR / "static" / "**" / "*.{html,js}").as_posix(),
(APP_DIR / "plugins" / "**" / "*.{js,html}").as_posix(),
]
user = _user_plugins_dir()
if user:
content.append((user / "**" / "*.{js,html}").as_posix())
# Exclude a user-installed highway_3d too (it ships its own sheet).
content.append("!" + (user / "highway_3d" / "**").as_posix())
# highway_3d ships its own sheet via the `styles` capability — keep it out
# of the core sheet, mirroring tailwind.config.js.
content.append("!" + (APP_DIR / "plugins" / "highway_3d" / "**").as_posix())
cfg = tmpdir / "tailwind.runtime.config.js"
cfg_js = (
"const base = require({base});\n"
"base.content = {content};\n"
"module.exports = base;\n"
).format(
base=json.dumps(base_cfg.as_posix()),
content=json.dumps(content),
)
cfg.write_text(cfg_js)
return cfg
def _run_build(cmd_prefix: list[str], out: Path, src: Path) -> bool:
"""Run one Tailwind build over the current plugin set. Never raises."""
with tempfile.TemporaryDirectory() as td:
cfg = _write_runtime_config(Path(td))
# Stage the output next to the live sheet so the final swap is an
# atomic same-filesystem os.replace (a reader never sees a partial).
staged = out.with_name(f".tailwind.min.css.{os.getpid()}.tmp")
cmd = cmd_prefix + [
"-c", str(cfg),
"-i", str(src),
"-o", str(staged),
"--minify",
]
try:
subprocess.run(
cmd, check=True, capture_output=True, text=True,
cwd=str(APP_DIR), timeout=120,
)
os.replace(staged, out)
return True
except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as e:
stderr = (getattr(e, "stderr", "") or "")[-500:]
log.warning("tailwind rebuild failed: %s", stderr)
return False
except Exception:
log.exception("tailwind rebuild errored")
return False
finally:
if staged.exists():
try:
staged.unlink()
except OSError:
pass
def rebuild(reason: str = "") -> bool:
"""Regenerate ``static/tailwind.min.css`` over baked-in + user plugins.
Returns ``True`` on a successful rebuild, ``False`` on any skip/failure.
Never raises callers treat CSS freshness as best-effort. Concurrent
triggers are coalesced: only one build runs at a time, and triggers that
arrive mid-build cause a single extra rerun rather than stacking builds.
"""
tag = f" [{reason}]" if reason else ""
cmd_prefix = _tailwind_cmd()
if cmd_prefix is None or not can_rebuild():
log.info("tailwind rebuild skipped — engine/inputs unavailable%s", tag)
return False
out = APP_DIR / "static" / "tailwind.min.css"
src = APP_DIR / "static" / "_tailwind.src.css"
# If a rebuild is already running, flag a rerun and return instead of
# queueing a redundant build behind it.
if not _lock.acquire(blocking=False):
_rerun.set()
log.info("tailwind rebuild already running — coalesced%s", tag)
return False
ok = False
try:
while True:
_rerun.clear()
ok = _run_build(cmd_prefix, out, src)
# A trigger arrived while we were building — run once more to pick
# up the newer plugin set, then stop.
if not _rerun.is_set():
break
finally:
_lock.release()
if not ok:
return False
# Guard the stat so the "never raises" contract holds even if the freshly
# written sheet is somehow not stat-able (odd FS / external cleanup).
try:
size = out.stat().st_size
except OSError:
size = -1
log.info("tailwind rebuilt over installed plugins%s (%d bytes)", tag, size)
return True
+70
View File
@@ -0,0 +1,70 @@
"""Tone helpers for sloppak playback.
A slopsmith 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
from its own ``.sloppak`` / arrangement JSON; it never reads or decrypts
proprietary archive formats.
"""
from __future__ import annotations
import logging
import math
import re
log = logging.getLogger("slopsmith.lib.tones")
def tokens(s: str) -> set[str]:
"""Split a name or file stem into lowercased alphanumeric tokens.
Used for fuzzy arrangementXML matching: arrangement names carry spaces
("Bonus Lead") while file stems are underscored ("song_bonus_lead"), and a
plain substring check is ambiguous ("lead" is a substring of "bonuslead").
Shared with the playback path in `server.py` so the two stay consistent.
"""
return {t for t in re.split(r"[^a-z0-9]+", (s or "").lower()) if t}
def sloppak_tone_changes(arr_tones) -> tuple[str, list[dict]]:
"""Build the highway tone-change payload from an arrangement's tone block.
Given ``Arrangement.tones`` (the dict embedded in the sloppak, or ``None``),
returns ``(base, changes)`` where ``base`` is the initial tone name and
``changes`` is a time-sorted ``[{"t", "name"}]`` list. Non-string names,
non-dict entries, and non-numeric / non-finite times are skipped a
hand-edited or third-party sloppak must not crash the highway WebSocket
or emit NaN/inf (which the client's ``JSON.parse`` rejects).
"""
if not isinstance(arr_tones, dict):
return "", []
base_val = arr_tones.get("base", "")
base = base_val.strip() if isinstance(base_val, str) else ""
changes: list[dict] = []
raw_changes = arr_tones.get("changes")
if not isinstance(raw_changes, list):
# A truthy non-list (e.g. `1`) would raise TypeError on iteration.
raw_changes = []
for c in raw_changes:
if not isinstance(c, dict):
continue
t = c.get("t")
name = c.get("name")
if t is None or not isinstance(name, str) or not name:
continue
try:
t = float(t)
except (TypeError, ValueError):
continue
if not math.isfinite(t):
continue
changes.append({"t": round(t, 3), "name": name})
changes.sort(key=lambda x: x["t"])
return base, changes
+111
View File
@@ -0,0 +1,111 @@
"""Tuning data and helpers.
Kept separate from server.py so tests can import it without triggering
FastAPI / SQLite module-level side effects.
"""
DEFAULT_REFERENCE_PITCH = 440.0
# Canonical tuning frequencies at 440 Hz reference, keyed by instrument then
# tuning name. This is the authoritative source; tuner/routes.py previously
# held a copy — it was removed in favour of this one.
DEFAULT_TUNINGS: dict[str, dict[str, list[float]]] = {
"guitar-6": {
"Standard": [82.41, 110.00, 146.83, 196.00, 246.94, 329.63],
"Eb Standard": [77.78, 103.83, 138.59, 185.00, 233.08, 311.13],
"Drop D": [73.42, 110.00, 146.83, 196.00, 246.94, 329.63],
"D Standard": [73.42, 98.00, 130.81, 174.61, 220.00, 293.66],
"Drop C": [65.41, 98.00, 130.81, 174.61, 220.00, 293.66],
"Open G": [73.42, 98.00, 146.83, 196.00, 246.94, 293.66],
"Open D": [73.42, 110.00, 146.83, 185.00, 220.00, 293.66],
"DADGAD": [73.42, 110.00, 146.83, 196.00, 220.00, 293.66],
"Open E": [82.41, 123.47, 164.81, 207.65, 246.94, 329.63],
},
"guitar-7": {
"Standard": [61.74, 82.41, 110.00, 146.83, 196.00, 246.94, 329.63],
"Drop A": [55.00, 82.41, 110.00, 146.83, 196.00, 246.94, 329.63],
"A Standard": [55.00, 73.42, 98.00, 130.81, 174.61, 220.00, 293.66],
"Drop G": [49.00, 73.42, 110.00, 146.83, 196.00, 246.94, 329.63],
"Bb Standard": [58.27, 77.78, 103.83, 138.59, 185.00, 233.08, 311.13],
},
"guitar-8": {
"Standard": [46.25, 61.74, 82.41, 110.00, 146.83, 196.00, 246.94, 329.63],
"Drop E": [41.20, 61.74, 82.41, 110.00, 146.83, 196.00, 246.94, 329.63],
"E Standard": [41.20, 55.00, 73.42, 98.00, 130.81, 174.61, 220.00, 293.66],
"Drop D": [36.71, 55.00, 73.42, 98.00, 130.81, 174.61, 220.00, 293.66],
"Eb Standard": [38.89, 51.91, 69.30, 92.50, 123.47, 164.81, 207.65, 277.18],
},
"bass-4": {
"Standard": [41.20, 55.00, 73.42, 98.00],
"Eb Standard": [38.89, 51.91, 69.30, 92.50],
"Drop D": [36.71, 55.00, 73.42, 98.00],
"D Standard": [36.71, 48.99, 65.41, 87.31],
"Drop C": [32.70, 48.99, 65.41, 87.31],
},
"bass-5": {
"Standard": [30.87, 41.20, 55.00, 73.42, 98.00],
"Eb Standard": [29.14, 38.89, 51.91, 69.30, 92.50],
"Drop D": [30.87, 36.71, 55.00, 73.42, 98.00],
"D Standard": [27.50, 36.71, 48.99, 65.41, 87.31],
"Drop C": [27.50, 32.70, 48.99, 65.41, 87.31],
},
}
def apply_reference_pitch(
tunings: dict[str, dict[str, list[float]]],
reference_pitch: float,
) -> dict[str, dict[str, list[float]]]:
"""Return a copy of tunings with all frequencies scaled to reference_pitch."""
scale = reference_pitch / DEFAULT_REFERENCE_PITCH
return {
instrument: {
name: [round(f * scale, 4) for f in freqs]
for name, freqs in names.items()
}
for instrument, names in tunings.items()
}
def tuning_name(offsets: list[int]) -> str:
# All three pattern checks below are gated on `len(offsets) == 6`. The
# naming conventions here are 6-string-specific — e.g. a 7-string all-zeros
# tuning has a low B, not an E, so labeling it "E Standard" would be wrong.
# 7+-string community content falls through to the numeric fallback. See #43.
# Standard tunings (all six strings same offset)
standard = {
0: "E Standard", -1: "Eb Standard", -2: "D Standard",
-3: "C# Standard", -4: "C Standard", -5: "B Standard",
-6: "Bb Standard", -7: "A Standard",
1: "F Standard", 2: "F# Standard",
}
if len(offsets) == 6 and all(o == offsets[0] for o in offsets):
name = standard.get(offsets[0])
if name:
return name
# Drop tunings (low string 2 semitones below the rest)
# Named after the low string's note: e.g. offsets[-2,0,0,0,0,0] = Drop D (low E dropped to D)
if len(offsets) == 6 and offsets[0] == offsets[1] - 2 and all(o == offsets[1] for o in offsets[1:]):
note_names = ["E", "F", "F#", "G", "Ab", "A", "Bb", "B", "C", "C#", "D", "Eb"]
low_note = note_names[offsets[0] % 12]
return f"Drop {low_note}"
# Common named tunings
named = {
(-2, 0, 0, 0, 0, 0): "Drop D",
(-4, -2, -2, -2, -2, -2): "Drop C",
(-2, -2, 0, 0, 0, 0): "Double Drop D",
(0, 0, 0, -1, 0, 0): "Open G",
(-2, -2, 0, 0, -2, -2): "Open D",
(-2, 0, 0, 0, -2, 0): "DADGAD",
(0, 2, 2, 1, 0, 0): "Open E",
(-2, 0, 0, 2, 3, 2): "Open D (alt)",
}
if len(offsets) == 6 and tuple(offsets) in named:
return named[tuple(offsets)]
if not offsets:
return "Unknown"
return "Custom Tuning"
+193
View File
@@ -0,0 +1,193 @@
"""Per-syllable vocal pitch extraction via the demucs server's /pitch endpoint.
Sibling to `lyrics_transcribe.py` on the karaoke side: once we have
isolated vocals + per-syllable lyric timing (both produced by the
WhisperX fallback or shipped in the source PSARC), 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 byrongamatos/slopsmith-plugin-lyrics-karaoke renderer
already consumes:
{"version": 1, "notes": [{"t": float, "d": float, "midi": int}, ...]}
Pre-generating during sloppak conversion means the karaoke plugin no
longer has to run pYIN locally on every first-play the file is
already there.
Engine selection
This module exposes only the remote path (`extract_pitch_remote`). The
demucs server's /pitch endpoint runs CREPE on a GPU when available,
which is materially better than the pYIN fallback the karaoke plugin
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
`byrongamatos/slopsmith-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
`"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:
* patch metadata-only or implementation fixes
* minor backward-compatible additions
* major output shape / semantics changed; existing
vocal_pitch.json should be regenerated and remote caches
should miss
"""
from __future__ import annotations
import json
import logging
import math
from pathlib import Path
from typing import Callable, Optional
log = logging.getLogger("slopsmith.lib.vocal_pitch")
ProgressCB = Optional[Callable[[float, str, str], None]]
# `pitch_extraction` manifest-block constants. See module docstring for
# semver semantics.
PITCH_EXTRACTION_ENGINE = "crepe"
PITCH_EXTRACTION_MODEL = "v1"
PITCH_EXTRACTION_SCHEMA_VERSION = "1.0.0"
def extract_pitch_remote(
vocals_path: Path,
lyrics: list[dict],
server_url: str,
*,
api_key: str | None = None,
timeout: int = 300,
progress_cb: ProgressCB = None,
) -> list[dict]:
"""POST the vocal stem + lyric timings to `{server_url}/pitch`.
`lyrics` is the same `[{t, d, w}, ...]` list slopsmith 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.
Returns a normalized `notes` list: each entry is
`{"t": float, "d": float, "midi": int}` with `t`/`d` rounded to 3
decimals and `midi` coerced to int. Malformed server entries
(missing key, non-numeric, wrong type) are skipped rather than
propagated. Tokens the server couldn't extract a pitch for
(silent, sub-threshold confidence, no neighbour to borrow from)
are omitted from the response the output may be shorter than
the input lyrics.
Errors raise `RuntimeError` with a truncated server response so
the caller can log+continue without bringing down the surrounding
transcription / split job. Transport-level failures
(`requests.RequestException`: connection, DNS, timeout, upload
aborted) are wrapped here so callers see one exception type, not
requests' hierarchy. Matches the failure idiom in
`transcribe_vocals_remote` and `_run_demucs_remote`."""
import requests
server_url = server_url.rstrip("/")
if progress_cb:
try:
progress_cb(0.10, "pitch", f"Uploading to CREPE server ({server_url})")
except Exception:
pass
headers: dict[str, str] = {}
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
# The /pitch endpoint validates each entry has numeric `t` + `d`;
# the `w` field is ignored server-side but harmless to forward.
# Wrap the upload so a connection / DNS / timeout failure becomes a
# RuntimeError instead of leaking requests' own exception hierarchy
# past the docstring contract.
log.debug("POST %s/pitch vocals=%s lyrics=%d timeout=%ds",
server_url, vocals_path.name, len(lyrics), timeout)
# Catch both `requests.RequestException` (network / DNS / timeout /
# upload aborted) AND `OSError` from the `open(vocals_path)` itself
# (file disappeared between gate-check and upload, permissions
# change, EIO). Both surface as `RuntimeError` to keep the
# docstring contract one-type and let the caller handle every
# failure mode with one `except`. RequestException MUST be caught
# first because it inherits from OSError — listing OSError first
# would steal the network-failure path and label it as a vocals
# read error.
try:
with open(vocals_path, "rb") as f:
resp = requests.post(
f"{server_url}/pitch",
files={"file": (vocals_path.name, f, "audio/ogg")},
data={"lyrics": json.dumps(lyrics)},
headers=headers or None,
timeout=timeout,
)
except requests.RequestException as e:
raise RuntimeError(f"CREPE server request failed: {e}") from e
except OSError as e:
raise RuntimeError(f"Reading vocals stem {vocals_path.name} failed: {e}") from e
if resp.status_code != 200:
raise RuntimeError(
f"CREPE server error ({resp.status_code}): {resp.text[:300]}"
)
try:
data = resp.json()
except ValueError as e:
raise RuntimeError(f"CREPE server returned non-JSON: {e}") from e
if not isinstance(data, dict) or "notes" not in data:
raise RuntimeError(
f"CREPE server returned unexpected shape: {str(data)[:300]}"
)
raw_notes = data.get("notes")
if not isinstance(raw_notes, list):
raise RuntimeError(
f"CREPE server `notes` is not a list: {type(raw_notes).__name__}"
)
# Defensive: skip malformed entries entirely rather than crashing
# the whole pass on one bad record. Same posture as the WhisperX
# remote path. Non-finite t/d (NaN, ±Inf — a misbehaving server or
# a numerical edge in CREPE could surface them) are also filtered
# so they can't reach the on-disk vocal_pitch.json and break
# strict-JSON consumers downstream.
out: list[dict] = []
for n in raw_notes:
if not isinstance(n, dict):
continue
if "t" not in n or "d" not in n or "midi" not in n:
continue
try:
t = float(n["t"])
d = float(n["d"])
if not math.isfinite(t) or not math.isfinite(d):
continue
out.append({
"t": round(t, 3),
"d": round(d, 3),
"midi": int(n["midi"]),
})
except (TypeError, ValueError):
continue
log.debug("CREPE /pitch returned %d raw notes, %d after normalization",
len(raw_notes), len(out))
if progress_cb:
try:
progress_cb(1.0, "pitch", f"Got {len(out)} pitch notes")
except Exception:
pass
return out
+85
View File
@@ -0,0 +1,85 @@
"""Pure Python WEM (Wwise Vorbis) to OGG converter.
Strips the RIFF/BKHD wrapper and reconstructs valid OGG Vorbis data.
This is a fallback for platforms without vgmstream (e.g. Android)."""
import logging
import struct
import os
log = logging.getLogger("slopsmith.lib.wem_decode")
def convert_wem_to_ogg(wem_path: str, output_path: str) -> bool:
"""Convert a WEM file to OGG by extracting the embedded Vorbis data.
Returns True if successful."""
try:
with open(wem_path, 'rb') as f:
data = f.read()
# WEM files are RIFF containers with Wwise-specific chunks
if data[:4] == b'RIFF':
return _convert_riff_wem(data, output_path)
return False
except Exception as e:
log.warning("WEM decode error for %s -> %s: %s", wem_path, output_path, e, exc_info=True)
return False
def _convert_riff_wem(data: bytes, output_path: str) -> bool:
"""Parse RIFF-based WEM and extract audio data."""
pos = 12 # skip RIFF header + size + WAVE
fmt_data = None
audio_data = None
vorb_data = None
while pos < len(data) - 8:
chunk_id = data[pos:pos+4]
chunk_size = struct.unpack_from('<I', data, pos+4)[0]
chunk_data = data[pos+8:pos+8+chunk_size]
if chunk_id == b'fmt ':
fmt_data = chunk_data
elif chunk_id == b'data':
audio_data = chunk_data
elif chunk_id == b'vorb':
vorb_data = chunk_data
pos += 8 + chunk_size
if chunk_size % 2: # RIFF chunks are word-aligned
pos += 1
if fmt_data is None or audio_data is None:
return False
# Check codec: 0xFFFF = Wwise Vorbis, 0x0002 = Wwise ADPCM
codec = struct.unpack_from('<H', fmt_data, 0)[0]
if codec == 0xFFFF or codec == 0x0069:
# Wwise Vorbis — audio_data contains raw Ogg pages or encoded Vorbis
# For Rocksmith CDLC, the data is typically packed Vorbis
# Try writing raw data as OGG (some WEM files have valid OGG inside)
if _try_extract_ogg_pages(audio_data, output_path):
return True
# Fallback: write raw data and hope the browser can play it
# Some WEM files are just renamed OGG/Opus
if audio_data[:4] == b'OggS':
with open(output_path, 'wb') as f:
f.write(audio_data)
return True
return False
def _try_extract_ogg_pages(data: bytes, output_path: str) -> bool:
"""Try to find and extract OGG pages from the data."""
# Search for OGG page headers
ogg_start = data.find(b'OggS')
if ogg_start >= 0:
with open(output_path, 'wb') as f:
f.write(data[ogg_start:])
return os.path.getsize(output_path) > 100
return False
+70
View File
@@ -0,0 +1,70 @@
"""Unified XP / level math for the fee[dB]ack player profile.
This is the single source of truth for the XP curve. It is intentionally the
SAME math the minigames plugin shipped (so promoting XP to a unified core
store does not change anyone's level):
xp_for_run(score) = floor(sqrt(score) * 10)
level_for_xp(xp) = floor(sqrt(xp / 100)) + 1 # L1@0, L2@100, L3@400, L4@900…
threshold(level) = (level - 1)^2 * 100 # xp needed to reach `level`
Pure functions, no IO, flat-importable (`from xp import ...`) per constitution
Principle V. Covered by tests/test_xp.py.
"""
from __future__ import annotations
import math
__all__ = [
"xp_for_run",
"level_for_xp",
"level_threshold",
"xp_in_level",
"xp_to_next",
"progress",
]
def xp_for_run(score: int) -> int:
"""XP awarded for a run/play with the given score. ``floor(sqrt(score)*10)``."""
if score is None or score <= 0:
return 0
return int(math.floor(math.sqrt(score) * 10))
def level_for_xp(xp: int) -> int:
"""Level for a total XP. ``floor(sqrt(xp/100)) + 1`` (minimum 1)."""
if xp is None or xp <= 0:
return 1
return int(math.floor(math.sqrt(xp / 100))) + 1
def level_threshold(level: int) -> int:
"""Total XP required to *reach* ``level``. ``(level-1)^2 * 100``."""
if level <= 1:
return 0
return (level - 1) ** 2 * 100
def xp_in_level(xp: int) -> int:
"""XP accumulated within the current level (xp above the current level's floor)."""
xp = max(0, int(xp or 0))
return xp - level_threshold(level_for_xp(xp))
def xp_to_next(xp: int) -> int:
"""XP remaining to reach the next level (0 only at exact-threshold edge cases)."""
xp = max(0, int(xp or 0))
return max(0, level_threshold(level_for_xp(xp) + 1) - xp)
def progress(xp: int) -> dict:
"""The full badge payload for a total XP: ``{xp, level, xp_in_level, xp_to_next}``."""
xp = max(0, int(xp or 0))
return {
"xp": xp,
"level": level_for_xp(xp),
"xp_in_level": xp_in_level(xp),
"xp_to_next": xp_to_next(xp),
}