Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]

### Added
- **Import GoPlayAlong projects (`.gp` + audio + a GoPlayAlong `.xml`).** A GoPlayAlong export is a `<track>` **sync sidecar** — it points at a Guitar Pro score and an audio file and stores the bar→audio sync points, but carries **no chart** — so feeding it to the arrangement importer failed with "not a recognised EOF arrangement XML". Now, in the New-dialog Content Import, drop the Guitar Pro tab + the audio + the GoPlayAlong `.xml` together: the `.xml` is content-sniffed and staged as a **GoPlayAlong sync source** (not mistaken for an EOF arrangement, and it prefills title/artist), and on Import the editor applies GoPlayAlong's **authored** per-bar sync instead of re-deriving it via onset detection. Under the hood: new `goplayalong.py` parser + `/api/plugins/editor/parse-goplayalong-sync` endpoint emit the same `sync_points` / `audio_offset` shape `autosync-gp` / `extract-gp-sync` return, which the existing `convert-gp` warp path consumes (`sync_applied: "warp"`). All GoPlayAlong UI logic is gated on a staged sidecar, so normal GP/EOF/audio imports are byte-for-byte unchanged. Tests: `tests/test_goplayalong.py` (10 cases vs a real export). Verified end-to-end against a real GoPlayAlong project ("Would?" — Alice in Chains): 73 sync points → the referenced `.gp` (87 bars, 7 tracks) → 73 warp anchors → a monotonic per-bar warp.
- **GP import with auto-sync now applies the full per-bar sync map (Songsterr-style), and the auto-sync audio can come from a YouTube URL.** Previously the auto-sync flow computed per-bar sync points but `convert-gp` applied only the scalar bar-1 offset, so recordings that drift from the tab's authored tempo went audibly out of sync over the song. Now: `convert-gp` accepts the `sync_points` payload back from the client and warps the whole converted chart (notes, sustains, beats, sections, handshapes, phrase levels, keys notation sidecars) onto the recording's timeline via core's new `lib.gp_autosync` warp helpers, responding with `sync_applied: "warp"`; it falls back to the scalar offset (`sync_applied: "offset"`) for GP3/4/5 files that use repeats/voltas/directions (their playback expansion can't be mapped from as-written sync points), when anchors degenerate, or when core lacks the new helpers. The create flow auto-runs the refine pass (onset phase sweep — requires the new core `refine_sync`, which this endpoint always imported but which never existed until now) right after the coarse DTW sync, and both refine calls send `gp_path` so refinement uses exact per-bar score times instead of a 4/4 approximation. The auto-sync section gains a YouTube URL input (reusing `/youtube-audio`) beside the file upload; the fetched audio becomes both the alignment target and the imported song audio.
- **Coarse triplet snap divisions — `1/3T` and `1/6T`.** The snap grid now offers
quarter-note (`1/3T`) and eighth-note (`1/6T`) triplet resolutions alongside the
Expand Down
193 changes: 193 additions & 0 deletions goplayalong.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
"""GoPlayAlong project parser.

GoPlayAlong (https://goplayalong.com) exports a small XML "sync sidecar" next to
a Guitar Pro score + an audio file. Unlike an EOF/RS arrangement XML (which
carries the actual notes), a GoPlayAlong ``<track>`` file carries **no chart** —
it points at a ``.gp`` score and an audio file and stores the sync points that
align the score's bars to the audio. That's why feeding one to the EOF importer
fails ("not a recognised EOF arrangement XML"): it's a different format.

This module turns that XML into the same bar→audio-time sync model the editor
already applies to Guitar Pro imports (see ``lib.gp8_audio_sync`` /
``convert-gp``), so a GoPlayAlong project (``.gp`` + audio + this XML) can be
imported with its authored sync instead of re-deriving it via onset detection.

Shape of the file::

<track id="1" title="Would?" artist="Alice in Chains">
<scoreUrl>1. Would.gp</scoreUrl>
<audioUrl>1. Would.mp3</audioUrl>
<sync>N#audioMs;bar;beat;msPerBeat#audioMs;bar;beat;msPerBeat#...</sync>
</track>

The ``<sync>`` payload is ``#``-separated: the first token is the sync-point
**count**, each remaining token is ``audioMs;bar;beat;msPerBeat`` where
``audioMs`` is the position in the audio (milliseconds), ``bar`` is the 1-indexed
score measure, ``beat`` is the beat offset within the bar (0 = downbeat), and
``msPerBeat`` is the local tempo in milliseconds per quarter note.

Pure stdlib — no dependency on the core ``lib`` package — so it unit-tests
standalone. ``routes.py`` maps :class:`GoPlayAlongProject` onto the editor's
existing ``sync_points`` response shape.
"""

