mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-09-15 09:20:06 +00:00
Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2281e6ce5e | ||
|
|
c7fa0ca3f1 | ||
|
|
7b85eef104 | ||
|
|
e9be269bb7 | ||
|
|
1b8186b076 | ||
|
|
9a2e98ed74 | ||
|
|
e0f1e2b641 |
@@ -1,90 +0,0 @@
|
|||||||
name: Content packs
|
|
||||||
|
|
||||||
# Build & publish opt-in career venue packs as per-pack, versioned, immutable
|
|
||||||
# releases (convention: tag `venue-<id>-v<N>`, asset `<id>-pack-v<N>.zip`),
|
|
||||||
# then open a PR bumping venues.json url/sha256/bytes. This is automation so
|
|
||||||
# publishing packs is never a person's manual job (SLIM-NIGHTLY item 1b).
|
|
||||||
#
|
|
||||||
# Immutable tags → publishing is a deliberate, versioned act, so this runs on
|
|
||||||
# manual dispatch (not push): a media change means a new version, a human call.
|
|
||||||
on:
|
|
||||||
workflow_dispatch:
|
|
||||||
inputs:
|
|
||||||
venues:
|
|
||||||
description: "Space-separated venue ids to (re)publish, e.g. 'club arena'"
|
|
||||||
required: true
|
|
||||||
default: "club"
|
|
||||||
version:
|
|
||||||
description: "Pack version N (tag venue-<id>-vN). Bump for new media."
|
|
||||||
required: true
|
|
||||||
default: "1"
|
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: content-packs
|
|
||||||
cancel-in-progress: false
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
pull-requests: write
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
publish:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
# Media lives in-tree today; add `lfs: true` once SLIM-NIGHTLY item 4
|
|
||||||
# moves venue-packs/** to Git LFS.
|
|
||||||
|
|
||||||
- uses: actions/setup-python@v5
|
|
||||||
with:
|
|
||||||
python-version: '3.12'
|
|
||||||
|
|
||||||
- name: Build & publish packs
|
|
||||||
env:
|
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
# Never interpolate dispatch inputs straight into the shell — a crafted
|
|
||||||
# value would execute on the runner with this job's write token. Pass
|
|
||||||
# via env, validate the formats, and use a Bash argument array.
|
|
||||||
VENUES: ${{ github.event.inputs.venues }}
|
|
||||||
VERSION: ${{ github.event.inputs.version }}
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
[[ "$VERSION" =~ ^[1-9][0-9]*$ ]] || { echo "::error::version must be a positive integer"; exit 1; }
|
|
||||||
[[ "$VENUES" =~ ^[a-z0-9][a-z0-9-]*(\ [a-z0-9][a-z0-9-]*)*$ ]] || { echo "::error::venues must be space-separated venue ids"; exit 1; }
|
|
||||||
read -r -a venues <<< "$VENUES"
|
|
||||||
dirs=()
|
|
||||||
for v in "${venues[@]}"; do
|
|
||||||
dirs+=("plugins/career/venue-packs/$v")
|
|
||||||
done
|
|
||||||
python tools/content_packs.py "${dirs[@]}" \
|
|
||||||
--version "$VERSION" \
|
|
||||||
--publish \
|
|
||||||
--manifest /tmp/packs-manifest.json
|
|
||||||
cat /tmp/packs-manifest.json
|
|
||||||
|
|
||||||
- name: Apply url/sha256/bytes to venues.json
|
|
||||||
run: |
|
|
||||||
python - <<'PY'
|
|
||||||
import json, pathlib
|
|
||||||
manifest = json.load(open("/tmp/packs-manifest.json"))
|
|
||||||
vpath = pathlib.Path("plugins/career/venues.json")
|
|
||||||
data = json.loads(vpath.read_text())
|
|
||||||
for v in data["venues"]:
|
|
||||||
m = manifest.get(v["id"])
|
|
||||||
if m and v.get("pack"):
|
|
||||||
v["pack"].update(url=m["url"], sha256=m["sha256"], bytes=m["bytes"])
|
|
||||||
vpath.write_text(json.dumps(data, indent=4) + "\n")
|
|
||||||
PY
|
|
||||||
|
|
||||||
- name: Open manifest-bump PR
|
|
||||||
uses: peter-evans/create-pull-request@v6
|
|
||||||
with:
|
|
||||||
commit-message: "career: refresh venue pack manifest (v${{ github.event.inputs.version }})"
|
|
||||||
title: "career: refresh venue pack manifest (v${{ github.event.inputs.version }})"
|
|
||||||
body: |
|
|
||||||
Automated by the content-packs workflow after publishing
|
|
||||||
`${{ github.event.inputs.venues }}` v${{ github.event.inputs.version }}
|
|
||||||
to their `venue-<id>-v${{ github.event.inputs.version }}` releases.
|
|
||||||
Bumps `venues.json` pack url/sha256/bytes to match the uploaded zips.
|
|
||||||
branch: content-packs/manifest-bump
|
|
||||||
delete-branch: true
|
|
||||||
+1
-27
@@ -8,30 +8,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
- **Opt-in career venue packs (#122)** — higher-tier venue crowd media
|
|
||||||
(`club`, `arena`) is no longer bundled; the app downloads each pack on demand
|
|
||||||
from its release when you reach the venue (sha256-verified), keeping the
|
|
||||||
starter `bar` venue bundled for offline play. Trims ~678 MB from the desktop
|
|
||||||
download; an unpublished pack shows "coming soon" and plays on the standard
|
|
||||||
stage until its release lands.
|
|
||||||
- **Session-sync relay WebSocket — `/ws/sync/{session_id}` (#1030).** A
|
|
||||||
deliberately dumb JSON fan-out room: a text frame received from one client is
|
|
||||||
forwarded verbatim to every other client on the same session id; the server
|
|
||||||
interprets nothing (message schemas are owned by consumers). Rooms are created
|
|
||||||
on first join and garbage-collected when the last socket leaves — no history,
|
|
||||||
no replay, no persistence, so a host that crashes and rejoins the same id
|
|
||||||
resumes publishing to reconnecting subscribers with no server-side
|
|
||||||
coordination. Session ids are client-generated (`[A-Za-z0-9_-]{4,64}`); DoS
|
|
||||||
hygiene for a LAN-exposed port via frame-size (16 KB), per-room (16 sockets),
|
|
||||||
total-room (32), and per-socket rate (120 msg/s sustained, 240 burst) caps —
|
|
||||||
over-limit sockets are closed with a policy code and the room carries on, and
|
|
||||||
a peer that dies — or stalls: fan-out sends are bounded by a 5 s timeout —
|
|
||||||
mid-fan-out is dropped without disturbing delivery to the rest. `main.py`
|
|
||||||
also caps inbound WS frames at the transport (`ws_max_size=64 KB`, down from
|
|
||||||
uvicorn's 16 MB default) so oversized frames never materialize server-side. First consumer: splitscreen's "pop out to LAN" follower mode
|
|
||||||
(feedBack-plugin-splitscreen#21), which relays playhead/playstate/song-change
|
|
||||||
frames from a host window to view-only followers on other LAN devices.
|
|
||||||
Implementation in `lib/routers/ws_sync.py`; tests in `tests/test_ws_sync.py`.
|
|
||||||
- **Drum-part picker (feedpak 1.17.0 "drums as arrangements").** When a song
|
- **Drum-part picker (feedpak 1.17.0 "drums as arrangements").** When a song
|
||||||
carries several drum charts, a **Drum part** selector appears beside the
|
carries several drum charts, a **Drum part** selector appears beside the
|
||||||
arrangement switcher (advanced settings) so a player can choose which drummer
|
arrangement switcher (advanced settings) so a player can choose which drummer
|
||||||
@@ -54,9 +30,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
loaded as fretted arrangements — the loader's file/notation gate keeps a drum
|
loaded as fretted arrangements — the loader's file/notation gate keeps a drum
|
||||||
part out of the fretted pipeline (and out of note-detection grading), pinned
|
part out of the fretted pipeline (and out of note-detection grading), pinned
|
||||||
by test. Legacy single-drum packs read exactly as before, as a one-part list.
|
by test. Legacy single-drum packs read exactly as before, as a one-part list.
|
||||||
- **`chart-transform` capability domain (#952)** — plugins can now remap the
|
- **Chart-transform coordinator.** Synchronous transforms run after difficulty filtering; host
|
||||||
chart before rendering and scoring through a core-owned provider
|
|
||||||
coordinator. Synchronous transforms run after difficulty filtering; host
|
|
||||||
data is isolated from providers, accepted timelines are time-sorted, and
|
data is isolated from providers, accepted timelines are time-sorted, and
|
||||||
failures fall back to the original chart with a fixed public reason.
|
failures fall back to the original chart with a fixed public reason.
|
||||||
Effective chart arrays and metadata are available to 2D/custom renderers
|
Effective chart arrays and metadata are available to 2D/custom renderers
|
||||||
|
|||||||
+2
-436
@@ -1,6 +1,6 @@
|
|||||||
"""MIDI file import — list tracks and convert tracks to sloppak payloads.
|
"""MIDI file import — list tracks and convert tracks to sloppak payloads.
|
||||||
|
|
||||||
Three parallel flows live here:
|
Two parallel flows live here:
|
||||||
|
|
||||||
- **Keys path** (`list_midi_tracks` + `convert_midi_track_to_keys_wire`):
|
- **Keys path** (`list_midi_tracks` + `convert_midi_track_to_keys_wire`):
|
||||||
filters channel-9 out and emits a standard guitar-style arrangement that
|
filters channel-9 out and emits a standard guitar-style arrangement that
|
||||||
@@ -11,19 +11,12 @@ Three parallel flows live here:
|
|||||||
`docs/sloppak-spec.md` §5.3, ready to drop alongside the sloppak
|
`docs/sloppak-spec.md` §5.3, ready to drop alongside the sloppak
|
||||||
manifest's `drum_tab:` key.
|
manifest's `drum_tab:` key.
|
||||||
|
|
||||||
- **Lyrics path** (`extract_midi_lyrics`): reads SMF Lyric (0x05) meta events
|
The editor's track picker uses both for the +Drums and +Keys modals.
|
||||||
(with a Text-event fallback on vocal-ish tracks, covering karaoke `.kar`
|
|
||||||
files) and emits the `lyrics.json` / `vocal_pitch.json` sidecar payloads
|
|
||||||
documented in feedpak-spec §7.1 / §7.2, ready to drop alongside the
|
|
||||||
manifest's `lyrics:` / `lyrics_source:` / `vocal_pitch:` keys.
|
|
||||||
|
|
||||||
The editor's track picker uses the first two for the +Drums and +Keys modals.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import math
|
import math
|
||||||
import re
|
|
||||||
from bisect import bisect_right
|
from bisect import bisect_right
|
||||||
from collections import deque
|
from collections import deque
|
||||||
from typing import Callable
|
from typing import Callable
|
||||||
@@ -784,430 +777,3 @@ def convert_drum_track_from_midi(
|
|||||||
],
|
],
|
||||||
"hits": out_hits,
|
"hits": out_hits,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
# ── Lyrics + vocal-melody extraction ─────────────────────────────────────────
|
|
||||||
|
|
||||||
# Vocal-track detection, mirroring the idiom in `lib/gp2rs_gpx.py`'s
|
|
||||||
# `_is_vocal_track` (GM voice/choir/lead-voice programs + name keywords).
|
|
||||||
# Kept as a local copy because that helper consumes gp2rs_gpx's own GP track
|
|
||||||
# dicts, not raw MIDI tracks. "melody" is added to the name hints: karaoke
|
|
||||||
# MIDIs commonly label the sung line "Melody" rather than "Vocals".
|
|
||||||
_VOCAL_MIDI_PROGRAMS = {52, 53, 54, 85, 86, 87} # Choir Aahs, Voice Oohs, Synth Voice, Lead 5-7 (voice)
|
|
||||||
_VOCAL_NAME_HINTS = ("vocal", "voice", "vox", "sing", "lyric", "choir", "melody")
|
|
||||||
|
|
||||||
# A dedicated karaoke *text* track (SMF 0x01 Text events, `.kar` convention)
|
|
||||||
# is usually noteless and named "Words" or "Soft Karaoke" — names the vocal
|
|
||||||
# hints above don't catch. Only the Text-event fallback consults this wider
|
|
||||||
# set; note-track detection sticks to the gp2rs_gpx idiom.
|
|
||||||
_LYRIC_TEXT_TRACK_HINTS = _VOCAL_NAME_HINTS + ("words", "karaoke")
|
|
||||||
|
|
||||||
# A lyric event pairs with a vocal note-on when their onsets sit within this
|
|
||||||
# window. Karaoke files place the lyric event at (or a hair before) the
|
|
||||||
# note-on tick, so real matches are ~0; the window only absorbs sloppy
|
|
||||||
# authoring, and staying well under a typical syllable gap keeps a melisma's
|
|
||||||
# extra notes from being stolen by the next syllable.
|
|
||||||
_LYRIC_PAIR_TOLERANCE_S = 0.30
|
|
||||||
|
|
||||||
# Duration bounds for lyric entries with no pairable note (spoken lines,
|
|
||||||
# lyrics-only files). "Until the next lyric event" is the natural display
|
|
||||||
# duration, capped so a verse-final syllable before a long instrumental
|
|
||||||
# break doesn't linger on screen, and floored so simultaneous/out-of-order
|
|
||||||
# events can't produce a zero or negative duration.
|
|
||||||
_UNPAIRED_LYRIC_MAX_D = 2.0
|
|
||||||
_UNPAIRED_LYRIC_MIN_D = 0.1
|
|
||||||
|
|
||||||
# Leading/word/trailing whitespace splitter for lyric tokens. DOTALL so
|
|
||||||
# embedded newlines land in a group rather than killing the match.
|
|
||||||
_LYRIC_TOKEN_RE = re.compile(r"^(\s*)(.*?)(\s*)$", re.S)
|
|
||||||
|
|
||||||
|
|
||||||
def _scan_tracks_for_lyrics(midi: mido.MidiFile) -> list[dict]:
|
|
||||||
"""One pass per track collecting the raw material `extract_midi_lyrics`
|
|
||||||
needs: name, per-channel programs, melodic (non-drum) notes with their
|
|
||||||
on/off ticks, and Lyric/Text meta events.
|
|
||||||
|
|
||||||
Each item: {name, channel_programs: {ch: program}, notes:
|
|
||||||
[(start_tick, end_tick, pitch, channel)], lyric_events: [(tick, text)],
|
|
||||||
text_events: [(tick, text)]}. Note pairing uses the same FIFO
|
|
||||||
note_on/note_off matching as the keys converter so retriggers don't
|
|
||||||
cross-wire durations.
|
|
||||||
"""
|
|
||||||
out: list[dict] = []
|
|
||||||
for track in midi.tracks:
|
|
||||||
name = ""
|
|
||||||
channel_programs: dict[int, int] = {}
|
|
||||||
lyric_events: list[tuple[int, str]] = []
|
|
||||||
text_events: list[tuple[int, str]] = []
|
|
||||||
notes: list[tuple[int, int, int, int]] = []
|
|
||||||
active: dict[tuple[int, int], deque[int]] = {}
|
|
||||||
abs_tick = 0
|
|
||||||
for msg in track:
|
|
||||||
abs_tick += msg.time
|
|
||||||
if msg.type == "track_name" and not name:
|
|
||||||
name = msg.name or ""
|
|
||||||
elif msg.type == "lyrics":
|
|
||||||
lyric_events.append((abs_tick, msg.text or ""))
|
|
||||||
elif msg.type == "text":
|
|
||||||
text_events.append((abs_tick, msg.text or ""))
|
|
||||||
elif msg.type == "program_change":
|
|
||||||
ch = int(getattr(msg, "channel", -1))
|
|
||||||
if ch != 9 and ch not in channel_programs:
|
|
||||||
channel_programs[ch] = int(msg.program)
|
|
||||||
elif msg.type == "note_on" and int(getattr(msg, "velocity", 0)) > 0:
|
|
||||||
ch = int(getattr(msg, "channel", -1))
|
|
||||||
if ch == 9:
|
|
||||||
continue
|
|
||||||
active.setdefault((ch, int(msg.note)), deque()).append(abs_tick)
|
|
||||||
elif msg.type == "note_off" or (
|
|
||||||
msg.type == "note_on" and int(getattr(msg, "velocity", 0)) == 0
|
|
||||||
):
|
|
||||||
ch = int(getattr(msg, "channel", -1))
|
|
||||||
pitch = int(msg.note)
|
|
||||||
stack = active.get((ch, pitch))
|
|
||||||
if not stack:
|
|
||||||
continue
|
|
||||||
start_tick = stack.popleft()
|
|
||||||
if not stack:
|
|
||||||
active.pop((ch, pitch), None)
|
|
||||||
notes.append((start_tick, abs_tick, pitch, ch))
|
|
||||||
# Close anything left hanging at end-of-track, mirroring the keys
|
|
||||||
# converter's end-of-track sweep.
|
|
||||||
for (ch, pitch), starts in active.items():
|
|
||||||
for start_tick in starts:
|
|
||||||
notes.append((start_tick, abs_tick, pitch, ch))
|
|
||||||
notes.sort(key=lambda n: n[0])
|
|
||||||
out.append({
|
|
||||||
"name": name,
|
|
||||||
"channel_programs": channel_programs,
|
|
||||||
"notes": notes,
|
|
||||||
"lyric_events": lyric_events,
|
|
||||||
"text_events": text_events,
|
|
||||||
})
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def _name_matches(name: str, hints: tuple[str, ...]) -> bool:
|
|
||||||
name_l = (name or "").lower()
|
|
||||||
return any(h in name_l for h in hints)
|
|
||||||
|
|
||||||
|
|
||||||
def _normalize_lyric_tokens(events: list[tuple[int, str]]) -> list[dict]:
|
|
||||||
"""Turn raw lyric/text meta events into clean syllable tokens.
|
|
||||||
|
|
||||||
Handles both encodings seen in the wild:
|
|
||||||
|
|
||||||
- **`.kar` / karaoke convention**: `/` prefix = new line, `\\` prefix =
|
|
||||||
new paragraph (both mean "the previous syllable ended a line"),
|
|
||||||
`-` suffix = syllable joins the next one, `@`-prefixed tokens are
|
|
||||||
file metadata (`@KMIDI`, `@T<title>`, ...) and are dropped.
|
|
||||||
- **Plain Lyric-event convention**: word boundaries carried by leading
|
|
||||||
or trailing spaces; line breaks carried by embedded CR/LF.
|
|
||||||
|
|
||||||
Each token: {tick, word, lead_ws, trail_ws, line_end}. Spacing-only and
|
|
||||||
newline-only events don't emit a token — they fold their meaning
|
|
||||||
(word-break / line-end) onto the previous one.
|
|
||||||
"""
|
|
||||||
toks: list[dict] = []
|
|
||||||
for tick, raw in events:
|
|
||||||
text = "" if raw is None else str(raw)
|
|
||||||
if not text:
|
|
||||||
continue
|
|
||||||
if text.lstrip().startswith(("@", "%")):
|
|
||||||
# .kar metadata / sequencer directives, not sung text.
|
|
||||||
continue
|
|
||||||
kar_break = text[0] in ("/", "\\")
|
|
||||||
if kar_break:
|
|
||||||
text = text[1:]
|
|
||||||
m = _LYRIC_TOKEN_RE.match(text)
|
|
||||||
head, body, tail = m.group(1), m.group(2), m.group(3)
|
|
||||||
nl_before = ("\n" in head) or ("\r" in head)
|
|
||||||
nl_after = ("\n" in tail) or ("\r" in tail)
|
|
||||||
if "\n" in body or "\r" in body:
|
|
||||||
# Rare multi-line event: keep it one token, treat the break as
|
|
||||||
# trailing so the line ends after this token.
|
|
||||||
body = re.sub(r"[\r\n]+", " ", body).strip()
|
|
||||||
nl_after = True
|
|
||||||
if (kar_break or nl_before) and toks:
|
|
||||||
toks[-1]["line_end"] = True
|
|
||||||
if not body:
|
|
||||||
# Pure spacing/newline token: fold onto the previous syllable.
|
|
||||||
if nl_after and toks:
|
|
||||||
toks[-1]["line_end"] = True
|
|
||||||
if toks:
|
|
||||||
toks[-1]["trail_ws"] = True
|
|
||||||
continue
|
|
||||||
toks.append({
|
|
||||||
"tick": tick,
|
|
||||||
"word": body,
|
|
||||||
"lead_ws": bool(head),
|
|
||||||
"trail_ws": bool(tail),
|
|
||||||
"line_end": nl_after,
|
|
||||||
})
|
|
||||||
return toks
|
|
||||||
|
|
||||||
|
|
||||||
def _apply_word_conventions(toks: list[dict]) -> list[str]:
|
|
||||||
"""Map tokens to spec §7.1 `w` strings: trailing ``-`` joins to the next
|
|
||||||
syllable, trailing ``+`` ends a line.
|
|
||||||
|
|
||||||
Which join convention the source used is detected per stream:
|
|
||||||
|
|
||||||
- Any token already carrying a ``-`` suffix → the stream is
|
|
||||||
hyphen-delimited (`.kar` style); those suffixes are the spec's own
|
|
||||||
join marker and pass through untouched.
|
|
||||||
- Otherwise, if the stream carries any spacing at all → space-delimited:
|
|
||||||
a token with no trailing space followed by a token with no leading
|
|
||||||
space is a mid-word syllable and gains a ``-``.
|
|
||||||
- No hyphens and no spacing anywhere → the tokens are whole words
|
|
||||||
(common for Text-event lyrics); no joins are synthesized.
|
|
||||||
"""
|
|
||||||
has_hyphens = any(t["word"].endswith("-") for t in toks)
|
|
||||||
has_spacing = any(t["lead_ws"] or t["trail_ws"] for t in toks)
|
|
||||||
words: list[str] = []
|
|
||||||
for i, tk in enumerate(toks):
|
|
||||||
w = tk["word"]
|
|
||||||
nxt = toks[i + 1] if i + 1 < len(toks) else None
|
|
||||||
if tk["line_end"]:
|
|
||||||
# A join can't cross a line break — the line marker wins.
|
|
||||||
if w.endswith("-"):
|
|
||||||
w = w[:-1]
|
|
||||||
if w and not w.endswith("+"):
|
|
||||||
w += "+"
|
|
||||||
elif nxt is not None and not has_hyphens and has_spacing:
|
|
||||||
if not tk["trail_ws"] and not nxt["lead_ws"] and not w.endswith("-"):
|
|
||||||
w += "-"
|
|
||||||
words.append(w)
|
|
||||||
return words
|
|
||||||
|
|
||||||
|
|
||||||
def _select_vocal_notes(
|
|
||||||
scans: list[dict],
|
|
||||||
lyric_track_index: int,
|
|
||||||
midi_type: int,
|
|
||||||
) -> tuple[int, list[tuple[int, int, int, int]]] | None:
|
|
||||||
"""Pick the note pool the lyric syllables should be pitch-paired with.
|
|
||||||
|
|
||||||
Returns ``(track_index, notes)`` or ``None`` when no vocal melody is
|
|
||||||
identifiable (→ lyrics-only import). Selection order:
|
|
||||||
|
|
||||||
1. The lyric-carrying track itself, when it has notes:
|
|
||||||
- channels with a vocal GM program → only those channels' notes
|
|
||||||
(isolates the sung line inside a format-0 everything-in-one-track
|
|
||||||
file);
|
|
||||||
- vocal-ish track name → all its non-drum notes;
|
|
||||||
- SMF type 1/2 with neither → still trusted: a track that interleaves
|
|
||||||
per-syllable Lyric events with its own notes *is* the karaoke
|
|
||||||
melody by construction. Format-0 files don't get this benefit of
|
|
||||||
the doubt — there the single track holds every instrument, so
|
|
||||||
without a vocal program/name there is no way to isolate the melody
|
|
||||||
and we fall back to lyrics-only.
|
|
||||||
2. Otherwise (dedicated noteless "Words" track), the vocal-ish track —
|
|
||||||
by name hint or vocal GM program, mirroring gp2rs_gpx — with the
|
|
||||||
most notes; within it, vocal-program channels only when present.
|
|
||||||
"""
|
|
||||||
def _vocal_channels(scan: dict) -> set[int]:
|
|
||||||
return {
|
|
||||||
ch for ch, prog in scan["channel_programs"].items()
|
|
||||||
if prog in _VOCAL_MIDI_PROGRAMS
|
|
||||||
}
|
|
||||||
|
|
||||||
def _pool(scan: dict) -> list[tuple[int, int, int, int]]:
|
|
||||||
chans = _vocal_channels(scan)
|
|
||||||
if chans:
|
|
||||||
return [n for n in scan["notes"] if n[3] in chans]
|
|
||||||
return scan["notes"]
|
|
||||||
|
|
||||||
src = scans[lyric_track_index]
|
|
||||||
if src["notes"]:
|
|
||||||
if _vocal_channels(src) or _name_matches(src["name"], _VOCAL_NAME_HINTS):
|
|
||||||
return lyric_track_index, _pool(src)
|
|
||||||
if midi_type != 0:
|
|
||||||
return lyric_track_index, src["notes"]
|
|
||||||
return None
|
|
||||||
|
|
||||||
best: tuple[int, list] | None = None
|
|
||||||
for i, scan in enumerate(scans):
|
|
||||||
if not scan["notes"]:
|
|
||||||
continue
|
|
||||||
if not (_vocal_channels(scan)
|
|
||||||
or _name_matches(scan["name"], _VOCAL_NAME_HINTS)):
|
|
||||||
continue
|
|
||||||
pool = _pool(scan)
|
|
||||||
if pool and (best is None or len(pool) > len(best[1])):
|
|
||||||
best = (i, pool)
|
|
||||||
return best
|
|
||||||
|
|
||||||
|
|
||||||
def extract_midi_lyrics(midi_path: str, audio_offset: float = 0.0) -> dict | None:
|
|
||||||
"""Extract lyrics (and, when pairable, the vocal melody) from a `.mid`.
|
|
||||||
|
|
||||||
Returns ``None`` when the file carries no usable lyric events — callers
|
|
||||||
then change nothing, leaving any existing manifest keys and sidecar
|
|
||||||
files untouched. Otherwise returns::
|
|
||||||
|
|
||||||
{
|
|
||||||
"lyrics": [{"t": float, "d": float, "w": str}, ...],
|
|
||||||
"lyrics_source": "authored",
|
|
||||||
"vocal_pitch": {"version": 1,
|
|
||||||
"notes": [{"t", "d", "midi"}, ...]} | None,
|
|
||||||
}
|
|
||||||
|
|
||||||
``lyrics`` is the feedpak `lyrics.json` payload (spec §7.1: flat list,
|
|
||||||
no version field; ``w`` uses trailing ``-`` for syllable joins and
|
|
||||||
trailing ``+`` for line ends). ``vocal_pitch`` is the `vocal_pitch.json`
|
|
||||||
payload (spec §7.2, same shape as gp2rs_gpx's
|
|
||||||
``convert_vocal_track_to_pitch_sidecar`` and the lyrics-karaoke
|
|
||||||
plugin's ``_persist_pitch``) — ``None`` when no vocal note track could
|
|
||||||
be identified, in which case the caller writes `lyrics.json` only.
|
|
||||||
``lyrics_source`` is always ``"authored"`` (spec §7.1 vocabulary):
|
|
||||||
lyric meta events are chart-author data, not machine transcription.
|
|
||||||
|
|
||||||
Callers assembling a pack write ``lyrics.json`` /
|
|
||||||
``vocal_pitch.json`` and set the manifest ``lyrics`` /
|
|
||||||
``lyrics_source`` / ``vocal_pitch`` keys — and should do so only for
|
|
||||||
keys not already present, so an import never clobbers lyrics that
|
|
||||||
arrived from another source.
|
|
||||||
|
|
||||||
Sourcing rules:
|
|
||||||
|
|
||||||
- Lyric text comes from SMF Lyric (0x05) meta events — the track with
|
|
||||||
the most of them wins when several carry some. When the file has
|
|
||||||
none at all, Text (0x01) events are accepted as a fallback, but only
|
|
||||||
from a vocal-ish track (gp2rs_gpx-style name/program detection,
|
|
||||||
widened with "words"/"karaoke" for `.kar` text tracks) — Text events
|
|
||||||
elsewhere are copyright notices / markers, not lyrics.
|
|
||||||
- `.kar` conventions are normalized (see ``_normalize_lyric_tokens`` /
|
|
||||||
``_apply_word_conventions``): ``/`` and ``\\`` line-break prefixes
|
|
||||||
become the spec's ``+`` suffix on the previous syllable, ``@``
|
|
||||||
metadata tokens are dropped, ``-`` hyphen joins pass through.
|
|
||||||
- Each syllable is paired with the vocal note (see
|
|
||||||
``_select_vocal_notes``) whose onset falls within
|
|
||||||
``_LYRIC_PAIR_TOLERANCE_S`` of the lyric event, greedily in time
|
|
||||||
order, one note per syllable. Paired syllables snap ``t``/``d`` to
|
|
||||||
the note (the authored melody is timing-authoritative, and keeps
|
|
||||||
`lyrics.json` and `vocal_pitch.json` mirrored per §7.2); a melisma's
|
|
||||||
extra notes are skipped. Unpaired syllables (talkies) keep the lyric
|
|
||||||
event's own time and run until the next syllable, clamped to
|
|
||||||
[``_UNPAIRED_LYRIC_MIN_D``, ``_UNPAIRED_LYRIC_MAX_D``] — they appear
|
|
||||||
in ``lyrics`` only, which spec §7.2 explicitly allows
|
|
||||||
(`vocal_pitch.notes` MAY be shorter than `lyrics.json`).
|
|
||||||
|
|
||||||
``audio_offset`` (seconds) shifts every emitted time, same handle as
|
|
||||||
the keys/drums converters. Tempo-map scope per SMF type also matches
|
|
||||||
them (type 2 reads only the involved track's tempo events).
|
|
||||||
"""
|
|
||||||
offset = float(audio_offset)
|
|
||||||
if not math.isfinite(offset):
|
|
||||||
raise ValueError(f"audio_offset must be a finite number, got {audio_offset!r}")
|
|
||||||
|
|
||||||
midi = mido.MidiFile(midi_path)
|
|
||||||
midi_type = getattr(midi, "type", 1)
|
|
||||||
scans = _scan_tracks_for_lyrics(midi)
|
|
||||||
|
|
||||||
# ── choose the lyric event stream ────────────────────────────────────
|
|
||||||
lyric_idx = -1
|
|
||||||
best_count = 0
|
|
||||||
for i, scan in enumerate(scans):
|
|
||||||
if len(scan["lyric_events"]) > best_count:
|
|
||||||
lyric_idx = i
|
|
||||||
best_count = len(scan["lyric_events"])
|
|
||||||
if lyric_idx >= 0:
|
|
||||||
toks = _normalize_lyric_tokens(scans[lyric_idx]["lyric_events"])
|
|
||||||
else:
|
|
||||||
# Text-event fallback: vocal-ish tracks only (plus .kar "Words" /
|
|
||||||
# "Soft Karaoke" text tracks). Normalize before counting so a track
|
|
||||||
# of @-metadata can't outscore a real lyric track.
|
|
||||||
toks = []
|
|
||||||
for i, scan in enumerate(scans):
|
|
||||||
if not scan["text_events"]:
|
|
||||||
continue
|
|
||||||
vocal_prog = any(
|
|
||||||
p in _VOCAL_MIDI_PROGRAMS
|
|
||||||
for p in scan["channel_programs"].values()
|
|
||||||
)
|
|
||||||
if not (vocal_prog
|
|
||||||
or _name_matches(scan["name"], _LYRIC_TEXT_TRACK_HINTS)):
|
|
||||||
continue
|
|
||||||
cand = _normalize_lyric_tokens(scan["text_events"])
|
|
||||||
if len(cand) > len(toks):
|
|
||||||
lyric_idx = i
|
|
||||||
toks = cand
|
|
||||||
if lyric_idx < 0 or not toks:
|
|
||||||
return None
|
|
||||||
|
|
||||||
words = _apply_word_conventions(toks)
|
|
||||||
lyric_tick_to_seconds = _build_tick_to_seconds(midi, lyric_idx)
|
|
||||||
|
|
||||||
# ── pick + time the vocal note pool ──────────────────────────────────
|
|
||||||
picked = _select_vocal_notes(scans, lyric_idx, midi_type)
|
|
||||||
vocal_notes: list[dict] = []
|
|
||||||
if picked is not None:
|
|
||||||
note_idx, pool = picked
|
|
||||||
# Type-2 tracks own independent timelines — time the notes through
|
|
||||||
# their own track's tempo scope (same map as the lyric track for
|
|
||||||
# type 0/1, where tempo is merged across tracks anyway).
|
|
||||||
note_tick_to_seconds = (
|
|
||||||
lyric_tick_to_seconds if note_idx == lyric_idx
|
|
||||||
else _build_tick_to_seconds(midi, note_idx)
|
|
||||||
)
|
|
||||||
for start_tick, end_tick, pitch, _ch in pool:
|
|
||||||
t = note_tick_to_seconds(start_tick)
|
|
||||||
vocal_notes.append({
|
|
||||||
"t": t,
|
|
||||||
"d": max(0.0, note_tick_to_seconds(end_tick) - t),
|
|
||||||
"midi": int(pitch),
|
|
||||||
})
|
|
||||||
vocal_notes.sort(key=lambda n: n["t"])
|
|
||||||
|
|
||||||
# ── pair syllables with notes (greedy, time-ordered) ─────────────────
|
|
||||||
entries: list[dict] = [] # {t, d (None until resolved), w, paired}
|
|
||||||
j = 0
|
|
||||||
for tk, w in zip(toks, words):
|
|
||||||
t_lyric = lyric_tick_to_seconds(tk["tick"])
|
|
||||||
while (j < len(vocal_notes)
|
|
||||||
and vocal_notes[j]["t"] < t_lyric - _LYRIC_PAIR_TOLERANCE_S):
|
|
||||||
j += 1
|
|
||||||
if (j < len(vocal_notes)
|
|
||||||
and vocal_notes[j]["t"] <= t_lyric + _LYRIC_PAIR_TOLERANCE_S):
|
|
||||||
note = vocal_notes[j]
|
|
||||||
j += 1
|
|
||||||
entries.append({
|
|
||||||
"t": note["t"], "d": note["d"], "w": w,
|
|
||||||
"midi": note["midi"], "paired": True,
|
|
||||||
})
|
|
||||||
else:
|
|
||||||
entries.append({"t": t_lyric, "d": None, "w": w, "paired": False})
|
|
||||||
|
|
||||||
# Snapping can nudge a paired syllable past an unpaired neighbour;
|
|
||||||
# sort so both sidecars stay chronological for downstream consumers.
|
|
||||||
entries.sort(key=lambda e: e["t"])
|
|
||||||
|
|
||||||
# Unpaired durations: until the next syllable, clamped. Resolved after
|
|
||||||
# the sort so "next" is the true chronological neighbour.
|
|
||||||
for i, e in enumerate(entries):
|
|
||||||
if e["d"] is None:
|
|
||||||
if i + 1 < len(entries):
|
|
||||||
gap = entries[i + 1]["t"] - e["t"]
|
|
||||||
d = min(gap, _UNPAIRED_LYRIC_MAX_D)
|
|
||||||
else:
|
|
||||||
d = _UNPAIRED_LYRIC_MAX_D
|
|
||||||
e["d"] = max(d, _UNPAIRED_LYRIC_MIN_D)
|
|
||||||
|
|
||||||
lyrics_out = [
|
|
||||||
{"t": round(e["t"] + offset, 3), "d": round(e["d"], 3), "w": e["w"]}
|
|
||||||
for e in entries
|
|
||||||
]
|
|
||||||
pitch_notes = [
|
|
||||||
{"t": round(e["t"] + offset, 3), "d": round(e["d"], 3),
|
|
||||||
"midi": int(e["midi"])}
|
|
||||||
for e in entries if e["paired"]
|
|
||||||
]
|
|
||||||
|
|
||||||
return {
|
|
||||||
"lyrics": lyrics_out,
|
|
||||||
"lyrics_source": "authored",
|
|
||||||
"vocal_pitch": (
|
|
||||||
{"version": 1, "notes": pitch_notes} if pitch_notes else None
|
|
||||||
),
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
|||||||
|
|
||||||
from song import (
|
from song import (
|
||||||
anchor_to_wire,
|
anchor_to_wire,
|
||||||
arrangement_is_bass,
|
|
||||||
arrangement_string_count,
|
arrangement_string_count,
|
||||||
base_open_string_midis,
|
base_open_string_midis,
|
||||||
chord_template_to_wire,
|
chord_template_to_wire,
|
||||||
@@ -274,8 +273,9 @@ async def highway_ws(websocket: WebSocket, filename: str, arrangement: int = -1,
|
|||||||
bass_idxs = [
|
bass_idxs = [
|
||||||
i
|
i
|
||||||
for i, a in enumerate(song.arrangements)
|
for i, a in enumerate(song.arrangements)
|
||||||
if arrangement_is_bass(a)
|
if getattr(a, "path_bass", False)
|
||||||
or (smart_names[i] or "").lower().startswith("bass")
|
or (smart_names[i] or "").lower().startswith("bass")
|
||||||
|
or "bass" in (getattr(a, "name", "") or "").lower()
|
||||||
]
|
]
|
||||||
if bass_idxs:
|
if bass_idxs:
|
||||||
# Among the bass parts: (1) honor the saved default-arrangement
|
# Among the bass parts: (1) honor the saved default-arrangement
|
||||||
@@ -1014,7 +1014,7 @@ async def highway_ws(websocket: WebSocket, filename: str, arrangement: int = -1,
|
|||||||
# base[string] + offset + capo + fret (matches the tuner / open-string
|
# base[string] + offset + capo + fret (matches the tuner / open-string
|
||||||
# labels). arrangement_string_count is O(notes), so compute once here.
|
# labels). arrangement_string_count is O(notes), so compute once here.
|
||||||
_base = base_open_string_midis(
|
_base = base_open_string_midis(
|
||||||
arrangement_string_count(arr), arrangement_is_bass(arr))
|
arrangement_string_count(arr), "bass" in (arr.name or "").lower())
|
||||||
_capo = int(getattr(arr, "capo", 0) or 0)
|
_capo = int(getattr(arr, "capo", 0) or 0)
|
||||||
|
|
||||||
def _fill_scale_degree(wire: dict, n, t: float) -> None:
|
def _fill_scale_degree(wire: dict, n, t: float) -> None:
|
||||||
|
|||||||
@@ -1,140 +0,0 @@
|
|||||||
"""Session-sync relay WebSocket — /ws/sync/{session_id} (feedBack#1030).
|
|
||||||
|
|
||||||
A deliberately dumb fan-out room: a JSON text frame received from one client
|
|
||||||
is forwarded verbatim to every OTHER client connected to the same session id.
|
|
||||||
The server interprets nothing beyond the limits below — message schemas are
|
|
||||||
owned entirely by consumers. First consumer: splitscreen's LAN follower mode
|
|
||||||
(feedBack-plugin-splitscreen#21), which relays playhead/playstate/song-change
|
|
||||||
frames from a host window to view-only followers on other LAN devices.
|
|
||||||
|
|
||||||
Design points (full spec in the issue):
|
|
||||||
|
|
||||||
- Rooms are created on first join and garbage-collected when the last socket
|
|
||||||
leaves. No history, no replay, no persistence — a late joiner simply waits
|
|
||||||
for the next frame. Consumers that need state on join re-send it themselves
|
|
||||||
(splitscreen answers every follower ``hello`` with a fresh ``config``).
|
|
||||||
- That statelessness is what makes consumer crash-recovery work: a host that
|
|
||||||
relaunches and rejoins the same session id resumes publishing to its
|
|
||||||
reconnecting subscribers with no server-side coordination, and an idle room
|
|
||||||
is indistinguishable from a nonexistent one.
|
|
||||||
- ``session_id`` is client-generated and opaque (``[A-Za-z0-9_-]{4,64}``);
|
|
||||||
consumers pick their own id policy (splitscreen uses a short typeable,
|
|
||||||
persistent room key).
|
|
||||||
- DoS hygiene for a port that may be LAN-exposed: frame-size cap, per-room and
|
|
||||||
total-room caps, and a per-socket inbound token-bucket rate cap. Over-limit
|
|
||||||
sockets are closed with a policy code; the room carries on. A peer that dies
|
|
||||||
mid-fan-out is dropped without wedging delivery to the rest.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import logging
|
|
||||||
import re
|
|
||||||
import time
|
|
||||||
|
|
||||||
from fastapi import APIRouter, WebSocket
|
|
||||||
|
|
||||||
log = logging.getLogger("feedBack.server")
|
|
||||||
|
|
||||||
router = APIRouter()
|
|
||||||
|
|
||||||
_SESSION_ID_RE = re.compile(r"^[A-Za-z0-9_-]{4,64}$")
|
|
||||||
|
|
||||||
# Limits. Sized generously above the first consumer's needs (splitscreen
|
|
||||||
# publishes time frames at ~15-20 Hz to a handful of viewers) while bounding
|
|
||||||
# what an open LAN port can be made to do. All module-level so tests (and a
|
|
||||||
# desperate operator) can override them.
|
|
||||||
MAX_FRAME_BYTES = 16 * 1024
|
|
||||||
MAX_CLIENTS_PER_ROOM = 16
|
|
||||||
MAX_ROOMS = 32
|
|
||||||
RATE_MSGS_PER_SEC = 120.0 # sustained inbound frames per socket
|
|
||||||
RATE_BURST = 240.0 # token-bucket burst headroom
|
|
||||||
# A peer that stops draining its socket would leave send_text() pending
|
|
||||||
# forever — and since publishers await the fan-out gather, one stalled peer
|
|
||||||
# would stall every publisher's receive loop behind it. Bounding the send
|
|
||||||
# turns the stall into an eviction through the normal failed-send drop path.
|
|
||||||
SEND_TIMEOUT_SECONDS = 5.0
|
|
||||||
|
|
||||||
# RFC 6455 close codes.
|
|
||||||
_WS_UNSUPPORTED_DATA = 1003 # binary frame on a text-only relay
|
|
||||||
_WS_POLICY_VIOLATION = 1008 # invalid session id / rate cap exceeded
|
|
||||||
_WS_MSG_TOO_BIG = 1009
|
|
||||||
_WS_TRY_AGAIN_LATER = 1013 # room or server at capacity
|
|
||||||
|
|
||||||
# session_id → {socket: per-socket send lock}. The lock serializes concurrent
|
|
||||||
# fan-out sends to the same peer (two publishers relaying at once must not
|
|
||||||
# interleave writes on a third socket's transport).
|
|
||||||
_rooms: dict[str, dict[WebSocket, asyncio.Lock]] = {}
|
|
||||||
|
|
||||||
|
|
||||||
async def _send_locked(peer: WebSocket, lock: asyncio.Lock, text: str) -> None:
|
|
||||||
async with lock:
|
|
||||||
await asyncio.wait_for(peer.send_text(text), timeout=SEND_TIMEOUT_SECONDS)
|
|
||||||
|
|
||||||
|
|
||||||
@router.websocket("/ws/sync/{session_id}")
|
|
||||||
async def sync_ws(websocket: WebSocket, session_id: str):
|
|
||||||
"""Join the fan-out room *session_id*; relay every inbound text frame."""
|
|
||||||
await websocket.accept()
|
|
||||||
|
|
||||||
if not _SESSION_ID_RE.fullmatch(session_id):
|
|
||||||
await websocket.close(code=_WS_POLICY_VIOLATION, reason="invalid session id")
|
|
||||||
return
|
|
||||||
|
|
||||||
# Capacity checks and insertion run with no await between them, so
|
|
||||||
# concurrent joiners on the event loop can't race past the caps.
|
|
||||||
room = _rooms.get(session_id)
|
|
||||||
if room is None:
|
|
||||||
if len(_rooms) >= MAX_ROOMS:
|
|
||||||
await websocket.close(code=_WS_TRY_AGAIN_LATER, reason="too many active sessions")
|
|
||||||
return
|
|
||||||
room = _rooms[session_id] = {}
|
|
||||||
log.debug("ws_sync: room %s created", session_id)
|
|
||||||
elif len(room) >= MAX_CLIENTS_PER_ROOM:
|
|
||||||
await websocket.close(code=_WS_TRY_AGAIN_LATER, reason="session full")
|
|
||||||
return
|
|
||||||
room[websocket] = asyncio.Lock()
|
|
||||||
|
|
||||||
tokens = RATE_BURST
|
|
||||||
last_refill = time.monotonic()
|
|
||||||
|
|
||||||
try:
|
|
||||||
while True:
|
|
||||||
message = await websocket.receive()
|
|
||||||
if message["type"] == "websocket.disconnect":
|
|
||||||
break
|
|
||||||
text = message.get("text")
|
|
||||||
if text is None:
|
|
||||||
await websocket.close(code=_WS_UNSUPPORTED_DATA, reason="text frames only")
|
|
||||||
break
|
|
||||||
if len(text.encode("utf-8", errors="ignore")) > MAX_FRAME_BYTES:
|
|
||||||
await websocket.close(code=_WS_MSG_TOO_BIG, reason="frame too large")
|
|
||||||
break
|
|
||||||
|
|
||||||
now = time.monotonic()
|
|
||||||
tokens = min(RATE_BURST, tokens + (now - last_refill) * RATE_MSGS_PER_SEC)
|
|
||||||
last_refill = now
|
|
||||||
tokens -= 1.0
|
|
||||||
if tokens < 0:
|
|
||||||
await websocket.close(code=_WS_POLICY_VIOLATION, reason="rate cap exceeded")
|
|
||||||
break
|
|
||||||
|
|
||||||
peers = [(ws, lock) for ws, lock in room.items() if ws is not websocket]
|
|
||||||
if not peers:
|
|
||||||
continue
|
|
||||||
results = await asyncio.gather(
|
|
||||||
*(_send_locked(ws, lock, text) for ws, lock in peers),
|
|
||||||
return_exceptions=True,
|
|
||||||
)
|
|
||||||
# A peer that failed mid-send is dropped from the room here; its
|
|
||||||
# own handler finishes cleanup (the finally below) when its
|
|
||||||
# receive loop observes the disconnect.
|
|
||||||
for (peer, _lock), result in zip(peers, results):
|
|
||||||
if isinstance(result, Exception):
|
|
||||||
room.pop(peer, None)
|
|
||||||
finally:
|
|
||||||
room.pop(websocket, None)
|
|
||||||
# Guard against deleting a NEW room another joiner created after this
|
|
||||||
# one emptied (only possible for a dict that is no longer ours).
|
|
||||||
if not room and _rooms.get(session_id) is room:
|
|
||||||
del _rooms[session_id]
|
|
||||||
log.debug("ws_sync: room %s closed", session_id)
|
|
||||||
+7
-21
@@ -882,25 +882,16 @@ def load_song(
|
|||||||
rel = rel_raw.strip() if isinstance(rel_raw, str) else ""
|
rel = rel_raw.strip() if isinstance(rel_raw, str) else ""
|
||||||
notation_raw = entry.get("notation")
|
notation_raw = entry.get("notation")
|
||||||
has_notation_key = isinstance(notation_raw, str) and bool(notation_raw.strip())
|
has_notation_key = isinstance(notation_raw, str) and bool(notation_raw.strip())
|
||||||
_etype = str(entry.get("type") or "").strip().lower()
|
if not rel and not has_notation_key:
|
||||||
is_drums = _etype in ("drums", "drum")
|
|
||||||
# A drums-typed entry MUST NEVER become a fretted Arrangement (grading
|
|
||||||
# invariant, spec §5.2/§7.5): route on `type` FIRST, not on file
|
|
||||||
# absence — a malformed drums entry that also carries a note file/
|
|
||||||
# notation would otherwise fall through and grade as garbage.
|
|
||||||
if is_drums or (not rel and not has_notation_key):
|
|
||||||
# A DRUM-PART POINTER entry (feedpak 1.17.0 "drums as
|
# A DRUM-PART POINTER entry (feedpak 1.17.0 "drums as
|
||||||
# arrangements"): `type: drums` with a per-arrangement `drum_tab`
|
# arrangements"): `type: drums` with a per-arrangement `drum_tab`
|
||||||
# file. Collect it for the drum-parts load after this loop.
|
# file and no note file. Collect it for the drum-parts load after
|
||||||
if is_drums and isinstance(entry.get("drum_tab"), str):
|
# this loop — but NEVER turn it into a fretted Arrangement: this
|
||||||
|
# skip is the grading invariant (a drum part must not reach the
|
||||||
|
# fretted pipeline, where its empty chart would grade as garbage).
|
||||||
|
_etype = str(entry.get("type") or "").strip().lower()
|
||||||
|
if _etype in ("drums", "drum") and isinstance(entry.get("drum_tab"), str):
|
||||||
drum_pointer_entries.append(entry)
|
drum_pointer_entries.append(entry)
|
||||||
elif is_drums:
|
|
||||||
# Drums-typed but no drum_tab pointer — drop it (any note
|
|
||||||
# file/notation it carries is ignored), never fret it.
|
|
||||||
log.warning(
|
|
||||||
"sloppak: drums-typed arrangement entry %r has no drum_tab pointer — dropped",
|
|
||||||
entry.get("id"),
|
|
||||||
)
|
|
||||||
elif isinstance(entry.get("drum_tab"), str):
|
elif isinstance(entry.get("drum_tab"), str):
|
||||||
log.warning(
|
log.warning(
|
||||||
"sloppak: arrangement entry has drum_tab %r but type=%r — ignored",
|
"sloppak: arrangement entry has drum_tab %r but type=%r — ignored",
|
||||||
@@ -935,11 +926,6 @@ def load_song(
|
|||||||
# the arrangement JSON (name, tuning, capo, centOffset).
|
# the arrangement JSON (name, tuning, capo, centOffset).
|
||||||
if entry.get("name"):
|
if entry.get("name"):
|
||||||
arr.name = str(entry["name"])
|
arr.name = str(entry["name"])
|
||||||
# Editor-authored instrument type (feedpak-spec §5.2 / editor PR #335).
|
|
||||||
# Drives arrangement_string_count's bass fallback so a bass authored on
|
|
||||||
# an arrangement whose NAME doesn't say "bass" still reports 4 strings.
|
|
||||||
if entry.get("type"):
|
|
||||||
arr.type = str(entry["type"]).strip().lower()
|
|
||||||
if "tuning" in entry:
|
if "tuning" in entry:
|
||||||
arr.tuning = list(entry["tuning"])
|
arr.tuning = list(entry["tuning"])
|
||||||
if "capo" in entry:
|
if "capo" in entry:
|
||||||
|
|||||||
+7
-43
@@ -182,12 +182,6 @@ class Arrangement:
|
|||||||
# `base`/`changes` drive the highway tone-change markers; `definitions`
|
# `base`/`changes` drive the highway tone-change markers; `definitions`
|
||||||
# feed the Tones plugin gear panel.
|
# feed the Tones plugin gear panel.
|
||||||
tones: dict | None = None
|
tones: dict | None = None
|
||||||
# Editor-authored instrument type (feedpak-spec §5.2 / editor PR #335):
|
|
||||||
# "bass" | "guitar" | "piano" | "keys" | "drums" | "". First-class DATA that
|
|
||||||
# lets a user author an instrument on an arrangement whose NAME doesn't say
|
|
||||||
# so. Lifted from the sloppak manifest entry by sloppak.load_song(); "" for
|
|
||||||
# archive/loose sources, which instead carry the path_* flags below.
|
|
||||||
type: str = ""
|
|
||||||
# arrangement XML <arrangementProperties> flags for smart naming (feedBack feat/arrangement).
|
# arrangement XML <arrangementProperties> flags for smart naming (feedBack feat/arrangement).
|
||||||
# Populated from the XML; default False/0 for sloppak / GP-imported sources.
|
# Populated from the XML; default False/0 for sloppak / GP-imported sources.
|
||||||
path_lead: bool = False
|
path_lead: bool = False
|
||||||
@@ -509,8 +503,8 @@ def note_pitch_midi(arr: "Arrangement", note: "Note") -> int | None:
|
|||||||
O(notes) via ``arrangement_string_count`` — for a whole arrangement, hoist
|
O(notes) via ``arrangement_string_count`` — for a whole arrangement, hoist
|
||||||
the base with :func:`base_open_string_midis` and call :func:`pitch_from_base`
|
the base with :func:`base_open_string_midis` and call :func:`pitch_from_base`
|
||||||
per note instead."""
|
per note instead."""
|
||||||
base = base_open_string_midis(arrangement_string_count(arr),
|
is_bass = "bass" in (arr.name or "").lower()
|
||||||
arrangement_is_bass(arr))
|
base = base_open_string_midis(arrangement_string_count(arr), is_bass)
|
||||||
return pitch_from_base(base, int(getattr(arr, "capo", 0) or 0),
|
return pitch_from_base(base, int(getattr(arr, "capo", 0) or 0),
|
||||||
arr.tuning or [], note.string, note.fret)
|
arr.tuning or [], note.string, note.fret)
|
||||||
|
|
||||||
@@ -639,23 +633,6 @@ def phrase_from_wire(d: dict) -> Phrase:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def arrangement_is_bass(arr: Arrangement) -> bool:
|
|
||||||
"""Whether ``arr`` is a bass, most-authoritative signal first: an
|
|
||||||
editor-authored ``type == "bass"`` (feedpak-spec §5.2 / editor PR #335,
|
|
||||||
lifted onto sloppaks by :func:`sloppak.load_song`), then the archive
|
|
||||||
``path_bass`` <arrangementProperties> flag, then the legacy "bass"
|
|
||||||
case-insensitive substring in the name. Single source of the bass decision
|
|
||||||
so string-count derivation and the open-string pitch base (via
|
|
||||||
:func:`base_open_string_midis`) agree — a bass authored on an arrangement
|
|
||||||
whose NAME doesn't say "bass" must get both 4 lanes AND the bass MIDI base,
|
|
||||||
not 4 lanes on a guitar octave."""
|
|
||||||
return (
|
|
||||||
(arr.type or "").strip().lower() == "bass"
|
|
||||||
or bool(arr.path_bass)
|
|
||||||
or "bass" in (arr.name or "").lower()
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def arrangement_string_count(arr: Arrangement) -> int:
|
def arrangement_string_count(arr: Arrangement) -> int:
|
||||||
"""Derive the active arrangement's string count.
|
"""Derive the active arrangement's string count.
|
||||||
|
|
||||||
@@ -673,17 +650,10 @@ def arrangement_string_count(arr: Arrangement) -> int:
|
|||||||
But this is a LOWER BOUND only — a 6-string lead chart that
|
But this is a LOWER BOUND only — a 6-string lead chart that
|
||||||
never plays string 5 reports 5, undercounting by 1.
|
never plays string 5 reports 5, undercounting by 1.
|
||||||
|
|
||||||
2. **Instrument-type fallback.** An arrangement whose authoritative
|
2. **Name-based fallback.** Arrangements named "Bass" (case-
|
||||||
instrument signal says bass defaults to 4; everything else
|
insensitive substring match) default to 4; everything else
|
||||||
defaults to 6. This catches the partial-string-usage case where
|
defaults to 6. This catches the partial-string-usage case
|
||||||
notes don't span all the instrument's strings. The bass signal is
|
where notes don't span all the instrument's strings.
|
||||||
any of: an editor-authored ``type == "bass"`` (feedpak-spec §5.2 /
|
|
||||||
editor PR #335 — lifted onto sloppaks by ``sloppak.load_song``),
|
|
||||||
the ``path_bass`` <arrangementProperties> flag (archive/DLC
|
|
||||||
sources), or the legacy "bass" case-insensitive substring in the
|
|
||||||
name. Trusting ``type``/``path_bass`` closes the gap where a user
|
|
||||||
authors a bass instrument on an arrangement whose NAME doesn't say
|
|
||||||
"bass" (the editor lays out 4 lanes; core must agree).
|
|
||||||
|
|
||||||
A third signal — ``len(arr.tuning)`` when it isn't the arrangement 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
|
padded value of 6 — folds in for sloppak / GP-imported sources
|
||||||
@@ -714,10 +684,6 @@ def arrangement_string_count(arr: Arrangement) -> int:
|
|||||||
max(0, 4, 0) = 4
|
max(0, 4, 0) = 4
|
||||||
* Empty arrangement named "Lead" (tuning len 6) →
|
* Empty arrangement named "Lead" (tuning len 6) →
|
||||||
max(0, 6, 0) = 6
|
max(0, 6, 0) = 6
|
||||||
* Editor-authored bass named "Low End" (type "bass", tuning len 6,
|
|
||||||
notes 0..3) → name_based=4 → max(4, 4, 0) = 4
|
|
||||||
* DLC bass named "Low End" (pathBass flag set, tuning len 6, notes
|
|
||||||
0..3) → name_based=4 → max(4, 4, 0) = 4
|
|
||||||
|
|
||||||
Topkoa's issue argues plugins shouldn't do arrangement-name
|
Topkoa's issue argues plugins shouldn't do arrangement-name
|
||||||
matching; server-side fallback IS the right place for it
|
matching; server-side fallback IS the right place for it
|
||||||
@@ -733,9 +699,7 @@ def arrangement_string_count(arr: Arrangement) -> int:
|
|||||||
if cn.string > max_s:
|
if cn.string > max_s:
|
||||||
max_s = cn.string
|
max_s = cn.string
|
||||||
notes_count = max_s + 1 if max_s >= 0 else 0
|
notes_count = max_s + 1 if max_s >= 0 else 0
|
||||||
# Bass signal (type / pathBass / name substring) — see arrangement_is_bass.
|
name_based = 4 if "bass" in arr.name.lower() else 6
|
||||||
# Any one being bass pulls the fallback to 4.
|
|
||||||
name_based = 4 if arrangement_is_bass(arr) else 6
|
|
||||||
# Tuning-length signal — only trustworthy when NOT the arrangement XML
|
# Tuning-length signal — only trustworthy when NOT the arrangement XML
|
||||||
# padded value of 6. Length 4/5 indicates explicit bass / 5-string
|
# padded value of 6. Length 4/5 indicates explicit bass / 5-string
|
||||||
# bass; length 7/8 indicates an extended-range guitar from GP.
|
# bass; length 7/8 indicates an extended-range guitar from GP.
|
||||||
|
|||||||
@@ -44,13 +44,6 @@ def run() -> None:
|
|||||||
# record — including early startup messages — passes through the same
|
# record — including early startup messages — passes through the same
|
||||||
# structured pipeline.
|
# structured pipeline.
|
||||||
log_config=None,
|
log_config=None,
|
||||||
# Cap inbound WebSocket frames at the transport, before uvicorn
|
|
||||||
# materializes them in memory (its default is 16 MB). No client sends
|
|
||||||
# large frames to this server: the highway WS receives only small
|
|
||||||
# control messages, and the /ws/sync relay enforces its own tighter
|
|
||||||
# 16 KB application cap (routers/ws_sync.py MAX_FRAME_BYTES) — this is
|
|
||||||
# the defense-in-depth bound above it.
|
|
||||||
ws_max_size=64 * 1024,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -104,13 +104,6 @@ def _bundled(venue_id):
|
|||||||
return (_bundled_venue_dir(venue_id) / "manifest.json").is_file()
|
return (_bundled_venue_dir(venue_id) / "manifest.json").is_file()
|
||||||
|
|
||||||
|
|
||||||
def _pack_published(pack):
|
|
||||||
"""A remote pack is downloadable only once a publish has stamped its real
|
|
||||||
size — the committed manifest carries a 0-byte placeholder (and an all-zero
|
|
||||||
sha) until then, so don't offer a download that can't succeed yet."""
|
|
||||||
return bool(pack and (pack.get("bytes") or 0) > 0)
|
|
||||||
|
|
||||||
|
|
||||||
def _stars():
|
def _stars():
|
||||||
"""(total, per-song dict, detail rows). Accuracy is a 0..1 fraction."""
|
"""(total, per-song dict, detail rows). Accuracy is a 0..1 fraction."""
|
||||||
db = _state["meta_db"]
|
db = _state["meta_db"]
|
||||||
@@ -671,7 +664,7 @@ def setup(app, context):
|
|||||||
"unlocked": stars_total >= v["star_threshold"],
|
"unlocked": stars_total >= v["star_threshold"],
|
||||||
"installed": _installed(v["id"]),
|
"installed": _installed(v["id"]),
|
||||||
"bundled": _bundled(v["id"]),
|
"bundled": _bundled(v["id"]),
|
||||||
"has_pack": _bundled(v["id"]) or _pack_published(v.get("pack")),
|
"has_pack": _bundled(v["id"]) or bool(v.get("pack")),
|
||||||
"download": dl,
|
"download": dl,
|
||||||
})
|
})
|
||||||
return {
|
return {
|
||||||
@@ -941,7 +934,7 @@ def setup(app, context):
|
|||||||
if venue is None:
|
if venue is None:
|
||||||
raise HTTPException(404, "Unknown venue.")
|
raise HTTPException(404, "Unknown venue.")
|
||||||
pack = venue.get("pack")
|
pack = venue.get("pack")
|
||||||
if not _pack_published(pack):
|
if not pack:
|
||||||
raise HTTPException(404, "No pack published for this venue yet.")
|
raise HTTPException(404, "No pack published for this venue yet.")
|
||||||
stars_total, _, _ = _stars()
|
stars_total, _, _ = _stars()
|
||||||
if stars_total < venue["star_threshold"]:
|
if stars_total < venue["star_threshold"]:
|
||||||
|
|||||||
@@ -17,22 +17,14 @@
|
|||||||
"name": "Velvet Room",
|
"name": "Velvet Room",
|
||||||
"description": "A proper club stage. People actually came to hear you.",
|
"description": "A proper club stage. People actually came to hear you.",
|
||||||
"star_threshold": 50,
|
"star_threshold": 50,
|
||||||
"pack": {
|
"pack": null
|
||||||
"url": "https://github.com/got-feedBack/feedBack/releases/download/venue-club-v1/club-pack-v1.zip",
|
|
||||||
"sha256": "0000000000000000000000000000000000000000000000000000000000000000",
|
|
||||||
"bytes": 0
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "arena",
|
"id": "arena",
|
||||||
"name": "Feedback Arena",
|
"name": "Feedback Arena",
|
||||||
"description": "Ten thousand seats. Try not to think about it.",
|
"description": "Ten thousand seats. Try not to think about it.",
|
||||||
"star_threshold": 150,
|
"star_threshold": 150,
|
||||||
"pack": {
|
"pack": null
|
||||||
"url": "https://github.com/got-feedBack/feedBack/releases/download/venue-arena-v1/arena-pack-v1.zip",
|
|
||||||
"sha256": "8898c7912c84123559ecfd8b0afd3be19da9a0a4b04aa9dbb5aa1e7f4e3e1669",
|
|
||||||
"bytes": 351284599
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ import demo_mode
|
|||||||
import scan
|
import scan
|
||||||
import tailwind_rebuild
|
import tailwind_rebuild
|
||||||
# Extracted route modules. They import `appstate`, never `server` — one-way graph.
|
# Extracted route modules. They import `appstate`, never `server` — one-way graph.
|
||||||
from routers import audio_effects, artist_aliases, loops, playlists, ws_highway, ws_sync, chart, wanted, library_extras, shop, progression, profile, stats, version, diagnostics
|
from routers import audio_effects, artist_aliases, loops, playlists, ws_highway, chart, wanted, library_extras, shop, progression, profile, stats, version, diagnostics
|
||||||
from routers import tunings as tunings_router
|
from routers import tunings as tunings_router
|
||||||
import enrichment
|
import enrichment
|
||||||
from routers import art as art_router
|
from routers import art as art_router
|
||||||
@@ -1618,12 +1618,6 @@ app.include_router(media_router.router)
|
|||||||
app.include_router(ws_highway.router)
|
app.include_router(ws_highway.router)
|
||||||
|
|
||||||
|
|
||||||
# ── Session-sync relay WebSocket (feedBack#1030) ─────────────────────────────
|
|
||||||
# Dumb JSON fan-out rooms for cross-device followers (splitscreen LAN mode).
|
|
||||||
# Implementation in lib/routers/ws_sync.py.
|
|
||||||
app.include_router(ws_sync.router)
|
|
||||||
|
|
||||||
|
|
||||||
# ── Audio serving ─────────────────────────────────────────────────────────────
|
# ── Audio serving ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -83,25 +83,10 @@ def test_download_without_published_pack_404s(client):
|
|||||||
|
|
||||||
def test_download_locked_venue_403s(client, monkeypatch):
|
def test_download_locked_venue_403s(client, monkeypatch):
|
||||||
club = career_routes._venue("club")
|
club = career_routes._venue("club")
|
||||||
monkeypatch.setitem(club, "pack", {"url": "http://x/pack.zip", "sha256": "0" * 64, "bytes": 123})
|
monkeypatch.setitem(club, "pack", {"url": "http://x/pack.zip", "sha256": "0" * 64})
|
||||||
assert client.post("/api/plugins/career/packs/club/download").status_code == 403
|
assert client.post("/api/plugins/career/packs/club/download").status_code == 403
|
||||||
|
|
||||||
|
|
||||||
def test_placeholder_pack_is_not_offered_until_published(client, monkeypatch):
|
|
||||||
# A committed manifest carries a 0-byte placeholder until its release is
|
|
||||||
# published. Such a pack must not be offered (has_pack False) and its
|
|
||||||
# download must 404 — else the UI shows a button that can only fail.
|
|
||||||
monkeypatch.setattr(career_routes, "_bundled", lambda vid: False)
|
|
||||||
club = career_routes._venue("club")
|
|
||||||
monkeypatch.setitem(club, "pack",
|
|
||||||
{"url": "http://x/c.zip", "sha256": "0" * 64, "bytes": 0})
|
|
||||||
by_id = {v["id"]: v for v in client.get("/api/plugins/career/state").json()["venues"]}
|
|
||||||
assert by_id["club"]["has_pack"] is False # placeholder → not offered
|
|
||||||
assert by_id["arena"]["has_pack"] is True # arena ships real bytes
|
|
||||||
# Even forced, an unpublished pack won't start a download.
|
|
||||||
assert client.post("/api/plugins/career/packs/club/download").status_code == 404
|
|
||||||
|
|
||||||
|
|
||||||
def test_bundled_bar_pack_is_installed_and_served(client):
|
def test_bundled_bar_pack_is_installed_and_served(client):
|
||||||
state = client.get("/api/plugins/career/state").json()
|
state = client.get("/api/plugins/career/state").json()
|
||||||
bar = {v["id"]: v for v in state["venues"]}["bar"]
|
bar = {v["id"]: v for v in state["venues"]}["bar"]
|
||||||
@@ -182,53 +167,9 @@ def test_download_worker_end_to_end(client, tmp_path):
|
|||||||
assert "sha256" in bad["error"]
|
assert "sha256" in bad["error"]
|
||||||
|
|
||||||
|
|
||||||
def test_content_packs_build_roundtrips_through_download(client, tmp_path):
|
|
||||||
# tools/content_packs.py must produce a zip the real career worker accepts:
|
|
||||||
# build_pack → manifest_entry → _download_pack → installed.
|
|
||||||
from tools import content_packs
|
|
||||||
|
|
||||||
src = tmp_path / "bar"
|
|
||||||
src.mkdir()
|
|
||||||
for s in career_routes.REQUIRED_LOOPS:
|
|
||||||
(src / f"{s}.mp4").write_bytes(b"fake-" + s.encode())
|
|
||||||
(src / "cheer.mp4").write_bytes(b"fake-cheer")
|
|
||||||
(src / "manifest.json").write_text(json.dumps({
|
|
||||||
"venue": "bar", "version": 1,
|
|
||||||
"loops": {s: f"{s}.mp4" for s in career_routes.REQUIRED_LOOPS},
|
|
||||||
"stingers": {"cheer": "cheer.mp4"},
|
|
||||||
}))
|
|
||||||
out_dir = tmp_path / "packs"
|
|
||||||
zip_path = out_dir / content_packs.pack_asset("bar", 1)
|
|
||||||
info = content_packs.build_pack(src, zip_path)
|
|
||||||
entry = content_packs.manifest_entry(zip_path, zip_path.resolve().as_uri())
|
|
||||||
assert entry["sha256"] == info["sha256"] and entry["bytes"] == info["bytes"]
|
|
||||||
|
|
||||||
progress = {"status": "running", "bytes_done": 0, "bytes_total": 0, "error": None}
|
|
||||||
career_routes._download_pack("bar", entry, progress)
|
|
||||||
assert progress["status"] == "done", progress["error"]
|
|
||||||
assert career_routes._installed("bar")
|
|
||||||
|
|
||||||
|
|
||||||
def test_content_packs_rejects_files_the_downloader_would_refuse(tmp_path):
|
|
||||||
# A stray file (e.g. macOS .DS_Store) must fail the build, not get published
|
|
||||||
# and then break every client's download at _validate_pack_dir.
|
|
||||||
from tools import content_packs
|
|
||||||
|
|
||||||
src = tmp_path / "bar"
|
|
||||||
src.mkdir()
|
|
||||||
(src / "bored.mp4").write_bytes(b"fake")
|
|
||||||
(src / ".DS_Store").write_bytes(b"junk")
|
|
||||||
try:
|
|
||||||
content_packs.build_pack(src, tmp_path / "bar-pack-v1.zip")
|
|
||||||
except ValueError as e:
|
|
||||||
assert "downloader will reject" in str(e)
|
|
||||||
else:
|
|
||||||
raise AssertionError("build_pack accepted a .DS_Store the downloader rejects")
|
|
||||||
|
|
||||||
|
|
||||||
def test_double_download_409s(client, monkeypatch):
|
def test_double_download_409s(client, monkeypatch):
|
||||||
bar = career_routes._venue("bar")
|
bar = career_routes._venue("bar")
|
||||||
monkeypatch.setitem(bar, "pack", {"url": "http://x/pack.zip", "sha256": "0" * 64, "bytes": 123})
|
monkeypatch.setitem(bar, "pack", {"url": "http://x/pack.zip", "sha256": "0" * 64})
|
||||||
# Pretend one is already running.
|
# Pretend one is already running.
|
||||||
career_routes._state["downloads"]["bar"] = {"status": "running"}
|
career_routes._state["downloads"]["bar"] = {"status": "running"}
|
||||||
assert client.post("/api/plugins/career/packs/bar/download").status_code == 409
|
assert client.post("/api/plugins/career/packs/bar/download").status_code == 409
|
||||||
|
|||||||
@@ -1,91 +0,0 @@
|
|||||||
"""VST-pack slicing (tools/content_packs.build_vst_pack): each platform pack
|
|
||||||
keeps only its own binaries + the shared bundle files, drops the rest, and is
|
|
||||||
reproducible."""
|
|
||||||
import zipfile
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from tools import content_packs
|
|
||||||
|
|
||||||
|
|
||||||
def _fake_vst_tree(root: Path):
|
|
||||||
# One fat .vst3 with all three platform binaries + shared files, plus a
|
|
||||||
# src/ build tree that must never ship.
|
|
||||||
c = root / "amps" / "Foo.vst3" / "Contents"
|
|
||||||
(c / "MacOS").mkdir(parents=True)
|
|
||||||
(c / "x86_64-win").mkdir(parents=True)
|
|
||||||
(c / "x86_64-linux").mkdir(parents=True)
|
|
||||||
(c / "Resources").mkdir(parents=True)
|
|
||||||
(c / "MacOS" / "Foo").write_bytes(b"mac-binary")
|
|
||||||
(c / "x86_64-win" / "Foo.vst3").write_bytes(b"win-binary")
|
|
||||||
(c / "x86_64-linux" / "Foo.so").write_bytes(b"linux-binary")
|
|
||||||
(c / "Info.plist").write_bytes(b"<plist/>")
|
|
||||||
(c / "Resources" / "moduleinfo.json").write_bytes(b"{}")
|
|
||||||
(root / "src" / "build").mkdir(parents=True)
|
|
||||||
(root / "src" / "build" / "junk.o").write_bytes(b"objfile")
|
|
||||||
|
|
||||||
|
|
||||||
def _names(zip_path):
|
|
||||||
with zipfile.ZipFile(zip_path) as zf:
|
|
||||||
return set(zf.namelist())
|
|
||||||
|
|
||||||
|
|
||||||
def test_slice_keeps_target_platform_and_shared_drops_foreign(tmp_path):
|
|
||||||
root = tmp_path / "vst"
|
|
||||||
_fake_vst_tree(root)
|
|
||||||
content_packs.build_vst_pack(root, tmp_path / "mac.zip", "mac")
|
|
||||||
names = _names(tmp_path / "mac.zip")
|
|
||||||
|
|
||||||
base = "amps/Foo.vst3/Contents"
|
|
||||||
assert f"{base}/MacOS/Foo" in names # target binary kept
|
|
||||||
assert f"{base}/Info.plist" in names # shared kept
|
|
||||||
assert f"{base}/Resources/moduleinfo.json" in names # shared kept
|
|
||||||
assert f"{base}/x86_64-win/Foo.vst3" not in names # foreign dropped
|
|
||||||
assert f"{base}/x86_64-linux/Foo.so" not in names # foreign dropped
|
|
||||||
assert not any(n.startswith("src/") for n in names) # build trees never ship
|
|
||||||
|
|
||||||
|
|
||||||
def test_each_platform_gets_its_own_binary(tmp_path):
|
|
||||||
root = tmp_path / "vst"
|
|
||||||
_fake_vst_tree(root)
|
|
||||||
wanted = {"mac": "MacOS/Foo", "win": "x86_64-win/Foo.vst3", "linux": "x86_64-linux/Foo.so"}
|
|
||||||
for plat, rel in wanted.items():
|
|
||||||
content_packs.build_vst_pack(root, tmp_path / f"{plat}.zip", plat)
|
|
||||||
names = _names(tmp_path / f"{plat}.zip")
|
|
||||||
assert f"amps/Foo.vst3/Contents/{rel}" in names
|
|
||||||
others = [v for k, v in wanted.items() if k != plat]
|
|
||||||
for o in others:
|
|
||||||
assert f"amps/Foo.vst3/Contents/{o}" not in names
|
|
||||||
|
|
||||||
|
|
||||||
def test_slice_is_reproducible(tmp_path):
|
|
||||||
root = tmp_path / "vst"
|
|
||||||
_fake_vst_tree(root)
|
|
||||||
a = content_packs.build_vst_pack(root, tmp_path / "a.zip", "linux")
|
|
||||||
b = content_packs.build_vst_pack(root, tmp_path / "b.zip", "linux")
|
|
||||||
assert a == b and a["sha256"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_slice_pins_create_system_for_cross_runner_reproducibility(tmp_path, monkeypatch):
|
|
||||||
# ZipInfo defaults create_system from the host OS (0 on Windows, 3 on Unix),
|
|
||||||
# and it lands in the central directory — so without an explicit pin the same
|
|
||||||
# tree hashes differently on a Windows runner, breaking the precomputable-hash
|
|
||||||
# guarantee exactly where it matters (native .vst3 are built on Windows). A
|
|
||||||
# same-machine reproducibility test can't catch that; simulate win32 and
|
|
||||||
# assert the pin forces 3 regardless.
|
|
||||||
monkeypatch.setattr(zipfile.sys, "platform", "win32")
|
|
||||||
root = tmp_path / "vst"
|
|
||||||
_fake_vst_tree(root)
|
|
||||||
content_packs.build_vst_pack(root, tmp_path / "w.zip", "linux")
|
|
||||||
with zipfile.ZipFile(tmp_path / "w.zip") as zf:
|
|
||||||
assert all(i.create_system == 3 for i in zf.infolist())
|
|
||||||
|
|
||||||
|
|
||||||
def test_unknown_platform_rejected(tmp_path):
|
|
||||||
root = tmp_path / "vst"
|
|
||||||
_fake_vst_tree(root)
|
|
||||||
try:
|
|
||||||
content_packs.build_vst_pack(root, tmp_path / "x.zip", "bsd")
|
|
||||||
except ValueError as e:
|
|
||||||
assert "unknown platform" in str(e)
|
|
||||||
else:
|
|
||||||
raise AssertionError("build_vst_pack accepted an unknown platform")
|
|
||||||
@@ -1,282 +0,0 @@
|
|||||||
"""Tests for lib/midi_import.py — extract_midi_lyrics (lyrics + vocal melody).
|
|
||||||
|
|
||||||
Synthetic mido.MidiFile objects are built in-memory and saved to tmp_path,
|
|
||||||
same style as test_midi_import.py.
|
|
||||||
|
|
||||||
Covers:
|
|
||||||
- Lyric (0x05) events on a vocal track → lyrics.json + vocal_pitch.json payloads
|
|
||||||
- lyric/note pairing snaps t/d to the note; midi pitch carried through
|
|
||||||
- lyrics with no identifiable vocal track → lyrics payload only
|
|
||||||
- no lyric events at all → None (import behavior unchanged)
|
|
||||||
- .kar '/' line-break prefixes → spec trailing '+' on the previous syllable
|
|
||||||
- .kar '-' hyphen joins pass through untouched
|
|
||||||
- space-delimited syllable streams gain '-' joins
|
|
||||||
- '@'-metadata tokens dropped; Text-event (0x01) fallback on vocal-ish tracks
|
|
||||||
- Text events on non-vocal tracks are NOT treated as lyrics
|
|
||||||
- unpaired lyric durations run to the next syllable, capped at 2.0 s
|
|
||||||
- format-0 mixed-channel file pairs only the vocal-program channel
|
|
||||||
- vocal GM program (52-54 / 85-87) detection without a track name
|
|
||||||
- audio_offset applied to both payloads
|
|
||||||
"""
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
import mido
|
|
||||||
|
|
||||||
from midi_import import extract_midi_lyrics
|
|
||||||
|
|
||||||
|
|
||||||
TPB = 480 # ticks per beat; default 120 BPM → 480 ticks = 0.5 s
|
|
||||||
|
|
||||||
|
|
||||||
def _save(mid: mido.MidiFile, tmp_path, name: str = "test.mid") -> str:
|
|
||||||
p = tmp_path / name
|
|
||||||
mid.save(str(p))
|
|
||||||
return str(p)
|
|
||||||
|
|
||||||
|
|
||||||
def _vocal_file(tmp_path, *, track_name="Vocals", program=None, meta="lyrics",
|
|
||||||
syllables=("Hel-", "lo", "world")):
|
|
||||||
"""Type-1 file: conductor + one melody track carrying notes with a lyric
|
|
||||||
event at each note-on. Notes: 60, 62, 64, each one beat (0.5 s) long,
|
|
||||||
back to back."""
|
|
||||||
mid = mido.MidiFile(type=1, ticks_per_beat=TPB)
|
|
||||||
conductor = mido.MidiTrack()
|
|
||||||
mid.tracks.append(conductor)
|
|
||||||
conductor.append(mido.MetaMessage("set_tempo", tempo=500000, time=0))
|
|
||||||
|
|
||||||
tr = mido.MidiTrack()
|
|
||||||
mid.tracks.append(tr)
|
|
||||||
if track_name:
|
|
||||||
tr.append(mido.MetaMessage("track_name", name=track_name, time=0))
|
|
||||||
if program is not None:
|
|
||||||
tr.append(mido.Message("program_change", channel=0, program=program, time=0))
|
|
||||||
for i, syl in enumerate(syllables):
|
|
||||||
tr.append(mido.MetaMessage(meta, text=syl, time=0))
|
|
||||||
tr.append(mido.Message("note_on", channel=0, note=60 + 2 * i,
|
|
||||||
velocity=90, time=0))
|
|
||||||
tr.append(mido.Message("note_off", channel=0, note=60 + 2 * i,
|
|
||||||
velocity=0, time=TPB))
|
|
||||||
return _save(mid, tmp_path)
|
|
||||||
|
|
||||||
|
|
||||||
# ── both sidecars from a lyric+vocal-track file ──────────────────────────────
|
|
||||||
|
|
||||||
def test_vocal_track_emits_both_payloads(tmp_path):
|
|
||||||
result = extract_midi_lyrics(_vocal_file(tmp_path))
|
|
||||||
assert result is not None
|
|
||||||
assert result["lyrics_source"] == "authored"
|
|
||||||
|
|
||||||
lyr = result["lyrics"]
|
|
||||||
assert [e["w"] for e in lyr] == ["Hel-", "lo", "world"]
|
|
||||||
assert [e["t"] for e in lyr] == pytest.approx([0.0, 0.5, 1.0])
|
|
||||||
# Paired syllables snap d to the note duration (1 beat = 0.5 s).
|
|
||||||
assert [e["d"] for e in lyr] == pytest.approx([0.5, 0.5, 0.5])
|
|
||||||
|
|
||||||
vp = result["vocal_pitch"]
|
|
||||||
assert vp is not None
|
|
||||||
assert vp["version"] == 1
|
|
||||||
assert [n["midi"] for n in vp["notes"]] == [60, 62, 64]
|
|
||||||
# vocal_pitch t/d mirror the matching lyrics entries (spec §7.2).
|
|
||||||
assert [(n["t"], n["d"]) for n in vp["notes"]] == \
|
|
||||||
[(e["t"], e["d"]) for e in lyr]
|
|
||||||
|
|
||||||
|
|
||||||
def test_vocal_program_detection_without_name(tmp_path):
|
|
||||||
"""GM program 53 (Voice Oohs) marks the track vocal even with no name."""
|
|
||||||
path = _vocal_file(tmp_path, track_name="", program=53)
|
|
||||||
result = extract_midi_lyrics(path)
|
|
||||||
assert result is not None
|
|
||||||
assert result["vocal_pitch"] is not None
|
|
||||||
assert [n["midi"] for n in result["vocal_pitch"]["notes"]] == [60, 62, 64]
|
|
||||||
|
|
||||||
|
|
||||||
# ── lyrics-only fallbacks ────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def test_no_vocal_track_emits_lyrics_only(tmp_path):
|
|
||||||
"""Lyric events on a noteless track + only a piano note track → the
|
|
||||||
lyrics payload is emitted but vocal_pitch is None (talkies path)."""
|
|
||||||
mid = mido.MidiFile(type=1, ticks_per_beat=TPB)
|
|
||||||
conductor = mido.MidiTrack()
|
|
||||||
mid.tracks.append(conductor)
|
|
||||||
conductor.append(mido.MetaMessage("set_tempo", tempo=500000, time=0))
|
|
||||||
|
|
||||||
words = mido.MidiTrack()
|
|
||||||
mid.tracks.append(words)
|
|
||||||
words.append(mido.MetaMessage("lyrics", text="Hello ", time=0))
|
|
||||||
words.append(mido.MetaMessage("lyrics", text="there ", time=TPB))
|
|
||||||
|
|
||||||
piano = mido.MidiTrack()
|
|
||||||
mid.tracks.append(piano)
|
|
||||||
piano.append(mido.MetaMessage("track_name", name="Piano", time=0))
|
|
||||||
piano.append(mido.Message("program_change", channel=0, program=0, time=0))
|
|
||||||
piano.append(mido.Message("note_on", channel=0, note=48, velocity=90, time=0))
|
|
||||||
piano.append(mido.Message("note_off", channel=0, note=48, velocity=0, time=TPB))
|
|
||||||
|
|
||||||
result = extract_midi_lyrics(_save(mid, tmp_path))
|
|
||||||
assert result is not None
|
|
||||||
assert [e["w"] for e in result["lyrics"]] == ["Hello", "there"]
|
|
||||||
assert result["vocal_pitch"] is None
|
|
||||||
|
|
||||||
|
|
||||||
def test_no_lyrics_returns_none(tmp_path):
|
|
||||||
"""A file without lyric events changes nothing — extraction reports None."""
|
|
||||||
mid = mido.MidiFile(type=0, ticks_per_beat=TPB)
|
|
||||||
tr = mido.MidiTrack()
|
|
||||||
mid.tracks.append(tr)
|
|
||||||
tr.append(mido.Message("note_on", channel=0, note=60, velocity=90, time=0))
|
|
||||||
tr.append(mido.Message("note_off", channel=0, note=60, velocity=0, time=TPB))
|
|
||||||
|
|
||||||
assert extract_midi_lyrics(_save(mid, tmp_path)) is None
|
|
||||||
|
|
||||||
|
|
||||||
def test_unpaired_duration_next_event_capped_at_2s(tmp_path):
|
|
||||||
"""Unpaired lyric entries last until the next syllable, capped at 2.0 s."""
|
|
||||||
mid = mido.MidiFile(type=1, ticks_per_beat=TPB)
|
|
||||||
conductor = mido.MidiTrack()
|
|
||||||
mid.tracks.append(conductor)
|
|
||||||
conductor.append(mido.MetaMessage("set_tempo", tempo=500000, time=0))
|
|
||||||
|
|
||||||
words = mido.MidiTrack()
|
|
||||||
mid.tracks.append(words)
|
|
||||||
words.append(mido.MetaMessage("lyrics", text="one ", time=0))
|
|
||||||
words.append(mido.MetaMessage("lyrics", text="two ", time=TPB)) # +0.5 s
|
|
||||||
words.append(mido.MetaMessage("lyrics", text="three ", time=TPB * 8)) # +4.0 s
|
|
||||||
|
|
||||||
result = extract_midi_lyrics(_save(mid, tmp_path))
|
|
||||||
lyr = result["lyrics"]
|
|
||||||
assert lyr[0]["d"] == pytest.approx(0.5) # gap to next syllable
|
|
||||||
assert lyr[1]["d"] == pytest.approx(2.0) # 4.0 s gap capped
|
|
||||||
assert lyr[2]["d"] == pytest.approx(2.0) # last entry: cap value
|
|
||||||
|
|
||||||
|
|
||||||
# ── .kar conventions ─────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def test_kar_slash_line_break_maps_to_plus(tmp_path):
|
|
||||||
"""A '/' prefix on a syllable marks the END of the previous line — the
|
|
||||||
previous syllable gains the spec's trailing '+'."""
|
|
||||||
path = _vocal_file(
|
|
||||||
tmp_path, syllables=("Hel-", "lo", "/world"))
|
|
||||||
result = extract_midi_lyrics(path)
|
|
||||||
words = [e["w"] for e in result["lyrics"]]
|
|
||||||
assert words == ["Hel-", "lo+", "world"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_kar_backslash_paragraph_break_maps_to_plus(tmp_path):
|
|
||||||
path = _vocal_file(tmp_path, syllables=("one", "\\two", "three"))
|
|
||||||
words = [e["w"] for e in extract_midi_lyrics(path)["lyrics"]]
|
|
||||||
assert words == ["one+", "two", "three"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_kar_hyphen_joins_pass_through(tmp_path):
|
|
||||||
""".kar hyphen suffixes already ARE the spec join marker — untouched,
|
|
||||||
and no extra '-' is synthesized onto hyphenless word-final syllables."""
|
|
||||||
path = _vocal_file(tmp_path, syllables=("beau-", "ti-", "ful", "day"))
|
|
||||||
words = [e["w"] for e in extract_midi_lyrics(path)["lyrics"]]
|
|
||||||
assert words == ["beau-", "ti-", "ful", "day"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_space_delimited_stream_gains_hyphen_joins(tmp_path):
|
|
||||||
"""Space-delimited Lyric streams ('Hel' 'lo ' 'world') carry word
|
|
||||||
boundaries in whitespace — mid-word syllables gain the '-' join."""
|
|
||||||
path = _vocal_file(tmp_path, syllables=("Hel", "lo ", "world "))
|
|
||||||
words = [e["w"] for e in extract_midi_lyrics(path)["lyrics"]]
|
|
||||||
assert words == ["Hel-", "lo", "world"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_newline_in_lyric_event_ends_line(tmp_path):
|
|
||||||
path = _vocal_file(tmp_path, syllables=("one \n", "two ", "three "))
|
|
||||||
words = [e["w"] for e in extract_midi_lyrics(path)["lyrics"]]
|
|
||||||
assert words == ["one+", "two", "three"]
|
|
||||||
|
|
||||||
|
|
||||||
# ── Text-event (0x01) fallback ───────────────────────────────────────────────
|
|
||||||
|
|
||||||
def test_text_event_fallback_on_vocal_track(tmp_path):
|
|
||||||
"""With no 0x05 events anywhere, Text events on a vocal-ish track are
|
|
||||||
accepted as lyrics; '@'-prefixed .kar metadata tokens are dropped."""
|
|
||||||
mid = mido.MidiFile(type=1, ticks_per_beat=TPB)
|
|
||||||
conductor = mido.MidiTrack()
|
|
||||||
mid.tracks.append(conductor)
|
|
||||||
conductor.append(mido.MetaMessage("set_tempo", tempo=500000, time=0))
|
|
||||||
|
|
||||||
tr = mido.MidiTrack()
|
|
||||||
mid.tracks.append(tr)
|
|
||||||
tr.append(mido.MetaMessage("track_name", name="Melody", time=0))
|
|
||||||
tr.append(mido.MetaMessage("text", text="@KMIDI KARAOKE FILE", time=0))
|
|
||||||
tr.append(mido.MetaMessage("text", text="@T A Song", time=0))
|
|
||||||
for i, syl in enumerate(("Some ", "words ")):
|
|
||||||
tr.append(mido.MetaMessage("text", text=syl, time=0))
|
|
||||||
tr.append(mido.Message("note_on", channel=0, note=64 + i, velocity=90,
|
|
||||||
time=0))
|
|
||||||
tr.append(mido.Message("note_off", channel=0, note=64 + i, velocity=0,
|
|
||||||
time=TPB))
|
|
||||||
|
|
||||||
result = extract_midi_lyrics(_save(mid, tmp_path))
|
|
||||||
assert result is not None
|
|
||||||
assert [e["w"] for e in result["lyrics"]] == ["Some", "words"]
|
|
||||||
assert [n["midi"] for n in result["vocal_pitch"]["notes"]] == [64, 65]
|
|
||||||
|
|
||||||
|
|
||||||
def test_text_events_on_non_vocal_track_ignored(tmp_path):
|
|
||||||
"""Text events on a plain instrument track (copyright notices, markers)
|
|
||||||
are not lyrics — extraction returns None."""
|
|
||||||
mid = mido.MidiFile(type=1, ticks_per_beat=TPB)
|
|
||||||
conductor = mido.MidiTrack()
|
|
||||||
mid.tracks.append(conductor)
|
|
||||||
conductor.append(mido.MetaMessage("set_tempo", tempo=500000, time=0))
|
|
||||||
|
|
||||||
tr = mido.MidiTrack()
|
|
||||||
mid.tracks.append(tr)
|
|
||||||
tr.append(mido.MetaMessage("track_name", name="Guitar", time=0))
|
|
||||||
tr.append(mido.MetaMessage("text", text="Copyright 2026", time=0))
|
|
||||||
tr.append(mido.Message("note_on", channel=0, note=52, velocity=90, time=0))
|
|
||||||
tr.append(mido.Message("note_off", channel=0, note=52, velocity=0, time=TPB))
|
|
||||||
|
|
||||||
assert extract_midi_lyrics(_save(mid, tmp_path)) is None
|
|
||||||
|
|
||||||
|
|
||||||
# ── format-0 channel isolation ───────────────────────────────────────────────
|
|
||||||
|
|
||||||
def test_format0_pairs_only_vocal_program_channel(tmp_path):
|
|
||||||
"""Format-0 file mixing a vocal-program channel with an accompaniment
|
|
||||||
channel: only the vocal channel's notes feed vocal_pitch."""
|
|
||||||
mid = mido.MidiFile(type=0, ticks_per_beat=TPB)
|
|
||||||
tr = mido.MidiTrack()
|
|
||||||
mid.tracks.append(tr)
|
|
||||||
tr.append(mido.Message("program_change", channel=0, program=53, time=0)) # Voice Oohs
|
|
||||||
tr.append(mido.Message("program_change", channel=1, program=0, time=0)) # Piano
|
|
||||||
# Simultaneous piano note that must NOT be paired.
|
|
||||||
tr.append(mido.Message("note_on", channel=1, note=40, velocity=90, time=0))
|
|
||||||
tr.append(mido.MetaMessage("lyrics", text="la ", time=0))
|
|
||||||
tr.append(mido.Message("note_on", channel=0, note=67, velocity=90, time=0))
|
|
||||||
tr.append(mido.Message("note_off", channel=0, note=67, velocity=0, time=TPB))
|
|
||||||
tr.append(mido.Message("note_off", channel=1, note=40, velocity=0, time=0))
|
|
||||||
|
|
||||||
result = extract_midi_lyrics(_save(mid, tmp_path))
|
|
||||||
assert result is not None
|
|
||||||
assert [n["midi"] for n in result["vocal_pitch"]["notes"]] == [67]
|
|
||||||
|
|
||||||
|
|
||||||
def test_format0_without_vocal_channel_is_lyrics_only(tmp_path):
|
|
||||||
"""Format-0 with lyrics but no vocal program/name: the merged note soup
|
|
||||||
cannot be trusted as a melody — lyrics.json only."""
|
|
||||||
mid = mido.MidiFile(type=0, ticks_per_beat=TPB)
|
|
||||||
tr = mido.MidiTrack()
|
|
||||||
mid.tracks.append(tr)
|
|
||||||
tr.append(mido.Message("program_change", channel=0, program=0, time=0))
|
|
||||||
tr.append(mido.MetaMessage("lyrics", text="la ", time=0))
|
|
||||||
tr.append(mido.Message("note_on", channel=0, note=60, velocity=90, time=0))
|
|
||||||
tr.append(mido.Message("note_off", channel=0, note=60, velocity=0, time=TPB))
|
|
||||||
|
|
||||||
result = extract_midi_lyrics(_save(mid, tmp_path))
|
|
||||||
assert result is not None
|
|
||||||
assert len(result["lyrics"]) == 1
|
|
||||||
assert result["vocal_pitch"] is None
|
|
||||||
|
|
||||||
|
|
||||||
# ── audio_offset ─────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def test_audio_offset_applied_to_both_payloads(tmp_path):
|
|
||||||
result = extract_midi_lyrics(_vocal_file(tmp_path), audio_offset=1.5)
|
|
||||||
assert result["lyrics"][0]["t"] == pytest.approx(1.5)
|
|
||||||
assert result["vocal_pitch"]["notes"][0]["t"] == pytest.approx(1.5)
|
|
||||||
@@ -19,7 +19,6 @@ a per-arrangement `drum_tab` file pointer and NO note `file`. The loader:
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import logging
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import yaml
|
import yaml
|
||||||
@@ -102,28 +101,6 @@ def test_pointer_entries_never_become_fretted_arrangements(tmp_path: Path):
|
|||||||
assert loaded.arrangement_ids == ["lead"]
|
assert loaded.arrangement_ids == ["lead"]
|
||||||
|
|
||||||
|
|
||||||
def test_drums_typed_entry_with_note_file_never_frets(tmp_path: Path):
|
|
||||||
# A malformed entry: type:drums but ALSO carrying a note `file`. Keying the
|
|
||||||
# skip on file absence would let it through as a fretted, selectable,
|
|
||||||
# gradeable Arrangement (spec §5.2/§7.5 MUST-NOT). Routing on `type` first
|
|
||||||
# drops it instead — it never reaches song.arrangements.
|
|
||||||
bogus = {
|
|
||||||
"name": "Bogus", "tuning": [0, 0, 0, 0, 0, 0], "capo": 0,
|
|
||||||
"notes": [], "chords": [], "anchors": [], "handshapes": [],
|
|
||||||
"templates": [], "beats": [], "sections": [],
|
|
||||||
}
|
|
||||||
pak = _write_pak(tmp_path, {
|
|
||||||
"arrangements": [
|
|
||||||
{"id": "lead", "name": "Lead", "file": "arrangements/lead.json"},
|
|
||||||
{"id": "bad", "name": "Bogus", "type": "drums",
|
|
||||||
"file": "arrangements/bogus.json"},
|
|
||||||
],
|
|
||||||
}, {"arrangements/bogus.json": bogus})
|
|
||||||
loaded = _load(pak, tmp_path)
|
|
||||||
assert [a.name for a in loaded.song.arrangements] == ["Lead"]
|
|
||||||
assert loaded.arrangement_ids == ["lead"]
|
|
||||||
|
|
||||||
|
|
||||||
# ── Parts resolution ─────────────────────────────────────────────────────────
|
# ── Parts resolution ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def test_two_parts_resolve_primary_first_with_alias_identity(tmp_path: Path):
|
def test_two_parts_resolve_primary_first_with_alias_identity(tmp_path: Path):
|
||||||
@@ -254,18 +231,7 @@ def test_drum_pointer_with_wrong_type_logs_warning(tmp_path: Path, caplog):
|
|||||||
{"id": "typo", "type": "druns", "drum_tab": "drum_tab_typo.json"},
|
{"id": "typo", "type": "druns", "drum_tab": "drum_tab_typo.json"},
|
||||||
],
|
],
|
||||||
}, {"drum_tab_typo.json": _tab("Typo")})
|
}, {"drum_tab_typo.json": _tab("Typo")})
|
||||||
# feedBack sets propagate=False, so pytest's root capture sees nothing from
|
loaded = _load(pak, tmp_path)
|
||||||
# it — attach caplog's handler to the feedBack logger and pin WARNING
|
|
||||||
# regardless of ambient level (a sibling test can leak ERROR onto this tree).
|
|
||||||
lg = logging.getLogger("feedBack")
|
|
||||||
orig_level = lg.level
|
|
||||||
lg.addHandler(caplog.handler)
|
|
||||||
lg.setLevel(logging.WARNING)
|
|
||||||
try:
|
|
||||||
loaded = _load(pak, tmp_path)
|
|
||||||
finally:
|
|
||||||
lg.removeHandler(caplog.handler)
|
|
||||||
lg.setLevel(orig_level)
|
|
||||||
assert loaded.drum_parts is None
|
assert loaded.drum_parts is None
|
||||||
assert "has drum_tab" in caplog.text and "type='druns'" in caplog.text
|
assert "has drum_tab" in caplog.text and "type='druns'" in caplog.text
|
||||||
|
|
||||||
|
|||||||
@@ -329,31 +329,6 @@ def test_note_pitch_midi_bass_uses_bass_base():
|
|||||||
assert note_pitch_midi(bass, Note(time=0, string=0, fret=0)) == 28
|
assert note_pitch_midi(bass, Note(time=0, string=0, fret=0)) == 28
|
||||||
|
|
||||||
|
|
||||||
def test_note_pitch_midi_authored_bass_type_uses_bass_base():
|
|
||||||
"""Editor PR #335: a bass authored via `type` on an arrangement whose NAME
|
|
||||||
doesn't say "bass". arrangement_string_count now returns 4 for it, so the
|
|
||||||
open-string base MUST also be the bass base (low E1 = 28), not the guitar
|
|
||||||
octave (40). Pre-fix note_pitch_midi keyed is_bass off the name only, so
|
|
||||||
this returned 40 (4 lanes on a guitar octave — the exact inconsistency)."""
|
|
||||||
bass = Arrangement(
|
|
||||||
name="Low End", type="bass",
|
|
||||||
tuning=[0, 0, 0, 0],
|
|
||||||
notes=[Note(time=float(i), string=i, fret=0) for i in range(4)],
|
|
||||||
)
|
|
||||||
assert note_pitch_midi(bass, Note(time=0, string=0, fret=0)) == 28
|
|
||||||
|
|
||||||
|
|
||||||
def test_note_pitch_midi_path_bass_flag_uses_bass_base():
|
|
||||||
"""Same guarantee via the archive <arrangementProperties> pathBass flag on a
|
|
||||||
non-"bass"-named arrangement: bass base (28), not guitar (40)."""
|
|
||||||
bass = Arrangement(
|
|
||||||
name="Low End", path_bass=True,
|
|
||||||
tuning=[0, 0, 0, 0],
|
|
||||||
notes=[Note(time=float(i), string=i, fret=0) for i in range(4)],
|
|
||||||
)
|
|
||||||
assert note_pitch_midi(bass, Note(time=0, string=0, fret=0)) == 28
|
|
||||||
|
|
||||||
|
|
||||||
def test_note_pitch_midi_out_of_range_string_is_none():
|
def test_note_pitch_midi_out_of_range_string_is_none():
|
||||||
arr = Arrangement(name="Lead", tuning=[0, 0, 0, 0, 0, 0])
|
arr = Arrangement(name="Lead", tuning=[0, 0, 0, 0, 0, 0])
|
||||||
assert note_pitch_midi(arr, Note(time=0, string=9, fret=0)) is None
|
assert note_pitch_midi(arr, Note(time=0, string=9, fret=0)) is None
|
||||||
@@ -1178,60 +1153,6 @@ def test_string_count_ignores_rs_padded_tuning_for_bass():
|
|||||||
assert arrangement_string_count(arr) == 4
|
assert arrangement_string_count(arr) == 4
|
||||||
|
|
||||||
|
|
||||||
def test_string_count_4_for_path_bass_flag_without_bass_in_name():
|
|
||||||
# Archive/DLC bass whose manifest ArrangementName isn't "Bass" but whose
|
|
||||||
# <arrangementProperties> pathBass flag is set. Notes on 0..3, tuning
|
|
||||||
# padded to the arrangement-XML length of 6. Pre-fix, name_based forced
|
|
||||||
# 6 (name has no "bass"), so this returned 6 despite the authoritative
|
|
||||||
# instrument flag saying bass.
|
|
||||||
arr = Arrangement(
|
|
||||||
name="Low End",
|
|
||||||
path_bass=True,
|
|
||||||
tuning=[0, 0, 0, 0, 0, 0],
|
|
||||||
notes=[Note(time=float(i), string=i, fret=0) for i in range(4)],
|
|
||||||
)
|
|
||||||
assert arrangement_string_count(arr) == 4
|
|
||||||
|
|
||||||
|
|
||||||
def test_string_count_4_for_authored_bass_type_without_bass_in_name():
|
|
||||||
# Editor PR #335: an instrument `type` authored as bass on an arrangement
|
|
||||||
# whose NAME does not contain "bass" (the sloppak loader lifts the manifest
|
|
||||||
# `type` onto Arrangement.type). Notes on 0..3, tuning padded to 6.
|
|
||||||
# The editor lays out 4 lanes off the type; core must agree.
|
|
||||||
arr = Arrangement(
|
|
||||||
name="Low End",
|
|
||||||
type="bass",
|
|
||||||
tuning=[0, 0, 0, 0, 0, 0],
|
|
||||||
notes=[Note(time=float(i), string=i, fret=0) for i in range(4)],
|
|
||||||
)
|
|
||||||
assert arrangement_string_count(arr) == 4
|
|
||||||
|
|
||||||
|
|
||||||
def test_string_count_6_for_authored_guitar_type_no_regression():
|
|
||||||
# A non-bass authored type on a generic name still resolves to the
|
|
||||||
# canonical 6 — the type signal only pulls DOWN to 4 for bass.
|
|
||||||
arr = Arrangement(
|
|
||||||
name="Track 1",
|
|
||||||
type="guitar",
|
|
||||||
notes=[Note(time=float(i), string=i, fret=0) for i in range(5)],
|
|
||||||
)
|
|
||||||
assert arrangement_string_count(arr) == 6
|
|
||||||
|
|
||||||
|
|
||||||
def test_arrangement_is_bass_signal_safety():
|
|
||||||
# The manifest `type` is lifted onto arr.type verbatim; the helper must be
|
|
||||||
# safe against the messy shapes a hand-edited/loose source can produce.
|
|
||||||
from song import arrangement_is_bass
|
|
||||||
assert arrangement_is_bass(Arrangement(name="Low End", type="bass"))
|
|
||||||
assert arrangement_is_bass(Arrangement(name="Low End", type=" BASS ")) # ws/case
|
|
||||||
assert arrangement_is_bass(Arrangement(name="Low End", path_bass=True))
|
|
||||||
assert arrangement_is_bass(Arrangement(name="Slap Bass")) # legacy name
|
|
||||||
# Non-bass / absent signals stay False (back-compat: no bass signal → 6).
|
|
||||||
assert not arrangement_is_bass(Arrangement(name="Lead", type=""))
|
|
||||||
assert not arrangement_is_bass(Arrangement(name="Rhythm", type="guitar"))
|
|
||||||
assert not arrangement_is_bass(Arrangement(name="", type=""))
|
|
||||||
|
|
||||||
|
|
||||||
# ── compute_smart_names ───────────────────────────────────────────────────────
|
# ── compute_smart_names ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
def _sarr(path_lead=False, path_rhythm=False, path_bass=False,
|
def _sarr(path_lead=False, path_rhythm=False, path_bass=False,
|
||||||
|
|||||||
@@ -60,14 +60,12 @@ def test_the_failure_is_actually_logged(registry, caplog):
|
|||||||
# capture_logger() context manager for this, but it is not importable from here:
|
# capture_logger() context manager for this, but it is not importable from here:
|
||||||
# pyproject pins pythonpath to [".", "lib"], so `tests` is not a package.)
|
# pyproject pins pythonpath to [".", "lib"], so `tests` is not a package.)
|
||||||
lg = logging.getLogger("feedBack")
|
lg = logging.getLogger("feedBack")
|
||||||
orig_level = lg.level
|
|
||||||
lg.addHandler(caplog.handler)
|
lg.addHandler(caplog.handler)
|
||||||
lg.setLevel(logging.ERROR)
|
lg.setLevel(logging.ERROR)
|
||||||
try:
|
try:
|
||||||
registry.get_merged()
|
registry.get_merged()
|
||||||
finally:
|
finally:
|
||||||
lg.removeHandler(caplog.handler)
|
lg.removeHandler(caplog.handler)
|
||||||
lg.setLevel(orig_level) # restore, or ERROR leaks onto the feedBack tree
|
|
||||||
|
|
||||||
assert any("bad-plugin" in r.getMessage() for r in caplog.records), (
|
assert any("bad-plugin" in r.getMessage() for r in caplog.records), (
|
||||||
"the raising provider was never named in the logs"
|
"the raising provider was never named in the logs"
|
||||||
|
|||||||
@@ -1,234 +0,0 @@
|
|||||||
"""Tests for the session-sync relay WebSocket (/ws/sync/{session_id}).
|
|
||||||
|
|
||||||
Behavior tests run against a minimal FastAPI app carrying just the router
|
|
||||||
(fast — no full-server import); one integration test imports the real server
|
|
||||||
to pin that the route is actually mounted there.
|
|
||||||
|
|
||||||
Covers the feedBack#1030 acceptance list: bidirectional fan-out, late join,
|
|
||||||
sender never echoed, room garbage collection, and the limit closes (invalid
|
|
||||||
session id, binary frames, frame size, room size, room count, rate cap) —
|
|
||||||
including that one client tripping a limit doesn't disturb the others.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import importlib
|
|
||||||
import sys
|
|
||||||
import time
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from fastapi import FastAPI
|
|
||||||
from fastapi.testclient import TestClient
|
|
||||||
from starlette.websockets import WebSocketDisconnect
|
|
||||||
|
|
||||||
from routers import ws_sync
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
|
||||||
def _clean_rooms():
|
|
||||||
ws_sync._rooms.clear()
|
|
||||||
yield
|
|
||||||
ws_sync._rooms.clear()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
|
||||||
def client():
|
|
||||||
app = FastAPI()
|
|
||||||
app.include_router(ws_sync.router)
|
|
||||||
with TestClient(app) as c:
|
|
||||||
yield c
|
|
||||||
|
|
||||||
|
|
||||||
def _expect_close(ws, code):
|
|
||||||
with pytest.raises(WebSocketDisconnect) as exc:
|
|
||||||
ws.receive_text()
|
|
||||||
assert exc.value.code == code
|
|
||||||
|
|
||||||
|
|
||||||
# ── Fan-out semantics ────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def test_two_clients_relay_both_directions_and_no_echo(client):
|
|
||||||
with client.websocket_connect("/ws/sync/ROOM01") as a, \
|
|
||||||
client.websocket_connect("/ws/sync/ROOM01") as b:
|
|
||||||
a.send_text('{"type":"time","t":1.5}')
|
|
||||||
assert b.receive_text() == '{"type":"time","t":1.5}'
|
|
||||||
b.send_text('{"type":"hello"}')
|
|
||||||
# A's first inbound frame is B's hello — NOT an echo of its own send.
|
|
||||||
assert a.receive_text() == '{"type":"hello"}'
|
|
||||||
|
|
||||||
|
|
||||||
def test_late_joiner_receives_subsequent_frames(client):
|
|
||||||
with client.websocket_connect("/ws/sync/ROOM02") as a, \
|
|
||||||
client.websocket_connect("/ws/sync/ROOM02") as b:
|
|
||||||
a.send_text("f1")
|
|
||||||
assert b.receive_text() == "f1"
|
|
||||||
with client.websocket_connect("/ws/sync/ROOM02") as c:
|
|
||||||
a.send_text("f2")
|
|
||||||
assert b.receive_text() == "f2"
|
|
||||||
assert c.receive_text() == "f2"
|
|
||||||
|
|
||||||
|
|
||||||
def test_rooms_are_isolated(client):
|
|
||||||
with client.websocket_connect("/ws/sync/ROOMA1") as a, \
|
|
||||||
client.websocket_connect("/ws/sync/ROOMB1") as b, \
|
|
||||||
client.websocket_connect("/ws/sync/ROOMA1") as a2:
|
|
||||||
a.send_text("for-room-a")
|
|
||||||
assert a2.receive_text() == "for-room-a"
|
|
||||||
# B (other room) got nothing: prove it by relaying within B's room.
|
|
||||||
with client.websocket_connect("/ws/sync/ROOMB1") as b2:
|
|
||||||
b2.send_text("for-room-b")
|
|
||||||
assert b.receive_text() == "for-room-b"
|
|
||||||
|
|
||||||
|
|
||||||
def test_client_disconnect_does_not_disrupt_remaining(client):
|
|
||||||
with client.websocket_connect("/ws/sync/ROOM03") as a, \
|
|
||||||
client.websocket_connect("/ws/sync/ROOM03") as b:
|
|
||||||
with client.websocket_connect("/ws/sync/ROOM03") as c:
|
|
||||||
a.send_text("before")
|
|
||||||
assert b.receive_text() == "before"
|
|
||||||
assert c.receive_text() == "before"
|
|
||||||
# C is gone; relay between A and B continues.
|
|
||||||
a.send_text("after")
|
|
||||||
assert b.receive_text() == "after"
|
|
||||||
|
|
||||||
|
|
||||||
def test_room_garbage_collected_when_last_client_leaves(client):
|
|
||||||
with client.websocket_connect("/ws/sync/ROOM04") as a:
|
|
||||||
with client.websocket_connect("/ws/sync/ROOM04") as b:
|
|
||||||
a.send_text("x")
|
|
||||||
assert b.receive_text() == "x"
|
|
||||||
assert "ROOM04" in ws_sync._rooms
|
|
||||||
assert "ROOM04" not in ws_sync._rooms
|
|
||||||
assert ws_sync._rooms == {}
|
|
||||||
|
|
||||||
|
|
||||||
# ── Limit enforcement ────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("bad_id", ["abc", "x" * 65, "has space", "bad$id", "nope!"])
|
|
||||||
def test_invalid_session_id_closed_with_policy_code(client, bad_id):
|
|
||||||
with client.websocket_connect(f"/ws/sync/{bad_id}") as ws:
|
|
||||||
_expect_close(ws, 1008)
|
|
||||||
assert ws_sync._rooms == {}
|
|
||||||
|
|
||||||
|
|
||||||
def test_binary_frame_closes_with_unsupported_data(client):
|
|
||||||
with client.websocket_connect("/ws/sync/ROOM05") as ws:
|
|
||||||
ws.send_bytes(b"\x00\x01")
|
|
||||||
_expect_close(ws, 1003)
|
|
||||||
|
|
||||||
|
|
||||||
def test_oversized_frame_closes_sender_only(client):
|
|
||||||
with client.websocket_connect("/ws/sync/ROOM06") as a, \
|
|
||||||
client.websocket_connect("/ws/sync/ROOM06") as b, \
|
|
||||||
client.websocket_connect("/ws/sync/ROOM06") as c:
|
|
||||||
a.send_text("x" * (ws_sync.MAX_FRAME_BYTES + 1))
|
|
||||||
_expect_close(a, 1009)
|
|
||||||
# The room carries on without A.
|
|
||||||
b.send_text("still-alive")
|
|
||||||
assert c.receive_text() == "still-alive"
|
|
||||||
|
|
||||||
|
|
||||||
def test_room_client_cap(client, monkeypatch):
|
|
||||||
monkeypatch.setattr(ws_sync, "MAX_CLIENTS_PER_ROOM", 2)
|
|
||||||
with client.websocket_connect("/ws/sync/ROOM07") as a, \
|
|
||||||
client.websocket_connect("/ws/sync/ROOM07") as b, \
|
|
||||||
client.websocket_connect("/ws/sync/ROOM07") as c:
|
|
||||||
_expect_close(c, 1013)
|
|
||||||
a.send_text("two-is-fine")
|
|
||||||
assert b.receive_text() == "two-is-fine"
|
|
||||||
|
|
||||||
|
|
||||||
def test_total_room_cap(client, monkeypatch):
|
|
||||||
monkeypatch.setattr(ws_sync, "MAX_ROOMS", 1)
|
|
||||||
with client.websocket_connect("/ws/sync/ROOM08"):
|
|
||||||
with client.websocket_connect("/ws/sync/ROOM09") as overflow:
|
|
||||||
_expect_close(overflow, 1013)
|
|
||||||
# Joining the EXISTING room is still fine at the room cap.
|
|
||||||
with client.websocket_connect("/ws/sync/ROOM08"):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def test_rate_cap_closes_flooding_sender(client, monkeypatch):
|
|
||||||
monkeypatch.setattr(ws_sync, "RATE_BURST", 3.0)
|
|
||||||
monkeypatch.setattr(ws_sync, "RATE_MSGS_PER_SEC", 0.0)
|
|
||||||
with client.websocket_connect("/ws/sync/ROOM10") as a, \
|
|
||||||
client.websocket_connect("/ws/sync/ROOM10") as b:
|
|
||||||
for i in range(3):
|
|
||||||
a.send_text(f"burst-{i}")
|
|
||||||
for i in range(3):
|
|
||||||
assert b.receive_text() == f"burst-{i}"
|
|
||||||
a.send_text("one-too-many")
|
|
||||||
_expect_close(a, 1008)
|
|
||||||
# The over-limit frame was dropped, not relayed, and B lives on.
|
|
||||||
with client.websocket_connect("/ws/sync/ROOM10") as c:
|
|
||||||
c.send_text("fresh-socket")
|
|
||||||
assert b.receive_text() == "fresh-socket"
|
|
||||||
|
|
||||||
|
|
||||||
class _StalledPeer:
|
|
||||||
"""A fake room member whose send never completes (peer stopped draining)."""
|
|
||||||
|
|
||||||
async def send_text(self, text):
|
|
||||||
await asyncio.Event().wait()
|
|
||||||
|
|
||||||
|
|
||||||
def test_stalled_peer_is_evicted_and_healthy_peers_still_receive(client, monkeypatch):
|
|
||||||
monkeypatch.setattr(ws_sync, "SEND_TIMEOUT_SECONDS", 0.2)
|
|
||||||
with client.websocket_connect("/ws/sync/ROOM11") as a, \
|
|
||||||
client.websocket_connect("/ws/sync/ROOM11") as b:
|
|
||||||
# Wait for both handlers to have registered in the room, then inject
|
|
||||||
# the stalled peer directly (a real stalled TCP peer isn't
|
|
||||||
# constructible under TestClient).
|
|
||||||
deadline = time.monotonic() + 2.0
|
|
||||||
while len(ws_sync._rooms.get("ROOM11", {})) < 2:
|
|
||||||
assert time.monotonic() < deadline, "room never filled"
|
|
||||||
time.sleep(0.01)
|
|
||||||
stalled = _StalledPeer()
|
|
||||||
ws_sync._rooms["ROOM11"][stalled] = asyncio.Lock()
|
|
||||||
|
|
||||||
# Healthy delivery is not blocked behind the stalled peer, and by the
|
|
||||||
# time a second frame has round-tripped, the first fan-out's timeout
|
|
||||||
# has fired and evicted it.
|
|
||||||
a.send_text("f1")
|
|
||||||
assert b.receive_text() == "f1"
|
|
||||||
a.send_text("f2")
|
|
||||||
assert b.receive_text() == "f2"
|
|
||||||
assert stalled not in ws_sync._rooms["ROOM11"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_main_run_caps_uvicorn_ws_max_size():
|
|
||||||
"""main.py must bound inbound WS frames at the transport (uvicorn defaults
|
|
||||||
to 16 MB, which would let a client materialize frames far past the relay's
|
|
||||||
16 KB application cap before the handler ever sees them)."""
|
|
||||||
import unittest.mock
|
|
||||||
|
|
||||||
import main
|
|
||||||
|
|
||||||
with (
|
|
||||||
unittest.mock.patch("logging_setup.configure_logging"),
|
|
||||||
unittest.mock.patch("uvicorn.run") as mock_run,
|
|
||||||
):
|
|
||||||
main.run()
|
|
||||||
|
|
||||||
kwargs = mock_run.call_args.kwargs
|
|
||||||
assert kwargs.get("ws_max_size") == 64 * 1024
|
|
||||||
assert kwargs["ws_max_size"] >= ws_sync.MAX_FRAME_BYTES
|
|
||||||
|
|
||||||
|
|
||||||
# ── Real-app integration ─────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def test_route_mounted_on_real_server(tmp_path, monkeypatch):
|
|
||||||
monkeypatch.setenv("CONFIG_DIR", str(tmp_path / "config"))
|
|
||||||
monkeypatch.setenv("DLC_DIR", str(tmp_path / "dlc"))
|
|
||||||
monkeypatch.setenv("FEEDBACK_SYNC_STARTUP", "1")
|
|
||||||
sys.modules.pop("server", None)
|
|
||||||
server = importlib.import_module("server")
|
|
||||||
monkeypatch.setattr(server, "load_plugins", lambda *a, **kw: None)
|
|
||||||
monkeypatch.setattr(server, "startup_scan", lambda: None)
|
|
||||||
|
|
||||||
with TestClient(server.app) as client:
|
|
||||||
with client.websocket_connect("/ws/sync/REALAPP") as a, \
|
|
||||||
client.websocket_connect("/ws/sync/REALAPP") as b:
|
|
||||||
a.send_text('{"type":"time","t":0}')
|
|
||||||
assert b.receive_text() == '{"type":"time","t":0}'
|
|
||||||
@@ -1,294 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Build & publish opt-in content packs (career venue media, rig VST slices).
|
|
||||||
|
|
||||||
Flat-zips a pack directory, sha256s it, and emits the ``{url, sha256, bytes}``
|
|
||||||
block that the career and rig_builder download paths consume
|
|
||||||
(``plugins/career/routes.py`` ``_download_pack``). Two modes:
|
|
||||||
|
|
||||||
--local <dir> write zips + a file:// manifest (dev/CI/tests; no network)
|
|
||||||
--publish create/upload each pack's per-pack release; emit release URLs
|
|
||||||
|
|
||||||
The zip is flat (files at the archive root) to satisfy career's zip-slip guard
|
|
||||||
(``PACK_FILENAME_RE``) and ``_validate_pack_dir``. This module is the reusable
|
|
||||||
core the content-packs CI workflow calls, so building packs is automation —
|
|
||||||
never a person's manual job.
|
|
||||||
|
|
||||||
Run ``python tools/content_packs.py --selfcheck`` for the built-in round-trip.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import hashlib
|
|
||||||
import json
|
|
||||||
import re
|
|
||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
import zipfile
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
REPO = "got-feedBack/feedBack" # where the content-packs release lives (public)
|
|
||||||
|
|
||||||
# Must mirror career's download-time whitelist (plugins/career/routes.py
|
|
||||||
# PACK_FILENAME_RE). If the builder packs a name the downloader rejects (e.g. a
|
|
||||||
# stray .DS_Store), the published pack fails _validate_pack_dir for every client.
|
|
||||||
PACK_FILENAME_RE = re.compile(r"^[a-z0-9_-]{1,64}\.(mp4|webm|mp3|json)$")
|
|
||||||
|
|
||||||
|
|
||||||
def build_pack(src_dir: Path, out_zip: Path) -> dict:
|
|
||||||
"""Flat-zip every file directly under src_dir; return {sha256, bytes}.
|
|
||||||
|
|
||||||
Only regular files at the top level are included (venue packs are flat).
|
|
||||||
Subdirectories are skipped — a nested tree would trip career's zip-slip
|
|
||||||
guard on download anyway.
|
|
||||||
|
|
||||||
The build is REPRODUCIBLE: identical file contents always yield a
|
|
||||||
byte-identical zip (fixed name order, fixed mtime, fixed permissions,
|
|
||||||
ZIP_STORED). So a sha256 computed on any machine matches the zip the CI
|
|
||||||
workflow or another contributor produces — anyone can precompute the
|
|
||||||
manifest values without having to be the one who uploads the asset.
|
|
||||||
"""
|
|
||||||
files = sorted((p for p in src_dir.iterdir() if p.is_file()),
|
|
||||||
key=lambda p: p.name)
|
|
||||||
if not files:
|
|
||||||
raise ValueError(f"no files to pack in {src_dir}")
|
|
||||||
bad = [p.name for p in files if not PACK_FILENAME_RE.fullmatch(p.name)]
|
|
||||||
if bad:
|
|
||||||
raise ValueError(
|
|
||||||
f"{src_dir}: files the downloader will reject: {bad} "
|
|
||||||
f"(allowed: {PACK_FILENAME_RE.pattern})")
|
|
||||||
out_zip.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
with zipfile.ZipFile(out_zip, "w", zipfile.ZIP_STORED) as zf:
|
|
||||||
# ZIP_STORED: the media (mp4/mp3) and .vst3 binaries are already
|
|
||||||
# compressed; deflating just burns CPU for ~0 gain.
|
|
||||||
for p in files:
|
|
||||||
# Fixed mtime (the zip epoch, 1980-01-01) + fixed perms so the
|
|
||||||
# bytes don't depend on the checkout's file timestamps.
|
|
||||||
info = zipfile.ZipInfo(p.name, date_time=(1980, 1, 1, 0, 0, 0))
|
|
||||||
info.compress_type = zipfile.ZIP_STORED
|
|
||||||
# Pin create_system: ZipInfo defaults it from the host OS (0 on
|
|
||||||
# Windows, 3 on Unix), which would otherwise make the same pack
|
|
||||||
# hash differently across runners. 3 = Unix.
|
|
||||||
info.create_system = 3
|
|
||||||
info.external_attr = 0o644 << 16
|
|
||||||
zf.writestr(info, p.read_bytes())
|
|
||||||
data = out_zip.read_bytes()
|
|
||||||
return {"sha256": hashlib.sha256(data).hexdigest(), "bytes": len(data)}
|
|
||||||
|
|
||||||
|
|
||||||
# rig_builder ships "fat" .vst3 bundles carrying all three platforms inside
|
|
||||||
# Contents/. A pack for one platform keeps that platform's binary dir + the
|
|
||||||
# shared bundle files, and drops the other two.
|
|
||||||
VST_PLATFORM_DIRS = {"mac": "MacOS", "win": "x86_64-win", "linux": "x86_64-linux"}
|
|
||||||
|
|
||||||
|
|
||||||
def build_vst_pack(vst_root: Path, out_zip: Path, platform: str) -> dict:
|
|
||||||
"""Reproducibly zip the VST tree keeping only `platform`'s binaries.
|
|
||||||
|
|
||||||
Slices each fat .vst3: everything is kept except the two foreign platform
|
|
||||||
dirs (MacOS / x86_64-win / x86_64-linux) and the vst/src build trees. Arc
|
|
||||||
names are relative to vst_root so the download endpoint extracts straight
|
|
||||||
into <plugin>/vst/. Same reproducible-build guarantees as build_pack.
|
|
||||||
"""
|
|
||||||
if platform not in VST_PLATFORM_DIRS:
|
|
||||||
raise ValueError(f"unknown platform {platform!r} (want mac/win/linux)")
|
|
||||||
foreign = set(VST_PLATFORM_DIRS.values()) - {VST_PLATFORM_DIRS[platform]}
|
|
||||||
files = []
|
|
||||||
for p in sorted(vst_root.rglob("*"), key=lambda q: q.as_posix()):
|
|
||||||
if not p.is_file():
|
|
||||||
continue
|
|
||||||
rel = p.relative_to(vst_root)
|
|
||||||
if rel.parts and rel.parts[0] == "src": # skip C++/JUCE build trees
|
|
||||||
continue
|
|
||||||
if set(rel.parts) & foreign: # drop foreign-platform binaries
|
|
||||||
continue
|
|
||||||
files.append((p, rel))
|
|
||||||
if not files:
|
|
||||||
raise ValueError(f"no VST files to pack for {platform} in {vst_root}")
|
|
||||||
out_zip.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
with zipfile.ZipFile(out_zip, "w", zipfile.ZIP_STORED) as zf:
|
|
||||||
for p, rel in files:
|
|
||||||
info = zipfile.ZipInfo(rel.as_posix(), date_time=(1980, 1, 1, 0, 0, 0))
|
|
||||||
info.compress_type = zipfile.ZIP_STORED
|
|
||||||
# Pin create_system like build_pack: ZipInfo defaults it from the
|
|
||||||
# host OS (0 on Windows, 3 on Unix), which would otherwise make the
|
|
||||||
# same pack hash differently across runners. VST packs are the most
|
|
||||||
# likely to be built on Windows (native .vst3), so without this pin
|
|
||||||
# the precomputable-hash guarantee breaks exactly where it's needed.
|
|
||||||
info.create_system = 3
|
|
||||||
info.external_attr = 0o644 << 16
|
|
||||||
zf.writestr(info, p.read_bytes())
|
|
||||||
data = out_zip.read_bytes()
|
|
||||||
return {"sha256": hashlib.sha256(data).hexdigest(), "bytes": len(data)}
|
|
||||||
|
|
||||||
|
|
||||||
def manifest_entry(out_zip: Path, url: str) -> dict:
|
|
||||||
"""Pack info as the download-path expects it: {url, sha256, bytes}."""
|
|
||||||
return {"url": url,
|
|
||||||
"sha256": hashlib.sha256(out_zip.read_bytes()).hexdigest(),
|
|
||||||
"bytes": out_zip.stat().st_size}
|
|
||||||
|
|
||||||
|
|
||||||
# Per-pack, versioned, immutable release convention (matches what the team
|
|
||||||
# already published, e.g. tag `venue-arena-v1` / asset `arena-pack-v1.zip`).
|
|
||||||
def pack_tag(pack_id: str, version: int) -> str:
|
|
||||||
return f"venue-{pack_id}-v{version}"
|
|
||||||
|
|
||||||
|
|
||||||
def pack_asset(pack_id: str, version: int) -> str:
|
|
||||||
return f"{pack_id}-pack-v{version}.zip"
|
|
||||||
|
|
||||||
|
|
||||||
def pack_url(pack_id: str, version: int, repo: str = REPO) -> str:
|
|
||||||
return (f"https://github.com/{repo}/releases/download/"
|
|
||||||
f"{pack_tag(pack_id, version)}/{pack_asset(pack_id, version)}")
|
|
||||||
|
|
||||||
|
|
||||||
# VST packs use the same immutable per-pack convention, keyed by platform:
|
|
||||||
# tag `vst-<plat>-v<N>`, asset `vst-<plat>-pack-v<N>.zip`. The manifest they
|
|
||||||
# emit is keyed by platform (mac/win/linux) — the shape the rig_builder plugin's
|
|
||||||
# data/vst_packs.json consumes.
|
|
||||||
def vst_tag(platform: str, version: int) -> str:
|
|
||||||
return f"vst-{platform}-v{version}"
|
|
||||||
|
|
||||||
|
|
||||||
def vst_asset(platform: str, version: int) -> str:
|
|
||||||
return f"vst-{platform}-pack-v{version}.zip"
|
|
||||||
|
|
||||||
|
|
||||||
def vst_url(platform: str, version: int, repo: str = REPO) -> str:
|
|
||||||
return (f"https://github.com/{repo}/releases/download/"
|
|
||||||
f"{vst_tag(platform, version)}/{vst_asset(platform, version)}")
|
|
||||||
|
|
||||||
|
|
||||||
def _publish_release(tag: str, zip_path: Path, title: str, notes: str,
|
|
||||||
repo: str = REPO) -> None:
|
|
||||||
"""Create the per-pack release if missing, then upload the versioned zip.
|
|
||||||
|
|
||||||
Tags are immutable: a media change means a new version (v1 → v2), never a
|
|
||||||
re-upload — so no --clobber. gh errors if the asset already exists, which is
|
|
||||||
the right guard against overwriting a published, referenced pack.
|
|
||||||
"""
|
|
||||||
if subprocess.run(["gh", "release", "view", tag, "--repo", repo],
|
|
||||||
capture_output=True).returncode != 0:
|
|
||||||
subprocess.run(
|
|
||||||
["gh", "release", "create", tag, "--repo", repo, "--latest=false",
|
|
||||||
"--title", title, "--notes", notes],
|
|
||||||
check=True)
|
|
||||||
subprocess.run(
|
|
||||||
["gh", "release", "upload", tag, str(zip_path), "--repo", repo], check=True)
|
|
||||||
|
|
||||||
|
|
||||||
def publish(pack_id: str, version: int, zip_path: Path, repo: str = REPO) -> None:
|
|
||||||
_publish_release(pack_tag(pack_id, version), zip_path,
|
|
||||||
f"{pack_id.capitalize()} venue pack v{version}",
|
|
||||||
"Opt-in career venue pack. Not a code release.", repo)
|
|
||||||
|
|
||||||
|
|
||||||
def _pack_id(src_dir: Path) -> str:
|
|
||||||
return src_dir.name
|
|
||||||
|
|
||||||
|
|
||||||
def main(argv=None) -> int:
|
|
||||||
ap = argparse.ArgumentParser(description=__doc__)
|
|
||||||
ap.add_argument("src", nargs="*", type=Path,
|
|
||||||
help="pack source dirs (e.g. plugins/career/venue-packs/club)")
|
|
||||||
ap.add_argument("--version", type=int, default=1,
|
|
||||||
help="pack version (tag venue-<id>-v<N>); default 1")
|
|
||||||
ap.add_argument("--local", type=Path, metavar="DIR",
|
|
||||||
help="write zips here + a file:// manifest.json; no upload")
|
|
||||||
ap.add_argument("--publish", action="store_true",
|
|
||||||
help="create/upload the per-pack release; emit release URLs")
|
|
||||||
ap.add_argument("--vst", action="store_true",
|
|
||||||
help="slice one rig VST root (src[0]) into per-platform "
|
|
||||||
"vst-<plat>-v<N> packs; manifest keyed by platform "
|
|
||||||
"(the shape rig_builder's data/vst_packs.json wants)")
|
|
||||||
ap.add_argument("--manifest", type=Path,
|
|
||||||
help="write the {id: {url,sha256,bytes}} map here (default: stdout)")
|
|
||||||
ap.add_argument("--selfcheck", action="store_true", help="run the round-trip demo and exit")
|
|
||||||
args = ap.parse_args(argv)
|
|
||||||
|
|
||||||
if args.selfcheck:
|
|
||||||
return _selfcheck()
|
|
||||||
if not args.src or (not args.local and not args.publish):
|
|
||||||
ap.error("need one or more src dirs and either --local or --publish")
|
|
||||||
|
|
||||||
out_dir = args.local if args.local else Path(args.src[0]).parent / "_packs"
|
|
||||||
manifest = {}
|
|
||||||
if args.vst:
|
|
||||||
vst_root = args.src[0]
|
|
||||||
for plat in VST_PLATFORM_DIRS:
|
|
||||||
zip_path = out_dir / vst_asset(plat, args.version)
|
|
||||||
build_vst_pack(vst_root, zip_path, plat)
|
|
||||||
if args.publish:
|
|
||||||
_publish_release(vst_tag(plat, args.version), zip_path,
|
|
||||||
f"Rig VST pack ({plat}) v{args.version}",
|
|
||||||
"Opt-in per-platform rig VST pack. Not a code release.")
|
|
||||||
url = vst_url(plat, args.version)
|
|
||||||
else:
|
|
||||||
url = (out_dir.resolve() / zip_path.name).as_uri()
|
|
||||||
manifest[plat] = manifest_entry(zip_path, url)
|
|
||||||
else:
|
|
||||||
for src in args.src:
|
|
||||||
pid = _pack_id(src)
|
|
||||||
zip_path = out_dir / pack_asset(pid, args.version)
|
|
||||||
build_pack(src, zip_path)
|
|
||||||
if args.publish:
|
|
||||||
publish(pid, args.version, zip_path)
|
|
||||||
url = pack_url(pid, args.version)
|
|
||||||
else:
|
|
||||||
url = (out_dir.resolve() / zip_path.name).as_uri()
|
|
||||||
manifest[pid] = manifest_entry(zip_path, url)
|
|
||||||
|
|
||||||
out = json.dumps(manifest, indent=2)
|
|
||||||
if args.manifest:
|
|
||||||
args.manifest.write_text(out + "\n", encoding="utf-8")
|
|
||||||
else:
|
|
||||||
print(out)
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
def _selfcheck() -> int:
|
|
||||||
"""Build a pack and confirm build_pack/manifest_entry agree on the digest."""
|
|
||||||
import tempfile
|
|
||||||
with tempfile.TemporaryDirectory() as td:
|
|
||||||
td = Path(td)
|
|
||||||
src = td / "bar"
|
|
||||||
src.mkdir()
|
|
||||||
(src / "manifest.json").write_text('{"venue":"bar"}')
|
|
||||||
(src / "bored.mp4").write_bytes(b"\x00fake-video")
|
|
||||||
zip_path = td / pack_asset("bar", 1)
|
|
||||||
info = build_pack(src, zip_path)
|
|
||||||
# Reproducible: a second build (into a different path) is byte-identical.
|
|
||||||
info2 = build_pack(src, td / "again.zip")
|
|
||||||
assert info2["sha256"] == info["sha256"], "build is not reproducible"
|
|
||||||
entry = manifest_entry(zip_path, pack_url("bar", 1))
|
|
||||||
assert entry["sha256"] == info["sha256"], "digest mismatch"
|
|
||||||
assert entry["bytes"] == info["bytes"]
|
|
||||||
assert entry["url"] == (
|
|
||||||
f"https://github.com/{REPO}/releases/download/venue-bar-v1/bar-pack-v1.zip")
|
|
||||||
# Round-trip: the zip must be flat (names == basenames).
|
|
||||||
with zipfile.ZipFile(zip_path) as zf:
|
|
||||||
names = zf.namelist()
|
|
||||||
assert set(names) == {"manifest.json", "bored.mp4"}, names
|
|
||||||
|
|
||||||
# VST slice: keep target platform + shared, drop foreign, reproducible.
|
|
||||||
c = td / "vst" / "Foo.vst3" / "Contents"
|
|
||||||
for d in ("MacOS", "x86_64-win", "x86_64-linux", "Resources"):
|
|
||||||
(c / d).mkdir(parents=True)
|
|
||||||
(c / "MacOS" / "Foo").write_bytes(b"mac")
|
|
||||||
(c / "x86_64-linux" / "Foo.so").write_bytes(b"linux")
|
|
||||||
(c / "Info.plist").write_bytes(b"<plist/>")
|
|
||||||
vzip = td / vst_asset("linux", 1)
|
|
||||||
vinfo = build_vst_pack(td / "vst", vzip, "linux")
|
|
||||||
assert vinfo == build_vst_pack(td / "vst", td / "v2.zip", "linux"), \
|
|
||||||
"vst slice is not reproducible"
|
|
||||||
with zipfile.ZipFile(vzip) as zf:
|
|
||||||
vnames = set(zf.namelist())
|
|
||||||
assert "Foo.vst3/Contents/x86_64-linux/Foo.so" in vnames
|
|
||||||
assert "Foo.vst3/Contents/Info.plist" in vnames
|
|
||||||
assert not any("MacOS" in n for n in vnames), vnames
|
|
||||||
print("content_packs selfcheck: ok")
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
sys.exit(main())
|
|
||||||
Reference in New Issue
Block a user