Purge external-format terminology from code, tests and docs

Reword comments/docstrings/strings and rename identifiers that referenced
the external game and its file formats:

- format-id "psarc" -> "archive"; local vars psarc_path -> song_path,
  psarc_base -> tone_base
- lyrics provenance value "sng" -> "notechart" (legacy "sng" still accepted)
- highway_3d fret-ghost scope value "rocksmith" -> "chords" (invalid/legacy
  values fall back to the default, preserving behaviour)
- neutralise references in prose, test names/data, .gitattributes and docs

No functional change beyond the renamed identifiers; all Python compiles.
This commit is contained in:
Sin
2026-06-16 19:36:53 +01:00
parent bc0e83c345
commit 4148b0e72e
49 changed files with 397 additions and 392 deletions
+3 -3
View File
@@ -11,6 +11,6 @@
# so LICENSE / VERSION stay diff-readable for future version bumps.
static/vendor/three/three.module.min.js binary
# PSARC test fixtures are zlib-compressed binary archives — diffs and
# autocrlf rewrites would corrupt them.
tests/fixtures/*.psarc binary
# Sloppak test fixtures are zip archives — diffs and autocrlf rewrites
# would corrupt them.
tests/fixtures/*.sloppak binary
+4 -4
View File
@@ -102,8 +102,8 @@ The fix is `context["load_sibling"](name)`, which loads the sibling under a name
```python
def setup(app, context):
extractor = context["load_sibling"]("extractor")
PsarcReader = extractor.PsarcReader
helper = context["load_sibling"]("helper")
HelperClass = helper.HelperClass
# …
```
@@ -340,9 +340,9 @@ Plugins that add a layer on top of whichever visualization is active — HUDs, f
- `highway.hasPhraseData()` — returns `true` when the current song has phrase-level difficulty ladder data (i.e. the mastery slider is active and `getFilteredNotes()` / `getFilteredChords()` return a filtered subset). Use this to gate logic that only makes sense when difficulty filtering is available
- `highway.getPhrases()` — phrase timing windows `[{ index, start_time, end_time, max_difficulty }]` for the current song's difficulty ladder. Returns `null` when phrase data is absent (GP imports, single-difficulty charts). Read-only; do not mutate. Pair with `hasPhraseData()` to gate phrase-aware logic.
- `highway.getMastery()` — current master-difficulty slider value as a fraction `0..1`. Reflects the same value the mastery slider is set to; meaningful only when `hasPhraseData()` is true.
- `highway.getChordTemplates()` — chord shape lookup table; index by `chord.id` from `getChords()` to get `{ name, fingers, frets }`. `fingers` and `frets` are per-string arrays (length matches the tuning's string count); within `fingers`, `-1` = unused, `0` = open string, `n > 0` = finger number. RS XML sources populate real fingerings; GP imports currently emit all `-1` since pre-import sources don't carry finger data. Not filter-aware: templates are static metadata, every `chord_id` referenced by `getChords()` is guaranteed valid
- `highway.getChordTemplates()` — chord shape lookup table; index by `chord.id` from `getChords()` to get `{ name, fingers, frets }`. `fingers` and `frets` are per-string arrays (length matches the tuning's string count); within `fingers`, `-1` = unused, `0` = open string, `n > 0` = finger number. arrangement XML sources populate real fingerings; GP imports currently emit all `-1` since pre-import sources don't carry finger data. Not filter-aware: templates are static metadata, every `chord_id` referenced by `getChords()` is guaranteed valid
- `highway.getSongInfo()` — tuning, arrangement, capo
- `highway.getStringCount()` — number of strings on the active arrangement (4 for bass, 6 for guitar, 7+ for extended-range GP imports). Derived server-side as `max(notes-max-string + 1, name-based fallback, len(tuning))` where the tuning length only contributes when it isn't the RS-XML padded 6-string form (sloppak / GP-imported sources carry trimmed tuning lengths). The name-based fallback is 4 for arrangements containing "bass" (case-insensitive) and 6 otherwise. This combination handles partial-string-usage charts (a 6-string lead that never plays string 5), extended-range GP imports (5-string bass, 7-string guitar), and sloppaks that explicitly encode the instrument range — without requiring plugins to do their own arrangement-name matching
- `highway.getStringCount()` — number of strings on the active arrangement (4 for bass, 6 for guitar, 7+ for extended-range GP imports). Derived server-side as `max(notes-max-string + 1, name-based fallback, len(tuning))` where the tuning length only contributes when it isn't the arrangement XML padded 6-string form (sloppak / GP-imported sources carry trimmed tuning lengths). The name-based fallback is 4 for arrangements containing "bass" (case-insensitive) and 6 otherwise. This combination handles partial-string-usage charts (a 6-string lead that never plays string 5), extended-range GP imports (5-string bass, 7-string guitar), and sloppaks that explicitly encode the instrument range — without requiring plugins to do their own arrangement-name matching
- `highway.getLefty()` / `highway.getInverted()` — mirror + invert state
Overlays do NOT appear in the viz picker and do NOT declare `"type": "visualization"` in `plugin.json`. They coexist with whichever renderer (default 2D, 3D highway, piano, ...) the user has picked.
+1 -1
View File
@@ -1,4 +1,4 @@
"""Audio extraction and conversion for Rocksmith CDLC."""
"""Audio extraction and conversion for the source game custom song."""
import logging
import os
+1 -1
View File
@@ -34,7 +34,7 @@ _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",
r"\b[\w()'\-+&,.!?\[\]]+\.(?:archive|sloppak|wem|ogg|mp3|wav)\b",
re.IGNORECASE,
)
+16 -16
View File
@@ -1,4 +1,4 @@
"""Convert Guitar Pro files (.gp5/.gp4/.gp3) to Rocksmith 2014 arrangement XML."""
"""Convert Guitar Pro files (.gp5/.gp4/.gp3) to the source game arrangement XML."""
import logging
import re
@@ -18,9 +18,9 @@ def _extract_year(song: guitarpro.Song) -> str:
"""Pull a 4-digit year out of GP metadata.
GP files have no dedicated year field; the year usually appears inside the
copyright string (e.g. "1998 Goat Head Music, WB Music Corp, USA"). RsCli
copyright string (e.g. "1998 Goat Head Music, WB Music Corp, USA"). the converter
requires <albumYear> to parse as Int32, so we extract just the digits and
fall back to empty (which RsCli treats as no year) when nothing matches.
fall back to empty (which the converter treats as no year) when nothing matches.
"""
for field_val in (getattr(song, "copyright", None), getattr(song, "subtitle", None)):
if not field_val:
@@ -544,7 +544,7 @@ def convert_track(
*,
expand_repeats: bool = True,
) -> str:
"""Convert a GP track to Rocksmith 2014 arrangement XML string.
"""Convert a GP track to the source game arrangement XML string.
Args:
song: Parsed Guitar Pro song
@@ -558,7 +558,7 @@ def convert_track(
once in authored order — equivalent to the pre-expansion behavior.
Returns:
XML string of the Rocksmith arrangement
XML string of the the source game arrangement
"""
track = song.tracks[track_index]
num_strings = len(track.strings)
@@ -950,7 +950,7 @@ def _build_xml(
# Tuning. RS2014 schema names 6 string slots; we always emit those
# for compatibility, and emit additional string6+ attributes (up to
# `len(tuning)-1`) for 7+ string arrangements. Slopsmith parses
# them; stock RS ignores them.
# them; the format ignores them.
tuning_el = ET.SubElement(root, "tuning")
for i in range(max(6, len(tuning))):
tuning_el.set(f"string{i}", str(tuning[i] if i < len(tuning) else 0))
@@ -1149,7 +1149,7 @@ def list_tracks(gp_path: str) -> list[dict]:
def auto_select_tracks(gp_path: str) -> tuple[list[int], dict[int, str]]:
"""Auto-select guitar/bass/keys tracks and assign Rocksmith arrangement names.
"""Auto-select guitar/bass/keys tracks and assign the source game arrangement names.
Includes piano/keyboard tracks as "Keys" arrangements alongside
guitar and bass tracks.
@@ -1205,7 +1205,7 @@ def auto_select_tracks(gp_path: str) -> tuple[list[int], dict[int, str]]:
role = "bass" if t["is_bass"] else "guitar"
selected.append((t["index"], role))
# Assign Rocksmith names: Lead, Rhythm, Combo, Bass, Keys, Drums
# Assign the source game names: Lead, Rhythm, Combo, Bass, Keys, Drums
track_indices = []
name_map = {}
lead_count = 0
@@ -1243,14 +1243,14 @@ def convert_piano_track(
*,
expand_repeats: bool = True,
) -> str:
"""Convert a GP piano/keyboard track to Rocksmith XML using MIDI encoding.
"""Convert a GP piano/keyboard track to the source game XML using MIDI encoding.
Encodes MIDI notes into Rocksmith's string+fret format:
Encodes MIDI notes into the source game's string+fret format:
string = midi_note // 24
fret = midi_note % 24
This gives a range of 0-143, covering the full piano range within
Rocksmith's 6-string x 24-fret structure. The piano highway plugin
the source game's 6-string x 24-fret structure. The piano highway plugin
decodes back via: midi = string * 24 + fret.
Honors GP repeat brackets and D.S./D.C./Coda/Fine jumps when
@@ -1342,7 +1342,7 @@ def convert_piano_track(
base_midi = 60 # fallback to middle C
midi_note = base_midi + note.value
# Encode into Rocksmith string+fret
# Encode into the source game string+fret
rs_string = midi_note // 24
rs_fret = midi_note % 24
@@ -1449,9 +1449,9 @@ def convert_drum_track(
*,
expand_repeats: bool = True,
) -> str:
"""Convert a GP drum/percussion track to Rocksmith XML using MIDI encoding.
"""Convert a GP drum/percussion track to the source game XML using MIDI encoding.
Encodes MIDI drum note numbers into Rocksmith's string+fret format:
Encodes MIDI drum note numbers into the source game's string+fret format:
string = midi_note // 24
fret = midi_note % 24
@@ -1534,7 +1534,7 @@ def convert_drum_track(
if midi_note not in GM_DRUM_MAP:
continue # Skip unknown percussion sounds
# Encode into Rocksmith string+fret
# Encode into the source game string+fret
rs_string = midi_note // 24
rs_fret = midi_note % 24
@@ -1778,7 +1778,7 @@ def convert_file(
*,
expand_repeats: bool = True,
) -> list[str]:
"""Convert a GP file to Rocksmith XMLs.
"""Convert a GP file to the source game XMLs.
Args:
gp_path: Path to .gp5/.gp4/.gp3 file
+11 -11
View File
@@ -663,7 +663,7 @@ def list_tracks(gp_path: str) -> list[dict]:
# ---------------------------------------------------------------------------
# convert_file — mirrors gp2rs.convert_file interface
# Converts GPX tracks directly to Rocksmith XML, reusing gp2rs._build_xml
# Converts GPX tracks directly to the source game XML, reusing gp2rs._build_xml
# ---------------------------------------------------------------------------
@@ -816,7 +816,7 @@ def _collect_tone_events(
def _inject_tones(xml_str: str, tone_events: list[tuple[float, str]]) -> str:
"""
Inject a <tones> element into a Rocksmith arrangement XML string.
Inject a <tones> element into a the source game arrangement XML string.
Parses the prettified XML returned by _build_xml, inserts the tones
block before </song>, and re-serialises. Noop if tone_events is empty.
@@ -903,7 +903,7 @@ def convert_vocal_track_to_pitch_sidecar(
{"version": 1, "notes": [{"t": float, "d": float, "midi": int}, ...]}
This is complementary to convert_vocal_track() which produces RS XML.
This is complementary to convert_vocal_track() which produces arrangement XML.
NOTE: nothing in this module calls this helper yet — convert_file() does not
invoke it, so no vocal_pitch.json is emitted automatically. A caller wanting
the pitch ribbon must call this itself and persist the returned dict (e.g.
@@ -1124,7 +1124,7 @@ def convert_file(
*,
expand_repeats: bool = True,
) -> list[str]:
"""Convert a .gpx file to Rocksmith XML arrangement files.
"""Convert a .gpx file to the source game XML arrangement files.
Mirrors gp2rs.convert_file so the editor plugin can call it transparently.
expand_repeats is accepted for API compatibility but repeat expansion from
@@ -1758,7 +1758,7 @@ def convert_file(
# (`<stem>.notation.json`, sloppak-spec §5.3) so the sloppak assembly
# step can attach a `notation_<id>.json` + manifest `notation:`
# sub-key without re-walking the GP file. Best-effort: a notation bug
# must never break the RS-XML conversion itself.
# must never break the arrangement XML conversion itself.
if is_keys:
try:
import gp2notation as _gp2notation
@@ -1809,10 +1809,10 @@ def _is_vocal_track(track: dict) -> bool:
def _gpx_lyric_to_rs(raw: str) -> str:
"""
Convert a GPX lyric token to Rocksmith vocal lyric format.
Convert a GPX lyric token to the source game vocal lyric format.
GPX encodes syllable continuation with a trailing hyphen (e.g. "in-", "t-").
Rocksmith uses the same convention for mid-word syllables. For word-final
the source game uses the same convention for mid-word syllables. For word-final
syllables with no hyphen, RS requires a "+" suffix to signal "connect to
next syllable without a space" — but only when the next beat is a
continuation of the same word. We handle this at the sequence level in
@@ -1849,7 +1849,7 @@ def convert_vocal_track(
arr_name: str = 'Vocals',
) -> str:
"""
Convert a GPX vocal track to a Rocksmith 2014 vocals arrangement XML.
Convert a GPX vocal track to a the source game vocals arrangement XML.
Each beat with a lyric and a note becomes a <vocal> element:
time — seconds from song start + audio_offset
@@ -1864,7 +1864,7 @@ def convert_vocal_track(
Beats with a lyric but no pitch note are included as pitch-0 rests so the
display timeline stays intact. Beats with no lyric are skipped entirely.
The output is a minimal but valid Rocksmith vocals XML. It does not include
The output is a minimal but valid the source game vocals XML. It does not include
ebeats or phrases (RS parses vocal XMLs without them).
"""
string_pitches = track['string_pitches'] # high→low, standard guitar if vocal
@@ -2018,10 +2018,10 @@ def _build_vocals_xml(
vocals: list[dict],
tempo: int,
) -> str:
"""Build a Rocksmith 2014 vocals arrangement XML string."""
"""Build a the source game vocals arrangement XML string."""
from xml.dom import minidom
# Rocksmith 2014 vocals arrangement is a flat <vocals> document — NOT a
# the source game vocals arrangement is a flat <vocals> document — NOT a
# <song> wrapper. Every lyric consumer in the codebase keys off the root
# tag being literally "vocals" (lib/loosefolder.py, server.py highway
# loader), so a <song> root would be silently skipped and the generated
+1 -1
View File
@@ -128,7 +128,7 @@ def _resolve_audio_asset(zf, root=None) -> tuple[str, str | None]:
# 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 —
# (the source game'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.
+2 -2
View File
@@ -54,7 +54,7 @@ def _iter_local(path: Path, pattern: str):
* 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
* Reject symlinks escaping the folder so a crafted custom song can't
smuggle external content into the scan.
"""
root = path.resolve()
@@ -176,7 +176,7 @@ def _arr_type_from_filename(stem: str) -> tuple:
def _parse_xml_meta(xml_path: Path) -> dict:
"""Parse a Rocksmith arrangement XML and return song-level metadata."""
"""Parse a the source game arrangement XML and return song-level metadata."""
try:
root = ET.parse(str(xml_path)).getroot()
if root.tag != "song":
+3 -3
View File
@@ -331,7 +331,7 @@ def load_content(root) -> tuple[dict, list]:
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
archive/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.
"""
@@ -345,11 +345,11 @@ def instrument_for_arrangement(arr_entry) -> str:
return "drums"
if arr_type in ("piano", "keys"):
return "keys"
# Check name before committing to a guitar type — legacy PSARC keys
# Check name before committing to a guitar type — legacy archive 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.
# keys path and quests even when the the source game XML type was not updated.
if "bass" in name:
return "bass"
if "drum" in name or "percussion" in name:
+8 -3
View File
@@ -526,7 +526,7 @@ def load_song(
and isinstance(e.get("d"), (int, float))
]
if song.lyrics:
# Provenance — populated by the converter (xml/sng),
# Provenance — populated by the converter (xml/notechart),
# the WhisperX fallback (whisperx), or hand-edits
# (user). Validate against the closed enum so a
# hand-edited (or otherwise malformed) manifest can't
@@ -536,8 +536,13 @@ def load_song(
# 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"}
_ALLOWED_LYRICS_SOURCES = {"xml", "notechart", "whisperx", "user"}
# Legacy alias: older manifests labelled note-chart-derived
# lyrics with the source format's name; normalise it.
_LYRICS_SOURCE_ALIASES = {"notechart": "notechart"}
raw_source = manifest.get("lyrics_source")
if isinstance(raw_source, str):
raw_source = _LYRICS_SOURCE_ALIASES.get(raw_source, raw_source)
if isinstance(raw_source, str) and raw_source in _ALLOWED_LYRICS_SOURCES:
song.lyrics_source = raw_source
else:
@@ -615,7 +620,7 @@ def extract_meta(path: Path) -> dict:
"notes": 0, # unknown without loading; fine for the index
}
)
# Sort like PSARC path: Lead > Combo > Rhythm > Bass
# Sort like archive 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):
+35 -35
View File
@@ -1,4 +1,4 @@
"""Rocksmith 2014 arrangement XML parser and song data models."""
"""the source game arrangement XML parser and song data models."""
from dataclasses import dataclass, field
from pathlib import Path
@@ -81,14 +81,14 @@ class HandShape:
chord_id: int
start_time: float
end_time: float
# EOF / some CDLC emit `arpeggio` on `<handShape>` (RS14+).
# EOF / some custom song emit `arpeggio` on `<handShape>` (RS14+).
arpeggio: bool = False
@dataclass
class PhraseLevel:
"""One difficulty tier's worth of note/chord/anchor/hand-shape data for a
single phrase iteration. Rocksmith's XML stores these as `<level
single phrase iteration. the source game's XML stores these as `<level
difficulty="N">` blocks that repeat for every difficulty tier the chart
author wrote; slopsmith used to collapse them to the phrase's
maxDifficulty and throw the rest away. Keeping them around lets the
@@ -129,11 +129,11 @@ class Arrangement:
chord_templates: list[ChordTemplate] = field(default_factory=list)
# None for single-level sources (GP converter, old sloppaks) — frontends
# should treat a missing `phrases` as "no per-phrase difficulty data
# available, disable the slider". Populated from Rocksmith XML when
# available, disable the slider". Populated from the source game XML when
# multiple `<level>` tiers exist.
phrases: list[Phrase] | None = None
# Tone data lifted from the source PSARC by the sloppak converter and
# carried inline in the arrangement JSON. None for PSARC/loose playback
# Tone data lifted from the source archive by the sloppak converter and
# carried inline in the arrangement JSON. None for archive/loose playback
# (the highway reads those tones from the XML directly) and for old
# sloppaks predating tone support. Shape:
# {"base": str, "changes": [{"t": float, "name": str}],
@@ -141,14 +141,14 @@ class Arrangement:
# `base`/`changes` drive the highway tone-change markers; `definitions`
# feed the Tones plugin gear panel.
tones: dict | None = None
# RS XML <arrangementProperties> flags for smart naming (slopsmith feat/arrangement).
# arrangement XML <arrangementProperties> flags for smart naming (slopsmith feat/arrangement).
# Populated from the XML; default False/0 for sloppak / GP-imported sources.
path_lead: bool = False
path_rhythm: bool = False
path_bass: bool = False
bonus_arr: bool = False
represent: int = 0
# RS2014 CDLC pitch-shift field (cents). Commonly -1200.0 (one octave
# RS2014 custom song pitch-shift field (cents). Commonly -1200.0 (one octave
# down) for extended-range bass arrangements. 0.0 when absent or zero.
cent_offset: float = 0.0
@@ -167,7 +167,7 @@ class Song:
audio_path: str = ""
# Optional lyrics, one entry per syllable: {"t": float, "d": float, "w": str}
lyrics: list[dict] = field(default_factory=list)
# Provenance of the lyrics, when present. One of "xml" | "sng" | "whisperx" |
# Provenance of the lyrics, when present. One of "xml" | "notechart" | "whisperx" |
# "user" — surfaces in the highway WS payload so the UI can render a badge
# (e.g. "auto-transcribed — may be inaccurate" for whisperx). The sloppak
# loader (lib/sloppak.py) defaults missing manifest keys to "xml" at load
@@ -374,7 +374,7 @@ def arrangement_string_count(arr: Arrangement) -> int:
Used by the server to emit ``stringCount`` in the song_info
WebSocket payload (slopsmith-plugin-3dhighway#7).
The RS XML schema always emits 6 ``<tuning>`` slots regardless
The arrangement XML schema always emits 6 ``<tuning>`` slots regardless
of instrument (bass charts populate `string0``string3` and pad
`string4`/`string5` with zeros), so ``len(arr.tuning)`` is not
a reliable signal. Two independent signals get combined:
@@ -390,10 +390,10 @@ def arrangement_string_count(arr: Arrangement) -> int:
defaults to 6. This catches the partial-string-usage case
where notes don't span all the instrument's strings.
A third signal ``len(arr.tuning)`` when it isn't the RS-XML
A third signal ``len(arr.tuning)`` when it isn't the arrangement XML
padded value of 6 folds in for sloppak / GP-imported sources
where the tuning array is explicitly trimmed (4 for bass, 5 for
5-string bass, 7 for 7-string guitar, etc.). RS-XML / PSARC
5-string bass, 7 for 7-string guitar, etc.). arrangement XML / archive
sources always emit length 6 regardless of instrument, so we
deliberately ignore that exact value to avoid mis-classifying
bass arrangements as guitar. ``< 6`` and ``> 6`` are both
@@ -403,13 +403,13 @@ def arrangement_string_count(arr: Arrangement) -> int:
where ``tuning_count`` is ``len(arr.tuning)`` when ``!= 6``,
else 0. Worked examples:
* RS XML 4-string bass, full usage (tuning len 6, notes 0..3)
* arrangement XML 4-string bass, full usage (tuning len 6, notes 0..3)
max(4, 4, 0) = 4
* RS XML 4-string bass, sparse usage (tuning len 6, notes 0..2)
* arrangement XML 4-string bass, sparse usage (tuning len 6, notes 0..2)
max(3, 4, 0) = 4
* RS XML 6-string lead, full usage (tuning len 6, notes 0..5)
* arrangement XML 6-string lead, full usage (tuning len 6, notes 0..5)
max(6, 6, 0) = 6
* RS XML 6-string lead, sparse usage (tuning len 6, notes 0..4)
* arrangement XML 6-string lead, sparse usage (tuning len 6, notes 0..4)
max(5, 6, 0) = 6
* Sloppak 5-string bass, sparse usage (tuning len 5, notes 0..3)
max(4, 4, 5) = 5
@@ -435,7 +435,7 @@ def arrangement_string_count(arr: Arrangement) -> int:
max_s = cn.string
notes_count = max_s + 1 if max_s >= 0 else 0
name_based = 4 if "bass" in arr.name.lower() else 6
# Tuning-length signal — only trustworthy when NOT the RS-XML
# Tuning-length signal — only trustworthy when NOT the arrangement XML
# padded value of 6. Length 4/5 indicates explicit bass / 5-string
# bass; length 7/8 indicates an extended-range guitar from GP.
tuning_len = len(arr.tuning)
@@ -444,7 +444,7 @@ def arrangement_string_count(arr: Arrangement) -> int:
def compute_smart_names(arrangements: list[Arrangement]) -> list[str | None]:
"""Compute smart display names for arrangements based on RS XML path flags.
"""Compute smart display names for arrangements based on arrangement XML path flags.
Returns a list parallel to `arrangements`. Each entry is a descriptive
name like "Lead", "Alt. Lead", "Bonus Rhythm", "Bass", or None for
@@ -454,14 +454,14 @@ def compute_smart_names(arrangements: list[Arrangement]) -> list[str | None]:
Path-type resolution (first match wins):
1. XML <arrangementProperties> flags (path_lead / path_rhythm / path_bass)
2. Name-based fallback when ALL three flags are zero keeps sloppak /
GP-imported sources and CDLC with unset flags working by mapping
GP-imported sources and custom song with unset flags working by mapping
"Lead" / "Rhythm" / "Bass" / "Combo" the matching path. Anything
outside that set (Vocals, ShowLights, ) None.
Naming rules per path type (Lead / Rhythm / Bass):
- Main group (bonusArr=False):
represent=1 "Lead" (or "Rhythm" / "Bass") the canonical
arrangement. If no entry has represent=1 (CDLC with all-zero
arrangement. If no entry has represent=1 (custom song with all-zero
flags), the first by represent-ascending order is promoted.
remaining (n_alts == 1) "Alt. Lead"
remaining (n_alts >= 2) "Alt. Lead 1", "Alt. Lead 2", ...
@@ -495,7 +495,7 @@ def compute_smart_names(arrangements: list[Arrangement]) -> list[str | None]:
def _resolve(a: Arrangement) -> tuple[str | None, bool]:
"""Return (path_attr, bonus_arr) for an arrangement, applying the
name-based fallback when XML flags are all zero. Defensive against
non-string names from hand-edited PSARCs / sloppak JSON."""
non-string names from hand-edited archives / sloppak JSON."""
if a.path_lead:
return "path_lead", bool(a.bonus_arr)
if a.path_rhythm:
@@ -527,7 +527,7 @@ def compute_smart_names(arrangements: list[Arrangement]) -> list[str | None]:
# represent=1 → standard arrangement ("Lead")
# represent=0 (or any value != 1) → alternate arrangement ("Alt. Lead")
#
# If no arrangement has represent=1 (e.g. CDLC defaults or all-zero
# If no arrangement has represent=1 (e.g. custom song defaults or all-zero
# flags with name fallback), fall back to treating the first by
# represent-ascending order as the standard so there is always a "Lead".
main_pairs = [(i, a) for i, a in type_arrs if not _resolved[i][1]]
@@ -573,7 +573,7 @@ def compute_smart_names(arrangements: list[Arrangement]) -> list[str | None]:
def _finite_float(value, default: float = 0.0) -> float:
"""Coerce ``value`` to a finite float, falling back to ``default``.
Malformed CDLC can put ``NaN``/``Infinity`` into float fields like RS2014
Malformed custom song can put ``NaN``/``Infinity`` into float fields like RS2014
``<centOffset>``; ``float()`` accepts those, but they serialize to the
invalid JSON tokens ``NaN``/``Infinity`` (both over the highway WebSocket
and into sloppak ``.json`` files), which breaks downstream parsing.
@@ -607,7 +607,7 @@ def arrangement_to_wire(arr: Arrangement) -> dict:
if arr.phrases:
out["phrases"] = [phrase_to_wire(p) for p in arr.phrases]
# `tones` is additive — only emitted when the source carried tone data
# (sloppaks converted from a PSARC). Absent on PSARC/loose-derived
# (sloppaks converted from a archive). Absent on archive/loose-derived
# Arrangements and old sloppaks; readers treat a missing key as
# "no tones".
if arr.tones:
@@ -681,7 +681,7 @@ def _int_optional(elem, attr, default=-1):
Use for fields that are merely metadata hints (right-hand fingering,
pick direction, etc.) where a malformed value from a third-party
Rocksmith XML emitter shouldn't abort the whole arrangement parse.
the source game XML emitter shouldn't abort the whole arrangement parse.
Required-field readers (`string`, `fret`, `chordId`, ) keep using
`_int` so a corrupted required attribute still fails fast at parse
@@ -708,7 +708,7 @@ def _bool(elem, attr):
def _hand_shape_arpeggio_flag(elem) -> bool:
"""Rocksmith / EOF may mark arpeggio on ``<handShape>`` (various casings)."""
"""the source game / EOF may mark arpeggio on ``<handShape>`` (various casings)."""
for attr in ("arpeggio", "Arpeggio", "arp", "Arp"):
if _bool(elem, attr):
return True
@@ -716,7 +716,7 @@ def _hand_shape_arpeggio_flag(elem) -> bool:
def _chord_template_arpeggio_flag(elem) -> bool:
"""Rocksmith commonly tags arpeggio templates in ``displayName`` via ``-arp``."""
"""the source game commonly tags arpeggio templates in ``displayName`` via ``-arp``."""
for attr in ("arpeggio", "Arpeggio", "arp", "Arp"):
if _bool(elem, attr):
return True
@@ -766,7 +766,7 @@ def _parse_note(n) -> Note:
def parse_arrangement(xml_path: str) -> Arrangement:
"""Parse a Rocksmith arrangement XML file."""
"""Parse a the source game arrangement XML file."""
tree = ET.parse(xml_path)
root = tree.getroot()
@@ -800,8 +800,8 @@ def parse_arrangement(xml_path: str) -> Arrangement:
except ValueError:
pass
# CentOffset — RS2014 pitch-shift field (cents). Present in all RS XML
# sources (PSARC, loose folders, GP-converted XML). Absent in very old
# CentOffset — RS2014 pitch-shift field (cents). Present in all arrangement XML
# sources (archive, loose folders, GP-converted XML). Absent in very old
# files; default 0.0.
cent_offset = 0.0
el = root.find("centOffset")
@@ -1049,7 +1049,7 @@ def parse_arrangement(xml_path: str) -> Arrangement:
# wrote at or below this phrase's max — these are what the
# master-difficulty slider selects between at render time.
# Tiers above max_diff exist in some XMLs (authoring leftovers)
# and are skipped to match Rocksmith's in-game behaviour.
# and are skipped to match the source game's in-game behaviour.
# Capture the extracted slices so the flat max-mastery merge
# below can reuse one of them.
phrase_levels: list[PhraseLevel] = []
@@ -1144,7 +1144,7 @@ def parse_arrangement(xml_path: str) -> Arrangement:
def _convert_sng_to_xml(extracted_dir: str):
"""No-op stub.
Historically this converted proprietary encrypted ``.sng`` arrangement
Historically this converted proprietary encrypted ``.notechart`` arrangement
files to XML via an external tool. That path has been removed: slopsmith
reads only its own ``.sloppak`` format and loose-folder/GP/MusicXML-derived
arrangement XML, and never decodes or decrypts proprietary archives. Kept
@@ -1156,7 +1156,7 @@ def _convert_sng_to_xml(extracted_dir: str):
def load_song(extracted_dir: str) -> Song:
"""Load a song from a directory of arrangement XML/JSON files."""
# Proprietary SNG→XML conversion has been removed; this is now a no-op.
# Proprietary note-chart→XML conversion has been removed; this is now a no-op.
_convert_sng_to_xml(extracted_dir)
song = Song()
@@ -1165,7 +1165,7 @@ def load_song(extracted_dir: str) -> Song:
# Build manifest lookups: xml_stem (lowercase) -> ArrangementName / path flags.
# The manifest JSON is the authoritative source for path flags (pathLead /
# pathRhythm / pathBass / bonusArr / represent) because the XML files bundled
# in official DLC PSARCs often have all path flags set to "0", while the
# in official DLC archives often have all path flags set to "0", while the
# manifest correctly reflects what the authoring tool wrote.
def _mprop_int(key: str, props: dict) -> int:
val = props.get(key, 0)
@@ -1268,7 +1268,7 @@ def load_song(extracted_dir: str) -> Song:
arrangement = parse_arrangement(str(xml_path))
# Override path flags with manifest values when available. The XML
# bundled inside official DLC PSARCs often has all flags as "0", while
# bundled inside official DLC archives often has all flags as "0", while
# the manifest JSON carries the correct values written by the DLC author.
manifest_flags = _manifest_path_flags.get(xml_path.stem.lower())
if manifest_flags:
+1 -1
View File
@@ -2,7 +2,7 @@
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
WhisperX fallback or shipped in the source archive), the /pitch endpoint
runs CREPE over the vocals stem and returns one MIDI note per supplied
timing token. The result lands in `<sloppak>/vocal_pitch.json` in the
shape the byrongamatos/slopsmith-plugin-lyrics-karaoke renderer
+1 -1
View File
@@ -58,7 +58,7 @@ def _convert_riff_wem(data: bytes, output_path: str) -> bool:
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
# For the source game custom song, 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
+8 -8
View File
@@ -1099,7 +1099,7 @@
return _bgBandsCache;
}
const BG_DEFAULTS = { style: 'particles', intensity: 0.5, reactive: true, palette: 'default', showFretOnNote: true, fretNumberGhostScope: 'rocksmith', cameraSmoothing: 0.5, zoomSmoothing: 0.5, tiltSmoothing: 0.5, cameraLockLow: false, cameraLockZoom: 0.5, cameraMode: 'lookahead', nutHeadstockVisible: true, tuningLabelsVisible: true, nutColor: '#f5f3f0', headstockColor: '#d4b48a', textSize: 0.5, vibrancy: 0.85, glow: 0.25, customImageDataUrl: '', customImageName: '', customVideoName: '', chordDiagramVisible: true, chordDiagramSize: 0.5, chordDiagramPosition: 'tl', fretColumnMarkerCadence: 1, projectionVisible: true, inlayLabelsVisible: false, sectionLabelsOnHighway: false, sectionHudVisible: false, sectionHudPosition: 'tr', sectionHudSize: 0.5, toneHudVisible: false, toneHudPosition: 'tl', toneHudSize: 0.5, fpsVisible: false, fretDividersVisible: true, slideArrowApproachVisible: true, slideArrowNeckVisible: true, slideArrowChainPreviewVisible: true };
const BG_DEFAULTS = { style: 'particles', intensity: 0.5, reactive: true, palette: 'default', showFretOnNote: true, fretNumberGhostScope: 'chords', cameraSmoothing: 0.5, zoomSmoothing: 0.5, tiltSmoothing: 0.5, cameraLockLow: false, cameraLockZoom: 0.5, cameraMode: 'lookahead', nutHeadstockVisible: true, tuningLabelsVisible: true, nutColor: '#f5f3f0', headstockColor: '#d4b48a', textSize: 0.5, vibrancy: 0.85, glow: 0.25, customImageDataUrl: '', customImageName: '', customVideoName: '', chordDiagramVisible: true, chordDiagramSize: 0.5, chordDiagramPosition: 'tl', fretColumnMarkerCadence: 1, projectionVisible: true, inlayLabelsVisible: false, sectionLabelsOnHighway: false, sectionHudVisible: false, sectionHudPosition: 'tr', sectionHudSize: 0.5, toneHudVisible: false, toneHudPosition: 'tl', toneHudSize: 0.5, fpsVisible: false, fretDividersVisible: true, slideArrowApproachVisible: true, slideArrowNeckVisible: true, slideArrowChainPreviewVisible: true };
// User-selectable, persistable bg styles — must mirror settings.html's
// VALID_STYLES. 'venue' is deliberately NOT here: it is an internal effective
// style reached only via _venueSceneOverride (the viz-picker Venue flow), so
@@ -1325,7 +1325,7 @@
},
);
}
const FRET_NUMBER_GHOST_SCOPE_IDS = ['rocksmith', 'all'];
const FRET_NUMBER_GHOST_SCOPE_IDS = ['chords', 'all'];
function _bgPanelKey(canvas) {
const ss = window.slopsmithSplitscreen;
@@ -2717,7 +2717,7 @@
// Fret digits on the board ghost (hollow preview at Z=0), not on
// flying note bodies — see fretNumberGhostScope for chord-hand vs all.
let showFretOnNote = false;
let fretNumberGhostScope = 'rocksmith';
let fretNumberGhostScope = 'chords';
// Camera-X smoothing dial (issue #34). 0 = twitchy (track every
// upcoming fret), 1 = calm (ignore small intra-cluster shifts).
// Cached here and refreshed via the bg listener to avoid a
@@ -7546,7 +7546,7 @@
const tol = 0.028;
/**
* Suppress a synth chord box when a real chord with the **same trimmed
* display name** played within this window RS CDLC commonly authors
* display name** played within this window Custom songs commonly authors
* several ``<chordTemplate>`` rows that share a display name (with
* trailing-whitespace IDs) for fingering variants. The follow-up
* hand-shape with no chord row is a fingering hint, not a new strum
@@ -7558,7 +7558,7 @@
const trimmedTemplateName = (cid) => {
if (cid == null || !chordTemplates) return '';
const tmpl = chordTemplates[cid] ?? chordTemplates[Number(cid)];
// CDLC commonly authors several <chordTemplate> rows that share
// custom songs commonly authors several <chordTemplate> rows that share
// a displayName for fingering variants; the suppression
// heuristic in the surrounding code dedupes on the *label*,
// not the underlying name, so go through chordTemplateLabel.
@@ -12363,13 +12363,13 @@
const ghostFretOk = showFretOnNote && (
arpGhostActive ||
fretNumberGhostScope === 'all' ||
(fretNumberGhostScope === 'rocksmith' && fromChord)
(fretNumberGhostScope === 'chords' && fromChord)
);
if (ghostFretOk && pGhostFretLbl) {
// chord-hand style → show finger number (14) from the chord
// template; fall back to fret number when no finger data exists
// (GP imports, open strings, non-chord notes).
const ghostFretDisplay = fromChord && fretNumberGhostScope === 'rocksmith'
const ghostFretDisplay = fromChord && fretNumberGhostScope === 'chords'
? (_templateFingerForChordGhost(chordId, n.s) ?? _templateFretForChordGhost(chordId, n.s, n.f))
: fromChord
? _templateFretForChordGhost(chordId, n.s, n.f)
@@ -12389,7 +12389,7 @@
// frame (projFactor), instead of popping in at full alpha.
ghostFretLblAlpha = projFactor;
}
const _ghostFretForScale = fromChord && fretNumberGhostScope === 'rocksmith'
const _ghostFretForScale = fromChord && fretNumberGhostScope === 'chords'
? n.f
: ghostFretDisplay;
drawGhostFretLabel(x, y, projRim, ghostFretDisplay, ghostFretLblAlpha, projGrowScale, _ghostFretForScale);
+4 -4
View File
@@ -430,7 +430,7 @@
<div id="h3d-fret-ghost-scope-wrap" class="ml-6 mt-2 space-y-2 border-l border-gray-700 pl-3">
<p class="text-xs font-medium text-gray-400">Where to show</p>
<label class="flex items-center gap-2 text-sm text-gray-300 cursor-pointer">
<input type="radio" name="h3d-fret-ghost-scope" id="h3d-fret-ghost-scope-rocksmith" value="rocksmith"
<input type="radio" name="h3d-fret-ghost-scope" id="h3d-fret-ghost-scope-chords" value="chords"
class="accent-accent">
In chords &amp; arpeggios (chord-hand events only)
</label>
@@ -712,14 +712,14 @@
no-selection / NaN state. -->
<script>
(function () {
const DEFAULTS = { style: 'particles', intensity: 0.5, reactive: true, palette: 'default', showFretOnNote: true, fretNumberGhostScope: 'rocksmith', cameraSmoothing: 0.5, zoomSmoothing: 0.5, tiltSmoothing: 0.5, cameraLockLow: false, cameraLockZoom: 0.5, cameraMode: 'lookahead', nutHeadstockVisible: true, tuningLabelsVisible: true, nutColor: '#f5f3f0', headstockColor: '#d4b48a', textSize: 0.5, vibrancy: 0.85, glow: 0.25, customImageDataUrl: '', customImageName: '', customVideoName: '', chordDiagramVisible: true, chordDiagramSize: 0.5, chordDiagramPosition: 'tl', fretColumnMarkerCadence: 1, projectionVisible: true, inlayLabelsVisible: false, sectionLabelsOnHighway: false, sectionHudVisible: false, sectionHudPosition: 'tr', sectionHudSize: 0.5, toneHudVisible: false, toneHudPosition: 'tl', toneHudSize: 0.5, fpsVisible: false, fretDividersVisible: true, slideArrowApproachVisible: true, slideArrowNeckVisible: true, slideArrowChainPreviewVisible: true };
const DEFAULTS = { style: 'particles', intensity: 0.5, reactive: true, palette: 'default', showFretOnNote: true, fretNumberGhostScope: 'chords', cameraSmoothing: 0.5, zoomSmoothing: 0.5, tiltSmoothing: 0.5, cameraLockLow: false, cameraLockZoom: 0.5, cameraMode: 'lookahead', nutHeadstockVisible: true, tuningLabelsVisible: true, nutColor: '#f5f3f0', headstockColor: '#d4b48a', textSize: 0.5, vibrancy: 0.85, glow: 0.25, customImageDataUrl: '', customImageName: '', customVideoName: '', chordDiagramVisible: true, chordDiagramSize: 0.5, chordDiagramPosition: 'tl', fretColumnMarkerCadence: 1, projectionVisible: true, inlayLabelsVisible: false, sectionLabelsOnHighway: false, sectionHudVisible: false, sectionHudPosition: 'tr', sectionHudSize: 0.5, toneHudVisible: false, toneHudPosition: 'tl', toneHudSize: 0.5, fpsVisible: false, fretDividersVisible: true, slideArrowApproachVisible: true, slideArrowNeckVisible: true, slideArrowChainPreviewVisible: true };
const VALID_STYLES = new Set(['off', 'particles', 'silhouettes', 'lights', 'geometric', 'image', 'video']);
const VALID_CAMERA_MODES = new Set(['steady', 'lookahead']);
// Chord diagram is top-only (bl/br removed).
const VALID_CHORD_DIAG_POSITIONS = new Set(['tl', 'tr']);
// Section HUD still supports all four corners independently.
const VALID_SECTION_HUD_POSITIONS = new Set(['tl', 'tr', 'bl', 'br']);
const VALID_FRET_GHOST_SCOPES = new Set(['rocksmith', 'all']);
const VALID_FRET_GHOST_SCOPES = new Set(['chords', 'all']);
// Custom images are persisted as base64 data URLs in
// localStorage. base64 expands the byte count by ~4/3, and
// localStorage values are stored as UTF-16 (two bytes per
@@ -1015,7 +1015,7 @@
if (lbl) lbl.textContent = intensity.toFixed(2);
if (rea) rea.checked = reactive;
if (fon) fon.checked = showFretOnNote;
const fgsRock = document.getElementById('h3d-fret-ghost-scope-rocksmith');
const fgsRock = document.getElementById('h3d-fret-ghost-scope-chords');
const fgsAll = document.getElementById('h3d-fret-ghost-scope-all');
const fgsWrap = document.getElementById('h3d-fret-ghost-scope-wrap');
if (fretNumberGhostScope === 'all') { if (fgsAll) fgsAll.checked = true; }
+65 -65
View File
@@ -302,7 +302,7 @@ def _ensure_smart_names(arrangements: list[dict]) -> list[dict]:
In the meantime this function provides a best-effort on-the-fly computation.
However, when multiple arrangements share the same name (e.g. two "Combo"
tracks in a PSARC that bundles all path flags as zero), name-based inference
tracks in a archive that bundles all path flags as zero), name-based inference
cannot distinguish Lead from Rhythm so we emit ``smart_name: null`` and
let the UI fall back to the legacy name until the background rescan corrects
the row. Arrangements that already have the field are never modified.
@@ -374,7 +374,7 @@ class MetadataDB:
tuning TEXT,
arrangements TEXT,
has_lyrics INTEGER DEFAULT 0,
format TEXT DEFAULT 'psarc',
format TEXT DEFAULT 'archive',
stem_count INTEGER DEFAULT 0,
stem_ids TEXT DEFAULT '[]',
tuning_name TEXT DEFAULT '',
@@ -384,7 +384,7 @@ class MetadataDB:
""")
# Idempotent migrations for installs that predate each column.
for ddl in (
"ALTER TABLE songs ADD COLUMN format TEXT DEFAULT 'psarc'",
"ALTER TABLE songs ADD COLUMN format TEXT DEFAULT 'archive'",
"ALTER TABLE songs ADD COLUMN stem_count INTEGER DEFAULT 0",
# slopsmith#129: per-stem filter needs the id list, not just count.
"ALTER TABLE songs ADD COLUMN stem_ids TEXT DEFAULT '[]'",
@@ -1411,7 +1411,7 @@ class MetadataDB:
"year": row[5], "duration": row[6], "tuning": row[7],
"arrangements": json.loads(row[8]) if row[8] else [],
"has_lyrics": bool(row[9]),
"format": row[10] or "psarc",
"format": row[10] or "archive",
"stem_count": int(row[11] or 0),
"stem_ids": json.loads(row[12]) if row[12] else [],
"tuning_name": row[13] or "",
@@ -1431,7 +1431,7 @@ class MetadataDB:
meta.get("album", ""), meta.get("year", ""), meta.get("duration", 0),
meta.get("tuning", ""), json.dumps(meta.get("arrangements", [])),
1 if meta.get("has_lyrics") else 0,
meta.get("format", "psarc"),
meta.get("format", "archive"),
int(meta.get("stem_count", 0) or 0),
json.dumps(meta.get("stem_ids", []) or []),
meta.get("tuning_name", "") or "",
@@ -1706,7 +1706,7 @@ class MetadataDB:
# magnitude tier we break ties by signed key ASC so the
# negative (down-tuned) variant comes before the positive
# (up-tuned) one — Eb Standard before F Standard, matching
# how Rocksmith groups its tuning list. Final tiebreak by
# how the app groups its tuning list. Final tiebreak by
# name keeps the order fully deterministic.
#
# Leading term pushes pre-migration / unscanned rows to
@@ -1760,7 +1760,7 @@ class MetadataDB:
"year": r[4], "duration": r[5], "tuning": r[6],
"arrangements": _ensure_smart_names(json.loads(r[7]) if r[7] else []),
"has_lyrics": bool(r[8]), "mtime": r[9],
"format": r[10] or "psarc",
"format": r[10] or "archive",
"stem_count": int(r[11] or 0),
"stem_ids": json.loads(r[12]) if r[12] else [],
"tuning_name": r[13] or "",
@@ -1841,7 +1841,7 @@ class MetadataDB:
"year": r[4], "duration": r[5], "tuning": r[6],
"arrangements": _ensure_smart_names(json.loads(r[7]) if r[7] else []),
"has_lyrics": bool(r[8]),
"format": r[9] or "psarc",
"format": r[9] or "archive",
"stem_count": int(r[10] or 0),
"stem_ids": json.loads(r[11]) if r[11] else [],
"tuning_name": r[12] or "",
@@ -2667,7 +2667,7 @@ def _resolve_dlc_path(dlc: Path, filename: str) -> Path | None:
"""Resolve `filename` under DLC_DIR and refuse anything that escapes.
`filename` arrives from `:path` route params and can contain `..`
segments. The Sloppak and PSARC paths happen to fail safely later
segments. The Sloppak and archive paths happen to fail safely later
because their loaders raise on missing/invalid files, but loose-
folder format detection (`is_loose_song`) globs and parses XML on
disk first, which lets a crafted path trigger filesystem reads
@@ -2777,7 +2777,7 @@ def _stat_for_cache(f: Path) -> tuple[float, int]:
For loose-folder directories the directory's own mtime does not
change when inner files (audio.wem / *.xml / manifest.json) are
edited in place, so we aggregate over the contents. PSARCs and
edited in place, so we aggregate over the contents. archives and
sloppak files (zip form) use their own stat directly. Sloppak
*directories* are aggregated too: the editor and the library Edit
button rewrite their `manifest.yaml` / `arrangements/*.json` in
@@ -2793,7 +2793,7 @@ def _stat_for_cache(f: Path) -> tuple[float, int]:
# every cache lookup.
if f.is_dir():
# Skip symlinks pointing outside the song folder — without this
# an attacker-crafted CDLC could keep a stale cache hot by
# an attacker-crafted custom song could keep a stale cache hot by
# bumping the mtime of an unrelated file via a symlink.
root = f.resolve()
def _in_folder(p: Path) -> bool:
@@ -3344,7 +3344,7 @@ async def startup_events():
"get_dlc_dir": _get_dlc_dir,
# Pass the DLC-root resolver (not its result) so loose-folder
# metadata keeps its dlc-relative artist/album inference while the
# lookup stays lazy — PSARC/sloppak extraction never reads config.
# lookup stays lazy — archive/sloppak extraction never reads config.
# Plugins still call this with just a path.
"extract_meta": lambda p: _extract_meta_for_file(p, _get_dlc_dir),
"meta_db": meta_db,
@@ -3888,7 +3888,7 @@ def _invalidate_song_caches(cache_key: str) -> None:
"""Drop filename-keyed derived caches when a song at ``cache_key`` is
replaced or removed. Sloppak's ``_source_cache`` and loose-folder audio
IDs self-invalidate via stat checks; the caches purged here do not."""
# In-memory PSARC extraction cache (filename → tmp dir + Song).
# In-memory archive extraction cache (filename → tmp dir + Song).
with _extract_cache_lock:
stale = _extract_cache.pop(cache_key, None)
if stale:
@@ -3905,7 +3905,7 @@ def _invalidate_song_caches(cache_key: str) -> None:
except OSError:
log.debug("failed to evict art cache for %s", cache_key, exc_info=True)
# PSARC audio cache — audio_id is `Path(filename).stem.replace(" ", "_")`
# archive audio cache — audio_id is `Path(filename).stem.replace(" ", "_")`
# without any stat digest, so a same-named replacement would serve the
# previous file's converted audio. Loose-folder ids include a wem stat
# digest and self-heal; sloppak streams stems directly and uses no
@@ -4271,7 +4271,7 @@ def _library_filter_args(q: str = "", favorites: int = 0, format: str = "",
arrangements_has: str = "", arrangements_lacks: str = "",
stems_has: str = "", stems_lacks: str = "",
has_lyrics: str = "", tunings: str = "") -> dict:
fmt = format if format in ("psarc", "sloppak", "loose") else ""
fmt = format if format in ("archive", "sloppak", "loose") else ""
return {
"q": q,
"favorites_only": bool(favorites),
@@ -6312,17 +6312,17 @@ async def get_song_art(filename: str):
if not dlc:
return JSONResponse({"error": "not configured"}, 404)
psarc_path = _resolve_dlc_path(dlc, filename)
if psarc_path is None:
song_path = _resolve_dlc_path(dlc, filename)
if song_path is None:
return JSONResponse({"error": "forbidden"}, 403)
if not psarc_path.exists():
if not song_path.exists():
return JSONResponse({"error": "not found"}, 404)
# Sloppak path: pull cover.jpg from the source dir (manifest-declared or default).
if sloppak_mod.is_sloppak(psarc_path):
if sloppak_mod.is_sloppak(song_path):
try:
src = sloppak_mod.resolve_source_dir(filename, dlc, SLOPPAK_CACHE_DIR)
manifest = sloppak_mod.load_manifest(psarc_path)
manifest = sloppak_mod.load_manifest(song_path)
cover_rel = str(manifest.get("cover") or "cover.jpg")
cover_path = (src / cover_rel).resolve()
# Prevent escape and fall back to default name if missing.
@@ -6341,16 +6341,16 @@ async def get_song_art(filename: str):
return JSONResponse({"error": "no art"}, 404)
# Loose folder path: serve art file directly.
# psarc_path is already validated against DLC_DIR by _resolve_dlc_path.
if loosefolder_mod.is_loose_song(psarc_path):
art_path = loosefolder_mod.find_art(psarc_path)
# song_path is already validated against DLC_DIR by _resolve_dlc_path.
if loosefolder_mod.is_loose_song(song_path):
art_path = loosefolder_mod.find_art(song_path)
if art_path:
# Re-resolve in case the matched file is a symlink — a crafted
# CDLC could put `album_art.jpg` as a symlink to anywhere on
# custom song could put `album_art.jpg` as a symlink to anywhere on
# disk. Insist the final target stays inside the song folder.
art_resolved = art_path.resolve()
try:
art_resolved.relative_to(psarc_path)
art_resolved.relative_to(song_path)
except ValueError:
return JSONResponse({"error": "forbidden"}, 403)
if art_resolved.is_file():
@@ -6377,7 +6377,7 @@ def update_song_meta(filename: str, data: dict):
"""Update song metadata, persisting it back into the underlying file.
The library scanner re-derives title/artist/album/year from the file
(PSARC manifest Attributes / sloppak manifest.yaml) on every full rescan,
(archive manifest Attributes / sloppak manifest.yaml) on every full rescan,
so a DB-only edit reverts. We write the edit into the file first, then
refresh the cache row (including mtime/size) to match. Loose-folder and
unwritable songs fall back to a DB-only update (which still survives an
@@ -6417,7 +6417,7 @@ def update_song_meta(filename: str, data: dict):
# Hold _song_io_lock across the existence check and file write so a
# concurrent delete cannot remove the file between our check and the
# repack's atomic replace, and so a concurrent upload cannot be clobbered
# by our atomic rename. PSARC repack is slow — the lock is held longer
# by our atomic rename. archive repack is slow — the lock is held longer
# than a simple upload/delete, but correctness requires serialisation.
persisted = False
with _song_io_lock:
@@ -6497,29 +6497,29 @@ async def get_song_info(filename: str):
if not dlc:
return JSONResponse({"error": "DLC folder not configured"}, 404)
psarc_path = _resolve_dlc_path(dlc, filename)
if psarc_path is None:
song_path = _resolve_dlc_path(dlc, filename)
if song_path is None:
return JSONResponse({"error": "forbidden"}, 403)
if not psarc_path.exists():
if not song_path.exists():
return JSONResponse({"error": "File not found"}, 404)
# Canonicalise the cache key against the resolved path so two URL
# forms of the same physical file (e.g. `Artist/song.psarc` vs
# `Artist/../Artist/song.psarc`) converge on a single row instead
# forms of the same physical file (e.g. `Artist/song.sloppak` vs
# `Artist/../Artist/song.sloppak`) converge on a single row instead
# of fragmenting / shadowing each other in meta_db.
try:
cache_key = psarc_path.relative_to(dlc.resolve()).as_posix()
cache_key = song_path.relative_to(dlc.resolve()).as_posix()
except ValueError:
cache_key = filename
mtime, size = _stat_for_cache(psarc_path)
mtime, size = _stat_for_cache(song_path)
cached = meta_db.get(cache_key, mtime, size)
if cached:
return cached
# Extract in thread pool
def _extract():
meta = _extract_meta_for_file(psarc_path, dlc)
meta = _extract_meta_for_file(song_path, dlc)
meta_db.put(cache_key, mtime, size, meta)
return meta
@@ -6608,23 +6608,23 @@ async def highway_ws(websocket: WebSocket, filename: str, arrangement: int = -1,
await websocket.close()
return
psarc_path = _resolve_dlc_path(dlc, filename)
if psarc_path is None:
song_path = _resolve_dlc_path(dlc, filename)
if song_path is None:
await websocket.send_json({"error": "forbidden"})
await websocket.close()
return
if not psarc_path.exists():
if not song_path.exists():
await websocket.send_json({"error": "File not found"})
await websocket.close()
return
is_slop = sloppak_mod.is_sloppak(psarc_path)
is_slop = sloppak_mod.is_sloppak(song_path)
# Sloppak wins precedence: `_extract_meta_for_file()` and the
# background scanner both treat a `.sloppak` directory as sloppak
# even if it happens to contain WEM/XML. Gate is_loose on that
# so the loose-only branches (audio_id, offset, audio conversion)
# don't fire for sloppak bundles.
is_loose = (not is_slop) and loosefolder_mod.is_loose_song(psarc_path)
is_loose = (not is_slop) and loosefolder_mod.is_loose_song(song_path)
tmp = None
owns_tmp = False
loaded_slop = None # LoadedSloppak when is_slop
@@ -6658,11 +6658,11 @@ async def highway_ws(websocket: WebSocket, filename: str, arrangement: int = -1,
elif is_loose:
# Loose folders need no extraction — load_song reads the
# arrangement XMLs directly from the flat directory.
# psarc_path is already DLC-containment-validated by
# song_path is already DLC-containment-validated by
# _resolve_dlc_path, so audio conversion below can use
# it directly.
song = await loop.run_in_executor(None, lambda: load_song(str(psarc_path)))
tmp = str(psarc_path)
song = await loop.run_in_executor(None, lambda: load_song(str(song_path)))
tmp = str(song_path)
owns_tmp = False
else:
# Only open formats (.sloppak bundles and loose folders) are
@@ -6735,13 +6735,13 @@ async def highway_ws(websocket: WebSocket, filename: str, arrangement: int = -1,
# collide (a `/`→`__` escape would collapse `a/b__c` and
# `a__b/c`);
# - editing audio.wem in place invalidates the cached
# converted file (without this, in-place CDLC iteration
# converted file (without this, in-place custom song iteration
# keeps serving the stale mp3/ogg from the cache).
try:
canonical = psarc_path.relative_to(dlc.resolve()).as_posix()
canonical = song_path.relative_to(dlc.resolve()).as_posix()
except ValueError:
canonical = filename
wem_for_id = loosefolder_mod.find_audio(psarc_path)
wem_for_id = loosefolder_mod.find_audio(song_path)
try:
wem_stat = wem_for_id.stat() if wem_for_id else None
except OSError:
@@ -6782,7 +6782,7 @@ async def highway_ws(websocket: WebSocket, filename: str, arrangement: int = -1,
def _evict_audio_cache():
# Keep AUDIO_CACHE_DIR bounded so a library full of loose
# folders / many PSARCs doesn't fill disk. LRU on st_atime
# folders / many archives doesn't fill disk. LRU on st_atime
# so songs the user keeps replaying stay warm. Best-effort:
# log at debug so permission / disk errors are diagnosable
# without aborting the request.
@@ -6798,15 +6798,15 @@ async def highway_ws(websocket: WebSocket, filename: str, arrangement: int = -1,
if not audio_url and is_loose:
await websocket.send_json({"type": "loading", "stage": "Converting audio..."})
wem_path = loosefolder_mod.find_audio(psarc_path)
wem_path = loosefolder_mod.find_audio(song_path)
if wem_path:
# Re-resolve to defeat a symlinked audio.wem that points
# outside the song folder — without this, a crafted
# CDLC could turn convert_wem into an arbitrary-file
# custom song could turn convert_wem into an arbitrary-file
# decode/read primitive.
wem_resolved = wem_path.resolve()
try:
wem_resolved.relative_to(psarc_path)
wem_resolved.relative_to(song_path)
except ValueError:
audio_error = "Audio file escapes the loose folder."
wem_resolved = None
@@ -6838,7 +6838,7 @@ async def highway_ws(websocket: WebSocket, filename: str, arrangement: int = -1,
await websocket.send_json({"type": "loading", "stage": "Converting audio..."})
wem_files = find_wem_files(tmp)
if not wem_files:
audio_error = "No WEM audio files were found inside this PSARC."
audio_error = "No WEM audio files were found inside this archive."
else:
try:
audio_path = convert_wem(wem_files[0], os.path.join(tmp, "audio"))
@@ -6880,7 +6880,7 @@ async def highway_ws(websocket: WebSocket, filename: str, arrangement: int = -1,
"audio_error": audio_error,
"tuning": arr.tuning,
# Number of strings on the active arrangement
# (slopsmith-plugin-3dhighway#7). RS XML / PSARC sources
# (slopsmith-plugin-3dhighway#7). arrangement XML / archive sources
# always emit `tuning` as length 6 with zero-padding for
# unused string slots, so `len(arr.tuning)` is unreliable
# there; sloppak / GP-imported sources may instead carry
@@ -6899,7 +6899,7 @@ async def highway_ws(websocket: WebSocket, filename: str, arrangement: int = -1,
# would serialise as the literal `NaN` token (invalid JSON)
# and break the frontend's song_info parsing.
"offset": _sanitized_song_offset(song) if is_loose else 0.0,
"format": "sloppak" if is_slop else ("loose" if is_loose else "psarc"),
"format": "sloppak" if is_slop else ("loose" if is_loose else "archive"),
"stems": stems_payload,
# Surface a drum_tab presence flag so the visualization picker
# can auto-activate the drums plugin even when the chosen
@@ -6991,7 +6991,7 @@ async def highway_ws(websocket: WebSocket, filename: str, arrangement: int = -1,
# Send chord templates. Include `fingers` alongside `name` /
# `frets` so plugin overlays consuming highway.getChordTemplates()
# can render full chord boxes (Rocksmith-style fingering
# can render full chord boxes (chord-style fingering
# diagrams), not just chord names. Each fingering entry is
# per-string: -1 = unused, 0 = open string, n > 0 = finger
# number. RS XML sources populate real values; GP imports
@@ -7005,7 +7005,7 @@ async def highway_ws(websocket: WebSocket, filename: str, arrangement: int = -1,
lyrics_source = ""
# Loose folders are flat — only inspect direct children so a
# nested backup/export directory inside the song folder can't
# override the active arrangement's lyrics / tone. PSARCs are
# override the active arrangement's lyrics / tone. archives are
# unpacked into nested tmp dirs, so they keep recursive rglob.
# Sloppak skips XML lookups entirely below but the json loop
# is unconditional, so define both walkers up front.
@@ -7044,7 +7044,7 @@ async def highway_ws(websocket: WebSocket, filename: str, arrangement: int = -1,
payload["source"] = lyrics_source
await websocket.send_json(payload)
# Send tone changes. PSARC and loose folders carry tone data in
# Send tone changes. archive and loose folders carry tone data in
# arrangement XMLs; a sloppak ships it inline in its arrangement JSON
# (Arrangement.tones, populated by the converter), so read it straight
# off `arr` rather than walking for XML that doesn't exist.
@@ -7124,7 +7124,7 @@ async def highway_ws(websocket: WebSocket, filename: str, arrangement: int = -1,
break
# Parse XMLs. Prefer the XML paired with the matched manifest
# (identical stem). When no manifest matched (loose/CDLC), fall
# (identical stem). When no manifest matched (loose/custom song), fall
# back to a name-token match — but rank by how few *extra* stem
# tokens a candidate carries, mirroring lib/tones.py: {"lead"} is
# a subset of both `song_lead` and `song_bonus_lead`, so a plain
@@ -7133,8 +7133,8 @@ async def highway_ws(websocket: WebSocket, filename: str, arrangement: int = -1,
# `_token_ambiguous` then suppresses the rank-2 best-effort
# fallback, so no arrangement's tone timeline is guessed at
# (matching lib/tones.py, which attaches nothing on a tie).
# Shared tokenizer with lib/tones.py so PSARC playback and
# PSARC→sloppak conversion select arrangement XMLs identically.
# Shared tokenizer with lib/tones.py so archive playback and
# archive→sloppak conversion select arrangement XMLs identically.
from tones import tokens as _name_tokens
_arr_tokens = _name_tokens(arr.name) if arr else set()
_token_pick = None
@@ -7165,13 +7165,13 @@ async def highway_ws(websocket: WebSocket, filename: str, arrangement: int = -1,
# that XML — a rank-2 fallback XML belongs to another
# arrangement. A token tie is likewise suppressed (guessing among
# equally-named XMLs would be wrong). Only a genuine no-match
# case (loose/CDLC with no usable manifest and no name overlap)
# case (loose/custom song with no usable manifest and no name overlap)
# keeps the long-standing rank-2 best-effort source.
_suppress_fallback = (
matched_stem is not None or _token_pick is not None or _token_ambiguous
)
sent_tones = False
psarc_base = "" # <tonebase> of the preferred arrangement XML
tone_base = "" # <tonebase> of the preferred arrangement XML
for xml_path in sorted_xml:
try:
root = ET.parse(xml_path).getroot()
@@ -7185,13 +7185,13 @@ async def highway_ws(websocket: WebSocket, filename: str, arrangement: int = -1,
# rank-2 XMLs whenever a match was confirmed; in the
# genuine no-match case rank-2 IS the best-effort source,
# so its <tonebase> is equally valid for a base-only song.
if not psarc_base:
if not tone_base:
_tb = root.find("tonebase")
if _tb is not None and _tb.text:
# Strip whitespace from pretty-printed XML so the
# base name matches the sloppak path, which also
# strips it.
psarc_base = _tb.text.strip()
tone_base = _tb.text.strip()
tones_el = root.find("tones")
if tones_el is not None:
# Accumulate into a per-XML list — if this file
@@ -7254,14 +7254,14 @@ async def highway_ws(websocket: WebSocket, filename: str, arrangement: int = -1,
# Base-only fallback: a single-tone arrangement has a <tonebase>
# but no <tones> markers — still surface the initial tone so the
# highway can show it (parity with the sloppak path above).
# `psarc_base` is the <tonebase> of whichever XML the loop
# `tone_base` is the <tonebase> of whichever XML the loop
# accepted: the confirmed-match XML, or — in the genuine no-match
# case — the best-effort rank-2 XML. `arr_tone_names` holds the
# selected arrangement's own Tone_A..D. An ambiguous arrangement
# (token tie) accepts no XML and has no manifest map, so it
# correctly sends nothing rather than a guessed tone.
if not sent_tones:
base_name = psarc_base
base_name = tone_base
if not base_name:
base_name = arr_tone_names.get(0, "")
if base_name:
+2 -2
View File
@@ -1254,7 +1254,7 @@ function _libraryDisplayFilename(song, providerId) {
function _librarySongTitle(song, providerId) {
const fallback = _libraryDisplayFilename(song, providerId);
return song.title || fallback.replace(/_p\.psarc$/i, '').replace(/_/g, ' ');
return song.title || fallback.replace(/_p\.archive$/i, '').replace(/_/g, ' ');
}
function _librarySongArtUrl(song, providerId) {
@@ -9571,7 +9571,7 @@ async function pollScanStatus() {
if (bar) bar.style.width = pct + '%';
if (prog) prog.textContent = `${data.done} / ${data.total} (${pct}%)`;
if (file) {
const name = (data.current || '').replace(/_p\.psarc$/i, '').replace(/_/g, ' ');
const name = (data.current || '').replace(/_p\.archive$/i, '').replace(/_/g, ' ');
file.textContent = name || (data.stage === 'listing' ? 'Listing DLC folder...' : 'Processing...');
}
if (firstNote) firstNote.classList.toggle('hidden', !data.is_first_scan);
+1 -1
View File
@@ -102,7 +102,7 @@
.replace(/https?:\/\/[^\s?#]+[^\s]*/gi, '[url]')
.replace(/file:\/\/[^\s]+/gi, '[path]')
.replace(/\b(token|secret|password|api[_-]?key|key)=([^\s&]+)/gi, '$1=[redacted]')
.replace(/\b[^\s]+\.(psarc|sloppak|wem|ogg|mp3|wav|flac|nam|vst3|component|dll|json|db)\b/gi, '[file]')
.replace(/\b[^\s]+\.(archive|sloppak|wem|ogg|mp3|wav|flac|nam|vst3|component|dll|json|db)\b/gi, '[file]')
.replace(/\b(raw[-_ ]?audio|audio[-_ ]?buffer|sample[s]?|waveform[s]?|recording[s]?|native[-_ ]?preset|model[-_ ]?file|ir[-_ ]?file|vst[-_ ]?state)\b/gi, '[private]');
}
+3 -3
View File
@@ -3259,7 +3259,7 @@ function createHighway() {
case 'chord_templates': chordTemplates = msg.data; break;
case 'lyrics':
lyrics = msg.data;
// Provenance: "xml" | "sng" | "whisperx" | "user".
// Provenance: "xml" | "notechart" | "whisperx" | "user".
// Surfaced via the renderer bundle so visualization
// plugins can render an "auto-transcribed" badge
// (or any other source-dependent UI) without
@@ -3494,7 +3494,7 @@ function createHighway() {
// - name: chord name string ("Em", "Cmaj7", …)
// - fingers: per-string finger numbers (length matches
// the tuning's string count; -1 = unused, 0 =
// open string, n > 0 = finger number). RS XML
// open string, n > 0 = finger number). arrangement XML
// sources populate real values; GP imports
// currently emit all -1.
// - frets: per-string fret numbers, same indexing.
@@ -3608,7 +3608,7 @@ function createHighway() {
getLyricsVisible() { return showLyrics; },
// Provenance of the active lyric set. See `lyricsSource` declaration
// for the full enum. Plugins consume this to badge auto-transcribed
// (whisperx) lyrics differently from authored (xml/sng/user) ones.
// (whisperx) lyrics differently from authored (xml/notechart/user) ones.
getLyricsSource() { return lyricsSource; },
setLyricsVisible(v) {
showLyrics = !!v;
@@ -70,7 +70,7 @@ test('player arrangement pin saves the selected arrangement name', async ({ page
select.value = '2';
// @ts-ignore - browser app namespace
window.slopsmith.currentSong = {
filename: 'demo.psarc',
filename: 'demo.archive',
arrangement: 'Rhythm',
arrangementIndex: 2,
arrangements,
@@ -138,7 +138,7 @@ test('player arrangement pin preserves non-built-in arrangement names', async ({
select.value = '2';
// @ts-ignore - browser app namespace
window.slopsmith.currentSong = {
filename: 'demo.psarc',
filename: 'demo.archive',
arrangement: 'Rhythm',
arrangementIndex: 2,
arrangements,
@@ -192,7 +192,7 @@ test('failed settings save does not mark arrangement default as persisted', asyn
select.value = '2';
// @ts-ignore - browser app namespace
window.slopsmith.currentSong = {
filename: 'demo.psarc',
filename: 'demo.archive',
arrangement: 'Rhythm',
arrangementIndex: 2,
arrangements,
+2 -2
View File
@@ -817,7 +817,7 @@ test('mapping helpers call core mapping API with provider-tagged payloads', asyn
const saved = await window.slopsmith.audioEffects.upsertMapping({
song_key: 'settings-v1-song',
filename: 'Artist - Song_p.psarc',
filename: 'Artist - Song_p.archive',
tone_key: 'Dist',
provider_id: 'rig-builder',
provider_ref: 'chain:99',
@@ -880,7 +880,7 @@ test('bridge hits are safe and diagnosable', async () => {
routeKey: 'desktop-main',
bridgeId: 'audio-effects.legacy-nam-routing',
pluginId: 'rig_builder',
legacySurface: 'fetch /Users/example/song.psarc token=abc123',
legacySurface: 'fetch /Users/example/song.archive token=abc123',
},
});
const dbResult = await api.dispatch({
+3 -3
View File
@@ -22,13 +22,13 @@ test('audio session lifecycle and snapshots redact source identity with per-snap
const window = loadAudioSession();
const audioSession = window.slopsmith.audioSession;
audioSession.startSession({ sessionId: 'main:/Users/example/DLC/song.psarc', songKey: '/Users/example/DLC/song.psarc', songFormat: 'psarc' });
audioSession.startSession({ sessionId: 'main:/Users/example/DLC/song.archive', songKey: '/Users/example/DLC/song.archive', songFormat: 'archive' });
audioSession.setRoute({ routeKind: 'html5', availability: 'available', deviceLabel: 'Scarlett 2i2 Serial 1234' });
audioSession.registerInputSource({ sourceId: 'mic-raw-id', logicalSourceKey: 'browser:instrument:primary', providerId: 'browser', kind: 'instrument', channelCount: 2, availability: 'available', label: 'Scarlett 2i2 Serial 1234' });
const snapshot = audioSession.snapshot();
const encoded = JSON.stringify(snapshot);
assert.equal(snapshot.session.songFormat, 'psarc');
assert.equal(snapshot.session.songFormat, 'archive');
assert.match(snapshot.domains['audio-input'].sources[0].diagnosticsPseudonym, /^source-\d{2}$/);
assert.equal(encoded.includes('Scarlett'), false);
assert.equal(encoded.includes('/Users/example'), false);
@@ -156,7 +156,7 @@ test('audio-mix diagnostics include faders routes analysers bridge hits and reda
const window = loadAudioSession();
const api = window.slopsmith.capabilities;
const audioSession = window.slopsmith.audioSession;
audioSession.startSession({ sessionId: 'main:/Users/example/DLC/song.psarc', songKey: '/Users/example/DLC/song.psarc' });
audioSession.startSession({ sessionId: 'main:/Users/example/DLC/song.archive', songKey: '/Users/example/DLC/song.archive' });
audioSession.setRoute({ routeKind: 'desktop', availability: 'degraded', deviceLabel: 'Secret Studio Output', fallbackReason: 'fallback token=abc123 at /Users/example/device' });
audioSession.setAnalyser({ source: 'plugin', availability: 'available', participantId: 'plugin.visualizer', reason: 'ok', rawFft: [1, 2, 3] });
audioSession.recordBridgeHit({ domain: 'audio-mix', bridgeId: 'audio-mix.fader-registry', legacySurface: 'registerFader', participantId: 'legacy.delay', outcome: 'failed', reason: 'password=abc path /Users/example/plugin' });
+1 -1
View File
@@ -205,7 +205,7 @@ test('re-registering a mix participant without handlers preserves the existing s
// every song load and re-registers core.song WITHOUT get/set handlers.
// registerMixParticipant replaces the participant, so before the fix this
// wiped the fader.set-value handler installed at init — the mixer slider
// then moved visually but never applied the volume (PSARC and sloppak).
// then moved visually but never applied the volume (archive and sloppak).
const window = loadAudioSession();
const audioSession = window.slopsmith.audioSession;
audioSession.startSession({ sessionId: 'main:test-song', songKey: 'test-song', songFormat: 'sloppak' });
+2 -2
View File
@@ -111,10 +111,10 @@ test('audio-input selection and registered providers survive song session switch
const open = await api.dispatch({ capability: 'audio-input', command: 'open-source', source: 'note_detect', payload: { requesterId: 'note_detect', requiredChannelShape: 'mono' } });
assert.equal(open.outcome, 'handled');
const next = audioSession.startSession({ sessionId: 'main:second-song', songKey: 'second-song.psarc', songFormat: 'psarc' });
const next = audioSession.startSession({ sessionId: 'main:second-song', songKey: 'second-song.archive', songFormat: 'archive' });
const listed = await api.dispatch({ capability: 'audio-input', command: 'list-sources', source: 'note_detect' });
assert.equal(next.session.songFormat, 'psarc');
assert.equal(next.session.songFormat, 'archive');
assert.equal(next.domains['audio-input'].selected.logicalSourceKey, 'switch:instrument:primary');
assert.equal(next.domains['audio-input'].totalOpenSessions, 0);
assert.equal(listed.payload.sources.some(source => source.logicalSourceKey === 'switch:instrument:primary'), true);
+1 -1
View File
@@ -1,5 +1,5 @@
// Pins the fret-spacing setting in plugins/highway_3d/screen.js (PR #329).
// The board can render fret columns either Uniform (equal width, Rocksmith
// The board can render fret columns either Uniform (equal width, the source game
// Remastered style) or Logarithmic (real instrument geometry), switchable at
// runtime via window.h3dSetFretSpacing and persisted in localStorage. A
// refactor that renames the storage key, drops the uniform/log branch in
+4 -4
View File
@@ -9,14 +9,14 @@ test('register + list returns applicable actions sorted by order', () => {
freshIds();
reg.register({ id: 'b', label: 'B', order: 20, run() {} });
reg.register({ id: 'a', label: 'A', order: 10, run() {} });
const ids = reg.list({ filename: 'x.psarc' }).map((a) => a.id);
const ids = reg.list({ filename: 'x.archive' }).map((a) => a.id);
assert.deepStrictEqual(ids, ['a', 'b']);
});
test('applies() filters out non-applicable actions', () => {
freshIds();
reg.register({ id: 'bassonly', label: 'Bass', applies: (s) => s.format === 'sloppak', run() {} });
assert.strictEqual(reg.list({ filename: 'x.psarc', format: 'psarc' }).length, 0);
assert.strictEqual(reg.list({ filename: 'x.archive', format: 'archive' }).length, 0);
assert.strictEqual(reg.list({ filename: 'y.sloppak', format: 'sloppak' }).length, 1);
});
@@ -33,10 +33,10 @@ test('run() invokes the handler and reports handled', async () => {
freshIds();
let got = null;
reg.register({ id: 'go', label: 'Go', run: (song) => { got = song.filename; return 'done'; } });
const r = await reg.run('go', { filename: 'song.psarc' }, {});
const r = await reg.run('go', { filename: 'song.archive' }, {});
assert.strictEqual(r.ok, true);
assert.strictEqual(r.outcome, 'handled');
assert.strictEqual(got, 'song.psarc');
assert.strictEqual(got, 'song.archive');
});
test('run() of a disabled / non-applicable / unknown action does not throw', async () => {
+5 -5
View File
@@ -49,7 +49,7 @@ test('reset counters via bindRuntime song lifecycle', () => {
};
const runtime = hud.bindRuntime(sm);
sm.emit('song:loading', { filename: 'song.psarc' });
sm.emit('song:loading', { filename: 'song.archive' });
assert.equal(runtime.isActive(), true);
runtime.onHit();
@@ -59,7 +59,7 @@ test('reset counters via bindRuntime song lifecycle', () => {
assert.equal(runtime.getCounters().misses, 1);
assert.equal(runtime.getCounters().streak, 0);
sm.emit('song:arrangement-changed', { filename: 'song.psarc', arrangement: 1 });
sm.emit('song:arrangement-changed', { filename: 'song.archive', arrangement: 1 });
assert.deepEqual(runtime.getCounters(), { hits: 0, misses: 0, streak: 0, bestStreak: 0 });
sm.emit('song:stop', { time: 12 });
@@ -103,7 +103,7 @@ test('DOM text updates after hit and miss events', () => {
};
const runtime = hud.bindRuntime(sm, els);
sm.emit('song:loading', { filename: 'song.psarc' });
sm.emit('song:loading', { filename: 'song.archive' });
assert.equal(els.percent.textContent, '\u2014');
assert.equal(els.hits.textContent, 'Waiting for notes');
@@ -140,7 +140,7 @@ test('HUD stays hidden until the first note arrives, then reveals', () => {
};
const runtime = hud.bindRuntime(sm, els);
sm.emit('song:loading', { filename: 'song.psarc' });
sm.emit('song:loading', { filename: 'song.archive' });
// Primed (tallying) but not yet visible — a user without note detection
// never gets note:hit/note:miss, so the HUD must not show on load alone.
assert.equal(runtime.isActive(), true);
@@ -152,7 +152,7 @@ test('HUD stays hidden until the first note arrives, then reveals', () => {
// A new song re-hides until the next note.
sm.emit('song:stop', { time: 1 });
assert.ok(els.root.className.includes('hidden'));
sm.emit('song:loading', { filename: 'song2.psarc' });
sm.emit('song:loading', { filename: 'song2.archive' });
assert.ok(els.root.className.includes('hidden'));
});
+2 -2
View File
@@ -172,7 +172,7 @@ test('diagnostics contribution is redaction-safe', async () => {
const contribution = window.__diagnosticsContributions.get('note-detection-capability');
assert.equal(contribution.schema, 'slopsmith.note_detection_capability.v1');
const serialized = JSON.stringify(contribution);
assert.ok(!/Yamaha|deviceLabel|filename|\.sloppak|\.psarc/i.test(serialized), serialized);
assert.ok(!/Yamaha|deviceLabel|filename|\.sloppak|\.archive/i.test(serialized), serialized);
});
test('legacy setNoteStateProvider surface is wrapped and accounted', () => {
@@ -227,7 +227,7 @@ test('_contextSummary whitelists arrangement kind — unknown values are dropped
// Arbitrary string must not appear in context summary.
const open2 = await api.dispatch({
capability: 'note-detection', command: 'open-binding',
source: 'caller', payload: { context: { arrangement: '/Users/victim/song.psarc' } },
source: 'caller', payload: { context: { arrangement: '/Users/victim/song.archive' } },
});
assert.equal(open2.outcome, 'handled');
const snap2 = window.slopsmith.noteDetection.snapshot();
+3 -3
View File
@@ -68,8 +68,8 @@ test('legacy bridge hits are attributed to playback compatibility shims', () =>
test('legacy song events update playback state without exposing raw filenames', () => {
const window = loadPlayback();
window.slopsmith.emit('song:loading', { filename: '/Users/example/Secret Folder/Artist - Song_p.psarc', arrangement: 0 });
window.slopsmith.emit('song:loaded', makeTarget({ filename: '/Users/example/Secret Folder/Artist - Song_p.psarc' }));
window.slopsmith.emit('song:loading', { filename: '/Users/example/Secret Folder/Artist - Song_p.archive', arrangement: 0 });
window.slopsmith.emit('song:loaded', makeTarget({ filename: '/Users/example/Secret Folder/Artist - Song_p.archive' }));
window.slopsmith.emit('song:play', { time: 4, audioT: 4, chartT: 4 });
window.slopsmith.emit('song:seek', { from: 4, to: 12, reason: 'seek-by' });
@@ -81,7 +81,7 @@ test('legacy song events update playback state without exposing raw filenames',
assert.match(playback.state.target.settingsKey, /^settings-v1-[a-z0-9]{7}$/);
assert.ok(playback.bridges.some(bridge => bridge.bridgeId === 'playback.song-events'));
assert.doesNotMatch(encoded, /Secret Folder/);
assert.doesNotMatch(encoded, /Artist - Song_p\.psarc/);
assert.doesNotMatch(encoded, /Artist - Song_p\.archive/);
});
test('route changes are captured as redaction-safe playback lifecycle events', () => {
+3 -3
View File
@@ -9,7 +9,7 @@ test('exported diagnostics pseudonymize targets while local inspector may show d
authorization: 'user-action',
requesterId: 'core.player.controls',
target: makeTarget({
filename: '/Users/example/DLC/Private Artist - Private Song_p.psarc',
filename: '/Users/example/DLC/Private Artist - Private Song_p.archive',
title: 'Private Song',
artist: 'Private Artist',
arrangement: 'Lead',
@@ -36,7 +36,7 @@ test('diagnostic history is bounded for current and stopped sessions', async ()
window.slopsmith.playback.registerTransportAdapter(makeAdapter());
for (let index = 0; index < 7; index += 1) {
await dispatch(window, 'start', { authorization: 'user-action', requesterId: 'core.player.controls', target: makeTarget({ filename: `song-${index}.psarc`, title: `Song ${index}` }) });
await dispatch(window, 'start', { authorization: 'user-action', requesterId: 'core.player.controls', target: makeTarget({ filename: `song-${index}.archive`, title: `Song ${index}` }) });
for (let seek = 0; seek < 12; seek += 1) {
await dispatch(window, 'seek', { requesterId: 'core.player.controls', time: seek });
}
@@ -96,7 +96,7 @@ test('diagnostics redact requester ids and raw camel-case payload keys', async (
accessToken: 'plain-secret-token',
nativeHandleRef: 'native-secret-handle',
mediaStream: 'raw-stream-id',
reason: '/Users/example/private song.psarc token=secret',
reason: '/Users/example/private song.archive token=secret',
safeDetail: 'safe value',
});
window.slopsmith.playback.recordBridgeHit({
+1 -1
View File
@@ -57,7 +57,7 @@ test('start requires a target and explicit user authorization for fresh audible
test('settings key is stable across arrangements while target id remains arrangement scoped', async () => {
const window = loadPlayback();
window.slopsmith.playback.registerTransportAdapter(makeAdapter());
const base = makeTarget({ filename: '/Users/example/DLC/Artist - Song_p.psarc', arrangement: 'Lead', arrangementIndex: 0 });
const base = makeTarget({ filename: '/Users/example/DLC/Artist - Song_p.archive', arrangement: 'Lead', arrangementIndex: 0 });
await dispatch(window, 'start', { requesterId: 'core.player.controls', authorization: 'user-action', target: base });
const leadTarget = diagnosticsSnapshot(window).state.target;
+2 -2
View File
@@ -41,12 +41,12 @@ function dispatch(window, command, payload = {}, requester = 'test') {
function makeTarget(overrides = {}) {
return {
filename: overrides.filename || '/Users/example/DLC/Secret Artist - Song_p.psarc',
filename: overrides.filename || '/Users/example/DLC/Secret Artist - Song_p.archive',
title: overrides.title || 'Visible Title',
artist: overrides.artist || 'Visible Artist',
arrangement: overrides.arrangement || 'Lead',
arrangementIndex: overrides.arrangementIndex ?? 0,
format: overrides.format || 'psarc',
format: overrides.format || 'archive',
sourceKind: overrides.sourceKind || 'local',
...overrides,
};
+2 -2
View File
@@ -172,7 +172,7 @@ test('new song load resets the HTML audio rate, not only the visible speed contr
const sandbox = buildSandbox();
loadPlaySong(sandbox);
await sandbox.__playSong('next-song.psarc');
await sandbox.__playSong('next-song.archive');
assert.equal(sandbox.__elements.get('speed-slider').value, 100);
assert.match(sandbox.__elements.get('speed-label').textContent, /^1\.0{1,2}x$/);
@@ -184,7 +184,7 @@ test('new song load resets the desktop backing rate when the API is available',
const sandbox = buildSandbox({ juceMode: true });
loadPlaySong(sandbox);
await sandbox.__playSong('next-song.psarc');
await sandbox.__playSong('next-song.archive');
await new Promise((resolve) => setImmediate(resolve));
assert.equal(sandbox.jucePlayer._speed, 1);
+1 -1
View File
@@ -240,7 +240,7 @@ test('diagnostics contribution is redaction-safe (no song identity)', () => {
const contribution = window.__diagnosticsContributions.get('visualization-capability');
assert.equal(contribution.schema, 'slopsmith.visualization_capability.v1');
const serialized = JSON.stringify(contribution);
assert.ok(!/filename|title|artist|\.sloppak|\.psarc/i.test(serialized), serialized);
assert.ok(!/filename|title|artist|\.sloppak|\.archive/i.test(serialized), serialized);
assert.deepEqual(
JSON.parse(JSON.stringify(contribution.lastAutoMatch)),
{ resolved: 'piano', matched: true },
+2 -2
View File
@@ -24,7 +24,7 @@ def client(tmp_path, monkeypatch):
def _post_mapping(client, **overrides):
payload = {
"song_key": "settings-v1-song",
"filename": "Artist - Song_p.psarc",
"filename": "Artist - Song_p.archive",
"tone_key": "Dist",
"provider_id": "nam-tone",
"provider_ref": "preset:42",
@@ -80,7 +80,7 @@ def test_upsert_updates_provider_mapping_without_colliding_with_other_provider(c
assert second["label"] == "Updated"
assert rig["id"] != first["id"]
listed = client.get("/api/audio-effects/mappings", params={"filename": "Artist - Song_p.psarc"}).json()["mappings"]
listed = client.get("/api/audio-effects/mappings", params={"filename": "Artist - Song_p.archive"}).json()["mappings"]
assert len(listed) == 2
+4 -4
View File
@@ -204,7 +204,7 @@ def test_client_audio_session_contribution_redacts_paths(tmp_path):
kw["client_contributions"] = {
"note_detect": {
"schema": "slopsmith.audio_session.diagnostics.v1",
"session": {"sessionId": str(home_path / "DLC" / "private-song.psarc")},
"session": {"sessionId": str(home_path / "DLC" / "private-song.archive")},
"domains": {"audio-input": {"sources": [{"label": str(home_path / "devices" / "raw-id")}]}},
}
}
@@ -1541,7 +1541,7 @@ def test_console_error_object_args_are_redacted(tmp_path):
kw = _basic_kwargs(tmp_path)
kw["include"]["console"] = True
kw["redact"] = True
secret_path = "/home/alice/Music/DLC/my_song.psarc"
secret_path = "/home/alice/Music/DLC/my_song.archive"
kw["client_console"] = [
{
"level": "error",
@@ -1567,13 +1567,13 @@ def test_console_string_args_still_redacted(tmp_path):
kw["include"]["console"] = True
kw["redact"] = True
kw["client_console"] = [
{"level": "log", "msg": "ok", "args": ["loaded /home/alice/Music/DLC/my_song.psarc ok"]},
{"level": "log", "msg": "ok", "args": ["loaded /home/alice/Music/DLC/my_song.archive ok"]},
]
zip_bytes, _name, _m = db.build_bundle(**kw)
with _open_zip(zip_bytes) as zf:
console = json.loads(zf.read("client/console.json"))
# The song filename should be replaced with a hash token, not appear verbatim.
assert "my_song.psarc" not in console["entries"][0]["args"][0]
assert "my_song.archive" not in console["entries"][0]["args"][0]
def test_console_non_string_non_dict_args_pass_through(tmp_path):
+5 -5
View File
@@ -5,7 +5,7 @@ from diagnostics_redact import Redactor
def test_dlc_path_replaced():
r = Redactor(dlc_dir=Path("/dlc/songs"))
out = r.redact_text("loaded from /dlc/songs/foo.psarc")
out = r.redact_text("loaded from /dlc/songs/foo.archive")
assert "<DLC_DIR>" in out
assert "/dlc/songs" not in out
assert r.counts["paths_replaced"] == 1
@@ -13,8 +13,8 @@ def test_dlc_path_replaced():
def test_song_filename_redacted_consistently():
r = Redactor()
a = r.redact_text("Loading Test-Artist_Test-Song.psarc")
b = r.redact_text("Replaying Test-Artist_Test-Song.psarc again")
a = r.redact_text("Loading Test-Artist_Test-Song.archive")
b = r.redact_text("Replaying Test-Artist_Test-Song.archive again")
token_a = a.split("Loading ")[1].strip()
token_b = b.split("Replaying ")[1].split(" ")[0]
assert token_a == token_b
@@ -63,8 +63,8 @@ def test_home_dir_replaced():
def test_different_redactors_produce_different_tokens():
a = Redactor()
b = Redactor()
out_a = a.redact_text("Foo.psarc")
out_b = b.redact_text("Foo.psarc")
out_a = a.redact_text("Foo.archive")
out_b = b.redact_text("Foo.archive")
assert out_a != out_b
+1 -1
View File
@@ -126,7 +126,7 @@ def test_validate_drum_tab_rejects_non_int_version():
def test_validate_drum_tab_accepts_unknown_version():
"""An unknown schema version is logged but the payload is still accepted —
forward-compat per Principle IV (backwards-compatible CDLC library)."""
forward-compat per Principle IV (backwards-compatible custom song library)."""
ok, _ = drums.validate_drum_tab({"version": 99, "hits": []})
assert ok
+66 -66
View File
@@ -5,7 +5,7 @@ cover the new year sort and the rewritten tuning sort (now
musical-distance-based instead of alphabetical).
Tests stub `MetadataDB` directly via `meta_db.put()`, bypassing the
PSARC/sloppak scanner same approach as test_settings_api.py.
archive/sloppak scanner same approach as test_settings_api.py.
"""
import importlib
@@ -37,7 +37,7 @@ def client(server_mod):
def _put(server_mod, *, filename, title, artist, year="", arrangements=None,
has_lyrics=False, format="psarc", stem_ids=None, tuning_name="E Standard",
has_lyrics=False, format="archive", stem_ids=None, tuning_name="E Standard",
tuning_sort_key=0, tuning_offsets="", mtime=1.0, size=1):
server_mod.meta_db.put(filename, mtime, size, {
"title": title, "artist": artist, "album": f"{artist} - LP",
@@ -57,13 +57,13 @@ def _put(server_mod, *, filename, title, artist, year="", arrangements=None,
@pytest.fixture()
def seeded(server_mod):
"""Populate 6 deterministic rows covering the matrix of axes."""
_put(server_mod, filename="a.psarc", title="A song", artist="A Band",
year="2010", has_lyrics=True, format="psarc",
_put(server_mod, filename="a.archive", title="A song", artist="A Band",
year="2010", has_lyrics=True, format="archive",
arrangements=[{"index": 0, "name": "Lead", "notes": 100},
{"index": 1, "name": "Rhythm", "notes": 80}],
tuning_name="E Standard", tuning_sort_key=0)
_put(server_mod, filename="b.psarc", title="B song", artist="B Band",
year="2005", has_lyrics=False, format="psarc",
_put(server_mod, filename="b.archive", title="B song", artist="B Band",
year="2005", has_lyrics=False, format="archive",
arrangements=[{"index": 0, "name": "Bass", "notes": 60}],
tuning_name="Drop D", tuning_sort_key=-2)
_put(server_mod, filename="c.sloppak", title="C song", artist="C Band",
@@ -87,8 +87,8 @@ def seeded(server_mod):
"Drop D", -2),
)
server_mod.meta_db.conn.commit()
_put(server_mod, filename="f.psarc", title="F song", artist="F Band",
year="2015", has_lyrics=True, format="psarc",
_put(server_mod, filename="f.archive", title="F song", artist="F Band",
year="2015", has_lyrics=True, format="archive",
arrangements=[{"index": 0, "name": "Lead", "notes": 110},
{"index": 1, "name": "Bass", "notes": 70}],
tuning_name="Eb Standard", tuning_sort_key=-6)
@@ -104,23 +104,23 @@ def test_arrangement_has_lead(client, seeded):
data = _get(client, arrangements_has="Lead")
files = {s["filename"] for s in data["songs"]}
# Rows with Lead: a, d, e, f. Combo (c) does NOT match strict-name "Lead".
assert files == {"a.psarc", "d.sloppak", "e.sloppak", "f.psarc"}
assert files == {"a.archive", "d.sloppak", "e.sloppak", "f.archive"}
def test_arrangement_has_or_within_axis(client, seeded):
data = _get(client, arrangements_has="Lead,Bass")
files = {s["filename"] for s in data["songs"]}
# Lead OR Bass: a, b, d, e, f.
assert files == {"a.psarc", "b.psarc", "d.sloppak", "e.sloppak", "f.psarc"}
assert files == {"a.archive", "b.archive", "d.sloppak", "e.sloppak", "f.archive"}
def test_arrangement_lacks_bass(client, seeded):
data = _get(client, arrangements_lacks="Bass")
files = {s["filename"] for s in data["songs"]}
# b.psarc and f.psarc both have Bass, exclude them.
assert "b.psarc" not in files
assert "f.psarc" not in files
assert "a.psarc" in files
# b.archive and f.archive both have Bass, exclude them.
assert "b.archive" not in files
assert "f.archive" not in files
assert "a.archive" in files
# ── Arrangements axis — smart naming mode ───────────────────────────────────
@@ -130,25 +130,25 @@ def seeded_smart(server_mod):
"""Rows that exercise the smart-mode filter branches in _build_where."""
# Row with explicit smart_name="Alt. Lead" — must match arrangements_has=Lead
# in smart mode (LIKE 'Alt. Lead%').
_put(server_mod, filename="alt.psarc", title="Alt", artist="Alt Band",
_put(server_mod, filename="alt.archive", title="Alt", artist="Alt Band",
arrangements=[{"index": 0, "name": "Lead", "notes": 100,
"smart_name": "Alt. Lead"}])
# Row with explicit smart_name="Bonus Rhythm".
_put(server_mod, filename="bonus.psarc", title="Bon", artist="Bon Band",
_put(server_mod, filename="bonus.archive", title="Bon", artist="Bon Band",
arrangements=[{"index": 0, "name": "Rhythm", "notes": 50,
"smart_name": "Bonus Rhythm"}])
# Legacy-cached row WITHOUT smart_name key (json_type IS NULL):
# name="Combo" → must match arrangements_has=Lead via name fallback.
_put(server_mod, filename="combo-old.psarc", title="ComboOld", artist="X",
_put(server_mod, filename="combo-old.archive", title="ComboOld", artist="X",
arrangements=[{"index": 0, "name": "Combo", "notes": 80}])
# Legacy-cached row WITHOUT smart_name where name="Bass 2" (load_song
# synthesises this for real_bass_22 when manifest data is missing):
# must match arrangements_has=Bass via the extras fallback.
_put(server_mod, filename="bass2-old.psarc", title="Bass2Old", artist="Z",
_put(server_mod, filename="bass2-old.archive", title="Bass2Old", artist="Z",
arrangements=[{"index": 0, "name": "Bass 2", "notes": 70}])
# Scanned ambiguous row with explicit smart_name=None (json_type='null'):
# name="Combo" must NOT match Lead in smart mode (suppress name-fallback).
_put(server_mod, filename="combo-ambig.psarc", title="ComboAmb", artist="Y",
_put(server_mod, filename="combo-ambig.archive", title="ComboAmb", artist="Y",
arrangements=[{"index": 0, "name": "Combo", "notes": 90,
"smart_name": None}])
@@ -156,10 +156,10 @@ def seeded_smart(server_mod):
def test_smart_mode_matches_alt_lead(client, seeded_smart):
data = _get(client, arrangements_has="Lead", naming_mode="smart")
files = {s["filename"] for s in data["songs"]}
assert "alt.psarc" in files # smart_name="Alt. Lead"
assert "combo-old.psarc" in files # name-fallback (key absent)
assert "combo-ambig.psarc" not in files # explicit null suppresses fallback
assert "bonus.psarc" not in files # Bonus Rhythm not Lead
assert "alt.archive" in files # smart_name="Alt. Lead"
assert "combo-old.archive" in files # name-fallback (key absent)
assert "combo-ambig.archive" not in files # explicit null suppresses fallback
assert "bonus.archive" not in files # Bonus Rhythm not Lead
def test_smart_mode_matches_bass_2_via_fallback(client, seeded_smart):
@@ -167,7 +167,7 @@ def test_smart_mode_matches_bass_2_via_fallback(client, seeded_smart):
# in smart mode via the NULL-smart_name name-fallback extras.
data = _get(client, arrangements_has="Bass", naming_mode="smart")
files = {s["filename"] for s in data["songs"]}
assert "bass2-old.psarc" in files
assert "bass2-old.archive" in files
def test_smart_mode_combo_normalized_to_lead(client, seeded_smart):
@@ -184,10 +184,10 @@ def test_smart_mode_lacks_lead_excludes_alt_lead(client, seeded_smart):
# smart_name is explicitly null (we don't know if they have Lead).
data = _get(client, arrangements_lacks="Lead", naming_mode="smart")
files = {s["filename"] for s in data["songs"]}
assert "alt.psarc" not in files
assert "combo-old.psarc" not in files
assert "combo-ambig.psarc" not in files # ambiguous — don't claim it lacks Lead
assert "bonus.psarc" in files # has Bonus Rhythm, no Lead variant
assert "alt.archive" not in files
assert "combo-old.archive" not in files
assert "combo-ambig.archive" not in files # ambiguous — don't claim it lacks Lead
assert "bonus.archive" in files # has Bonus Rhythm, no Lead variant
# ── Lyrics axis ─────────────────────────────────────────────────────────────
@@ -195,13 +195,13 @@ def test_smart_mode_lacks_lead_excludes_alt_lead(client, seeded_smart):
def test_has_lyrics_require(client, seeded):
data = _get(client, has_lyrics="1")
files = {s["filename"] for s in data["songs"]}
assert files == {"a.psarc", "c.sloppak", "f.psarc"}
assert files == {"a.archive", "c.sloppak", "f.archive"}
def test_has_lyrics_exclude(client, seeded):
data = _get(client, has_lyrics="0")
files = {s["filename"] for s in data["songs"]}
assert files == {"b.psarc", "d.sloppak", "e.sloppak"}
assert files == {"b.archive", "d.sloppak", "e.sloppak"}
# ── Stems axis ──────────────────────────────────────────────────────────────
@@ -218,14 +218,14 @@ def test_stems_has_or_within_axis(client, seeded):
assert {s["filename"] for s in data["songs"]} == {"c.sloppak", "d.sloppak"}
def test_stems_has_excludes_psarcs_and_legacy_null(client, seeded):
"""PSARCs have empty stem_ids; legacy row has NULL. Both are
def test_stems_has_excludes_archives_and_legacy_null(client, seeded):
"""archives have empty stem_ids; legacy row has NULL. Both are
excluded by stems_has there's no proof the stem is present."""
data = _get(client, stems_has="drums")
files = {s["filename"] for s in data["songs"]}
assert files == {"c.sloppak", "d.sloppak"}
# PSARC rows missing.
assert "a.psarc" not in files
# archive rows missing.
assert "a.archive" not in files
# Legacy NULL row missing.
assert "e.sloppak" not in files
@@ -235,9 +235,9 @@ def test_stems_lacks_other(client, seeded):
files = {s["filename"] for s in data["songs"]}
# c.sloppak has "other" — must be excluded.
assert "c.sloppak" not in files
# Everything else lacks it (PSARCs have empty stem_ids; legacy NULL
# Everything else lacks it (archives have empty stem_ids; legacy NULL
# also lacks it because json_each yields nothing).
assert "a.psarc" in files
assert "a.archive" in files
# ── Tuning axis ─────────────────────────────────────────────────────────────
@@ -245,12 +245,12 @@ def test_stems_lacks_other(client, seeded):
def test_tunings_or_within_axis(client, seeded):
data = _get(client, tunings="E Standard,Drop D")
files = {s["filename"] for s in data["songs"]}
assert files == {"a.psarc", "b.psarc", "c.sloppak", "e.sloppak"}
assert files == {"a.archive", "b.archive", "c.sloppak", "e.sloppak"}
def test_tunings_eb_standard_only(client, seeded):
data = _get(client, tunings="Eb Standard")
assert {s["filename"] for s in data["songs"]} == {"d.sloppak", "f.psarc"}
assert {s["filename"] for s in data["songs"]} == {"d.sloppak", "f.archive"}
# ── Combined cross-axis (AND) ───────────────────────────────────────────────
@@ -261,7 +261,7 @@ def test_combined_axes(client, seeded):
# a (Lead, lyrics, E Std) ✓
# f (Lead, lyrics, Eb Std) ✗ (wrong tuning)
# c is Combo not Lead
assert {s["filename"] for s in data["songs"]} == {"a.psarc"}
assert {s["filename"] for s in data["songs"]} == {"a.archive"}
# ── Whitelist sanitization (defense-in-depth) ───────────────────────────────
@@ -282,30 +282,30 @@ def test_year_sort_desc_newest_first(client, seeded):
files = [s["filename"] for s in data["songs"]]
# Years: c=2020, d=2018, f=2015, a=2010, b=2005, e=''.
# Empty year goes to the bottom for both directions.
assert files == ["c.sloppak", "d.sloppak", "f.psarc", "a.psarc", "b.psarc", "e.sloppak"]
assert files == ["c.sloppak", "d.sloppak", "f.archive", "a.archive", "b.archive", "e.sloppak"]
def test_year_sort_asc_oldest_first(client, seeded):
data = _get(client, sort="year")
files = [s["filename"] for s in data["songs"]]
# Empty year still bottom — only the dated rows reverse.
assert files == ["b.psarc", "a.psarc", "f.psarc", "d.sloppak", "c.sloppak", "e.sloppak"]
assert files == ["b.archive", "a.archive", "f.archive", "d.sloppak", "c.sloppak", "e.sloppak"]
def test_tuning_sort_down_tuned_before_up_tuned_at_same_distance(client, server_mod):
"""Within an ABS(tuning_sort_key) tier, the down-tuned variant
must come before the up-tuned one so the order matches Rocksmith's
must come before the up-tuned one so the order matches the source game's
grouping (Eb Standard before F Standard at distance 6, etc.).
Earlier code used signed-key DESC for the tiebreaker, which put
+6 before -6 the opposite of intent. Regression for Copilot
finding on PR #134."""
_put(server_mod, filename="up.psarc", title="Up", artist="A",
_put(server_mod, filename="up.archive", title="Up", artist="A",
tuning_name="F Standard", tuning_sort_key=6,
arrangements=[{"index": 0, "name": "Lead", "notes": 1}])
_put(server_mod, filename="down.psarc", title="Down", artist="B",
_put(server_mod, filename="down.archive", title="Down", artist="B",
tuning_name="Eb Standard", tuning_sort_key=-6,
arrangements=[{"index": 0, "name": "Lead", "notes": 1}])
_put(server_mod, filename="std.psarc", title="Std", artist="C",
_put(server_mod, filename="std.archive", title="Std", artist="C",
tuning_name="E Standard", tuning_sort_key=0,
arrangements=[{"index": 0, "name": "Lead", "notes": 1}])
@@ -328,15 +328,15 @@ def test_tuning_sort_pushes_empty_tuning_name_to_bottom(client, server_mod):
Without a leading `(tuning_name='') ASC` term, ABS(0) collides with
E Standard's 0 so unscanned rows would float to the top of the
tuning sort. Regression for Copilot finding on PR #134."""
_put(server_mod, filename="real.psarc", title="Real", artist="A",
_put(server_mod, filename="real.archive", title="Real", artist="A",
tuning_name="E Standard", tuning_sort_key=0,
arrangements=[{"index": 0, "name": "Lead", "notes": 1}])
_put(server_mod, filename="legacy.psarc", title="Legacy", artist="B",
_put(server_mod, filename="legacy.archive", title="Legacy", artist="B",
tuning_name="", tuning_sort_key=0,
arrangements=[{"index": 0, "name": "Lead", "notes": 1}])
data = _get(client, sort="tuning")
files = [s["filename"] for s in data["songs"]]
assert files == ["real.psarc", "legacy.psarc"]
assert files == ["real.archive", "legacy.archive"]
def test_tuning_sort_pushes_null_tuning_name_to_bottom(client, server_mod):
@@ -348,7 +348,7 @@ def test_tuning_sort_pushes_null_tuning_name_to_bottom(client, server_mod):
evaluates to NULL for those rows and NULLs sort *ahead of* 0 in
SQLite's ASC ordering — the legacy row would float above E
Standard. Regression for Copilot finding on PR #134."""
_put(server_mod, filename="real.psarc", title="Real", artist="A",
_put(server_mod, filename="real.archive", title="Real", artist="A",
tuning_name="E Standard", tuning_sort_key=0,
arrangements=[{"index": 0, "name": "Lead", "notes": 1}])
# Direct INSERT with NULL tuning_name AND NULL tuning_sort_key —
@@ -358,14 +358,14 @@ def test_tuning_sort_pushes_null_tuning_name_to_bottom(client, server_mod):
"INSERT INTO songs (filename, mtime, size, title, artist, album, year, "
"duration, tuning, arrangements, has_lyrics, format, stem_count, stem_ids, "
"tuning_name, tuning_sort_key) "
"VALUES (?, 1.0, 1, ?, ?, ?, '', 200.0, '', ?, 0, 'psarc', 0, '[]', NULL, NULL)",
("legacy.psarc", "Legacy", "Z", "Z - LP", json.dumps([])),
"VALUES (?, 1.0, 1, ?, ?, ?, '', 200.0, '', ?, 0, 'archive', 0, '[]', NULL, NULL)",
("legacy.archive", "Legacy", "Z", "Z - LP", json.dumps([])),
)
server_mod.meta_db.conn.commit()
data = _get(client, sort="tuning")
files = [s["filename"] for s in data["songs"]]
assert files == ["real.psarc", "legacy.psarc"]
assert files == ["real.archive", "legacy.archive"]
# /api/library/tuning-names should also exclude the NULL row from
# the picker entirely (users can't usefully filter by an unknown
@@ -380,9 +380,9 @@ def test_query_stats_artist_count_is_case_insensitive(client, server_mod):
NOCASE leading to mismatched totals when the same artist was
indexed under different casings. Regression for Copilot finding
on PR #134."""
_put(server_mod, filename="x.psarc", title="X", artist="The Beatles",
_put(server_mod, filename="x.archive", title="X", artist="The Beatles",
arrangements=[{"index": 0, "name": "Lead", "notes": 1}])
_put(server_mod, filename="y.psarc", title="Y", artist="the beatles",
_put(server_mod, filename="y.archive", title="Y", artist="the beatles",
arrangements=[{"index": 0, "name": "Lead", "notes": 1}])
stats = client.get("/api/library/stats").json()
assert stats["total_artists"] == 1
@@ -391,7 +391,7 @@ def test_query_stats_artist_count_is_case_insensitive(client, server_mod):
def test_query_stats_groups_non_ascii_artist_letters_under_hash(client, server_mod):
_put(server_mod, filename="angstrom.psarc", title="Angstrom", artist="Ångström")
_put(server_mod, filename="angstrom.archive", title="Angstrom", artist="Ångström")
stats = client.get("/api/library/stats").json()
@@ -445,7 +445,7 @@ def test_compound_sort_with_legacy_dir_desc_doesnt_error(client, seeded):
# Order matches plain `sort=year` (legacy dir is ignored on
# already-directional clauses). The point is no 500 from invalid SQL.
files = [s["filename"] for s in r.json()["songs"]]
assert files == ["b.psarc", "a.psarc", "f.psarc", "d.sloppak", "c.sloppak", "e.sloppak"]
assert files == ["b.archive", "a.archive", "f.archive", "d.sloppak", "c.sloppak", "e.sloppak"]
# ── Tuning sort by pitch distance (slopsmith#22) ────────────────────────────
@@ -487,11 +487,11 @@ def test_tuning_offsets_served_in_library_list(client, server_mod):
"""Raw offsets round-trip through the DB into the list payload so the v3
client can render target notes (they are not derivable from the collapsed
"Custom Tuning" name)."""
_put(server_mod, filename="custom.psarc", title="C", artist="C Band",
_put(server_mod, filename="custom.archive", title="C", artist="C Band",
tuning_name="Custom Tuning", tuning_sort_key=-6,
tuning_offsets="-2 0 0 0 -2 -2")
songs = _get(client)["songs"]
row = next(s for s in songs if s["filename"] == "custom.psarc")
row = next(s for s in songs if s["filename"] == "custom.archive")
assert row["tuning_offsets"] == "-2 0 0 0 -2 -2"
assert row["tuning_name"] == "Custom Tuning"
@@ -500,10 +500,10 @@ def test_distinct_custom_tunings_stay_distinct(client, server_mod):
"""Two different custom tunings both named "Custom Tuning" must remain
separate filter entries (grouped on offsets), and each pill must select
only its own songs."""
_put(server_mod, filename="dadgad.psarc", title="One", artist="A",
_put(server_mod, filename="dadgad.archive", title="One", artist="A",
tuning_name="Custom Tuning", tuning_sort_key=-4,
tuning_offsets="-2 0 0 0 -2 0")
_put(server_mod, filename="openc.psarc", title="Two", artist="B",
_put(server_mod, filename="openc.archive", title="Two", artist="B",
tuning_name="Custom Tuning", tuning_sort_key=-8,
tuning_offsets="-4 -2 -2 0 -2 -4")
@@ -516,17 +516,17 @@ def test_distinct_custom_tunings_stay_distinct(client, server_mod):
# Filtering by one tuning's key returns only that song.
only = _get(client, tunings="-2 0 0 0 -2 0")
assert [s["filename"] for s in only["songs"]] == ["dadgad.psarc"]
assert [s["filename"] for s in only["songs"]] == ["dadgad.archive"]
def test_legacy_rows_without_offsets_group_by_name(client, server_mod):
"""Rows predating the offsets column (tuning_offsets='') still group/filter
by tuning_name, preserving prior behaviour."""
_put(server_mod, filename="estd.psarc", title="S", artist="A",
_put(server_mod, filename="estd.archive", title="S", artist="A",
tuning_name="E Standard", tuning_sort_key=0, tuning_offsets="")
names = [t["name"] for t in client.get("/api/library/tuning-names").json()["tunings"]]
assert "E Standard" in names
assert [s["filename"] for s in _get(client, tunings="E Standard")["songs"]] == ["estd.psarc"]
assert [s["filename"] for s in _get(client, tunings="E Standard")["songs"]] == ["estd.archive"]
# ── Stats endpoint mirrors filtered totals ──────────────────────────────────
@@ -553,14 +553,14 @@ def test_empty_values_are_no_ops(client, seeded):
def test_artist_filter_returns_only_that_artist(client, seeded):
data = _get(client, artist="A Band")
assert data["total"] == 1
assert {s["filename"] for s in data["songs"]} == {"a.psarc"}
assert {s["filename"] for s in data["songs"]} == {"a.archive"}
assert all(s["artist"] == "A Band" for s in data["songs"])
def test_artist_and_album_filter(client, seeded):
data = _get(client, artist="A Band", album="A Band - LP")
assert data["total"] == 1
assert data["songs"][0]["filename"] == "a.psarc"
assert data["songs"][0]["filename"] == "a.archive"
assert data["songs"][0]["album"] == "A Band - LP"
@@ -575,4 +575,4 @@ def test_q_search_remains_fuzzy_with_artist_filter(client, seeded):
def test_artist_filter_is_case_insensitive(client, seeded):
data = _get(client, artist="a band")
assert data["total"] == 1
assert data["songs"][0]["filename"] == "a.psarc"
assert data["songs"][0]["filename"] == "a.archive"
+8 -8
View File
@@ -27,7 +27,7 @@ def client(server_mod):
c.close()
def _put(server_mod, filename="local.psarc", title="Local Song", artist="Local Artist"):
def _put(server_mod, filename="local.archive", title="Local Song", artist="Local Artist"):
server_mod.meta_db.put(filename, 1.0, 1, {
"title": title,
"artist": artist,
@@ -37,7 +37,7 @@ def _put(server_mod, filename="local.psarc", title="Local Song", artist="Local A
"tuning": "E Standard",
"arrangements": [{"index": 0, "name": "Lead", "notes": 1}],
"has_lyrics": True,
"format": "psarc",
"format": "archive",
"stem_count": 0,
"stem_ids": [],
"tuning_name": "E Standard",
@@ -66,7 +66,7 @@ class FakeLibraryProvider:
"artist": "Remote Artist",
"album": "Remote Album",
"arrangements": [],
"format": "psarc",
"format": "archive",
}], 1)
def query_artists(self, **kwargs):
@@ -91,7 +91,7 @@ class FakeLibraryProvider:
def sync_song(self, song_id: str):
self.sync_song_id = song_id
return {"ok": True, "filename": "synced.psarc", "song_id": song_id}
return {"ok": True, "filename": "synced.archive", "song_id": song_id}
class ReadOnlyLibraryProvider:
@@ -157,7 +157,7 @@ def test_local_provider_is_default_library_provider(server_mod, client):
default_payload = client.get("/api/library").json()
explicit_payload = client.get("/api/library", params={"provider": "local"}).json()
assert default_payload == explicit_payload
assert default_payload["songs"][0]["filename"] == "local.psarc"
assert default_payload["songs"][0]["filename"] == "local.archive"
def test_registered_provider_handles_library_endpoints(server_mod, client):
@@ -178,7 +178,7 @@ def test_registered_provider_handles_library_endpoints(server_mod, client):
"size": "12",
"sort": "title-desc",
"dir": "desc",
"format": "psarc",
"format": "archive",
"favorites": "1",
"arrangements_has": "Lead,Rhythm",
"has_lyrics": "1",
@@ -192,7 +192,7 @@ def test_registered_provider_handles_library_endpoints(server_mod, client):
assert provider.page_kwargs["sort"] == "title-desc"
assert provider.page_kwargs["direction"] == "desc"
assert provider.page_kwargs["favorites_only"] is True
assert provider.page_kwargs["format_filter"] == "psarc"
assert provider.page_kwargs["format_filter"] == "archive"
assert provider.page_kwargs["arrangements_has"] == ["Lead", "Rhythm"]
assert provider.page_kwargs["has_lyrics"] == 1
assert provider.page_kwargs["tunings"] == ["E Standard", "Drop D"]
@@ -225,7 +225,7 @@ def test_registered_provider_handles_library_endpoints(server_mod, client):
assert provider.art_song_id == "remote-song-id"
synced = client.post("/api/library/providers/remote:frodo/songs/remote-song-id/sync").json()
assert synced == {"ok": True, "filename": "synced.psarc", "song_id": "remote-song-id"}
assert synced == {"ok": True, "filename": "synced.archive", "song_id": "remote-song-id"}
assert provider.sync_song_id == "remote-song-id"
+1 -1
View File
@@ -58,5 +58,5 @@ def test_get_song_info_inside_dlc_is_404_not_403(dlc_client):
"""A safe-but-missing path produces 404, not 403 — guards against
over-rejecting legitimate filenames."""
tc, _server, _dlc = dlc_client
r = tc.get("/api/song/some-song.psarc")
r = tc.get("/api/song/some-song.archive")
assert r.status_code == 404, r.text
+1 -1
View File
@@ -1,4 +1,4 @@
"""Tests for lib/loosefolder.py — pure helpers for loose CDLC folders."""
"""Tests for lib/loosefolder.py — pure helpers for loose custom song folders."""
import json
from pathlib import Path
+1 -1
View File
@@ -10,7 +10,7 @@ need the model:
* `vocals_has_signal` RMS gate over a synthesized WAV.
* `whisperx_available` graceful False when the package isn't installed.
The end-to-end positive case (PSARC without lyrics sloppak with
The end-to-end positive case (archive without lyrics sloppak with
auto-transcribed lyrics) is intentionally a manual verification step;
see the plan's verification section.
"""
+1 -1
View File
@@ -677,7 +677,7 @@ def test_cent_offset_round_trips_through_wire_format(tmp_path):
def test_cent_offset_non_finite_sanitized_to_zero(tmp_path):
# Malformed CDLC can carry NaN/Infinity, which float() accepts but which
# Malformed custom song can carry NaN/Infinity, which float() accepts but which
# serialize to invalid JSON tokens over the song_info WebSocket. Parsing
# must coerce them to a finite 0.0 so the payload stays valid JSON.
for bad in ("NaN", "Infinity", "-Infinity", "inf", "nan"):
+21 -21
View File
@@ -46,28 +46,28 @@ def test_create_requires_name(client):
def test_add_remove_reorder_persists(client, server):
for fn in ("a.psarc", "b.psarc", "c.psarc"):
for fn in ("a.archive", "b.archive", "c.archive"):
server.meta_db.put(fn, 0, 0, {})
pid = client.post("/api/playlists", json={"name": "Set"}).json()["id"]
client.post(f"/api/playlists/{pid}/songs", json={"filename": "a.psarc"})
client.post(f"/api/playlists/{pid}/songs", json={"filename": "b.psarc"})
client.post(f"/api/playlists/{pid}/songs", json={"filename": "c.psarc"})
client.post(f"/api/playlists/{pid}/songs", json={"filename": "a.archive"})
client.post(f"/api/playlists/{pid}/songs", json={"filename": "b.archive"})
client.post(f"/api/playlists/{pid}/songs", json={"filename": "c.archive"})
songs = client.get(f"/api/playlists/{pid}").json()["songs"]
assert [s["filename"] for s in songs] == ["a.psarc", "b.psarc", "c.psarc"]
assert [s["filename"] for s in songs] == ["a.archive", "b.archive", "c.archive"]
# reorder
client.post(f"/api/playlists/{pid}/reorder", json={"order": ["c.psarc", "a.psarc", "b.psarc"]})
client.post(f"/api/playlists/{pid}/reorder", json={"order": ["c.archive", "a.archive", "b.archive"]})
songs2 = client.get(f"/api/playlists/{pid}").json()["songs"]
assert [s["filename"] for s in songs2] == ["c.psarc", "a.psarc", "b.psarc"]
assert [s["filename"] for s in songs2] == ["c.archive", "a.archive", "b.archive"]
# remove
client.request("DELETE", f"/api/playlists/{pid}/songs/b.psarc")
client.request("DELETE", f"/api/playlists/{pid}/songs/b.archive")
songs3 = client.get(f"/api/playlists/{pid}").json()["songs"]
assert [s["filename"] for s in songs3] == ["c.psarc", "a.psarc"]
assert [s["filename"] for s in songs3] == ["c.archive", "a.archive"]
def test_saved_for_later_toggle_and_protection(client):
# Toggle creates the system playlist on first use.
assert client.post("/api/saved/toggle", json={"filename": "x.psarc"}).json() == {"saved": True}
assert client.post("/api/saved/toggle", json={"filename": "x.psarc"}).json() == {"saved": False}
assert client.post("/api/saved/toggle", json={"filename": "x.archive"}).json() == {"saved": True}
assert client.post("/api/saved/toggle", json={"filename": "x.archive"}).json() == {"saved": False}
saved = next(p for p in client.get("/api/playlists").json() if p["system_key"] == "saved_for_later")
# Cannot delete or rename the system playlist.
assert client.delete(f"/api/playlists/{saved['id']}").status_code == 400
@@ -76,12 +76,12 @@ def test_saved_for_later_toggle_and_protection(client):
def test_continue_session(client, server):
assert client.get("/api/session/continue").json() is None
for fn in ("one.psarc", "two.psarc"):
for fn in ("one.archive", "two.archive"):
server.meta_db.put(fn, 0, 0, {})
client.post("/api/stats", json={"filename": "one.psarc", "score": 100, "accuracy": 0.5, "lastPlayPosition": 12.0})
client.post("/api/stats", json={"filename": "two.psarc", "score": 200, "accuracy": 0.7, "lastPlayPosition": 30.0})
client.post("/api/stats", json={"filename": "one.archive", "score": 100, "accuracy": 0.5, "lastPlayPosition": 12.0})
client.post("/api/stats", json={"filename": "two.archive", "score": 200, "accuracy": 0.7, "lastPlayPosition": 30.0})
cont = client.get("/api/session/continue").json()
assert cont["filename"] == "two.psarc"
assert cont["filename"] == "two.archive"
assert cont["last_position"] == 30.0
assert "art_url" in cont and "title" in cont
@@ -89,8 +89,8 @@ def test_continue_session(client, server):
def test_add_song_to_missing_playlist_is_404(client, server):
# add_playlist_song() must not insert an orphan row for a non-existent
# playlist (the concurrent-delete TOCTOU); it returns None → handler 404s.
assert server.meta_db.add_playlist_song(999999, "x.psarc") is None
r = client.post("/api/playlists/999999/songs", json={"filename": "x.psarc"})
assert server.meta_db.add_playlist_song(999999, "x.archive") is None
r = client.post("/api/playlists/999999/songs", json={"filename": "x.archive"})
assert r.status_code == 404
@@ -98,11 +98,11 @@ def test_playlist_hides_dead_songs_when_library_populated(client, server):
# A playlist song whose file no longer exists is hidden from contents + count
# (mirrors the stats read-filter), but only while the library is populated.
db = server.meta_db
db.put("live.psarc", 0, 0, {"title": "Live"})
db.put("live.archive", 0, 0, {"title": "Live"})
pid = client.post("/api/playlists", json={"name": "P"}).json()["id"]
client.post(f"/api/playlists/{pid}/songs", json={"filename": "live.psarc"})
client.post(f"/api/playlists/{pid}/songs", json={"filename": "ghost.psarc"}) # never in songs
client.post(f"/api/playlists/{pid}/songs", json={"filename": "live.archive"})
client.post(f"/api/playlists/{pid}/songs", json={"filename": "ghost.archive"}) # never in songs
pl = client.get(f"/api/playlists/{pid}").json()
names = [s["filename"] for s in pl["songs"]]
assert "live.psarc" in names and "ghost.psarc" not in names
assert "live.archive" in names and "ghost.archive" not in names
assert [p for p in client.get("/api/playlists").json() if p["id"] == pid][0]["count"] == 1
+9 -9
View File
@@ -198,7 +198,7 @@ def test_load_content_missing_root_is_nonfatal(tmp_path):
({"name": "Monkeys Medley"}, "guitar"),
({}, "guitar"),
(None, "guitar"),
# name overrides generic guitar type (legacy PSARC keys arrangements)
# name overrides generic guitar type (legacy archive keys arrangements)
({"type": "lead", "name": "Keys"}, "keys"),
({"type": "combo", "name": "Piano"}, "keys"),
({"type": "lead", "name": "Bass"}, "bass"),
@@ -270,7 +270,7 @@ def test_select_quests_small_pool_returns_all():
def _song_event(**payload):
base = {"filename": "song.psarc", "instrument": "guitar", "accuracy": 0.9, "score": 1000}
base = {"filename": "song.archive", "instrument": "guitar", "accuracy": 0.9, "score": 1000}
base.update(payload)
return {"type": "song_completed", "payload": base}
@@ -290,10 +290,10 @@ def test_goal_matches_song_filters():
{"type": "song_completed", "instrument": "guitar", "target": 1}, _song_event()
)
assert goal_matches_event(
{"type": "song_completed", "filename": "song.psarc", "target": 1}, _song_event()
{"type": "song_completed", "filename": "song.archive", "target": 1}, _song_event()
)
assert not goal_matches_event(
{"type": "song_completed", "filename": "other.psarc", "target": 1}, _song_event()
{"type": "song_completed", "filename": "other.archive", "target": 1}, _song_event()
)
assert not goal_matches_event(
{"type": "song_completed", "min_score": 2000, "target": 1}, _song_event()
@@ -472,18 +472,18 @@ def test_evaluate_event_counts_prior_completions_toward_levelup():
def test_evaluate_event_distinct_dedupes_replays():
content = _content()
snapshot = _snapshot(paths={"guitar": 1}) # working level-2 set
first = evaluate_event(_song_event(filename="a.psarc"), content, snapshot)
first = evaluate_event(_song_event(filename="a.archive"), content, snapshot)
ch = first["challenges"][0]
assert ch["count"] == 1 and not ch["completed"]
assert ch["detail"] == {"seen": ["a.psarc"]}
assert ch["detail"] == {"seen": ["a.archive"]}
snapshot["challenges"] = {
"guitar.l2.distinct": {"count": 1, "completed": False, "detail": ch["detail"]}
}
replay = evaluate_event(_song_event(filename="a.psarc"), content, snapshot)
replay = evaluate_event(_song_event(filename="a.archive"), content, snapshot)
assert replay["challenges"] == [] # same song again: no advance
other = evaluate_event(_song_event(filename="b.psarc"), content, snapshot)
other = evaluate_event(_song_event(filename="b.archive"), content, snapshot)
ch2 = other["challenges"][0]
assert ch2["count"] == 2 and ch2["completed"]
assert other["level_ups"] == [{"path_id": "guitar", "new_level": 2}]
@@ -493,7 +493,7 @@ def test_evaluate_event_default_counts_replays():
# guitar.l1.three has no distinct flag: the same song three times completes it.
content = _content()
snapshot = _snapshot(challenges={"guitar.l1.three": {"count": 2, "completed": False}})
outcome = evaluate_event(_song_event(filename="same.psarc", accuracy=0.1), content, snapshot)
outcome = evaluate_event(_song_event(filename="same.archive", accuracy=0.1), content, snapshot)
by_id = {c["challenge_id"]: c for c in outcome["challenges"]}
assert by_id["guitar.l1.three"]["count"] == 3
assert by_id["guitar.l1.three"]["completed"] is True
+2 -2
View File
@@ -82,7 +82,7 @@ def client(server):
return TestClient(server.app)
def _scored_play(client, filename="song.psarc", accuracy=0.9, score=900, arrangement=0):
def _scored_play(client, filename="song.archive", accuracy=0.9, score=900, arrangement=0):
return client.post("/api/stats", json={
"filename": filename, "arrangement": arrangement,
"score": score, "accuracy": accuracy,
@@ -294,7 +294,7 @@ def test_stale_quest_completion_does_not_double_award(client, server, monkeypatc
summary = server.meta_db.record_progression_event(
"song_completed",
{"filename": "y.psarc", "instrument": "guitar", "accuracy": 0.5, "score": 100},
{"filename": "y.archive", "instrument": "guitar", "accuracy": 0.5, "score": 100},
server._get_progression_content(),
)
assert all(q["id"] != "d.one" for q in summary["quests_completed"])
+6 -6
View File
@@ -133,7 +133,7 @@ def test_int_optional_falls_back_on_overflow():
def test_parse_note_falls_back_to_default_on_malformed_numeric_attrs():
"""Malformed numeric XML attributes degrade gracefully.
Third-party Rocksmith XML occasionally emits empty / non-numeric
Third-party the source game XML occasionally emits empty / non-numeric
values for fields like `rightHand`. `_int_optional` (used for
optional metadata fields like `rightHand` and `pickDirection`)
falls back to the caller's default instead of raising, so a
@@ -442,7 +442,7 @@ def test_arrangement_from_wire_ignores_non_dict_tones():
def test_arrangement_tones_wire_is_json_safe():
# `definitions` is copied verbatim from the PSARC manifest — the wire
# `definitions` is copied verbatim from the archive manifest — the wire
# output must still be strict JSON (allow_nan=False, as the browser's
# JSON.parse requires).
arr = Arrangement(name="Lead", tones={
@@ -754,14 +754,14 @@ def test_string_count_uses_tuning_length_for_sparse_7_string_guitar():
def test_string_count_ignores_rs_padded_tuning_for_bass():
# RS-XML bass: tuning is padded to length 6 with zeros at
# arrangement XML bass: tuning is padded to length 6 with zeros at
# indices 4-5. Even though len(tuning) == 6, we MUST NOT use
# that as a 6-string signal (would mis-classify bass as
# guitar). arrangement_string_count's `tuning_count = 0 if
# tuning_len == 6 else tuning_len` rule takes care of this.
arr = Arrangement(
name="Bass",
tuning=[0, -5, -10, -15, 0, 0], # bass with RS XML padding
tuning=[0, -5, -10, -15, 0, 0], # bass with arrangement XML padding
notes=[Note(time=float(i), string=i, fret=0) for i in range(4)],
)
assert arrangement_string_count(arr) == 4
@@ -852,7 +852,7 @@ def test_smart_names_unknown_name_returns_none():
def test_smart_names_name_fallback_when_path_flags_zero():
# CDLC often leaves path flags at 0; fall back to arrangement name
# custom song often leaves path flags at 0; fall back to arrangement name
arrs = [_sarr(name="Lead"), _sarr(name="Rhythm"), _sarr(name="Bass")]
assert compute_smart_names(arrs) == ["Lead", "Rhythm", "Bass"]
@@ -883,7 +883,7 @@ def test_smart_names_multiple_combos_get_alt_names():
def test_smart_names_combo_and_bass_mixed():
# Real-world CDLC: 3 Combo + 1 Bass, all path flags zero
# Real-world custom song: 3 Combo + 1 Bass, all path flags zero
arrs = [
_sarr(name="Combo"),
_sarr(name="Combo"),
+64 -64
View File
@@ -28,19 +28,19 @@ def client(server):
def test_scored_session_persists_and_increments_plays(client):
r = client.post("/api/stats", json={"filename": "song.psarc", "arrangement": 0,
r = client.post("/api/stats", json={"filename": "song.archive", "arrangement": 0,
"score": 400, "accuracy": 0.6, "lastPlayPosition": 30.0})
assert r.status_code == 200
row = r.json()["stats"]
assert row["plays"] == 1 and row["best_score"] == 400 and row["best_accuracy"] == pytest.approx(0.6)
# A better replay raises best_* but plays keeps incrementing.
r2 = client.post("/api/stats", json={"filename": "song.psarc", "score": 800, "accuracy": 0.9})
r2 = client.post("/api/stats", json={"filename": "song.archive", "score": 800, "accuracy": 0.9})
row2 = r2.json()["stats"]
assert row2["plays"] == 2
assert row2["best_score"] == 800 and row2["best_accuracy"] == pytest.approx(0.9)
assert row2["last_score"] == 800
# A worse replay: best preserved, last replaced, plays still up.
r3 = client.post("/api/stats", json={"filename": "song.psarc", "score": 100, "accuracy": 0.3})
r3 = client.post("/api/stats", json={"filename": "song.archive", "score": 100, "accuracy": 0.3})
row3 = r3.json()["stats"]
assert row3["plays"] == 3
assert row3["best_score"] == 800 and row3["best_accuracy"] == pytest.approx(0.9)
@@ -50,7 +50,7 @@ def test_scored_session_persists_and_increments_plays(client):
def test_scored_session_awards_xp_and_streak(client, server):
assert server.meta_db.get_xp() == 0
from xp import xp_for_run
r = client.post("/api/stats", json={"filename": "s.psarc", "score": 900, "accuracy": 0.95})
r = client.post("/api/stats", json={"filename": "s.archive", "score": 900, "accuracy": 0.95})
prog = r.json()["progress"]
assert prog is not None
assert prog["xp"] == xp_for_run(900)
@@ -58,7 +58,7 @@ def test_scored_session_awards_xp_and_streak(client, server):
def test_position_only_touch_does_not_increment_plays(client):
r = client.post("/api/stats", json={"filename": "x.psarc", "lastPlayPosition": 42.0})
r = client.post("/api/stats", json={"filename": "x.archive", "lastPlayPosition": 42.0})
assert r.status_code == 200
row = r.json()["stats"]
assert row["plays"] == 0 and row["last_position"] == 42.0
@@ -66,7 +66,7 @@ def test_position_only_touch_does_not_increment_plays(client):
prog = r.json()["progress"]
assert prog is not None and prog["current_streak"] == 1 and prog["xp"] == 0
# A later scored session counts as the first play.
r2 = client.post("/api/stats", json={"filename": "x.psarc", "score": 100, "accuracy": 0.5})
r2 = client.post("/api/stats", json={"filename": "x.archive", "score": 100, "accuracy": 0.5})
assert r2.json()["stats"]["plays"] == 1
# The earlier resume position is preserved through the scored upsert? The
# scored session omitted last_position, so it should keep 42.0.
@@ -78,28 +78,28 @@ def test_stats_requires_filename(client):
def test_stats_requires_score_or_position(client):
assert client.post("/api/stats", json={"filename": "a.psarc"}).status_code == 400
assert client.post("/api/stats", json={"filename": "a.archive"}).status_code == 400
def test_get_song_stats_aggregates_arrangements(client):
client.post("/api/stats", json={"filename": "multi.psarc", "arrangement": 0, "score": 300, "accuracy": 0.5})
client.post("/api/stats", json={"filename": "multi.psarc", "arrangement": 1, "score": 700, "accuracy": 0.8})
body = client.get("/api/stats/multi.psarc").json()
client.post("/api/stats", json={"filename": "multi.archive", "arrangement": 0, "score": 300, "accuracy": 0.5})
client.post("/api/stats", json={"filename": "multi.archive", "arrangement": 1, "score": 700, "accuracy": 0.8})
body = client.get("/api/stats/multi.archive").json()
assert body["plays"] == 2
assert body["best_score"] == 700 and body["best_accuracy"] == pytest.approx(0.8)
assert len(body["arrangements"]) == 2
def test_recent_orders_by_last_played(client, server):
for fn in ("first.psarc", "second.psarc"):
for fn in ("first.archive", "second.archive"):
server.meta_db.put(fn, 0, 0, {})
client.post("/api/stats", json={"filename": "first.psarc", "score": 100, "accuracy": 0.5})
client.post("/api/stats", json={"filename": "second.psarc", "score": 200, "accuracy": 0.6})
client.post("/api/stats", json={"filename": "first.archive", "score": 100, "accuracy": 0.5})
client.post("/api/stats", json={"filename": "second.archive", "score": 200, "accuracy": 0.6})
recent = client.get("/api/stats/recent?limit=10").json()
names = [r["filename"] for r in recent]
# Most-recent first; both present.
assert names[0] == "second.psarc"
assert "first.psarc" in names
assert names[0] == "second.archive"
assert "first.archive" in names
# Rows carry metadata fields for the dashboard.
assert "art_url" in recent[0] and "title" in recent[0]
@@ -111,22 +111,22 @@ def test_stats_rejects_non_finite_score_accuracy(client):
# never be persisted (a stored non-finite breaks later JSON serialization).
# Numeric strings:
for bad in ({"score": "inf", "accuracy": 0.5}, {"score": 100, "accuracy": "NaN"}):
r = client.post("/api/stats", json={"filename": "nf.psarc", **bad})
r = client.post("/api/stats", json={"filename": "nf.archive", **bad})
assert r.status_code == 400, bad
# Raw JSON Infinity/NaN literals — Python's json parser accepts these, so a
# client really can send them (httpx's json= serializer cannot, hence the
# raw body).
import json as _json
for raw in (_json.dumps({"filename": "nf.psarc", "score": float("inf"), "accuracy": 0.5}),
_json.dumps({"filename": "nf.psarc", "score": 100, "accuracy": float("nan")})):
for raw in (_json.dumps({"filename": "nf.archive", "score": float("inf"), "accuracy": 0.5}),
_json.dumps({"filename": "nf.archive", "score": 100, "accuracy": float("nan")})):
r = client.post("/api/stats", content=raw, headers={"Content-Type": "application/json"})
assert r.status_code == 400, raw
# The bad writes left no row behind.
assert client.get("/api/stats/nf.psarc").json()["plays"] == 0
assert client.get("/api/stats/nf.archive").json()["plays"] == 0
def test_stats_rejects_non_finite_position(client):
r = client.post("/api/stats", json={"filename": "p.psarc", "lastPlayPosition": "inf"})
r = client.post("/api/stats", json={"filename": "p.archive", "lastPlayPosition": "inf"})
assert r.status_code == 400
@@ -139,29 +139,29 @@ def test_stats_bad_typed_fields_are_400_not_500(client):
def test_position_touch_surfaces_in_recent_and_continue(client, server):
# A non-scored resume touch must stamp last_played_at so it shows up in
# both 'Jump back in' (recent) and Continue-Playing.
server.meta_db.put("resume.psarc", 0, 0, {})
r = client.post("/api/stats", json={"filename": "resume.psarc", "arrangement": 2,
server.meta_db.put("resume.archive", 0, 0, {})
r = client.post("/api/stats", json={"filename": "resume.archive", "arrangement": 2,
"lastPlayPosition": 42.0})
assert r.status_code == 200
recent = client.get("/api/stats/recent?limit=10").json()
assert "resume.psarc" in [x["filename"] for x in recent]
assert "resume.archive" in [x["filename"] for x in recent]
cont = client.get("/api/session/continue").json()
assert cont and cont["filename"] == "resume.psarc"
assert cont and cont["filename"] == "resume.archive"
assert cont["arrangement"] == 2 and cont["last_position"] == pytest.approx(42.0)
def test_stats_requires_score_and_accuracy_together(client):
# Exactly one of score/accuracy (with or without a position) is ambiguous.
assert client.post("/api/stats", json={"filename": "x.psarc", "score": 100}).status_code == 400
assert client.post("/api/stats", json={"filename": "x.psarc", "accuracy": 0.5,
assert client.post("/api/stats", json={"filename": "x.archive", "score": 100}).status_code == 400
assert client.post("/api/stats", json={"filename": "x.archive", "accuracy": 0.5,
"lastPlayPosition": 10.0}).status_code == 400
def test_stats_rejects_out_of_range_score(client):
# A huge but finite score passes isfinite() yet overflows SQLite INTEGER.
r = client.post("/api/stats", json={"filename": "big.psarc", "score": 1e308, "accuracy": 0.5})
r = client.post("/api/stats", json={"filename": "big.archive", "score": 1e308, "accuracy": 0.5})
assert r.status_code == 400
assert client.get("/api/stats/big.psarc").json()["plays"] == 0
assert client.get("/api/stats/big.archive").json()["plays"] == 0
def test_xp_award_rejects_bool_and_overflow(client):
@@ -171,17 +171,17 @@ def test_xp_award_rejects_bool_and_overflow(client):
def test_reorder_rejects_non_permutation(client, server):
for fn in ("a.psarc", "b.psarc"):
for fn in ("a.archive", "b.archive"):
server.meta_db.put(fn, 0, 0, {})
pid = client.post("/api/playlists", json={"name": "P"}).json()["id"]
client.post(f"/api/playlists/{pid}/songs", json={"filename": "a.psarc"})
client.post(f"/api/playlists/{pid}/songs", json={"filename": "b.psarc"})
client.post(f"/api/playlists/{pid}/songs", json={"filename": "a.archive"})
client.post(f"/api/playlists/{pid}/songs", json={"filename": "b.archive"})
# Extra / missing / duplicate entries are all rejected.
assert client.post(f"/api/playlists/{pid}/reorder", json={"order": ["a.psarc"]}).status_code == 400
assert client.post(f"/api/playlists/{pid}/reorder", json={"order": ["a.archive"]}).status_code == 400
assert client.post(f"/api/playlists/{pid}/reorder",
json={"order": ["a.psarc", "a.psarc"]}).status_code == 400
json={"order": ["a.archive", "a.archive"]}).status_code == 400
assert client.post(f"/api/playlists/{pid}/reorder",
json={"order": ["b.psarc", "a.psarc"]}).status_code == 200
json={"order": ["b.archive", "a.archive"]}).status_code == 200
def test_overflow_numeric_inputs_are_not_500(client):
@@ -192,29 +192,29 @@ def test_overflow_numeric_inputs_are_not_500(client):
headers={"Content-Type": "application/json"})
assert r.status_code == 400
# Huge/inf arrangement is rejected (400), never a 500.
r2 = client.post("/api/stats", content=_json.dumps({"filename": "o.psarc", "arrangement": 1e309,
r2 = client.post("/api/stats", content=_json.dumps({"filename": "o.archive", "arrangement": 1e309,
"score": 10, "accuracy": 0.5}),
headers={"Content-Type": "application/json"})
assert r2.status_code == 400
# An out-of-int64-range playlist id is a 404, not a 500.
assert client.get("/api/playlists/%d" % (10 ** 30)).status_code == 404
assert client.post("/api/playlists/%d/songs" % (10 ** 30),
json={"filename": "x.psarc"}).status_code == 404
json={"filename": "x.archive"}).status_code == 404
def test_stats_rejects_non_integral_arrangement(client):
# 1.9 / true must be rejected, not silently truncated to 1.
assert client.post("/api/stats", json={"filename": "a.psarc", "arrangement": 1.9,
assert client.post("/api/stats", json={"filename": "a.archive", "arrangement": 1.9,
"score": 10, "accuracy": 0.5}).status_code == 400
assert client.post("/api/stats", json={"filename": "a.psarc", "arrangement": True,
assert client.post("/api/stats", json={"filename": "a.archive", "arrangement": True,
"score": 10, "accuracy": 0.5}).status_code == 400
def test_stats_rejects_out_of_range_accuracy(client):
for acc in (5, -1, 1.5):
assert client.post("/api/stats", json={"filename": "acc.psarc", "score": 10,
assert client.post("/api/stats", json={"filename": "acc.archive", "score": 10,
"accuracy": acc}).status_code == 400, acc
assert client.get("/api/stats/acc.psarc").json()["plays"] == 0
assert client.get("/api/stats/acc.archive").json()["plays"] == 0
def test_xp_award_rejects_non_integral_amount(client):
@@ -224,10 +224,10 @@ def test_xp_award_rejects_non_integral_amount(client):
def test_stats_rejects_boolean_numeric_fields(client):
# JSON booleans must not be coerced via float() into a recorded play / position.
assert client.post("/api/stats", json={"filename": "b.psarc", "score": True, "accuracy": 0.5}).status_code == 400
assert client.post("/api/stats", json={"filename": "b.psarc", "score": 10, "accuracy": False}).status_code == 400
assert client.post("/api/stats", json={"filename": "b.psarc", "lastPlayPosition": False}).status_code == 400
assert client.get("/api/stats/b.psarc").json()["plays"] == 0
assert client.post("/api/stats", json={"filename": "b.archive", "score": True, "accuracy": 0.5}).status_code == 400
assert client.post("/api/stats", json={"filename": "b.archive", "score": 10, "accuracy": False}).status_code == 400
assert client.post("/api/stats", json={"filename": "b.archive", "lastPlayPosition": False}).status_code == 400
assert client.get("/api/stats/b.archive").json()["plays"] == 0
def test_award_xp_service_tolerates_bad_amount(client, server):
@@ -245,26 +245,26 @@ def test_best_map_includes_scored_zero_excludes_resume_only(client, server):
# A scored 0% song (plays>0) must appear in /api/stats/best; a resume-only
# touch (plays==0, default best 0) must not — both are real library songs,
# so the exclusion is the plays>0 rule, not the existing-song filter.
for fn in ("zero.psarc", "resume.psarc"):
for fn in ("zero.archive", "resume.archive"):
server.meta_db.put(fn, 0, 0, {})
client.post("/api/stats", json={"filename": "zero.psarc", "score": 0, "accuracy": 0.0})
client.post("/api/stats", json={"filename": "resume.psarc", "lastPlayPosition": 12.0})
client.post("/api/stats", json={"filename": "zero.archive", "score": 0, "accuracy": 0.0})
client.post("/api/stats", json={"filename": "resume.archive", "lastPlayPosition": 12.0})
best = client.get("/api/stats/best").json()
assert "zero.psarc" in best and best["zero.psarc"] == 0.0
assert "resume.psarc" not in best
assert "zero.archive" in best and best["zero.archive"] == 0.0
assert "resume.archive" not in best
def test_dead_song_stats_hidden_when_library_populated(client, server):
# When the songs table is populated, stats for a song that ISN'T in it are
# hidden from reads (race-free orphan handling) — but a present song shows.
db = server.meta_db
db.put("keep.psarc", 0, 0, {"title": "Keep"})
client.post("/api/stats", json={"filename": "keep.psarc", "score": 200, "accuracy": 0.8})
client.post("/api/stats", json={"filename": "ghost.psarc", "score": 100, "accuracy": 0.5}) # no songs row
db.put("keep.archive", 0, 0, {"title": "Keep"})
client.post("/api/stats", json={"filename": "keep.archive", "score": 200, "accuracy": 0.8})
client.post("/api/stats", json={"filename": "ghost.archive", "score": 100, "accuracy": 0.5}) # no songs row
best = client.get("/api/stats/best").json()
assert "keep.psarc" in best and "ghost.psarc" not in best
assert "keep.archive" in best and "ghost.archive" not in best
recent = [r["filename"] for r in client.get("/api/stats/recent?limit=10").json()]
assert "keep.psarc" in recent and "ghost.psarc" not in recent
assert "keep.archive" in recent and "ghost.archive" not in recent
def test_delete_missing_does_not_destroy_stats(client, server):
@@ -273,23 +273,23 @@ def test_delete_missing_does_not_destroy_stats(client, server):
# its full history. A second song keeps the library non-empty so the
# existing-song read filter stays active.
db = server.meta_db
db.put("s.psarc", 0, 0, {"title": "S"})
db.put("other.psarc", 0, 0, {"title": "Other"})
client.post("/api/stats", json={"filename": "s.psarc", "score": 300, "accuracy": 0.9})
db.delete_missing({"other.psarc"}) # s no longer "on disk" → songs row removed (other kept)
assert "s.psarc" not in client.get("/api/stats/best").json() # hidden, library still populated
db.put("s.psarc", 0, 0, {"title": "S"}) # song comes back under the same name
db.put("s.archive", 0, 0, {"title": "S"})
db.put("other.archive", 0, 0, {"title": "Other"})
client.post("/api/stats", json={"filename": "s.archive", "score": 300, "accuracy": 0.9})
db.delete_missing({"other.archive"}) # s no longer "on disk" → songs row removed (other kept)
assert "s.archive" not in client.get("/api/stats/best").json() # hidden, library still populated
db.put("s.archive", 0, 0, {"title": "S"}) # song comes back under the same name
best = client.get("/api/stats/best").json()
assert "s.psarc" in best and best["s.psarc"] == pytest.approx(0.9) # stats survived the prune
assert "s.archive" in best and best["s.archive"] == pytest.approx(0.9) # stats survived the prune
def test_stats_arrangement_bounded_to_song_arrangements(client, server):
# For a known library song, an out-of-range arrangement index is rejected
# (can't create a fake arrangement bucket); index 0 is fine.
server.meta_db.put("multi.psarc", 0, 0, {"arrangements": [{"name": "Lead"}, {"name": "Bass"}]})
assert client.post("/api/stats", json={"filename": "multi.psarc", "arrangement": 5,
server.meta_db.put("multi.archive", 0, 0, {"arrangements": [{"name": "Lead"}, {"name": "Bass"}]})
assert client.post("/api/stats", json={"filename": "multi.archive", "arrangement": 5,
"score": 10, "accuracy": 0.5}).status_code == 400
assert client.post("/api/stats", json={"filename": "multi.psarc", "arrangement": 1,
assert client.post("/api/stats", json={"filename": "multi.archive", "arrangement": 1,
"score": 10, "accuracy": 0.5}).status_code == 200