from __future__ import annotations

import math
from dataclasses import dataclass, field

# Prefer defusedxml (hardened against entity-expansion / external-entity attacks
# on untrusted uploads); fall back to the stdlib parser when it isn't installed.
try: # pragma: no cover - import wiring
from defusedxml import ElementTree as _ET
except Exception: # pragma: no cover - import wiring
import xml.etree.ElementTree as _ET
Comment on lines +39 to +44

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== Files of interest ==\n'
git ls-files | rg '(^|/)(goplayalong\.py|routes\.py|requirements(\.txt)?|pyproject\.toml|Pipfile|poetry\.lock|setup\.py|setup\.cfg)$'

printf '\n== goplayalong.py import + XML parsing locations ==\n'
ast-grep outline goplayalong.py --view expanded || true

printf '\n== routes.py upload flow ==\n'
ast-grep outline routes.py --view expanded || true

printf '\n== defusedxml dependency declaration ==\n'
rg -n --hidden --glob '!**/.git/**' 'defusedxml' . || true

printf '\n== Relevant snippets ==\n'
sed -n '1,120p' goplayalong.py
printf '\n--- routes.py ---\n'
sed -n '1,220p' routes.py

Repository: got-feedBack/feedBack-plugin-editor

Length of output: 19001


Make defusedxml mandatory here. goplayalong.py:38-43 still falls back to xml.etree.ElementTree, which reopens XXE/entity-expansion risk when parsing untrusted uploads. Fail fast instead of silently downgrading to the stdlib parser.

🧰 Tools
🪛 Ruff (0.15.20)

[warning] 42-42: Do not catch blind exception: Exception

