diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c262781..6b8f1cb5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- **Import Rock Band / Clone Hero / YARG drum charts (feedpak 1.20.0).** + `scripts/ch_to_sloppak.py` turns a CH/YARG song folder (`notes.mid` PART + DRUMS or `notes.chart`, `song.ini`, audio) — or an `.sng` package — into a + drums pack: a `type: drums` arrangement whose `drum_tab.json` carries the + Expert part as 4-lane pro piece hits plus the chart's gameplay data in the + 1.20.0 "drum gameplay metadata" shape — accents, double kicks (`x2`, so the + Expert / Expert+ split survives), star power, fills, solos, and the chart's + own Easy/Medium/Hard parts as `levels` — and a `song_timeline.json` with its + tempo map, beats and sections. song.ini `delay` is applied, so the hits line + up with the audio as YARG plays it. A chart with star power but no fills gets + YARG's generated activation fills, and a missing lower level is reduced from + Expert and marked `generated` so a player can tell it wasn't charted by hand. + `scripts/sloppak_to_ch.py` goes the other way for a pack's drum part (or + `--part `), writing `notes.mid` + `song.ini` + OGG stems that YARG and + Clone Hero read, and the two round-trip. The parsing rules — tom markers, + 5-lane → pro conversion, disco flip, dynamics, delay — are ported from + YARG.Core (LGPL-3.0, attributed in `lib/drumchart.py`); piece-ids come from + `lib/drums.py`, so the importer can't drift from the vocabulary the drums + plugin renders. This is the writer side only: the loader already plays the + tab's hits, and streaming the gameplay keys over the highway WS to a drums + renderer is a separate follow-up. + Implementation in `lib/drumchart.py`, `lib/drumchart_gen.py`, `lib/sngfile.py`; + tests in `tests/test_drumchart.py` and `tests/test_sloppak_to_ch.py`. - **Core reader for source rigs (feedpak 1.18.0).** A pack can declare what a MIDI part should sound like by binding a rig; core now reads that binding and hands it to the client instead of dropping it. Three parts: the diff --git a/lib/drumchart.py b/lib/drumchart.py new file mode 100644 index 00000000..a637eb1e --- /dev/null +++ b/lib/drumchart.py @@ -0,0 +1,659 @@ +"""Rock Band / Clone Hero / YARG drum charts → feedpak `drum_tab.json`. + +Reads the drums part of a ``notes.mid`` (PART DRUMS) or ``notes.chart`` +([ExpertDrums] and the easier difficulties) plus ``song.ini``, and turns it +into 4-lane pro-drums hits with absolute (audio) times, a tempo map, beats, +sections, star-power phrases, fills and solos. `drum_tab()` encodes the result +as a feedpak `drum_tab.json` payload — piece-ids through `lib/drums.py`, the +gameplay keys per feedpak 1.20.0 §7.5 "Drum gameplay metadata" — and +`song_timeline()` as a `song_timeline.json` payload. Generated levels and +fills for charts that lack them live in lib/drumchart_gen.py. + +The chart-parsing rules are ported from YARG.Core (MoonscraperChartParser: +MidReader / MidReader.ProcessLists / MidIOHelper / ChartReader / +ChartReader.ProcessLists / ChartIOHelper; Chart/Loaders/MoonSong/ +MoonSongLoader.Drums.cs; Parsing/TextEvents.cs): + + Copyright (c) 2016-2020 Alexander Ong (Moonscraper) + Copyright (c) YARC (YARG) contributors + Licensed under the GNU Lesser General Public License v3.0 + (https://github.com/YARC-Official/YARG.Core/blob/master/LICENSE). + +Ported to Python and modified (drums rules only, no Elite Drums). Distributed +here as part of feedBack under AGPL-3.0, which LGPL-3.0 permits: LGPL-3.0 is +GPL-3.0 plus additional permissions, and GPL-3.0 section 13 allows combining +with AGPL-3.0 code. + +Rules implemented (YARG behaviour in brackets): +- MIDI pads 96..101 (Expert; 84/72/60 for Hard/Medium/Easy) = kick, red, + yellow, blue, orange (4-lane green), green (5-lane); 95 = 2x kick + [InstrumentPlus]. Yellow/blue/orange default to cymbals; tom markers + 110/111/112 XOR the cymbal flag for that pad over [start, end-1] ticks. +- MIDI dynamics only when the track has the ``[ENABLE_CHART_DYNAMICS]`` text + event: velocity 127 = accent, 1 = ghost, never on kicks. +- .chart ``N 0..5`` = kick..green, ``N 32`` = 2x kick, ``N 66/67/68`` = cymbal + markers for yellow/blue/orange (pads default to toms), ``N 34..38`` accents + and ``N 40..44`` ghosts for red..green. ``S 2`` star power, ``S 64`` fill. +- 5-lane charts (any 5-lane green note, or ``five_lane_drums`` in song.ini) are + converted to 4-lane pro with YARG's table (yellow->Y cym, blue->B tom, + orange->G cym, green->G tom, orange+green chord -> B cym + G tom). +- Disco flip (``[mix drumsd]``) swaps red <-> yellow cymbal until the + next mix event. +- Star power = MIDI 116 / .chart S 2; fills (activation) = MIDI 120..124 / + S 64; solos = MIDI 103; sections = ``[section x]`` / ``[prc_x]`` text events. +- ``song.ini`` ``delay`` (ms; .chart ``Offset`` seconds as fallback): chart + time = audio time - delay [SongRunner.AudioTime], so audio time = chart + time + delay. +""" + +from __future__ import annotations + +import bisect +import configparser +import logging +import re +from dataclasses import dataclass, field +from pathlib import Path + +import drums as drums_mod + +log = logging.getLogger("feedBack.lib.drumchart") + +PADS = ("kick", "red", "yellow", "blue", "green") + +# 4-lane pro pad (+cymbal) -> General MIDI drum note. `drums.midi_to_piece` +# turns these into drum_tab piece-ids (hh_closed, tom_hi, ride, tom_mid, +# crash_l, tom_floor, ...), so the piece vocabulary stays owned by lib/drums.py. +GM_KICK, GM_KICK2X, GM_SNARE = 36, 35, 38 +PAD_TO_GM = { + ("kick", False): GM_KICK, + ("red", False): GM_SNARE, + ("yellow", True): 42, # closed hi-hat + ("yellow", False): 48, # hi tom + ("blue", True): 51, # ride + ("blue", False): 45, # mid tom + ("green", True): 49, # crash + ("green", False): 41, # floor tom +} + +# drum_tab piece-id -> 4-lane pro pad (+cymbal), for writing a chart back out +# (scripts/sloppak_to_ch.py). Covers the whole closed vocabulary in lib/drums.py. +PIECE_TO_PAD = { + "kick": ("kick", False), + "snare": ("red", False), "snare_xstick": ("red", False), + "hh_closed": ("yellow", True), "hh_open": ("yellow", True), "hh_pedal": ("yellow", True), + "tom_hi": ("yellow", False), + "tom_mid": ("blue", False), + "tom_low": ("green", False), "tom_floor": ("green", False), + "ride": ("blue", True), "ride_bell": ("blue", True), + "crash_l": ("green", True), "crash_r": ("green", True), "splash": ("green", True), + "china": ("green", True), "stack": ("green", True), "bell": ("green", True), +} + +# MIDI (RB / PS / CH) constants - MidIOHelper.cs +DRUM_TRACK_NAMES = ("PART DRUMS", "PART DRUM", "PART REAL_DRUMS_PS") +DIFF_START = {"easy": 60, "medium": 72, "hard": 84, "expert": 96} +MIDI_STARPOWER, MIDI_SOLO = 116, 103 +MIDI_FILLS = (120, 121, 122, 123, 124) +MIDI_TOM_MARKERS = {110: 2, 111: 3, 112: 4} # -> raw pad index (yellow, blue, orange) +VELOCITY_ACCENT, VELOCITY_GHOST = 127, 1 +CHART_DYNAMICS_TEXT = "ENABLE_CHART_DYNAMICS" + +# Raw (Moonscraper) pad indices +RAW_KICK, RAW_RED, RAW_YELLOW, RAW_BLUE, RAW_ORANGE, RAW_GREEN = range(6) + +# Easier difficulties, easiest first — the feedpak 1.20.0 `levels` ids. +LOWER_LEVELS = ("easy", "medium", "hard") + + +@dataclass +class DrumHit: + time: float # seconds, audio time (song.ini delay applied) + pad: str # kick/red/yellow/blue/green (4-lane pro) + cymbal: bool = False + kick2x: bool = False + dyn: str | None = None # "accent" | "ghost" | None + tick: int = 0 + + @property + def gm(self) -> int: + if self.pad == "kick": + return GM_KICK2X if self.kick2x else GM_KICK + return PAD_TO_GM[(self.pad, self.cymbal if self.pad != "red" else False)] + + +@dataclass +class TempoMap: + resolution: int + tempos: list = field(default_factory=lambda: [(0, 120.0)]) # (tick, bpm) + time_sigs: list = field(default_factory=lambda: [(0, 4, 4)]) # (tick, numerator, denominator) + + def __post_init__(self): + self.tempos = sorted(self.tempos) or [(0, 120.0)] + if self.tempos[0][0] != 0: + self.tempos.insert(0, (0, 120.0)) + self.time_sigs = sorted(self.time_sigs) or [(0, 4, 4)] + if self.time_sigs[0][0] != 0: + self.time_sigs.insert(0, (0, 4, 4)) + # cumulative seconds at each tempo change + self._starts = [0.0] + for (t0, bpm), (t1, _) in zip(self.tempos, self.tempos[1:]): + self._starts.append(self._starts[-1] + (t1 - t0) / self.resolution * 60.0 / bpm) + + def tick_to_time(self, tick: float) -> float: + i = max(bisect.bisect_right([t for t, _ in self.tempos], tick) - 1, 0) + t0, bpm = self.tempos[i] + return self._starts[i] + (tick - t0) / self.resolution * 60.0 / bpm + + def time_to_tick(self, time: float) -> float: + i = max(bisect.bisect_right(self._starts, time) - 1, 0) if time > 0 else 0 + t0, bpm = self.tempos[i] + return t0 + (time - self._starts[i]) * bpm / 60.0 * self.resolution + + def beats(self, end_tick: float) -> list[tuple[int, int]]: + """[(tick, measure number or -1)] for every beat up to end_tick (inclusive of the + measure containing it). Beat unit = 1/denominator note.""" + out = [] + measure = 0 + sigs = self.time_sigs + [(float("inf"), 4, 4)] + for (t0, num, den), (t1, _, _) in zip(sigs, sigs[1:]): + num = max(1, num) + step = self.resolution * 4 / max(1, den) + tick = t0 + while tick < t1: + if tick > end_tick: + return out + measure += 1 + for b in range(num): + bt = tick + b * step + if bt >= t1: + break + out.append((int(round(bt)), measure if b == 0 else -1)) + tick += num * step + return out + + +@dataclass +class DrumChart: + hits: list[DrumHit] + tempo: TempoMap + offset: float = 0.0 # seconds added to chart time (song.ini delay) + sections: list = field(default_factory=list) # (time, name) + star_power: list = field(default_factory=list) # (start, end) seconds + fills: list = field(default_factory=list) # (start, end) + solos: list = field(default_factory=list) # (start, end) + source_format: str = "" + five_lane: bool = False + dynamics: bool = False + levels: dict = field(default_factory=dict) # "easy"/"medium"/"hard" -> [DrumHit] + levels_generated: list = field(default_factory=list) # which of those were reduced by software + + def beat_list(self) -> list[dict]: + end = max((h.tick for h in self.hits), default=0) + return [{"time": round(self.tempo.tick_to_time(t) + self.offset, 3), "measure": m} + for t, m in self.tempo.beats(end)] + + +# ── song.ini ──────────────────────────────────────────────────────────────── + +def load_song_ini(path: Path) -> dict: + """[song]/[Song] section of a song.ini as a lowercase-key dict ({} if missing).""" + path = Path(path) + if not path.exists(): + return {} + cp = configparser.ConfigParser(interpolation=None, strict=False, comment_prefixes=(";", "#", "//")) + text = path.read_text(encoding="utf-8-sig", errors="replace") + try: + cp.read_string(text) + except configparser.MissingSectionHeaderError: + cp.read_string("[song]\n" + text) + for sec in cp.sections(): + if sec.strip().lower() == "song": + return {k.strip().lower(): v.strip() for k, v in cp.items(sec)} + return {} + + +def ini_bool(v) -> bool: + return str(v).strip().lower() in ("1", "true", "yes", "on") + + +def ini_delay_seconds(ini: dict, chart_offset: float = 0.0) -> float: + """song.ini ``delay`` (ms) wins; ``delay_seconds`` / .chart Offset are fallbacks.""" + try: + d = int(float(ini.get("delay", 0) or 0)) + except ValueError: + d = 0 + if d: + return d / 1000.0 + try: + if ini.get("delay_seconds"): + return float(ini["delay_seconds"]) + except ValueError: + pass + return float(chart_offset or 0.0) + + +# ── shared post-processing (MoonSongLoader.Drums) ─────────────────────────── + +@dataclass +class _RawNote: + tick: int + pad: int # raw pad 0..5 + cymbal: bool = False + plus: bool = False # 2x kick + dyn: str | None = None + + +def _normalize_text(text: str) -> str: + m = re.search(r"\[(.*?)\]", text) + return (m.group(1) if m else text).strip() + + +def _section_name(text: str) -> str | None: + t = _normalize_text(text) + for p in ("section", "prc"): + if t.startswith(p): + name = t[len(p):].lstrip("_").strip() + return name.replace("_", " ") or None + return None + + +def _mix_event(text: str): + """(difficulty, setting) for [mix drums], else None (TextEvents).""" + m = re.match(r"mix[ _]*([0-3])[ _]*drums[ _]*([0-5])(.*)$", _normalize_text(text)) + if not m: + return None + diff = ("easy", "medium", "hard", "expert")[int(m.group(1))] + s = m.group(3).strip() + setting = {"d": "disco", "dnoflip": "disco_noflip", "easy": "easy", "easynokick": "easynokick"}.get(s, "none") + return diff, setting + + +def _to_four_lane(raw: list[_RawNote], five_lane: bool, mix: list[tuple[int, str]]) -> list[tuple]: + """Raw notes -> [(tick, pad, cymbal, plus, dyn)] 4-lane pro (GetFourLaneDrumPad).""" + by_tick: dict[int, list[_RawNote]] = {} + for n in raw: + by_tick.setdefault(n.tick, []).append(n) + mix = sorted(mix) + out = [] + for n in raw: + if five_lane: + pad, cym = {RAW_KICK: ("kick", False), RAW_RED: ("red", False), RAW_YELLOW: ("yellow", True), + RAW_BLUE: ("blue", False), RAW_ORANGE: ("green", True), + RAW_GREEN: ("green", False)}[n.pad] + if n.pad == RAW_ORANGE and any(o.pad == RAW_GREEN for o in by_tick[n.tick] if o is not n): + pad, cym = "blue", True + else: + pad = {RAW_KICK: "kick", RAW_RED: "red", RAW_YELLOW: "yellow", RAW_BLUE: "blue", + RAW_ORANGE: "green", RAW_GREEN: "green"}[n.pad] + cym = n.cymbal and pad in ("yellow", "blue", "green") + setting = "none" + for t, s in mix: + if t <= n.tick: + setting = s + if setting == "disco": + if pad == "red": + pad, cym = "yellow", True + elif pad == "yellow": + pad, cym = "red", False + out.append((n.tick, pad, cym, n.plus and pad == "kick", n.dyn)) + # one gem per (tick, pad, cymbal, 2x) + seen, uniq = set(), [] + for t in sorted(out, key=lambda x: (x[0], PADS.index(x[1]), x[2])): + k = t[:4] + if k not in seen: + seen.add(k) + uniq.append(t) + return uniq + + +def _finish(raw, five_lane, mix, tempo: TempoMap, offset, sections, sp, fills, solos, fmt, dynamics): + hits = [DrumHit(time=round(tempo.tick_to_time(t) + offset, 4), pad=p, cymbal=c, kick2x=k, dyn=d, tick=t) + for t, p, c, k, d in _to_four_lane(raw, five_lane, mix)] + + def span(rng): + return sorted({(round(tempo.tick_to_time(a) + offset, 4), round(tempo.tick_to_time(b) + offset, 4)) + for a, b in rng}) + return DrumChart(hits=hits, tempo=tempo, offset=offset, + sections=[(round(tempo.tick_to_time(t) + offset, 4), n) for t, n in sorted(sections)], + star_power=span(sp), fills=span(fills), solos=span(solos), + source_format=fmt, five_lane=five_lane, dynamics=dynamics) + + +# ── notes.mid ─────────────────────────────────────────────────────────────── + +def parse_mid(path: Path, ini: dict | None = None, difficulty: str = "expert") -> DrumChart: + import mido + ini = ini or {} + mid = mido.MidiFile(str(path), clip=True) # Phase Shift sysex carries bytes > 127 + if mid.ticks_per_beat <= 0: + raise ValueError("SMPTE-timed MIDI files are not supported") + tempos, sigs, sections = [], [], [] + drum_track = None + for tr in mid.tracks: + name = next((m.name for m in tr if m.type == "track_name"), "").strip() + tick = 0 + for m in tr: + tick += m.time + if m.type == "set_tempo": + tempos.append((tick, 60_000_000 / m.tempo)) + elif m.type == "time_signature": + sigs.append((tick, m.numerator, m.denominator)) + up = name.upper() + if up == "EVENTS": + tick = 0 + for m in tr: + tick += m.time + if m.type in ("text", "marker", "lyrics"): + s = _section_name(m.text) + if s: + sections.append((tick, s)) + if up in DRUM_TRACK_NAMES: + # PART DRUMS overrides; the alternates only fill in when it's missing (TrackOverrides) + if drum_track is None or up == "PART DRUMS": + drum_track = tr + if drum_track is None: + raise ValueError(f"{Path(path).name}: no PART DRUMS track") + # tempo changes at the same tick: last one wins + tempo = TempoMap(mid.ticks_per_beat, list({t: b for t, b in sorted(tempos)}.items()), + list({t: (t, n, d) for t, n, d in sorted(sigs)}.values())) + + base = DIFF_START[difficulty] + texts, events = [], [] # (tick, text); (start, end, note, velocity) + open_notes: dict[tuple, list] = {} + tick = 0 + for m in drum_track: + tick += m.time + if m.type in ("text", "marker", "lyrics"): + texts.append((tick, m.text)) + elif m.type == "note_on" and m.velocity > 0: + open_notes.setdefault((m.note, m.channel), []).append((tick, m.velocity)) + elif m.type == "note_off" or (m.type == "note_on" and m.velocity == 0): + q = open_notes.get((m.note, m.channel)) + if q: + st, vel = q.pop(0) + events.append((st, tick, m.note, vel)) + for (note, _), q in open_notes.items(): # unterminated: zero length + events += [(st, st, note, vel) for st, vel in q] + events.sort() + + dynamics = any(_normalize_text(t) == CHART_DYNAMICS_TEXT for _, t in texts) + raw, sp, fills, solos, markers = [], [], [], [], [] + for st, en, note, vel in events: + off = note - base + if 0 <= off <= 5: + dyn = None + if dynamics and off != RAW_KICK: + dyn = "accent" if vel == VELOCITY_ACCENT else "ghost" if vel == VELOCITY_GHOST else None + raw.append(_RawNote(st, off, cymbal=off in (RAW_YELLOW, RAW_BLUE, RAW_ORANGE), dyn=dyn)) + elif note == base - 1 and difficulty == "expert": + raw.append(_RawNote(st, RAW_KICK, plus=True)) + elif note == MIDI_STARPOWER: + sp.append((st, en)) + elif note in MIDI_FILLS: + fills.append((st, en)) + elif note == MIDI_SOLO: + solos.append((st, en)) + elif note in MIDI_TOM_MARKERS: + markers.append((st, en, MIDI_TOM_MARKERS[note])) + raw.sort(key=lambda n: (n.tick, n.pad)) + # tom markers toggle the cymbal flag of that pad's notes in [start, end-1] + for st, en, pad in markers: + last = en - 1 if en > st else en + for n in raw: + if n.pad == pad and st <= n.tick <= last: + n.cymbal = not n.cymbal + five = ini_bool(ini.get("five_lane_drums", "")) or any(n.pad == RAW_GREEN for n in raw) + mix = [(t, ev[1]) for t, txt in texts if (ev := _mix_event(txt)) and ev[0] == difficulty] + return _finish(raw, five, mix, tempo, ini_delay_seconds(ini), sections, sp, fills, solos, + "mid", dynamics) + + +# ── notes.chart ───────────────────────────────────────────────────────────── + +_CHART_SECTION = re.compile(r"^\s*\[([^\]]+)\]\s*$") + + +def _chart_sections(text: str) -> dict[str, list[str]]: + out, cur, body = {}, None, None + for line in text.splitlines(): + m = _CHART_SECTION.match(line) + if m and body is None: + cur = m.group(1).strip() + continue + s = line.strip() + if s == "{": + body = [] + elif s == "}": + if cur is not None and body is not None: + out[cur] = body + cur, body = None, None + elif body is not None and s: + body.append(s) + return out + + +def _chart_song_section(secs: dict) -> dict: + song = {} + for line in secs.get("Song", []): + k, _, v = line.partition("=") + song[k.strip()] = v.strip().strip('"') + return song + + +def parse_chart(path: Path, ini: dict | None = None, difficulty: str = "expert") -> DrumChart: + ini = ini or {} + secs = _chart_sections(Path(path).read_text(encoding="utf-8-sig", errors="replace")) + song = _chart_song_section(secs) + res = int(float(song.get("Resolution", 192))) + tempos, sigs = [], [] + for line in secs.get("SyncTrack", []): + k, _, v = line.partition("=") + parts = v.split() + if not parts: + continue + t = int(k.strip()) + if parts[0] == "B": + tempos.append((t, int(parts[1]) / 1000.0)) + elif parts[0] == "TS": + den = 2 ** int(parts[2]) if len(parts) > 2 else 4 + sigs.append((t, int(parts[1]), den)) + tempo = TempoMap(res, tempos, sigs) + sections = [] + for line in secs.get("Events", []): + k, _, v = line.partition("=") + parts = v.strip().split(None, 1) + if len(parts) == 2 and parts[0] == "E": + s = _section_name(parts[1].strip().strip('"')) + if s: + sections.append((int(k.strip()), s)) + name = difficulty.capitalize() + "Drums" + track = secs.get(name) + if track is None: + raise ValueError(f"{Path(path).name}: no [{name}] section") + raw, flags, sp, fills, texts = [], [], [], [], [] + for line in track: + k, _, v = line.partition("=") + parts = v.split() + if len(parts) < 2: + continue + t = int(k.strip()) + if parts[0] == "N": + n = int(parts[1]) + if 0 <= n <= 5: + raw.append(_RawNote(t, n)) + elif n == 32: + raw.append(_RawNote(t, RAW_KICK, plus=True)) + elif n in (66, 67, 68): + flags.append((t, n - 64, "cymbal")) + elif 34 <= n <= 38: + flags.append((t, n - 33, "accent")) + elif 40 <= n <= 44: + flags.append((t, n - 39, "ghost")) + elif parts[0] == "S": + p, length = int(parts[1]), int(parts[2]) if len(parts) > 2 else 0 + if p == 2: + sp.append((t, t + length)) + elif p == 64: + fills.append((t, t + length)) + elif parts[0] == "E": + texts.append((t, " ".join(parts[1:]).strip('"'))) + for t, pad, kind in flags: + for n in raw: + if n.tick == t and n.pad == pad and not n.plus: + if kind == "cymbal": + n.cymbal = True + elif n.dyn is None: # NoteFlagPriority: accent and ghost block each other + n.dyn = kind + raw.sort(key=lambda n: (n.tick, n.pad)) + five = ini_bool(ini.get("five_lane_drums", "")) or any(n.pad == RAW_GREEN for n in raw) + mix = [(t, ev[1]) for t, txt in texts if (ev := _mix_event(txt)) and ev[0] == difficulty] + try: + chart_off = float(song.get("Offset", 0) or 0) + except ValueError: + chart_off = 0.0 + return _finish(raw, five, mix, tempo, ini_delay_seconds(ini, chart_off), sections, sp, fills, [], + "chart", bool(flags)) + + +# ── song folders ──────────────────────────────────────────────────────────── + +AUDIO_EXTS = (".ogg", ".opus", ".mp3", ".wav", ".flac", ".m4a") + + +def find_chart_file(folder: Path) -> Path | None: + folder = Path(folder) + for name in ("notes.mid", "notes.midi", "notes.chart"): + p = folder / name + if p.exists(): + return p + hits = sorted(list(folder.glob("*.mid")) + list(folder.glob("*.chart"))) + return hits[0] if hits else None + + +def load_song_folder(folder: Path, difficulty: str = "expert", levels: bool = True) -> tuple[DrumChart, dict]: + """Parse a CH/YARG song folder. With ``levels`` (Expert only), the chart's own + Easy/Medium/Hard parts are read into ``chart.levels`` where they have notes.""" + folder = Path(folder) + ini = load_song_ini(folder / "song.ini") + chart_path = find_chart_file(folder) + if chart_path is None: + raise FileNotFoundError(f"{folder.name}: no notes.mid or notes.chart") + is_chart = chart_path.suffix.lower() == ".chart" + parse = parse_chart if is_chart else parse_mid + chart = parse(chart_path, ini, difficulty) + if is_chart and (not ini.get("name") or not ini.get("artist")): + song = _chart_song_section(_chart_sections(chart_path.read_text(encoding="utf-8-sig", errors="replace"))) + for key, ini_key in (("Name", "name"), ("Artist", "artist"), ("Album", "album")): + if song.get(key): + ini.setdefault(ini_key, song[key]) + if song.get("Year"): + ini.setdefault("year", song["Year"].strip(", ")) + if levels and difficulty == "expert": + for lv in LOWER_LEVELS: + try: + c = parse(chart_path, ini, lv) + except ValueError: # no [EasyDrums] section etc. + continue + if c.hits: + chart.levels[lv] = c.hits + return chart, ini + + +def song_audio_files(folder: Path) -> dict[str, list[Path]]: + """CH/YARG audio files grouped by role: song, guitar, rhythm, bass, drums, vocals, keys, crowd.""" + out: dict[str, list[Path]] = {} + for p in sorted(Path(folder).iterdir()): + if p.suffix.lower() not in AUDIO_EXTS or p.name.lower().startswith("preview"): + continue + m = re.match(r"^(song|guitar|rhythm|bass|drums|vocals|keys|crowd)(?:_\d+)?$", p.stem.lower()) + if m: + out.setdefault(m.group(1), []).append(p) + return out + + +# ── feedpak encoding (drum_tab.json / song_timeline.json) ─────────────────── + +def hit_to_tab(h: DrumHit, *, top: bool = True) -> dict: + """One hit in drum_tab `hits[]` form. ``x2`` only on the top-level chart (spec §7.5).""" + out: dict = {"t": round(h.time, 3), "p": drums_mod.midi_to_piece(h.gm)} + if h.dyn == "accent": + out["a"] = True + elif h.dyn == "ghost": + out["g"] = True + if top and h.kick2x: + out["x2"] = True + return out + + +def _tab_hits(hits, *, top: bool = True) -> list[dict]: + return sorted((hit_to_tab(h, top=top) for h in hits), key=lambda d: (d["t"], d["p"])) + + +def _ranges(spans) -> list[dict]: + return [{"start_time": a, "end_time": b} for a, b in spans] + + +def drum_tab(chart: DrumChart, name: str = "Drums") -> dict: + """The chart as a feedpak `drum_tab.json` payload: ``hits[]`` is Expert including + the 2x kicks (flagged ``x2``), plus the 1.20.0 gameplay keys that are non-empty.""" + hits = _tab_hits(chart.hits) + kit = [{"id": p, "name": p.replace("_", " ").title()} + for p in dict.fromkeys(h["p"] for h in hits)] + tab: dict = {"version": drums_mod.SCHEMA_VERSION, "name": name, "kit": kit, "hits": hits} + for key, spans in (("star_power", chart.star_power), ("fills", chart.fills), ("solos", chart.solos)): + if spans: + tab[key] = _ranges(spans) + levels = {} + for lv in LOWER_LEVELS: + if chart.levels.get(lv): + levels[lv] = {"hits": _tab_hits(chart.levels[lv], top=False)} + if lv in chart.levels_generated: + levels[lv]["generated"] = True + if levels: + tab["levels"] = levels + return tab + + +def tab_to_hits(hits: list) -> list[DrumHit]: + """drum_tab ``hits[]`` -> 4-lane pro hits. Pieces outside the 4-lane map + (unknown ids) are dropped; ``x2`` only counts on kicks.""" + out = [] + for h in hits or []: + if not isinstance(h, dict): + continue + pc = PIECE_TO_PAD.get(h.get("p")) + try: + t = float(h.get("t")) + except (TypeError, ValueError): + continue + if pc is None: + continue + dyn = "accent" if h.get("a") else "ghost" if h.get("g") else None + out.append(DrumHit(t, pc[0], pc[1], kick2x=bool(h.get("x2")) and pc[0] == "kick", dyn=dyn)) + out.sort(key=lambda d: (d.time, PADS.index(d.pad))) + return out + + +def tab_spans(tab: dict, key: str) -> list[tuple[float, float]]: + """A drum_tab range list (``star_power`` / ``fills`` / ``solos``) as (start, end) pairs.""" + out = [] + for r in (tab or {}).get(key) or []: + try: + out.append((float(r["start_time"]), float(r["end_time"]))) + except (TypeError, ValueError, KeyError): + continue + return out + + +def song_timeline(chart: DrumChart) -> dict: + """The chart's tempo map, beats and sections as a `song_timeline.json` payload.""" + tm, off = chart.tempo, chart.offset + return { + "version": 1, + "tempos": [{"time": round(tm.tick_to_time(t) + off, 4), "bpm": round(bpm, 4)} for t, bpm in tm.tempos], + "time_signatures": [{"time": round(tm.tick_to_time(t) + off, 4), "ts": [int(n), int(d)]} + for t, n, d in tm.time_sigs], + "beats": chart.beat_list(), + "sections": [{"name": n, "number": i + 1, "time": t} for i, (t, n) in enumerate(chart.sections)], + } diff --git a/lib/drumchart_gen.py b/lib/drumchart_gen.py new file mode 100644 index 00000000..3853fa27 --- /dev/null +++ b/lib/drumchart_gen.py @@ -0,0 +1,160 @@ +"""Generated drum gameplay data for charts that lack it. + +Rock Band / Clone Hero charts usually carry their own Easy/Medium/Hard parts and +fills; not every source does. `ensure_levels` fills missing lower difficulties by +reducing Expert (feedBack's own rules, `reduce_level`), and `auto_fills` places +star-power activation fills the way YARG does for charts without them. Results are +drumchart.DrumHit lists / (start, end) spans, encoded by `drumchart.drum_tab`. + +`auto_fills` is ported from YARG.Core SongChart.AutoGeneration +(ParseForActivationPhrases) — Copyright (c) YARC (YARG) contributors, LGPL-3.0 +(https://github.com/YARC-Official/YARG.Core/blob/master/LICENSE) — and is +distributed as part of feedBack under AGPL-3.0, as described in lib/drumchart.py. +""" + +from __future__ import annotations + +import bisect + +from drumchart import LOWER_LEVELS, DrumChart, DrumHit + + +# ── difficulty reduction (for sources without hand-charted lower levels) ─── + +# Fitted on 136 hand-authored Rock Band / Clone Hero charts (their Easy/Medium/Hard vs +# their Expert): F1 of matching notes vs the human levels Easy 0.67, Medium 0.78, Hard 0.89, +# note counts 1.12x / 0.98x / 1.05x the human charts. Easy never pairs a kick with hands. +REDUCE = { # level: (grid in beats, max gems per chord, kick together with hands?) + "hard": (0.5, 3, True), + "medium": (1.0, 2, True), + "easy": (1.0, 2, False), +} +_HAND_PRIORITY = {("red", False): 0, ("yellow", True): 1, ("green", True): 2, ("blue", True): 3, + ("yellow", False): 4, ("blue", False): 5, ("green", False): 6} + + +def _beat_pos(bt, t): + """Fractional beat index of time t on beat grid bt.""" + if len(bt) < 2: + return t / 0.5 + i = min(max(bisect.bisect_right(bt, t) - 1, 0), len(bt) - 2) + return i + (t - bt[i]) / (bt[i + 1] - bt[i]) + + +def reduce_level(hits, beats, level: str) -> list[DrumHit]: + """feedBack's own reduction of an Expert chart for sources without hand-charted + levels (not from YARG/Harmonix; rules fitted to authored charts, see REDUCE). Drops + 2x kicks and ghost notes; keeps chords on the level's grid (8ths / quarters) first, + then off-grid ones that still leave a full grid step to both neighbours; caps gems + per chord (snare and cymbals before toms; a kick is kept with one hand gem except on + Easy). ``beats``: [(time, measure)].""" + grid, max_gems, kick_with_hands = REDUCE[level] + # strictly increasing grid: a beat map can repeat a time (zero-length beat) + bt = [] + for t in sorted(t for t, _ in beats or []): + if not bt or t - bt[-1] > 1e-6: + bt.append(t) + chords: dict[float, list] = {} + for h in hits: + if h.kick2x or h.dyn == "ghost": + continue + chords.setdefault(round(h.time, 3), []).append(h) + ts = sorted(chords) + pos = {t: _beat_pos(bt, t) for t in ts} + on = {t for t in ts if abs(pos[t] / grid - round(pos[t] / grid)) < 0.12} + kept = sorted(on) + for t in ts: # off-grid chords where they don't crowd the grid + if t in on: + continue + i = bisect.bisect_left(kept, t) + prev_ok = i == 0 or pos[t] - pos[kept[i - 1]] >= grid - 0.05 + next_ok = i == len(kept) or pos[kept[i]] - pos[t] >= grid - 0.05 + if prev_ok and next_ok: + kept.insert(i, t) + out = [] + for t in kept: + hs = sorted({(h.pad, h.cymbal): h for h in chords[t] if h.pad != "kick"}.values(), + key=lambda h: _HAND_PRIORITY.get((h.pad, h.cymbal), 9)) + kick = [h for h in chords[t] if h.pad == "kick"][:1] + if kick and hs and kick_with_hands: + out.extend(kick + hs[:max_gems - 1]) + elif hs: + out.extend(hs[:max_gems]) + else: + out.extend(kick) + return out + + +def ensure_levels(chart: DrumChart, beats=None) -> DrumChart: + """Fill in every missing lower level by reducing Expert (marked as generated).""" + if beats is None: + beats = [(b["time"], b["measure"]) for b in chart.beat_list()] if chart.hits else [] + for lv in LOWER_LEVELS: + if not chart.levels.get(lv): + chart.levels[lv] = reduce_level(chart.hits, beats, lv) + if lv not in chart.levels_generated: + chart.levels_generated.append(lv) + return chart + + +# ── fill (star power activation) generation ──────────────────────────────── + +def _lower_bound(xs, v) -> int: + """Index of the last element <= v, -1 if none (YARG ChartEventExtensions.LowerBound).""" + return bisect.bisect_right(xs, v + 1e-9) - 1 + + +def auto_fills(note_times, beats, star_power, solos=(), sections=()) -> list[tuple[float, float]]: + """Fill (star power activation) phrases, ported from YARG.Core + SongChart.AutoGeneration.ParseForActivationPhrases: every 4 measures (2 if that's more + than 10 s away), snapped to section starts, at least 2 s after the last SP/solo/fill, + only where 16+ notes follow within 4 measures; the fill spans the measure before the + activation bar line. (Time-signature snapping is skipped: the beat list carries no + time signatures.) Returns nothing when there is no star power, like YARG.""" + MIN_SPACING, MAX_SPACING, SP_MIN_NOTES = 2.0, 10.0, 16 + measures = [t for t, m in beats if m != -1] + sp = sorted(star_power) + if not sp or len(measures) < 6: + return [] + solos = sorted(solos) + sections = sorted(sections) + sp_starts = [a for a, _ in sp] + solo_starts = [a for a, _ in solos] + last_solo = solos[-1][1] if solos else 0.0 + nt = sorted(note_times) + spacing_ref = sp[0][1] + cur = max(0, _lower_bound(measures, spacing_ref)) + sec_i = _lower_bound(sections, spacing_ref) + sp_i, solo_i = 0, _lower_bound(solo_starts, spacing_ref) + out = [] + total = len(measures) + while cur < total - 4: + per = 4 + if measures[cur + per] - spacing_ref > MAX_SPACING: + per = 2 + cur += per + mt = measures[cur] + new_sec = _lower_bound(sections, mt) + if new_sec > sec_i: + sec_i = new_sec + cur = max(0, _lower_bound(measures, sections[sec_i])) + mt = measures[cur] + new_sp = _lower_bound(sp_starts, mt) + if new_sp > sp_i: + sp_i = new_sp + spacing_ref = max(sp[sp_i][1], spacing_ref) + if solos and mt < last_solo: + new_solo = _lower_bound(solo_starts, mt) + if new_solo > solo_i: + solo_i = new_solo + spacing_ref = max(solos[solo_i][1], spacing_ref) + if mt - spacing_ref < MIN_SPACING: + continue + end = measures[min(cur + 4, total - 1)] + n = bisect.bisect_right(nt, end + 1e-6) - bisect.bisect_right(nt, mt + 1e-6) + if n < SP_MIN_NOTES: + continue + spacing_ref = mt + if cur >= 1: + out.append((round(measures[cur - 1], 4), round(mt, 4))) + return out diff --git a/lib/sngfile.py b/lib/sngfile.py new file mode 100644 index 00000000..0c220507 --- /dev/null +++ b/lib/sngfile.py @@ -0,0 +1,115 @@ +"""Read Clone Hero / YARG ``.sng`` packages (a song folder in one masked file). + +Layout (SngFileFormat, github.com/mdsitton/SngFileFormat, version 1, little endian): + "SNGPKG" | uint32 version | 16-byte xor mask + uint64 metadata_len | uint64 count | count x (int32 klen, key, int32 vlen, value) - song.ini pairs + uint64 filemeta_len | uint64 count | count x (uint8 nlen, name, uint64 size, uint64 offset) + uint64 data_len | file data, each byte masked: b ^ mask[i % 16] ^ (i & 0xFF), i = index in its file +""" + +from __future__ import annotations + +import struct +from pathlib import Path + +# Never unpack these: CH/YARG background videos are large and nothing here plays them. +_VIDEO_EXTS = (".mp4", ".webm", ".avi", ".mkv", ".vp8") + + +class SngError(ValueError): + pass + + +class NeedMore(SngError): + """The buffer ends inside the header; fetch at least ``need`` bytes.""" + + def __init__(self, need): + super().__init__(f"need {need} bytes") + self.need = need + + +def parse_header(buf: bytes) -> tuple[dict, dict, int]: + """(metadata incl. "mask", {filename: (offset, size)}, header_end) from the start of an .sng.""" + pos = 0 + + def take(n): + nonlocal pos + if pos + n > len(buf): + raise NeedMore(max(pos + n, 2 * len(buf))) + b = buf[pos:pos + n] + pos += n + return b + + if take(6) != b"SNGPKG": + raise SngError("not an SNG package") + (version,) = struct.unpack(" tuple[dict, dict]: + """(metadata incl. "mask", {filename: (offset, size)}) without reading file contents.""" + with open(path, "rb") as f: + buf = f.read(65536) + while True: + try: + meta, files, _ = parse_header(buf) + return meta, files + except NeedMore as e: + f.seek(0) + more = f.read(e.need) + if len(more) <= len(buf): + raise SngError(f"{Path(path).name}: truncated header") from None + buf = more + + +def unmask(data: bytes, mask: bytes) -> bytes: + """Undo the per-file xor mask. The key stream repeats every 256 bytes + (lcm of the 16-byte mask and the ``i & 0xFF`` counter), so xor the whole + buffer against that period as one big integer instead of byte by byte.""" + if not data: + return b"" + period = bytes(mask[i % 16] ^ i for i in range(256)) + key = (period * (len(data) // 256 + 1))[:len(data)] + n = int.from_bytes(data, "little") ^ int.from_bytes(key, "little") + return n.to_bytes(len(data), "little") + + +def extract_sng(path, out_dir, names=None) -> Path: + """Unpack an .sng into a normal song folder (song.ini written from its metadata). + ``names``: only these files; default everything but video.""" + meta, files = read_sng(path) + mask = meta.pop("mask") + out = Path(out_dir) + out.mkdir(parents=True, exist_ok=True) + with open(path, "rb") as f: + for name, (off, size) in files.items(): + if names is not None and name not in names: + continue + if names is None and Path(name).suffix.lower() in _VIDEO_EXTS: + continue + safe = Path(name.replace("\\", "/")).name # no directories from the package + if not safe or safe in (".", ".."): + continue + f.seek(off) + (out / safe).write_bytes(unmask(f.read(size), mask)) + ini = "".join(f"{k} = {v}\n" for k, v in meta.items() if "\n" not in k + v) + (out / "song.ini").write_text("[song]\n" + ini, encoding="utf-8") + return out diff --git a/scripts/ch_to_sloppak.py b/scripts/ch_to_sloppak.py new file mode 100644 index 00000000..cea8a0d3 --- /dev/null +++ b/scripts/ch_to_sloppak.py @@ -0,0 +1,236 @@ +#!/usr/bin/env python3 +"""Clone Hero / YARG / Rock Band song folder (or .sng) -> a drums feedpak. + +Usage: + python scripts/ch_to_sloppak.py SONG_FOLDER_OR_SNG OUT.sloppak + [--title T --artist A --album X --year N] [--dir] [-v] + +SONG_FOLDER holds ``notes.mid`` (PART DRUMS) or ``notes.chart`` ([ExpertDrums]), +``song.ini`` and the audio (``song.ogg`` and/or ``guitar``/``rhythm``/``bass``/ +``drums[_1-4]``/``vocals``/``keys`` stems). An ``.sng`` package is unpacked first. + +The pack it writes (feedpak 1.20.0): + +- ``drum_tab.json`` — the Expert drums part as 4-lane pro piece hits (2x kicks + flagged ``x2``), plus the chart's star power, fills and solos, and its + Easy/Medium/Hard parts as ``levels`` (any missing level is reduced from Expert + and marked ``generated``). A chart with star power but no fills gets YARG's + generated activation fills. Spec §7.5 "Drum gameplay metadata". +- ``song_timeline.json`` — tempo map, time signatures, beats and sections. +- ``manifest.yaml`` — one ``type: drums`` arrangement pointing at the tab, with the + song-level ``drum_tab`` key as its primary-part alias (spec §7.5 "Multiple drum + parts"; the pack has no pitched arrangement, and ``arrangements`` may not be empty). + +song.ini ``delay`` is applied, so chart times line up with the audio as YARG plays +it. Audio: a lone ``song.ogg`` becomes the ``full`` stem; separate CH stems become +pack stems (guitar+rhythm -> guitar, keys -> piano, song -> other, drums_1..4 mixed +into drums; crowd is dropped). A single ``.ogg`` per stem is copied as-is; anything +else is mixed / transcoded with ffmpeg. Parsing rules: lib/drumchart.py. +""" + +from __future__ import annotations + +import argparse +import json +import logging +import re +import shutil +import subprocess +import sys +import tempfile +import zipfile +from pathlib import Path + +_ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(_ROOT / "lib")) + +import yaml # noqa: E402 + +import audio # noqa: E402 +import drumchart # noqa: E402 +import drumchart_gen # noqa: E402 +import sngfile # noqa: E402 + +log = logging.getLogger("feedBack.scripts.ch_to_sloppak") + +# The format version this converter writes: drum gameplay metadata (1.20.0). +FEEDPAK_VERSION = "1.20.0" + +STEM_ORDER = ["full", "guitar", "bass", "drums", "vocals", "piano", "other"] +ROLE_TO_STEM = {"guitar": "guitar", "rhythm": "guitar", "bass": "bass", "drums": "drums", + "vocals": "vocals", "keys": "piano", "song": "other"} + + +def _ffmpeg() -> str: + ff = audio._ffmpeg_cmd() + if not ff: + raise RuntimeError("ffmpeg is required to mix or transcode these stems") + return ff + + +def audio_seconds(path: Path) -> float: + """Duration via ffprobe next to ffmpeg; 0.0 when unavailable.""" + probe = audio._bundled_or_path("ffprobe") + if not probe: + return 0.0 + try: + r = subprocess.run([probe, "-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0", + str(path)], capture_output=True, text=True, timeout=60) + return float(r.stdout.strip()) + except (OSError, ValueError, subprocess.SubprocessError): + return 0.0 + + +def encode_stem(inputs: list[Path], out: Path) -> None: + """One or more audio files -> one OGG (summed, not normalised, like the CH mixer).""" + if len(inputs) == 1 and inputs[0].suffix.lower() == ".ogg": + shutil.copyfile(inputs[0], out) + return + cmd = [_ffmpeg(), "-v", "error", "-y"] + for p in inputs: + cmd += ["-i", str(p)] + if len(inputs) > 1: + cmd += ["-filter_complex", f"amix=inputs={len(inputs)}:duration=longest:normalize=0"] + cmd += ["-vn", "-c:a", "libvorbis", "-q:a", "6", str(out)] + subprocess.run(cmd, check=True) + + +def build_stems(folder: Path, stems_dir: Path) -> list[dict]: + files = drumchart.song_audio_files(folder) + files.pop("crowd", None) + if not files: + raise ValueError(f"{folder.name}: no audio (song.ogg / stems) found") + stems_dir.mkdir(parents=True, exist_ok=True) + if set(files) == {"song"}: + encode_stem(files["song"], stems_dir / "full.ogg") + return [{"id": "full", "file": "stems/full.ogg", "default": True}] + groups: dict[str, list[Path]] = {} + for role, paths in files.items(): + groups.setdefault(ROLE_TO_STEM[role], []).extend(paths) + out = [] + for sid in sorted(groups, key=STEM_ORDER.index): + encode_stem(groups[sid], stems_dir / f"{sid}.ogg") + out.append({"id": sid, "file": f"stems/{sid}.ogg", "default": True}) + return out + + +def parse_year(v) -> int: + m = re.search(r"\d{4}", str(v or "")) + return int(m.group(0)) if m else 0 + + +def find_cover(folder: Path) -> Path | None: + for name in ("album.jpg", "album.png", "album.jpeg", "cover.jpg", "cover.png"): + p = folder / name + if p.exists(): + return p + return None + + +def zip_dir(work: Path, out: Path) -> None: + tmp = out.with_name(out.name + ".tmp") + with zipfile.ZipFile(tmp, "w", zipfile.ZIP_STORED) as z: + for f in sorted(work.rglob("*")): + if f.is_file(): + z.write(f, f.relative_to(work).as_posix()) + if out.exists(): + out.unlink() if out.is_file() else shutil.rmtree(out) + tmp.rename(out) + + +def prepare_chart(folder: Path) -> tuple[drumchart.DrumChart, dict]: + """Parse the folder's drums and fill in what YARG/feedBack generate for charts + without it: activation fills (when there is star power) and lower levels.""" + chart, ini = drumchart.load_song_folder(folder) + if not chart.hits: + raise ValueError(f"{folder.name}: the expert drums part has no notes") + if chart.star_power and not chart.fills: + beats = [(b["time"], b["measure"]) for b in chart.beat_list()] + chart.fills = drumchart_gen.auto_fills([h.time for h in chart.hits], beats, chart.star_power, + chart.solos, [t for t, _ in chart.sections]) + drumchart_gen.ensure_levels(chart) + return chart, ini + + +def convert(src: Path, out: Path, title=None, artist=None, album=None, year=None, as_dir=False) -> dict: + """Write a drums pack for a CH/YARG song folder or .sng. Returns a summary dict.""" + src = Path(src) + unpacked = None + work = Path(tempfile.mkdtemp(prefix="ch2pak_")) + try: + if src.is_file() and src.suffix.lower() == ".sng": + unpacked = Path(tempfile.mkdtemp(prefix="sng_")) + folder = sngfile.extract_sng(src, unpacked) + else: + folder = src + chart, ini = prepare_chart(folder) + stems = build_stems(folder, work / "stems") + dur = max([audio_seconds(work / s["file"]) for s in stems] + [chart.hits[-1].time + 1.0]) + (work / "drum_tab.json").write_text(json.dumps(drumchart.drum_tab(chart), separators=(",", ":")), + encoding="utf-8") + (work / "song_timeline.json").write_text(json.dumps(drumchart.song_timeline(chart), + separators=(",", ":")), encoding="utf-8") + manifest = { + "feedpak_version": FEEDPAK_VERSION, + "title": title or ini.get("name") or folder.name, + "artist": artist or ini.get("artist") or "Unknown", + "album": album if album is not None else ini.get("album", ""), + "year": year if year is not None else parse_year(ini.get("year")), + "duration": round(dur, 3), + } + cover = find_cover(folder) + if cover: + name = "cover" + cover.suffix.lower().replace(".jpeg", ".jpg") + shutil.copyfile(cover, work / name) + manifest["cover"] = name + manifest["stems"] = stems + manifest["arrangements"] = [{"id": "drums", "name": "Drums", "type": "drums", + "drum_tab": "drum_tab.json"}] + manifest["drum_tab"] = "drum_tab.json" + manifest["song_timeline"] = "song_timeline.json" + (work / "manifest.yaml").write_text(yaml.safe_dump(manifest, sort_keys=False, allow_unicode=True), + encoding="utf-8") + out = Path(out) + if as_dir: + if out.exists(): + shutil.rmtree(out) if out.is_dir() else out.unlink() + shutil.copytree(work, out) + else: + zip_dir(work, out) + finally: + shutil.rmtree(work, ignore_errors=True) + if unpacked: + shutil.rmtree(unpacked, ignore_errors=True) + return {"out": str(out), "notes": len(chart.hits), "format": chart.source_format, + "star_power": len(chart.star_power), "fills": len(chart.fills), "solos": len(chart.solos), + "sections": len(chart.sections), "delay": chart.offset, "five_lane": chart.five_lane, + "levels_generated": sorted(chart.levels_generated), + "stems": [s["id"] for s in stems], "duration": manifest["duration"]} + + +def main(argv: list[str] | None = None) -> int: + ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("src", type=Path, help="CH/YARG song folder or .sng package") + ap.add_argument("out", type=Path) + ap.add_argument("--title") + ap.add_argument("--artist") + ap.add_argument("--album") + ap.add_argument("--year", type=int) + ap.add_argument("--dir", action="store_true", help="write the directory form instead of a zip") + ap.add_argument("-v", "--verbose", action="store_true", help="debug logging") + a = ap.parse_args(argv) + logging.basicConfig(level=logging.DEBUG if a.verbose else logging.INFO, format="%(levelname)s %(message)s") + try: + s = convert(a.src, a.out, a.title, a.artist, a.album, a.year, a.dir) + except (OSError, ValueError, RuntimeError, subprocess.CalledProcessError) as e: + log.error("%s", e) + return 1 + log.info("wrote %s: %d drum notes (%s), %d star power, %d fills, %d solos, %d sections, " + "delay %+.3fs, generated levels %s, stems %s", s["out"], s["notes"], s["format"], + s["star_power"], s["fills"], s["solos"], s["sections"], s["delay"], + ", ".join(s["levels_generated"]) or "none", ", ".join(s["stems"])) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/sloppak_to_ch.py b/scripts/sloppak_to_ch.py new file mode 100644 index 00000000..85b15d84 --- /dev/null +++ b/scripts/sloppak_to_ch.py @@ -0,0 +1,286 @@ +#!/usr/bin/env python3 +"""Drums feedpak -> Clone Hero / YARG song folder (notes.mid + song.ini + OGG stems). + +Usage: + python scripts/sloppak_to_ch.py SONG.sloppak OUT_DIR [--part ID] [-v] + +The inverse of scripts/ch_to_sloppak.py, for the pack's drums. notes.mid gets: + +- a tempo map + time signatures rebuilt from the pack's beat grid (480 ticks per + beat; a pickup before the first beat), and EVENTS sections; +- PART DRUMS from the drum part's ``drum_tab`` (the primary part, or ``--part``): + pieces fold onto 4-lane pro pads (lib/drumchart.py PIECE_TO_PAD); cymbals are + the MIDI default and toms get tom markers (110/111/112); ``x2`` kicks go on note + 95; ``a`` / ``g`` become velocity 127 / 1 under [ENABLE_CHART_DYNAMICS]; the + tab's ``levels`` become the Easy/Medium/Hard parts; ``star_power`` (116), + ``fills`` (120-124) and ``solos`` (103) become their phrase notes. + +The MIDI layout follows YARG.Core's MidIOHelper / MidReader (LGPL-3.0, see +lib/drumchart.py). Stems: drums/bass/guitar/vocals/piano(keys)/other(song) are +copied as OGG; the ``full`` mix becomes song.ogg when there are no other stems. +Cover -> album.. +""" + +from __future__ import annotations + +import argparse +import bisect +import logging +import shutil +import subprocess +import sys +import tempfile +from pathlib import Path + +_ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(_ROOT / "lib")) + +import audio # noqa: E402 +import drumchart # noqa: E402 +import sloppak # noqa: E402 + +log = logging.getLogger("feedBack.scripts.sloppak_to_ch") + +TPQ = 480 +STEM_TO_CH = {"drums": "drums", "bass": "bass", "guitar": "guitar", "vocals": "vocals", "piano": "keys", + "other": "song", "full": "song"} +TOM_MARKER = {"yellow": 110, "blue": 111, "green": 112} +PAD_INDEX = {p: i for i, p in enumerate(drumchart.PADS)} + + +# ── tempo map from beats ──────────────────────────────────────────────────── + +class BeatClock: + """Seconds <-> ticks from a beat grid. Beat k sits at tick (pickup + k) * TPQ; when the + first beat isn't at 0 s, a pickup measure of ``pickup`` beats fills the gap.""" + + def __init__(self, beats: list[dict], first_event: float | None = None): + if len(beats) < 2: # no grid: 120 BPM from 0 + beats = [{"time": 0.5 * i, "measure": i // 4 + 1 if i % 4 == 0 else -1} for i in range(2)] + bt = [float(b["time"]) for b in beats] + flags = [b.get("measure", -1) not in (-1, None) for b in beats] + self.delay = 0.0 + if 0.02 < bt[0] < 0.5 * (bt[1] - bt[0]) and (first_event is None or first_event >= bt[0] - 1e-6): + # less than half a beat before the grid and nothing in it: song.ini delay, not a pickup + self.delay = bt[0] + bt = [t - self.delay for t in bt] + if bt[0] > 0.02: + self.pickup = max(1, int(round(bt[0] / (bt[1] - bt[0])))) + self.times = [0.0] + bt + self.ticks = [0] + [(self.pickup + k) * TPQ for k in range(len(bt))] + self.measure_flags = [True] + [False] * (self.pickup - 1) + flags + else: + self.pickup = 0 + self.times = [0.0] + bt[1:] + self.ticks = [k * TPQ for k in range(len(bt))] + self.measure_flags = flags + + def tick(self, t: float) -> int: + t = float(t) - self.delay + if t >= self.times[-1]: + per = self.times[-1] - self.times[-2] + return int(round(self.ticks[-1] + (t - self.times[-1]) / per * TPQ)) + if t <= self.times[0]: + return self.ticks[0] + i = bisect.bisect_right(self.times, t) - 1 + t0, t1 = self.times[i], self.times[i + 1] + frac = (t - t0) / (t1 - t0) if t1 > t0 else 0.0 + return int(round(self.ticks[i] + frac * (self.ticks[i + 1] - self.ticks[i]))) + + def tempo_events(self) -> list[tuple[int, int]]: + """[(tick, microseconds per quarter)] - one per beat, deduplicated.""" + out, last = [], None + for i in range(len(self.times) - 1): + beats = (self.ticks[i + 1] - self.ticks[i]) / TPQ + us = int(round((self.times[i + 1] - self.times[i]) / beats * 1_000_000)) + if us <= 0: + continue + if us != last: + out.append((int(self.ticks[i]), us)) + last = us + return out + + def time_sigs(self) -> list[tuple[int, int]]: + """[(tick, numerator)] from measure starts (x/4).""" + starts = [i for i, f in enumerate(self.measure_flags) if f] or [0] + if starts[0] != 0: + starts = [0] + starts + out, last = [], None + for a, b in zip(starts, starts[1:] + [len(self.measure_flags)]): + num = max(1, min(b - a, 16)) + if num != last: + out.append((a * TPQ, num)) + last = num + return out + + +# ── tracks ────────────────────────────────────────────────────────────────── + +def _msgs_track(name, events): + """events: (tick, order, mido message) -> MidiTrack with delta times.""" + import mido + tr = mido.MidiTrack() + tr.append(mido.MetaMessage("track_name", name=name, time=0)) + last = 0 + for tick, _, msg in sorted(events, key=lambda e: (e[0], e[1])): + msg.time = max(0, tick - last) + last = max(last, tick) + tr.append(msg) + tr.append(mido.MetaMessage("end_of_track", time=0)) + return tr + + +def _note(events, tick, note, vel=100, length=TPQ // 8): + import mido + events.append((tick, 2, mido.Message("note_on", note=note, velocity=vel))) + events.append((tick + max(1, length), 1, mido.Message("note_off", note=note, velocity=0))) + + +def _phrase(events, clock, spans, notes): + for a, b in spans: + ta, tb = clock.tick(a), clock.tick(b) + for n in notes: + _note(events, ta, n, 100, length=max(1, tb - ta)) + + +def drums_track(tab: dict, clock: BeatClock): + """PART DRUMS for a drum_tab payload -> (track, Expert note count).""" + import mido + hits = drumchart.tab_to_hits(tab.get("hits")) + levels = [("expert", hits)] + for lv in drumchart.LOWER_LEVELS: + entry = (tab.get("levels") or {}).get(lv) + if isinstance(entry, dict): + levels.append((lv, drumchart.tab_to_hits(entry.get("hits")))) + ev = [] + if any(h.dyn for _, lv_hits in levels for h in lv_hits): + ev.append((0, 0, mido.MetaMessage("text", text="[ENABLE_CHART_DYNAMICS]"))) + toms = set() + for lv, lv_hits in levels: + base = drumchart.DIFF_START[lv] + seen = set() + for h in lv_hits: + tk = clock.tick(h.time) + if h.pad == "kick": + if h.kick2x and lv != "expert": + continue + note, vel = (base - 1 if h.kick2x else base), 100 + else: + note = base + PAD_INDEX[h.pad] + vel = 127 if h.dyn == "accent" else 1 if h.dyn == "ghost" else 100 + if (tk, note) in seen: + continue + seen.add((tk, note)) + _note(ev, tk, note, vel) + if h.pad in TOM_MARKER and not h.cymbal and (tk, h.pad) not in toms: + toms.add((tk, h.pad)) + _note(ev, tk, TOM_MARKER[h.pad], 100, length=2) # covers [tk, tk+1], every level + _phrase(ev, clock, drumchart.tab_spans(tab, "star_power"), [drumchart.MIDI_STARPOWER]) + _phrase(ev, clock, drumchart.tab_spans(tab, "fills"), drumchart.MIDI_FILLS) + _phrase(ev, clock, drumchart.tab_spans(tab, "solos"), [drumchart.MIDI_SOLO]) + return _msgs_track("PART DRUMS", ev), len(hits) + + +# ── driver ────────────────────────────────────────────────────────────────── + +def _pick_tab(loaded, part: str | None) -> dict | None: + if part and loaded.drum_parts: + for p in loaded.drum_parts: + if p["id"] == part: + return p["drum_tab"] + raise ValueError(f"no drum part {part!r} (have: {', '.join(p['id'] for p in loaded.drum_parts)})") + return loaded.drum_tab + + +def _copy_audio(files: list[Path], dst: Path) -> None: + if len(files) == 1 and files[0].suffix.lower() == ".ogg": + shutil.copyfile(files[0], dst) + return + ff = audio._ffmpeg_cmd() + if not ff: + raise RuntimeError("ffmpeg is required to mix or transcode these stems") + cmd = [ff, "-v", "error", "-y"] + for f in files: + cmd += ["-i", str(f)] + if len(files) > 1: + cmd += ["-filter_complex", f"amix=inputs={len(files)}:duration=longest:normalize=0"] + subprocess.run(cmd + ["-c:a", "libvorbis", "-q:a", "6", str(dst)], check=True) + + +def export(pack: Path, out_dir: Path, part: str | None = None) -> dict: + """Write a CH/YARG song folder for a pack's drum part. Returns a summary dict.""" + import mido + pack = Path(pack) + cache = Path(tempfile.mkdtemp(prefix="pak2ch_")) + try: + loaded = sloppak.load_song(pack.name, pack.parent, cache) + tab = _pick_tab(loaded, part) + if tab is None: + raise ValueError(f"{pack.name}: no drum_tab to export") + song, man = loaded.song, loaded.manifest + beats = [{"time": b.time, "measure": b.measure} for b in song.beats] + first = min((float(h["t"]) for h in tab.get("hits") or [] if isinstance(h, dict) and "t" in h), + default=None) + clock = BeatClock(beats, first) + mid = mido.MidiFile(type=1, ticks_per_beat=TPQ) + sync = [(t, 0, mido.MetaMessage("set_tempo", tempo=us)) for t, us in clock.tempo_events()] + sync += [(t, 0, mido.MetaMessage("time_signature", numerator=n, denominator=4)) + for t, n in clock.time_sigs()] + mid.tracks.append(_msgs_track(str(man.get("title") or "song"), sync)) + mid.tracks.append(_msgs_track("EVENTS", [ + (clock.tick(s.start_time), 0, mido.MetaMessage("text", text=f"[section {s.name}]")) + for s in song.sections])) + tr, n = drums_track(tab, clock) + mid.tracks.append(tr) + out_dir = Path(out_dir) + out_dir.mkdir(parents=True, exist_ok=True) + mid.save(str(out_dir / "notes.mid")) + + src = loaded.source_dir + groups: dict[str, list[Path]] = {} + stems = list(loaded.stems) + if loaded.full_mix and not stems: + stems = [{"id": "full", "file": loaded.full_mix}] + for s in stems: + ch = STEM_TO_CH.get(s.get("id")) + f = src / str(s.get("file", "")) + if ch and f.is_file(): + groups.setdefault(ch, []).append(f) + for ch, files in groups.items(): + _copy_audio(files, out_dir / f"{ch}.ogg") + cover = man.get("cover") + if isinstance(cover, str) and cover and (src / cover).is_file(): + shutil.copyfile(src / cover, out_dir / ("album" + Path(cover).suffix.lower())) + ini = {"name": man.get("title", ""), "artist": man.get("artist", ""), "album": man.get("album", ""), + "year": man.get("year", "") or "", "charter": "feedBack export", + "delay": int(round(clock.delay * 1000)), + "song_length": int(round(float(man.get("duration", 0) or 0) * 1000)), + "pro_drums": "True", "five_lane_drums": "False", "diff_drums": -1} + (out_dir / "song.ini").write_text( + "[song]\n" + "".join(f"{k} = {v}\n" for k, v in ini.items() if v != ""), encoding="utf-8") + return {"out": str(out_dir), "parts": {"PART DRUMS": n}, "stems": sorted(groups), + "tempo_events": len(clock.tempo_events())} + finally: + shutil.rmtree(cache, ignore_errors=True) + + +def main(argv: list[str] | None = None) -> int: + ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("pack", type=Path) + ap.add_argument("out_dir", type=Path) + ap.add_argument("--part", help="drum part (type: drums arrangement id); default: the primary part") + ap.add_argument("-v", "--verbose", action="store_true", help="debug logging") + a = ap.parse_args(argv) + logging.basicConfig(level=logging.DEBUG if a.verbose else logging.INFO, format="%(levelname)s %(message)s") + try: + s = export(a.pack, a.out_dir, a.part) + except (OSError, ValueError, RuntimeError, subprocess.CalledProcessError) as e: + log.error("%s", e) + return 1 + log.info("wrote %s: PART DRUMS %d notes; stems %s", s["out"], s["parts"]["PART DRUMS"], + ", ".join(s["stems"]) or "none") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/drum_fixtures.py b/tests/drum_fixtures.py new file mode 100644 index 00000000..00b0cd8b --- /dev/null +++ b/tests/drum_fixtures.py @@ -0,0 +1,114 @@ +"""Synthetic Clone Hero / YARG charts and packages for the drum-chart import tests.""" + +from __future__ import annotations + +import struct +from pathlib import Path + +TPQ = 480 + +# Stand-in OGG bytes: the converters copy single .ogg stems verbatim, so the +# tests never need ffmpeg or real audio. +FAKE_OGG = b"OggS\x00fake-audio" + + +def write_mid(path: Path, notes, *, tempos=((0, 120.0),), sigs=((0, 4, 4),), texts=(), sections=(), + tpq: int = TPQ, track_name: str = "PART DRUMS"): + """notes: (tick, midi_note, velocity, length_ticks); texts: (tick, text) in the drum track; + sections: (tick, name) -> EVENTS "[section name]".""" + import mido + mid = mido.MidiFile(type=1, ticks_per_beat=tpq) + + def track(name, events): + tr = mido.MidiTrack() + tr.append(mido.MetaMessage("track_name", name=name, time=0)) + last = 0 + for tick, _order, msg in sorted(events, key=lambda e: (e[0], e[1])): + msg.time = tick - last + last = tick + tr.append(msg) + tr.append(mido.MetaMessage("end_of_track", time=0)) + mid.tracks.append(tr) + + sync = [(t, 0, mido.MetaMessage("set_tempo", tempo=int(round(60_000_000 / bpm)))) for t, bpm in tempos] + sync += [(t, 0, mido.MetaMessage("time_signature", numerator=n, denominator=d)) for t, n, d in sigs] + track("sync", sync) + track("EVENTS", [(t, 0, mido.MetaMessage("text", text=f"[section {n}]")) for t, n in sections]) + ev = [(t, 0, mido.MetaMessage("text", text=x)) for t, x in texts] + for tick, note, vel, length in notes: + ev.append((tick, 2, mido.Message("note_on", note=note, velocity=vel, channel=0))) + ev.append((tick + max(1, length), 1, mido.Message("note_off", note=note, velocity=0, channel=0))) + track(track_name, ev) + mid.save(str(path)) + return path + + +def write_chart(path: Path, lines, *, resolution=192, tempos=((0, 120.0),), sigs=((0, 4),), offset=None, + events=(), name="Song", artist="Band"): + """lines: (tick, 'N'|'S'|'E', a, b) entries for [ExpertDrums].""" + song = [f' Name = "{name}"', f' Artist = "{artist}"', f" Resolution = {resolution}"] + if offset is not None: + song.append(f" Offset = {offset}") + sync = [f" {t} = TS {n}" for t, n in sigs] + [f" {t} = B {int(round(b * 1000))}" for t, b in tempos] + sync.sort(key=lambda s: int(s.split("=")[0])) + ev = [f' {t} = E "section {n}"' for t, n in events] + drums = [] + for item in sorted(lines, key=lambda x: (x[0], x[1])): + t, kind = item[0], item[1] + if kind in ("N", "S"): + drums.append(f" {t} = {kind} {item[2]} {item[3]}") + else: + drums.append(f" {t} = E {item[2]}") + body = ["[Song]", "{", *song, "}", "[SyncTrack]", "{", *sync, "}", "[Events]", "{", *ev, "}", + "[ExpertDrums]", "{", *drums, "}"] + Path(path).write_text("\n".join(body) + "\n", encoding="utf-8") + return path + + +def write_ini(path: Path, **kv): + Path(path).write_text("[song]\n" + "".join(f"{k} = {v}\n" for k, v in kv.items()), encoding="utf-8") + return path + + +def drum_pattern(n_measures, tpq=TPQ): + """A deterministic pro-drums groove: [(tick, MIDI note, velocity, length)].""" + notes = [] + e = tpq // 2 + for m in range(n_measures): + for slot in range(8): + t = (m * 8 + slot) * e + if slot in (0, 3, 4) or (m % 3 == 1 and slot == 7): + notes.append((t, 96, 100, 10)) # kick + if slot in (2, 6): + notes.append((t, 97, 100, 10)) # snare + if slot not in (0,) or m % 4: + notes.append((t, 98, 100, 10)) # hat (cymbal) + if slot == 0 and m % 4 == 0: + notes.append((t, 100, 100, 10)) # crash + if m % 5 == 2 and slot == 5: + notes.append((t, 99, 100, 10)) + notes.append((t, 111, 100, 10)) # blue tom + return notes + + +def _mask_bytes(data: bytes, mask: bytes) -> bytes: + # Independent, byte-by-byte reference of the SngFileFormat mask. + return bytes(b ^ mask[i % 16] ^ (i & 0xFF) for i, b in enumerate(data)) + + +def write_sng(path, files: dict, meta: dict, mask=bytes(range(7, 23))): + """Pack {name: bytes} into an .sng (SngFileFormat v1) with the given song.ini metadata.""" + md = b"".join(struct.pack(" blue cym + green tom + + +def test_mid_disco_flip_swaps_red_and_yellow(tmp_path): + notes = [(0, RED, 100, 10), (480, RED, 100, 10), (480, YEL, 100, 10), (1440, RED, 100, 10)] + texts = [(480, "[mix 3 drums0d]"), (960, "[mix 3 drums0]")] + c = drumchart.parse_mid(write_mid(tmp_path / "n.mid", notes, texts=texts)) + assert [(round(h.time, 2), h.pad, h.cymbal) for h in c.hits] == [ + (0.0, "red", False), (0.5, "red", False), (0.5, "yellow", True), (1.5, "red", False)] + + +def test_mid_only_part_drums_expert(tmp_path): + notes = [(0, 60, 100, 10), (0, 72, 100, 10), (0, 84, 100, 10), (480, RED, 100, 10)] + c = drumchart.parse_mid(write_mid(tmp_path / "n.mid", notes)) + assert _hits(c) == [(0.5, "red", False, False, None)] + with pytest.raises(ValueError): + drumchart.parse_mid(write_mid(tmp_path / "g.mid", notes, track_name="PART GUITAR")) + + +def test_beats_follow_time_signatures(tmp_path): + notes = [(0, KICK, 100, 10), (TPQ * 10, KICK, 100, 10)] + c = drumchart.parse_mid(write_mid(tmp_path / "n.mid", notes, sigs=[(0, 4, 4), (TPQ * 4, 3, 4)])) + beats = c.beat_list() + assert [b["measure"] for b in beats[:8]] == [1, -1, -1, -1, 2, -1, -1, 3] + assert beats[4]["time"] == pytest.approx(2.0) + assert beats[-1]["time"] >= c.hits[-1].time - 0.5 + + +# ── .chart ────────────────────────────────────────────────────────────────── + +def test_chart_markers_dynamics_and_phrases(tmp_path): + R = 192 + lines = [(0, "N", 0, 0), (0, "N", 2, 0), (0, "N", 66, 0), # kick + yellow cymbal + (R, "N", 2, 0), # yellow tom (default) + (2 * R, "N", 1, 0), (2 * R, "N", 34, 0), # red accent + (3 * R, "N", 3, 0), (3 * R, "N", 42, 0), (3 * R, "N", 67, 0), # blue ghost cymbal + (4 * R, "N", 32, 0), (4 * R, "N", 4, 0), (4 * R, "N", 68, 0), # 2x kick + green cymbal + (5 * R, "N", 4, 0), # green tom + (0, "S", 2, 4 * R), (4 * R, "S", 64, 2 * R)] + write_chart(tmp_path / "notes.chart", lines, events=[(0, "Intro")], offset=0.1) + write_ini(tmp_path / "song.ini", pro_drums="True") + c, ini = drumchart.load_song_folder(tmp_path) + assert c.source_format == "chart" and ini["name"] == "Song" and ini["artist"] == "Band" + assert c.offset == pytest.approx(0.1) + assert [(round(h.time - 0.1, 3), h.pad, h.cymbal, h.kick2x, h.dyn) for h in c.hits] == [ + (0.0, "kick", False, False, None), (0.0, "yellow", True, False, None), + (0.5, "yellow", False, False, None), + (1.0, "red", False, False, "accent"), + (1.5, "blue", True, False, "ghost"), + (2.0, "kick", False, True, None), (2.0, "green", True, False, None), + (2.5, "green", False, False, None)] + assert c.star_power == [(0.1, 2.1)] + assert c.fills == [(2.1, 3.1)] + assert c.sections == [(0.1, "Intro")] + + +def test_ini_delay_beats_chart_offset(tmp_path): + write_chart(tmp_path / "notes.chart", [(0, "N", 1, 0)], offset=0.5) + write_ini(tmp_path / "song.ini", delay=-120) + c, _ = drumchart.load_song_folder(tmp_path) + assert c.hits[0].time == pytest.approx(-0.12) + + +# ── drum_tab / song_timeline encoding ─────────────────────────────────────── + +def test_every_pad_maps_to_a_drums_piece_and_back(): + hits = [drumchart.DrumHit(1.0, "kick"), drumchart.DrumHit(1.0, "kick", kick2x=True), + drumchart.DrumHit(1.0, "red", dyn="ghost"), drumchart.DrumHit(1.0, "yellow", True), + drumchart.DrumHit(1.0, "yellow", False), drumchart.DrumHit(1.0, "blue", True, dyn="accent"), + drumchart.DrumHit(1.0, "blue", False), drumchart.DrumHit(1.0, "green", True), + drumchart.DrumHit(1.0, "green", False)] + tab = [drumchart.hit_to_tab(h) for h in hits] + assert [d["p"] for d in tab] == ["kick", "kick", "snare", "hh_closed", "tom_hi", "ride", "tom_mid", + "crash_l", "tom_floor"] + assert tab[1]["x2"] and tab[2]["g"] and tab[5]["a"] + assert "a" not in tab[2] and "g" not in tab[5] and "x2" not in tab[0] + key = lambda h: (h.pad, h.cymbal, h.kick2x, h.dyn) # noqa: E731 + assert sorted(map(key, drumchart.tab_to_hits(tab))) == sorted(map(key, hits)) + + +def test_piece_to_pad_covers_the_closed_vocabulary(): + import drums + assert set(drumchart.PIECE_TO_PAD) == set(drums.PIECES) + back = drumchart.tab_to_hits([{"t": 1, "p": "cowbell"}, {"t": 0.5, "p": "snare", "x2": True}, + {"t": "x", "p": "kick"}, "junk"]) + assert [(h.time, h.pad, h.kick2x) for h in back] == [(0.5, "red", False)] # x2 only on kicks + + +def test_drum_tab_carries_gameplay_metadata_and_validates(): + import drums + hits = [drumchart.DrumHit(0.5 * i, "red" if i % 2 else "kick") for i in range(8)] + hits.append(drumchart.DrumHit(1.25, "kick", kick2x=True)) + c = drumchart.DrumChart(hits=hits, tempo=drumchart.TempoMap(480), star_power=[(0.0, 1.0)], + fills=[(2.0, 3.0)], solos=[(1.0, 2.0)], + levels={"hard": hits[:4], "easy": [hits[0]]}, levels_generated=["easy"]) + tab = drumchart.drum_tab(c) + assert drums.validate_drum_tab(tab) == (True, "") + assert tab["version"] == drums.SCHEMA_VERSION and tab["name"] == "Drums" + assert [k["id"] for k in tab["kit"]] == ["kick", "snare"] + assert sum(1 for h in tab["hits"] if h.get("x2")) == 1 + assert tab["star_power"] == [{"start_time": 0.0, "end_time": 1.0}] + assert tab["fills"] == [{"start_time": 2.0, "end_time": 3.0}] + assert tab["solos"] == [{"start_time": 1.0, "end_time": 2.0}] + assert set(tab["levels"]) == {"hard", "easy"} + assert tab["levels"]["easy"]["generated"] is True and "generated" not in tab["levels"]["hard"] + # every hit survives the core wire normaliser the highway WS streams through + assert len(drums.hits_to_wire(tab["hits"])) == len(hits) + # empty metadata is omitted, not written as [] + bare = drumchart.drum_tab(drumchart.DrumChart(hits=hits[:1], tempo=drumchart.TempoMap(480))) + assert set(bare) == {"version", "name", "kit", "hits"} + + +def test_level_hits_never_carry_x2(): + k2 = drumchart.DrumHit(0.25, "kick", kick2x=True) + c = drumchart.DrumChart(hits=[k2], tempo=drumchart.TempoMap(480), levels={"hard": [k2]}) + tab = drumchart.drum_tab(c) + assert tab["hits"][0]["x2"] and "x2" not in tab["levels"]["hard"]["hits"][0] + + +def test_song_timeline_has_tempo_map_beats_and_sections(tmp_path): + p = write_mid(tmp_path / "notes.mid", [(0, KICK, 100, 10), (TPQ * 8, KICK, 100, 10)], + tempos=[(0, 120.0), (TPQ * 4, 60.0)], sigs=[(0, 4, 4), (TPQ * 4, 3, 4)], + sections=[(0, "Intro"), (TPQ * 4, "Verse")]) + write_ini(tmp_path / "song.ini", delay=100) + c, _ = drumchart.load_song_folder(tmp_path) + tl = drumchart.song_timeline(c) + assert tl["version"] == 1 + assert tl["tempos"] == [{"time": 0.1, "bpm": 120.0}, {"time": 2.1, "bpm": 60.0}] + assert tl["time_signatures"] == [{"time": 0.1, "ts": [4, 4]}, {"time": 2.1, "ts": [3, 4]}] + assert tl["beats"][0] == {"time": 0.1, "measure": 1} + assert [(s["name"], s["number"], s["time"]) for s in tl["sections"]] == [("Intro", 1, 0.1), ("Verse", 2, 2.1)] + assert p.exists() + + +# ── difficulty levels ─────────────────────────────────────────────────────── + +def test_mid_lower_difficulties_are_read(tmp_path): + notes = [(0, KICK, 100, 10), (0, RED, 100, 10), (480, YEL, 100, 10), # expert + (0, 84, 100, 10), (480, 86, 100, 10), # hard: kick + yellow + (0, 73, 100, 10), # medium: red + (480, 62, 100, 10), (480, 110, 100, 10)] # easy: yellow tom + write_mid(tmp_path / "notes.mid", notes) + c, _ = drumchart.load_song_folder(tmp_path) + assert len(c.hits) == 3 + assert [(h.pad, h.cymbal) for h in c.levels["hard"]] == [("kick", False), ("yellow", False)] + assert [(h.pad, h.cymbal) for h in c.levels["medium"]] == [("red", False)] + assert [(h.pad, h.cymbal) for h in c.levels["easy"]] == [("yellow", False)] # tom markers apply to all + tab = drumchart.drum_tab(c) + assert tab["levels"]["hard"] == {"hits": [{"t": 0.0, "p": "kick"}, {"t": 0.5, "p": "tom_hi"}]} + + +def test_chart_lower_difficulties(tmp_path): + p = tmp_path / "notes.chart" + write_chart(p, [(0, "N", 1, 0), (192, "N", 2, 0), (192, "N", 66, 0)]) + p.write_text(p.read_text() + "[EasyDrums]\n{\n 0 = N 1 0\n}\n") + c, _ = drumchart.load_song_folder(tmp_path) + assert list(c.levels) == ["easy"] and len(c.levels["easy"]) == 1 + + +def test_reduce_levels_follow_charting_rules(): + beats = [(0.5 * i, i // 4 + 1 if i % 4 == 0 else -1) for i in range(200)] + hits = [] + for i in range(320): # 16th-note groove at 120 BPM + t = 0.125 * i + hits.append(drumchart.DrumHit(t, "yellow", True)) + if i % 8 == 0: + hits += [drumchart.DrumHit(t, "kick"), drumchart.DrumHit(t, "green", True)] + if i % 8 == 4: + hits.append(drumchart.DrumHit(t, "red")) + if i % 16 == 15: + hits += [drumchart.DrumHit(t, "kick", kick2x=True), drumchart.DrumHit(t, "red", dyn="ghost")] + out = {lv: drumchart_gen.reduce_level(hits, beats, lv) for lv in drumchart.LOWER_LEVELS} + for lv, hs in out.items(): + assert not any(h.kick2x or h.dyn == "ghost" for h in hs) + by_t = {} + for h in hs: + by_t.setdefault(h.time, []).append(h) + cap = drumchart_gen.REDUCE[lv][1] + assert max(len(v) for v in by_t.values()) <= cap + ts = sorted(by_t) + gaps = [b - a for a, b in zip(ts, ts[1:])] + assert min(gaps) >= drumchart_gen.REDUCE[lv][0] * 0.5 - 1e-6 or lv == "hard" + if lv == "easy": + assert not any(len(v) > 1 and any(h.pad == "kick" for h in v) for v in by_t.values()) + assert len(out["easy"]) <= len(out["medium"]) < len(out["hard"]) < len(hits) + + +def test_ensure_levels_marks_generated_and_keeps_authored(): + hits = [drumchart.DrumHit(0.25 * i, "red") for i in range(64)] + c = drumchart.DrumChart(hits=hits, tempo=drumchart.TempoMap(480), levels={"easy": hits[::4]}) + drumchart_gen.ensure_levels(c) + assert c.levels["easy"] == hits[::4] and sorted(c.levels_generated) == ["hard", "medium"] + tab = drumchart.drum_tab(c) + assert "generated" not in tab["levels"]["easy"] and tab["levels"]["hard"]["generated"] + + +def test_reduce_level_tolerates_repeated_beat_times(): + # a beat map can repeat a time; that used to divide by zero + beats = [(0.5 * i, -1) for i in range(20)] + beats.insert(5, beats[4]) + hits = [drumchart.DrumHit(0.25 * i, "red") for i in range(32)] + for lv in drumchart.LOWER_LEVELS: + assert drumchart_gen.reduce_level(hits, beats, lv) + + +def test_auto_fills_need_star_power_and_respect_spacing(): + beats = [(0.5 * i, i // 4 + 1 if i % 4 == 0 else -1) for i in range(160)] # 40 bars, 2 s each + notes = [0.125 * i for i in range(640)] + assert drumchart_gen.auto_fills(notes, beats, []) == [] + fills = drumchart_gen.auto_fills(notes, beats, [(0.0, 8.0)]) + assert fills and all(b - a == pytest.approx(2.0) for a, b in fills) # one measure each + assert fills[0][1] - 8.0 >= 2.0 # >= 2 s after the SP phrase + assert all(b2 - b1 >= 2.0 for (_, b1), (_, b2) in zip(fills, fills[1:])) + + +# ── end to end: song folder -> pack -> core loader ────────────────────────── + +def _song_folder(tmp_path, *, stems=False): + folder = tmp_path / "Band - Song" + folder.mkdir() + notes = [(i * 480, KICK if i % 2 == 0 else RED, 100, 10) for i in range(16)] + notes += [(i * 240, YEL, 100, 10) for i in range(32)] + notes += [(0, 116, 100, 1920), (TPQ * 2, 95, 100, 10)] + write_mid(folder / "notes.mid", notes, sections=[(0, "Intro"), (3840, "Verse")]) + write_ini(folder / "song.ini", name="Song", artist="Band", album="Record", year="2004", delay=0, charter="me") + if stems: + for name in ("drums.ogg", "song.ogg", "crowd.ogg"): + (folder / name).write_bytes(FAKE_OGG) + else: + (folder / "song.ogg").write_bytes(FAKE_OGG) + (folder / "album.png").write_bytes(b"\x89PNG\r\n\x1a\n") + return folder + + +def test_convert_folder_to_pack_and_load(tmp_path): + from scripts import ch_to_sloppak + from sloppak import load_song + folder = _song_folder(tmp_path) + out = tmp_path / "dlc" / "Song_-_Band.sloppak" + out.parent.mkdir() + s = ch_to_sloppak.convert(folder, out) + assert s["notes"] == 49 and s["stems"] == ["full"] and s["star_power"] == 1 + assert s["levels_generated"] == ["easy", "hard", "medium"] + with zipfile.ZipFile(out) as z: + man = yaml.safe_load(z.read("manifest.yaml")) + tab = json.loads(z.read("drum_tab.json")) + tl = json.loads(z.read("song_timeline.json")) + assert z.read("stems/full.ogg") == FAKE_OGG + assert man["feedpak_version"] == "1.20.0" + assert man["title"] == "Song" and man["artist"] == "Band" and man["album"] == "Record" and man["year"] == 2004 + assert man["arrangements"] == [{"id": "drums", "name": "Drums", "type": "drums", "drum_tab": "drum_tab.json"}] + assert man["drum_tab"] == "drum_tab.json" and man["song_timeline"] == "song_timeline.json" + assert man["stems"] == [{"id": "full", "file": "stems/full.ogg", "default": True}] + assert man["cover"] == "cover.png" + assert man["duration"] >= 8.0 + assert tab["star_power"] == [{"start_time": 0.0, "end_time": 2.0}] + assert {lv["generated"] for lv in tab["levels"].values()} == {True} + assert tl["beats"][0] == {"time": 0.0, "measure": 1} + assert [s["name"] for s in tl["sections"]] == ["Intro", "Verse"] + + loaded = load_song(out.name, out.parent, tmp_path / "cache") + assert loaded.drum_tab is not None and len(loaded.drum_tab["hits"]) == 49 + assert [p["id"] for p in loaded.drum_parts] == ["drums"] + # no pitched arrangement: never graded as a fretted part, only the drum placeholder + assert [a.name for a in loaded.song.arrangements] == ["Drums"] + assert not loaded.song.arrangements[0].notes + assert loaded.song.beats and [s.name for s in loaded.song.sections] == ["Intro", "Verse"] + assert loaded.tempos and loaded.time_signatures + + +def test_convert_maps_ch_stems_and_dir_form(tmp_path): + from scripts import ch_to_sloppak + folder = _song_folder(tmp_path, stems=True) + out = tmp_path / "x.sloppak" + s = ch_to_sloppak.convert(folder, out, title="Other Title", as_dir=True) + assert s["stems"] == ["drums", "other"] # crowd dropped, song -> other + assert (out / "stems" / "drums.ogg").is_file() and (out / "stems" / "other.ogg").is_file() + assert yaml.safe_load((out / "manifest.yaml").read_text())["title"] == "Other Title" + + +def test_convert_sng_package(tmp_path): + from scripts import ch_to_sloppak + from tests.drum_fixtures import write_sng + folder = _song_folder(tmp_path) + files = {n: (folder / n).read_bytes() for n in ("notes.mid", "song.ogg")} + sng = write_sng(tmp_path / "song.sng", files, {"name": "Packed", "artist": "Band", "delay": "0"}) + out = tmp_path / "p.sloppak" + s = ch_to_sloppak.convert(sng, out) + assert s["notes"] == 49 + with zipfile.ZipFile(out) as z: + assert yaml.safe_load(z.read("manifest.yaml"))["title"] == "Packed" + assert z.read("stems/full.ogg") == FAKE_OGG + + +def test_convert_cli_reports_errors(tmp_path, caplog): + from scripts import ch_to_sloppak + empty = tmp_path / "empty" + empty.mkdir() + assert ch_to_sloppak.main([str(empty), str(tmp_path / "o.sloppak")]) == 1 + assert "no notes.mid or notes.chart" in caplog.text + assert not (tmp_path / "o.sloppak").exists() diff --git a/tests/test_sloppak_to_ch.py b/tests/test_sloppak_to_ch.py new file mode 100644 index 00000000..0a7dc23b --- /dev/null +++ b/tests/test_sloppak_to_ch.py @@ -0,0 +1,178 @@ +"""scripts/sloppak_to_ch.py, incl. a round trip CH folder -> pack -> CH folder.""" + +from __future__ import annotations + +import json +import zipfile + +import mido +import pytest +import yaml + +import drumchart +import sngfile +from scripts import ch_to_sloppak, sloppak_to_ch +from tests.drum_fixtures import FAKE_OGG, TPQ, _mask_bytes, drum_pattern, write_ini, write_mid, write_sng + +KICK, RED, YEL, BLU, ORG = 96, 97, 98, 99, 100 + + +def _key(h): + return (round(h.time, 2), h.pad, h.cymbal, h.kick2x, h.dyn) + + +def _source_folder(tmp_path): + folder = tmp_path / "src" + folder.mkdir() + notes = drum_pattern(12) + notes += [(TPQ * 3, 95, 100, 10), # 2x kick + (TPQ * 5, BLU, 100, 10), (TPQ * 5, 111, 100, 10), # blue tom + (TPQ * 6, ORG, 100, 10), (TPQ * 6, 112, 100, 10), # green tom + (TPQ * 7, RED, 127, 10), (TPQ * 9, RED, 1, 10), # accent, ghost + (TPQ * 8, 116, 100, TPQ * 4), (TPQ * 20, 120, 100, TPQ * 4), + (TPQ * 28, 103, 100, TPQ * 8)] # solo + write_mid(folder / "notes.mid", notes, tempos=[(0, 120.0), (TPQ * 16, 150.0), (TPQ * 32, 96.0)], + sigs=[(0, 4, 4), (TPQ * 24, 3, 4), (TPQ * 30, 4, 4)], + texts=[(0, "[ENABLE_CHART_DYNAMICS]")], sections=[(0, "Intro"), (TPQ * 16, "Verse")]) + write_ini(folder / "song.ini", name="Round Trip", artist="Band", year=1999, delay=40) + (folder / "drums.ogg").write_bytes(FAKE_OGG) + (folder / "song.ogg").write_bytes(FAKE_OGG + b"song") + return folder + + +def test_round_trip_ch_pack_ch(tmp_path): + folder = _source_folder(tmp_path) + orig, _ = drumchart.load_song_folder(folder) + pack = tmp_path / "rt.sloppak" + ch_to_sloppak.convert(folder, pack) + out = tmp_path / "out" + s = sloppak_to_ch.export(pack, out) + assert s["parts"]["PART DRUMS"] == len(orig.hits) + assert s["stems"] == ["drums", "song"] + assert (out / "drums.ogg").read_bytes() == FAKE_OGG and (out / "song.ogg").read_bytes() == FAKE_OGG + b"song" + back, ini = drumchart.load_song_folder(out) + assert ini["name"] == "Round Trip" and ini["pro_drums"] == "True" + assert ini["delay"] == "40" # first beat 40 ms in -> exported as delay, not a pickup beat + assert sorted(map(_key, back.hits)) == sorted(map(_key, orig.hits)) + assert sorted(h.time for h in back.hits) == pytest.approx(sorted(h.time for h in orig.hits), abs=0.002) + assert back.dynamics + # the source has no lower levels, so ch_to_sloppak generated them; they export as + # real Easy/Medium/Hard parts and come back identical + tab = json.loads(zipfile.ZipFile(pack).read("drum_tab.json")) + assert {lv: v.get("generated") for lv, v in tab["levels"].items()} == { + "easy": True, "medium": True, "hard": True} + for lv in drumchart.LOWER_LEVELS: + want = sorted(map(_key, drumchart.tab_to_hits(tab["levels"][lv]["hits"]))) + assert sorted(map(_key, back.levels[lv])) == want, lv + assert back.star_power == pytest.approx(orig.star_power, abs=0.002) + assert back.fills == pytest.approx(orig.fills, abs=0.002) + assert back.solos == pytest.approx(orig.solos, abs=0.002) + assert [n for _, n in back.sections] == ["Intro", "Verse"] + assert [t for t, _ in back.sections] == pytest.approx([t for t, _ in orig.sections], abs=0.002) + # tempo map survives well enough that beats line up + b0 = [b["time"] for b in orig.beat_list()] + b1 = [b["time"] for b in back.beat_list()] + n = min(len(b0), len(b1)) + assert b1[:n] == pytest.approx(b0[:n], abs=0.002) + # and a second conversion gives the same drum tab + pack2 = tmp_path / "rt2.sloppak" + ch_to_sloppak.convert(out, pack2) + tab2 = json.loads(zipfile.ZipFile(pack2).read("drum_tab.json")) + assert [(h["p"], h.get("a"), h.get("g"), h.get("x2")) for h in tab["hits"]] == \ + [(h["p"], h.get("a"), h.get("g"), h.get("x2")) for h in tab2["hits"]] + + +def _ons(track): + tick, out = 0, [] + for m in track: + tick += m.time + if m.type == "note_on" and m.velocity > 0: + out.append((tick, m.note, m.velocity)) + return out + + +def _drum_pack(tmp_path, tabs: dict, *, beats, primary="drums"): + """Directory-form pack with one or more drum parts, a song_timeline and a full stem.""" + root = tmp_path / "d.sloppak" + (root / "stems").mkdir(parents=True) + (root / "stems" / "full.ogg").write_bytes(FAKE_OGG) + arrs = [] + for pid, tab in tabs.items(): + (root / f"drum_tab_{pid}.json").write_text(json.dumps(tab)) + arrs.append({"id": pid, "name": pid.title(), "type": "drums", "drum_tab": f"drum_tab_{pid}.json"}) + (root / "song_timeline.json").write_text(json.dumps({"version": 1, "beats": beats, "sections": []})) + man = {"feedpak_version": "1.20.0", "title": "Parts", "artist": "Band", "duration": 12.0, + "stems": [{"id": "full", "file": "stems/full.ogg", "default": True}], "arrangements": arrs, + "drum_tab": f"drum_tab_{primary}.json", "song_timeline": "song_timeline.json"} + (root / "manifest.yaml").write_text(yaml.safe_dump(man, sort_keys=False)) + return root + + +def test_export_picks_part_maps_pieces_and_handles_pickup(tmp_path): + beats = [{"time": round(0.3 + 0.5 * i, 3), "measure": i // 4 + 1 if i % 4 == 0 else -1} for i in range(24)] + main = {"version": 1, "hits": [{"t": 0.3 + 0.5 * i, "p": "kick"} for i in range(8)]} + alt = {"version": 1, "hits": [{"t": 0.3, "p": "ride_bell"}, {"t": 0.8, "p": "tom_low", "a": True}, + {"t": 1.3, "p": "cowbell"}, {"t": 1.8, "p": "hh_open", "g": True}], + "star_power": [{"start_time": 0.3, "end_time": 1.3}]} + pack = _drum_pack(tmp_path, {"drums": main, "live": alt}, beats=beats) + s = sloppak_to_ch.export(pack, tmp_path / "a") + assert s["parts"]["PART DRUMS"] == 8 and s["stems"] == ["song"] # lone full mix -> song.ogg + back, _ = drumchart.load_song_folder(tmp_path / "a") + assert len(back.hits) == 8 and all(h.pad == "kick" for h in back.hits) + assert back.hits[0].time == pytest.approx(0.3, abs=0.002) + # pickup: first beat at 0.3 s -> 1 pickup beat, beat 1 lands on tick 480 + clock = sloppak_to_ch.BeatClock(beats) + assert clock.pickup == 1 and clock.tick(0.3) == TPQ and clock.tick(0.0) == 0 and clock.tick(0.8) == 2 * TPQ + + s = sloppak_to_ch.export(pack, tmp_path / "b", part="live") + assert s["parts"]["PART DRUMS"] == 3 # unknown piece dropped + mid = mido.MidiFile(str(tmp_path / "b" / "notes.mid")) + ons = _ons(next(t for t in mid.tracks if t.name == "PART DRUMS")) + notes = {n for _, n, _ in ons} + assert {99, 100, 98, 112, 116} <= notes # blue cym, green tom + marker, SP + assert (TPQ * 2, 100, 127) in ons # accented floor tom + back, _ = drumchart.load_song_folder(tmp_path / "b") + assert [(h.pad, h.cymbal, h.dyn) for h in back.hits] == [ + ("blue", True, None), ("green", False, "accent"), ("yellow", True, "ghost")] + assert back.star_power == pytest.approx([(0.3, 1.3)], abs=0.002) + + with pytest.raises(ValueError, match="no drum part"): + sloppak_to_ch.export(pack, tmp_path / "c", part="nope") + + +def test_export_cli_without_drums(tmp_path, caplog): + root = tmp_path / "g.sloppak" + (root / "arrangements").mkdir(parents=True) + (root / "arrangements" / "lead.json").write_text(json.dumps({"notes": [{"t": 1.0, "s": 0, "f": 3}]})) + (root / "manifest.yaml").write_text(yaml.safe_dump( + {"title": "G", "artist": "B", "duration": 2.0, "stems": [], + "arrangements": [{"id": "lead", "name": "Lead", "file": "arrangements/lead.json"}]})) + assert sloppak_to_ch.main([str(root), str(tmp_path / "o")]) == 1 + assert "no drum_tab" in caplog.text + + +# ── .sng ──────────────────────────────────────────────────────────────────── + +def test_sng_unmask_matches_reference_and_extract(tmp_path): + mask = bytes(range(40, 56)) + data = bytes((i * 37) % 251 for i in range(1000)) + assert sngfile.unmask(_mask_bytes(data, mask), mask) == data + assert sngfile.unmask(b"", mask) == b"" + sng = write_sng(tmp_path / "s.sng", {"notes.chart": b"[Song]", "../evil.ogg": b"x", "video.mp4": b"v"}, + {"name": "N", "artist": "A"}, mask=mask) + meta, files = sngfile.read_sng(sng) + assert meta["name"] == "N" and set(files) == {"notes.chart", "../evil.ogg", "video.mp4"} + out = sngfile.extract_sng(sng, tmp_path / "x") + assert sorted(p.name for p in out.iterdir()) == ["evil.ogg", "notes.chart", "song.ini"] + assert (out / "notes.chart").read_bytes() == b"[Song]" + assert drumchart.load_song_ini(out / "song.ini") == {"name": "N", "artist": "A"} + + +def test_sng_rejects_garbage(tmp_path): + p = tmp_path / "bad.sng" + p.write_bytes(b"NOTSNG" + bytes(40)) + with pytest.raises(sngfile.SngError): + sngfile.read_sng(p) + p.write_bytes(b"SNGPKG" + (1).to_bytes(4, "little") + bytes(10)) + with pytest.raises(sngfile.SngError, match="truncated"): + sngfile.read_sng(p)