mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-09-10 19:44:30 +00:00
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:
+3
-3
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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 (1–4) 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);
|
||||
|
||||
@@ -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 & 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; }
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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' });
|
||||
|
||||
@@ -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' });
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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'));
|
||||
});
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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,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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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.
|
||||
"""
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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"),
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user