From 49f4d45a0695b0d45f6d328a35f1f9dca228d2bb Mon Sep 17 00:00:00 2001 From: mattromano <42412983+mattromano@users.noreply.github.com> Date: Fri, 9 Oct 2026 16:57:35 -0700 Subject: [PATCH] feat(spec): drum gameplay metadata in drum_tab (1.20.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A drum chart imported from Rock Band / Clone Hero / YARG carries more than hits: authored easier difficulties, star-power phrases, fills, solo sections, accents, and the double-kick notes that split Expert from Expert+. drum_tab had nowhere to put any of it, so importers either dropped it or parked it in extension keys no other Reader understands. Additive, optional, all inside drum_tab.json (no manifest key): - hits[].a (authored accent; MUST NOT combine with g) and hits[].x2 (double kick). hits[] stays the complete chart including x2 kicks, so an x2-unaware Reader plays exactly what the drummer plays. - star_power / fills / solos: arrays of {start_time, end_time}, the same pair arrangement phrases use. - levels: {easy|medium|hard: {hits, generated}}; hits[] is always the hardest chart, generated marks a software-reduced level. Together per CONTRIBUTING: spec §7.5 + version ledger, drum-tab schema (hit factored into $defs/hit so level hits validate identically), extended example exercising every field, CHANGELOG, 1.20.0 bump, validator tests for accepted and rejected shapes. Checks (local): check_versions, validate.py (both packs), pytest (72), ruff, mkdocs build --strict - all pass. Co-Authored-By: Claude Opus 5.5 Signed-off-by: mattromano <42412983+mattromano@users.noreply.github.com> --- CHANGELOG.md | 32 ++++++++++ README.md | 4 +- examples/extended.feedpak/drum_tab.json | 28 ++++++++- examples/extended.feedpak/manifest.yaml | 5 +- schemas/drum-tab.schema.json | 67 ++++++++++++++++----- spec/feedpak-v1.md | 78 +++++++++++++++++++++++-- tests/test_validate.py | 55 +++++++++++++++++ 7 files changed, 244 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8380eb7..c51395c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,38 @@ and the specification is versioned per [Semantic Versioning](https://semver.org/ [spec §4](spec/feedpak-v1.md#4-versioning) for how format, side-file, and document versions relate. +## [1.20.0] - 2026-10-09 + +Additive (MINOR) release: **drum gameplay metadata** — a `drum_tab` file can now carry what a +rhythm-game drum chart (Rock Band, Clone Hero, YARG) has beyond its hits: easier difficulties, +star-power phrases, fills, solo sections, accents, and the double-kick notes that separate +"Expert" from "Expert+". Until now a drum chart imported from one of those games lost all of it, +or a Writer had to park it in extension keys no other Reader understood. + +### Added +- **Per-hit `a` (accent) and `x2` (double-kick) flags** on `hits[]` + ([§7.5](spec/feedpak-v1.md#75-drum_tabjson)). `a` is an *authored* accent — velocity can't + carry that, since a performance MIDI's natural velocities would read as accents. A hit **MUST + NOT** set both `a` and `g` (schema-enforced). `x2` marks a second-pedal kick: `hits[]` stays the + musically complete chart, *including* those kicks, and a Reader offering the Expert / Expert+ + split derives Expert by dropping them — so a Reader that knows nothing about `x2` plays exactly + what the drummer plays. +- **Top-level `star_power`, `fills`, `solos`** — arrays of `{start_time, end_time}` ranges (the + pair arrangement `phrases` already use) — and **`levels`**, the easier difficulties keyed by a + closed id list `easy` / `medium` / `hard`, each `{hits, generated}` with `hits` in exactly the + top-level shape and `generated: true` marking a level reduced by software rather than charted + ([§7.5 "Drum gameplay metadata"](spec/feedpak-v1.md#drum-gameplay-metadata)). `hits[]` is + always the hardest chart. The keys describe the chart only; scoring stays the consumer's. + `schemas/drum-tab.schema.json` gains the fields (hits factored into a shared `$defs/hit` so + level hits validate identically); `examples/extended.feedpak/drum_tab.json` exercises every one; + the reference test suite covers the accepted shapes and the rejected ones. + +### Compatibility +Purely additive: no manifest key, no new file, and every 1.19.0 package stays valid. An older +Reader ignores the new keys and flags and plays `hits[]` — which, by design, is the full chart. A +Reader that re-validates `drum_tab` against its bundled pre-1.20 schema also passes, since that +schema already allowed additional properties. + ## [1.19.0] - 2026-07-22 Additive (MINOR) release: a **pickup measure can finally carry its conventional number**. diff --git a/README.md b/README.md index 69c5645..f4273b1 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ with them. One `.feedpak` holds all the authored data for a single song. | | | |---|---| -| **Specification version** | 1.19.0 | +| **Specification version** | 1.20.0 | | **Format major version** | 1 | | **Status** | Draft | | **Canonical extension** | `.feedpak` | @@ -97,7 +97,7 @@ This repository is dual-licensed so the format stays maximally open: Attribution is **not required**, but if you build on feedpak a link back to this repository is appreciated. Suggested citation: *"feedpak format specification, version -1.19.0, https://github.com/got-feedback/feedpak-spec"*. +1.20.0, https://github.com/got-feedback/feedpak-spec"*. ## Contributing diff --git a/examples/extended.feedpak/drum_tab.json b/examples/extended.feedpak/drum_tab.json index ab3fe87..dbe607e 100644 --- a/examples/extended.feedpak/drum_tab.json +++ b/examples/extended.feedpak/drum_tab.json @@ -9,11 +9,35 @@ ], "hits": [ {"t": 0.5, "p": "kick", "v": 110}, - {"t": 0.5, "p": "crash_r", "v": 120, "k": 0.08}, + {"t": 0.5, "p": "crash_r", "v": 120, "k": 0.08, "a": true}, {"t": 1.0, "p": "hh_closed", "v": 70}, {"t": 1.5, "p": "snare", "v": 92}, {"t": 2.0, "p": "hh_closed", "v": 70, "g": true}, {"t": 2.5, "p": "kick", "v": 110}, + {"t": 2.75, "p": "kick", "v": 100, "x2": true}, {"t": 3.5, "p": "snare", "v": 100, "f": true} - ] + ], + "star_power": [{"start_time": 0.5, "end_time": 1.5}], + "fills": [{"start_time": 2.5, "end_time": 3.5}], + "solos": [{"start_time": 2.0, "end_time": 4.0}], + "levels": { + "hard": { + "hits": [ + {"t": 0.5, "p": "kick", "v": 110}, + {"t": 0.5, "p": "crash_r", "v": 120, "a": true}, + {"t": 1.0, "p": "hh_closed", "v": 70}, + {"t": 1.5, "p": "snare", "v": 92}, + {"t": 2.5, "p": "kick", "v": 110}, + {"t": 3.5, "p": "snare", "v": 100} + ] + }, + "easy": { + "hits": [ + {"t": 0.5, "p": "crash_r", "v": 120}, + {"t": 1.5, "p": "snare", "v": 92}, + {"t": 3.5, "p": "snare", "v": 100} + ], + "generated": true + } + } } diff --git a/examples/extended.feedpak/manifest.yaml b/examples/extended.feedpak/manifest.yaml index b908029..8f3c2f5 100644 --- a/examples/extended.feedpak/manifest.yaml +++ b/examples/extended.feedpak/manifest.yaml @@ -1,8 +1,9 @@ # Extended feedpak exercising optional side-files: lyrics, vocal pitch, -# song timeline, and drum tabs (incl. multiple drum-part arrangements). +# song timeline, and drum tabs (incl. multiple drum-part arrangements and +# drum gameplay metadata — levels, star power, fills, solos — in drum_tab.json). # See spec/feedpak-v1.md §5 and §7. # SPDX-License-Identifier: MIT -feedpak_version: "1.19.0" +feedpak_version: "1.20.0" title: "Extended Demo" artist: "Example Artist" album: "Spec Examples" diff --git a/schemas/drum-tab.schema.json b/schemas/drum-tab.schema.json index 07ef780..8f1e40c 100644 --- a/schemas/drum-tab.schema.json +++ b/schemas/drum-tab.schema.json @@ -21,20 +21,59 @@ } } }, - "hits": { - "type": "array", - "items": { - "type": "object", - "required": ["t", "p"], - "additionalProperties": true, - "properties": { - "t": { "type": "number" }, - "p": { "type": "string" }, - "v": { "type": "integer", "minimum": 1, "maximum": 127 }, - "g": { "type": "boolean" }, - "f": { "type": "boolean" }, - "k": { "type": "number" } - } + "hits": { "type": "array", "items": { "$ref": "#/$defs/hit" } }, + "star_power": { "$ref": "#/$defs/ranges", "$comment": "Star-power phrases (1.20.0)." }, + "fills": { "$ref": "#/$defs/ranges", "$comment": "Fill / star-power activation phrases (1.20.0)." }, + "solos": { "$ref": "#/$defs/ranges", "$comment": "Drum-solo sections (1.20.0)." }, + "levels": { + "type": "object", + "$comment": "Easier difficulties (1.20.0). Closed id list easy/medium/hard; a Reader ignores any other id.", + "additionalProperties": true, + "properties": { + "easy": { "$ref": "#/$defs/level" }, + "medium": { "$ref": "#/$defs/level" }, + "hard": { "$ref": "#/$defs/level" } + } + } + }, + "$defs": { + "hit": { + "type": "object", + "required": ["t", "p"], + "additionalProperties": true, + "properties": { + "t": { "type": "number" }, + "p": { "type": "string" }, + "v": { "type": "integer", "minimum": 1, "maximum": 127 }, + "g": { "type": "boolean" }, + "f": { "type": "boolean" }, + "k": { "type": "number" }, + "a": { "type": "boolean", "$comment": "Accent (1.20.0)." }, + "x2": { "type": "boolean", "$comment": "Double-kick (Expert+) note (1.20.0); meaningful on kick in the top-level hits[] only." } + }, + "not": { + "$comment": "A hit is not both accented and ghosted (1.20.0).", + "required": ["a", "g"], + "properties": { "a": { "const": true }, "g": { "const": true } } + } + }, + "range": { + "type": "object", + "required": ["start_time", "end_time"], + "additionalProperties": true, + "properties": { + "start_time": { "type": "number" }, + "end_time": { "type": "number" } + } + }, + "ranges": { "type": "array", "items": { "$ref": "#/$defs/range" } }, + "level": { + "type": "object", + "required": ["hits"], + "additionalProperties": true, + "properties": { + "hits": { "type": "array", "items": { "$ref": "#/$defs/hit" } }, + "generated": { "type": "boolean" } } } } diff --git a/spec/feedpak-v1.md b/spec/feedpak-v1.md index f001081..5d0f684 100644 --- a/spec/feedpak-v1.md +++ b/spec/feedpak-v1.md @@ -7,10 +7,10 @@ The JSON Schemas, examples, and reference code that accompany it are MIT-license # feedpak Format Specification -- **Specification version:** 1.19.0 +- **Specification version:** 1.20.0 - **Format major version:** 1 - **Status:** Draft -- **Date:** 2026-07-22 +- **Date:** 2026-10-09 - **Editors:** The feedpak authors - **License:** [CC0 1.0 Universal](../LICENSE) (this document) - **Machine-readable schemas:** [`schemas/`](../schemas/) (MIT) @@ -147,11 +147,11 @@ The manifest **SHOULD** carry a top-level `feedpak_version` key whose value is a which version of *this format* the package conforms to. ```yaml -feedpak_version: "1.19.0" +feedpak_version: "1.20.0" ``` - A Writer producing a feedpak that conforms to this document **SHOULD** set - `feedpak_version: "1.19.0"`. (The optional fields added since 1.0.0 — + `feedpak_version: "1.20.0"`. (The optional fields added since 1.0.0 — [`authors`](#54-authors) in 1.1.0; the song-level [`tempos`](#74-song_timelinejson) / [`time_signatures`](#74-song_timelinejson) plus the per-arrangement [`tempos`](#610-per-arrangement-tempo-optional) override in 1.2.0; the per-note bend shape @@ -202,7 +202,11 @@ feedpak_version: "1.19.0" opening pickup measure, which previously had no valid number at all. Every earlier package stays valid; a pack that *uses* `idx: 0` falls under the [§4.2 opt-in carve-out](#42-compatibility-policy) — the same opt-in shape as `.jsonc` — so a - pre-1.19.0 Reader MAY reject it.) + pre-1.19.0 Reader MAY reject it. 1.20.0 adds no manifest key: it adds optional + [drum gameplay metadata](#drum-gameplay-metadata) to the `drum_tab` file — the per-hit + [`a`](#75-drum_tabjson) (accent) and [`x2`](#75-drum_tabjson) (double-kick) flags and the + top-level `star_power`, `fills`, `solos`, and `levels` — all additive, an older Reader + ignores them and plays the full `hits[]` chart as before.) - If `feedpak_version` is **absent**, a Reader **MUST** treat the package as `"1.0.0"`. (This makes every package authored before the field existed a valid 1.0.0 package.) - The value **MUST** be a valid semver string when present. A Reader **MUST** reject a value @@ -1159,6 +1163,13 @@ same for both). A pack MAY carry **several** drum parts as `type: drums` arrange | `g` | boolean | Ghost note. | | `f` | boolean | Flam. | | `k` | number | Cymbal-choke tail duration (s). | +| `a` | boolean | Accent — an authored *hit harder* mark (1.20.0). A hit **MUST NOT** set both `a` and `g`. | +| `x2` | boolean | Double-kick note (1.20.0) — the second-pedal kicks a rhythm game hides below its top ("Expert+") difficulty. Meaningful on `kick` only. See [Drum gameplay metadata](#drum-gameplay-metadata). | + +`a` exists because velocity alone cannot say *authored accent*: a chart imported from a performance +MIDI carries natural velocities, so "`v` is high" is not a mark a scorer can grade against. A Writer +that knows a hit is accented sets `a` (and MAY also raise `v`); one that only has velocities leaves +`a` off. `kit[]` is the legend (stable metadata); `hits[]` is the hot path (short field names). Open and closed hi-hat are **distinct piece-ids**, not articulation flags — a scorer can only reject a @@ -1188,6 +1199,63 @@ round-trip (a Reader renders them with a sensible default rather than erroring). | `ride_bell` | cymbal | 53 | | `bell` | cymbal | 80 | +#### Drum gameplay metadata + +Rhythm-game drum charts (Rock Band, Clone Hero, YARG and the like) carry more than hits: authored +easier difficulties, star-power phrases, fills, and solo sections. A `drum_tab` file MAY carry them +in the OPTIONAL top-level keys below (1.20.0). They describe the **chart**, not a scoring +algorithm — how many points a phrase is worth, or what a fill does when played, is the consumer's +choice. Every key is optional and independent; an older Reader ignores them all and plays `hits[]`. + +```json +{ + "version": 1, + "name": "Drums", + "hits": [ + {"t": 0.500, "p": "kick"}, + {"t": 0.500, "p": "crash_l", "a": true}, + {"t": 0.625, "p": "kick", "x2": true}, + {"t": 0.750, "p": "snare", "g": true} + ], + "star_power": [{"start_time": 0.500, "end_time": 2.500}], + "fills": [{"start_time": 6.000, "end_time": 8.000}], + "solos": [{"start_time": 32.000, "end_time": 48.000}], + "levels": { + "hard": {"hits": [{"t": 0.500, "p": "kick"}, {"t": 0.500, "p": "crash_l"}]}, + "easy": {"hits": [{"t": 0.500, "p": "crash_l"}], "generated": true} + } +} +``` + +| Key | Type | Notes | +|---|---|---| +| `star_power` | array of ranges | Star-power phrases: hitting every note of a phrase earns the player star power (overdrive) in games that have it. | +| `fills` | array of ranges | Fill (star-power activation) phrases: the stretch where a player with banked star power activates it, usually by a freestyle fill ending on a final strike at `end_time`. | +| `solos` | array of ranges | Drum-solo sections, for solo scoring or display. | +| `levels` | object | Easier difficulties, keyed by difficulty id (below). | + +A **range** is an object `{"start_time": number, "end_time": number}` in seconds, the same pair the +arrangement [`phrases`](#6-arrangement-json) (§6.7) use, with `end_time` **≥** `start_time`. A Writer +**SHOULD** emit each array sorted by `start_time` and non-overlapping. + +**Difficulty levels.** `hits[]` is always the drum part's **hardest** chart. `levels` adds easier +ones, each `{"hits": [...], "generated": boolean}` where `hits` has exactly the shape of the +top-level `hits[]` and `generated: true` marks a level reduced from `hits[]` by software rather than +authored by a charter (absent ⇒ authored), so a player can be told which it is. The difficulty ids +are a closed list for v1 — `easy`, `medium`, `hard`, from easiest — and a Reader **MUST** ignore an +id it does not recognise. A Writer **SHOULD NOT** add a level that is merely a copy of `hits[]`. + +**Double kick (`x2`) and the top difficulty.** Rhythm games split the hardest chart in two: +"Expert" without the second-pedal notes, "Expert+" with them. In `drum_tab`, `hits[]` is the full +chart — **including** the `x2` kicks — so a Reader that knows nothing about `x2` plays every note +the drummer plays, which is the musically complete part. A Reader offering the split derives the +lower one by dropping `x2` hits. `x2` is meaningful only on `kick` and only in the top-level +`hits[]`; a Reader **MUST** ignore it elsewhere, and a Writer **SHOULD NOT** emit it in a `levels` +entry. + +Each drum part carries its own metadata: with [several drum parts](#multiple-drum-parts), the keys in +one part's `drum_tab` file describe that part only. + #### Multiple drum parts A song MAY have more than one drum chart — two drummers recorded separately, a programmed diff --git a/tests/test_validate.py b/tests/test_validate.py index e4884dc..a4fb481 100644 --- a/tests/test_validate.py +++ b/tests/test_validate.py @@ -241,6 +241,61 @@ def test_per_arrangement_drum_tab_is_validated(tmp_path): assert any("drum_tab" in e and "hits" in e for e in rep.errors) +def _drum_pack(tmp_path, drum_tab: dict): + m = _base_manifest() + m["drum_tab"] = "drum_tab.json" + return _make_pack(tmp_path / "drums.feedpak", m, extra={"drum_tab.json": json.dumps(drum_tab)}) + + +_GAMEPLAY_TAB = { + "version": 1, + "hits": [ + {"t": 0.5, "p": "kick"}, + {"t": 0.5, "p": "crash_l", "a": True}, + {"t": 0.625, "p": "kick", "x2": True}, + {"t": 0.75, "p": "snare", "g": True}, + ], + "star_power": [{"start_time": 0.5, "end_time": 2.5}], + "fills": [{"start_time": 6.0, "end_time": 8.0}], + "solos": [{"start_time": 32.0, "end_time": 48.0}], + "levels": { + "hard": {"hits": [{"t": 0.5, "p": "kick"}]}, + "easy": {"hits": [{"t": 0.5, "p": "crash_l"}], "generated": True}, + }, +} + + +def test_drum_gameplay_metadata_passes(tmp_path): + # 1.20.0: accent / double-kick flags, star power, fills, solos, levels. + rep = validate.resolve_and_validate(_drum_pack(tmp_path, _GAMEPLAY_TAB)) + assert rep.ok, rep.errors + + +def test_drum_unknown_level_id_is_ignored(tmp_path): + # The difficulty ids are a closed list, but an unknown one is forward-compatible. + tab = dict(_GAMEPLAY_TAB, levels={"expert_plus_plus": {"anything": 1}}) + rep = validate.resolve_and_validate(_drum_pack(tmp_path, tab)) + assert rep.ok, rep.errors + + +@pytest.mark.parametrize( + "patch, needle", + [ + ({"star_power": [{"start_time": 0.5}]}, "end_time"), # half a range + ({"fills": [[6.0, 8.0]]}, "fills"), # pair, not object + ({"solos": {"start_time": 1.0, "end_time": 2.0}}, "solos"), # not an array + ({"levels": {"hard": {"generated": True}}}, "hits"), # level without hits + ({"levels": {"easy": {"hits": [{"t": 0.5}]}}}, "'p'"), # level hit w/o piece + ({"hits": [{"t": 0.5, "p": "snare", "a": True, "g": True}]}, "hits/0"), # accent + ghost + ({"hits": [{"t": 0.5, "p": "kick", "x2": 1}]}, "x2"), # not a boolean + ], +) +def test_drum_gameplay_metadata_rejects_malformed(tmp_path, patch, needle): + rep = validate.resolve_and_validate(_drum_pack(tmp_path, dict(_GAMEPLAY_TAB, **patch))) + assert not rep.ok + assert any(needle in e for e in rep.errors), rep.errors + + # --------------------------------------------------------------------------- # # .jsonc support # --------------------------------------------------------------------------- #