mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-08-11 03:09:57 +00:00
Clean release snapshot
This commit is contained in:
+523
@@ -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
@@ -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,
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
@@ -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 0–2 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
File diff suppressed because it is too large
Load Diff
+2141
File diff suppressed because it is too large
Load Diff
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
@@ -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),
|
||||
}
|
||||
@@ -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]}")
|
||||
@@ -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 on→off 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
@@ -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
|
||||
@@ -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
|
||||
@@ -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))
|
||||
@@ -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
|
||||
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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
|
||||
@@ -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
|
||||
@@ -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 arrangement↔XML 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
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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),
|
||||
}
|
||||
Reference in New Issue
Block a user