(BLE001)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@goplayalong.py` around lines 38 - 43, Make defusedxml mandatory in
goplayalong.py by removing the fallback to xml.etree.ElementTree in the import
block. Update the top-level import logic around the _ET alias so it fails fast
if defusedxml is unavailable, instead of silently downgrading to the stdlib
parser and reintroducing unsafe XML parsing for untrusted uploads.



@dataclass
class GpaSyncPoint:
"""One bar→audio anchor. ``modified_bpm`` is the local tempo derived from the
file's ``msPerBeat`` (``60000 / msPerBeat``)."""
bar: int
time_secs: float
modified_bpm: float
beat: float = 0.0


@dataclass
class GoPlayAlongProject:
title: str = ""
artist: str = ""
score_url: str = "" # referenced .gp filename (as authored in the XML)
audio_url: str = "" # referenced audio filename
audio_offset: float = 0.0 # seconds of audio before score bar 1 (extrapolated)
sync_points: list[GpaSyncPoint] = field(default_factory=list)
declared_count: int = 0 # the count token the file declared (for validation)


def _local_name(tag: str) -> str:
"""Strip any XML namespace so ``{ns}track`` compares as ``track``."""
return tag.rsplit("}", 1)[-1] if "}" in tag else tag


def is_goplayalong_xml(text: str | bytes) -> bool:
"""True when ``text`` is a GoPlayAlong ``<track>`` sync file.

Cheap + tolerant: parses the root and checks it's a ``track`` element that
carries a ``<sync>`` child (or a ``<scoreUrl>``). Returns False (never
raises) for EOF ``<song>`` arrangements, MusicXML, and anything unparseable —
so callers can use it as a routing gate before the EOF importer.
"""
try:
root = _ET.fromstring(text.encode("utf-8") if isinstance(text, str) else text)
except Exception:
return False
if _local_name(root.tag) != "track":
return False
children = {_local_name(c.tag) for c in root}
return "sync" in children or "scoreUrl" in children


def _parse_sync_payload(payload: str) -> tuple[int, list[GpaSyncPoint]]:
"""Parse the ``<sync>`` text into (declared_count, sync_points).

Tolerant of trailing/empty segments and malformed individual points (a bad
point is skipped, not fatal) so a single stray ``#`` can't sink the import.
"""
parts = [p for p in payload.strip().split("#") if p != ""]
if not parts:
return 0, []

declared = 0
first = parts[0]
# The leading token is the point count *iff* it's a bare integer (no ';').
# If the file omitted it (or it's actually a point), fall through and treat
# every token as a point.
if ";" not in first:
try:
declared = int(first.strip())
parts = parts[1:]
except ValueError:
declared = 0

points: list[GpaSyncPoint] = []
for seg in parts:
fields = seg.split(";")
if len(fields) < 2:
continue
try:
audio_ms = float(fields[0])
bar_f = float(fields[1])
beat = float(fields[2]) if len(fields) > 2 and fields[2] != "" else 0.0
ms_per_beat = float(fields[3]) if len(fields) > 3 and fields[3] != "" else 0.0
except (ValueError, IndexError, OverflowError):
continue
# Reject non-finite values (inf / -inf / nan): they can't be a real sync
# point, int(inf) raises OverflowError, and an inf time_secs would
# serialize to non-JSON-compliant `Infinity` and 500 the endpoint at
# response render. Skip the point rather than fail the whole import.
if not (math.isfinite(audio_ms) and math.isfinite(bar_f)
and math.isfinite(beat) and math.isfinite(ms_per_beat)):
continue
bar = int(bar_f)
bpm = (60000.0 / ms_per_beat) if ms_per_beat > 0 else 0.0
points.append(GpaSyncPoint(bar=bar, time_secs=audio_ms / 1000.0,
modified_bpm=round(bpm, 4), beat=beat))
# Keep chronological order regardless of file ordering.
points.sort(key=lambda sp: (sp.time_secs, sp.bar))
return declared, points


def _extrapolate_bar1_offset(points: list[GpaSyncPoint]) -> float:
"""Audio time (seconds) of score bar 1, extrapolated from the first two
sync points' per-bar slope. Returns the first point's own time when there's
only one point, or 0.0 when there are none. This is the coarse
``audio_offset`` (seconds of audio before bar 1); the sync points remain the
authoritative fine mapping.
"""
if not points:
return 0.0
if len(points) == 1:
return points[0].time_secs
a, b = points[0], points[1]
if b.bar == a.bar:
return a.time_secs
slope = (b.time_secs - a.time_secs) / (b.bar - a.bar) # seconds per bar
return a.time_secs - (a.bar - 1) * slope


def parse_goplayalong(text: str | bytes) -> GoPlayAlongProject:
"""Parse a GoPlayAlong ``<track>`` XML into a :class:`GoPlayAlongProject`.

Raises ``ValueError`` if the root isn't a GoPlayAlong ``track`` element or it
carries no usable sync points.
"""
try:
root = _ET.fromstring(text.encode("utf-8") if isinstance(text, str) else text)
except Exception as e: # noqa: BLE001 - surface a clean message to the caller
raise ValueError(f"Not valid XML: {e}") from e

if _local_name(root.tag) != "track":
raise ValueError(
"Not a GoPlayAlong file (expected a <track> root with <sync> data)."
)

proj = GoPlayAlongProject(
title=(root.get("title") or "").strip(),
artist=(root.get("artist") or "").strip(),
)
for child in root:
name = _local_name(child.tag)
val = (child.text or "").strip()
if name == "scoreUrl":
proj.score_url = val
elif name == "audioUrl":
proj.audio_url = val
elif name == "sync":
proj.declared_count, proj.sync_points = _parse_sync_payload(val)

if not proj.sync_points:
raise ValueError("GoPlayAlong file carried no usable <sync> points.")

proj.audio_offset = round(_extrapolate_bar1_offset(proj.sync_points), 4)
return proj
58 changes: 58 additions & 0 deletions routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -4989,6 +4989,64 @@ def _run():
_elog.getLogger("slopsmith.plugin.editor").exception("refine-sync failed")
return JSONResponse({"error": "Refine failed. See server logs for details."}, 500)

# ── GoPlayAlong sync sidecar → sync_points ───────────────────────

def _load_goplayalong():
"""Load the sibling goplayalong parser (namespaced via load_sibling when
the host provides it; bare import otherwise — the plugin dir is on
sys.path and the module name is unique)."""
_ls = context.get("load_sibling")
if _ls:
return _ls("goplayalong")
import goplayalong # noqa: PLC0415 - lazy, optional fallback
return goplayalong
Comment on lines +4994 to +5002

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

No fallback for a failed module load.

_load_goplayalong() can raise ImportError (bare import goplayalong failing) with nothing catching it in parse_goplayalong_sync, producing an unhandled 500 instead of a clear error. Compare with autosync-gp/refine-sync above, which wrap their optional imports in try/except ImportError and return a clean 503.

Suggested fix
     `@app.post`("/api/plugins/editor/parse-goplayalong-sync")
     async def parse_goplayalong_sync(file: UploadFile = File(...)):
         ...
         raw = await file.read()
-        gpa = _load_goplayalong()
+        try:
+            gpa = _load_goplayalong()
+        except ImportError:
+            return JSONResponse(
+                {"error": "GoPlayAlong parser module is unavailable on this server."}, 503)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@routes.py` around lines 4994 - 5002, Update _load_goplayalong() so the
optional parser import cannot bubble up an ImportError into
parse_goplayalong_sync. Mirror the autosync-gp/refine-sync pattern by wrapping
the bare import goplayalong fallback in try/except ImportError and returning a
clear failure path (or sentinel) that the caller can turn into a 503, while
keeping the load_sibling("goplayalong") path intact.


@app.post("/api/plugins/editor/parse-goplayalong-sync")
async def parse_goplayalong_sync(file: UploadFile = File(...)):
"""Parse a GoPlayAlong (.xml) sync sidecar into the editor's sync model.

GoPlayAlong (goplayalong.com) exports a ``<track>`` XML that points at a
Guitar Pro score + an audio file and stores the bar→audio sync points —
it carries **no chart**, which is why the EOF importer rejects it as "not
a recognised EOF arrangement XML". This returns the same ``sync_points`` /
``audio_offset`` shape as ``autosync-gp`` / ``extract-gp-sync``, so the
existing ``convert-gp`` path applies the authored sync to the referenced
``.gp`` instead of re-deriving it via onset detection. ``score_url`` /
``audio_url`` tell the caller which files the project references.
"""
raw = await file.read()
gpa = _load_goplayalong()
if not gpa.is_goplayalong_xml(raw):
return JSONResponse(
{"error": "Not a GoPlayAlong file — a GoPlayAlong export is a "
"<track> XML with <sync> data alongside a .gp score."}, 400)
try:
proj = gpa.parse_goplayalong(raw)
Comment on lines +5017 to +5024

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

Is Python's xml.etree.ElementTree vulnerable to billion laughs / entity expansion attacks by default?

💡 Result:

Yes, Python's xml.etree.ElementTree module can be vulnerable to the "billion laughs" (exponential entity expansion) and other entity-based attacks depending on the underlying Expat library version used by your Python installation [1][2][3]. The module relies on the Expat XML parser. Whether it is vulnerable depends on the version of Expat installed on your system [4][3]. Recent versions of Expat (specifically 2.4.1 and newer) contain built-in protections against the "billion laughs" and "quadratic blowup" vulnerabilities [4][3]. However, if your environment is linked against an older version of Expat, it may remain vulnerable [4][1][3]. You can check your current version using pyexpat.EXPAT_VERSION [4][3]. Because Python's standard library XML modules, including ElementTree, are generally not considered secure against maliciously constructed data, official documentation explicitly recommends using the defusedxml package when parsing untrusted or unauthenticated XML data [3]. Key takeaways: - Vulnerability status is dependent on the underlying system Expat library, not just the Python version [4][3]. - Expat versions below 2.4.1 are known to be vulnerable to billion laughs and quadratic blowup attacks [4][3]. - Even with updated Expat versions, the standard library XML modules are advised against for untrusted data because they are not hardened against other XML-based attacks [3]. - Using defusedxml is the standard recommended practice for securing XML parsing in Python [3].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Locate the GoPlayAlong helpers and the route in question.
git ls-files | rg '(^|/)(routes\.py|.*goplayalong.*|.*xml.*)$'

echo '--- routes.py around the cited lines ---'
sed -n '4995,5045p' routes.py

echo '--- search for GoPlayAlong parser definitions/usages ---'
rg -n "is_goplayalong_xml|parse_goplayalong|_load_goplayalong|xml\.etree|ElementTree|fromstring|defusedxml" .

Repository: got-feedBack/feedBack-plugin-editor

Length of output: 5557


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- goplayalong.py outline ---'
ast-grep outline goplayalong.py --view expanded

echo '--- goplayalong.py relevant sections ---'
sed -n '1,220p' goplayalong.py

echo '--- dependency mentions for defusedxml ---'
rg -n "defusedxml" pyproject.toml requirements*.txt setup*.py README* . .

Repository: got-feedBack/feedBack-plugin-editor

Length of output: 8905


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Check whether the project pins or documents defusedxml, and whether tests cover malformed XML / entity handling.
rg -n "defusedxml|billion laughs|entity|DOCTYPE|external entity|XXE" goplayalong.py tests pyproject.toml requirements*.txt README* .

Repository: got-feedBack/feedBack-plugin-editor

Length of output: 2010


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- goplayalong.py lines 1-210 ---'
cat -n goplayalong.py | sed -n '1,210p'

echo '--- dependency/config files mentioning defusedxml ---'
git ls-files | rg '^(pyproject\.toml|requirements.*\.txt|setup\.py|setup\.cfg|Pipfile|poetry\.lock|uv\.lock|README.*)$' | xargs -r -I{} sh -c 'echo "--- {} ---"; rg -n "defusedxml" "{}" || true'

Repository: got-feedBack/feedBack-plugin-editor

Length of output: 8911


Make hardened XML parsing mandatory for GoPlayAlong uploads. goplayalong.py still falls back to xml.etree.ElementTree when defusedxml isn't installed, so this unauthenticated upload path can still hit entity-expansion DoS unless the dependency is required or the fallback is removed.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@routes.py` around lines 5017 - 5024, Make hardened XML parsing mandatory in
the GoPlayAlong upload flow by updating _load_goplayalong and the goplayalong.py
parser path so it no longer falls back to xml.etree.ElementTree for
parse_goplayalong or is_goplayalong_xml. Require defusedxml as a hard dependency
(or remove the fallback entirely) and ensure the upload handler that calls
gpa.parse_goplayalong only ever uses the hardened parser implementation.

except (ValueError, OverflowError) as e:
return JSONResponse(
{"error": f"Could not parse GoPlayAlong file: {e}"}, 400)
return {
"ok": True,
"title": proj.title,
"artist": proj.artist,
"score_url": proj.score_url,
"audio_url": proj.audio_url,
"audio_offset": proj.audio_offset,
"sync_point_count": len(proj.sync_points),
"sync_points": [
{
"bar": sp.bar,
"time_secs": round(sp.time_secs, 3),
"modified_bpm": round(sp.modified_bpm, 2),
# GoPlayAlong stores only the synced tempo, not the score's
# authored tempo; mirror it so convert-gp's warp ratio is a
# no-op (the bar→time_secs pairs are the authoritative map).
"original_bpm": round(sp.modified_bpm, 2),
}
for sp in proj.sync_points
],
}

# ── MIDI import: list tracks ─────────────────────────────────────

@app.post("/api/plugins/editor/import-midi")
Expand Down
Loading