feat(player): drum-part picker for multiple drum charts (feedpak 1.17.0)

The last mile of the multiple-drum-parts feature: let a player CHOOSE which
drum chart plays. #1020 taught the loader + highway WS to carry several drum
parts (song_info.drum_parts + ?drum_part=<id> + a part_id echo on drum_tab);
this adds the host-chrome selector that drives it.

A "Drum part" <select> sits beside the arrangement switcher in the advanced
settings popover, shown only when a song has 2+ drum charts (drum_parts is
always present — empty for non-drum songs — so single-drum / no-drum songs
hide the row and nothing changes for them). Selecting a part re-streams that
part's tab over the highway WS, exactly like an arrangement switch.

- static/highway.js:
  - reconnect() gains a third `drumPart` arg → sets `?drum_part=<id>` on the WS
    URL (mirrors the existing `arrangement` param one line up). Empty/undefined
    → the primary part, i.e. byte-identical to today for any pack untouched.
  - song_info handler populates #drum-part-select from msg.drum_parts and
    shows/hides #v3-drum-part-row on `length > 1` (parallel to the #arr-select
    block right above it).
  - drum_tab handler carries msg.part_id onto hwState.drumTab (plugins can read
    bundle.drumTab.part_id) and reflects it as the picker's selected value, so
    the dropdown stays honest even when the server resolves an unknown/absent
    selection to the primary.
- static/app.js:
  - changeArrangement() gains an optional `drumPart`; at reconnect it forwards
    the explicit part, else preserves the current picker selection — so an
    ARRANGEMENT switch keeps the chosen drum part (parts are song-level).
  - new changeDrumPart(id) delegates to changeArrangement with the current
    arrangement held + the new part applied (a part switch is the same
    re-stream, so it reuses all the transition ceremony). Exported on window.
- static/v3/index.html: the #drum-part-select row (hidden by default).

No plugin change: the drum renderers just draw whatever drum_tab streams.

RUNTIME-VERIFIED (Playwright, the core player, a 2-drum pack + a no-drum pack):
10/10 — the picker populates with both parts and shows for the multi-drum song;
song_info.drum_parts reaches getSongInfo(); the primary is pre-selected;
selecting the 2nd part drives highway.reconnect with the id and the WS URL
carries `?drum_part=drums-2`; the picker then reflects the server's part_id
echo; a no-drum song hides the row; no page errors. ESLint 0 errors (the two
max-lines warnings are pre-existing on these files). No pytest touched (JS-only).
Stacked on #1020.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017xGPjDBF8NTwTK7VQvizix
Signed-off-by: ChrisBeWithYou <christian.a.cowan@gmail.com>
This commit is contained in:
ChrisBeWithYou 2026-07-20 17:13:31 -05:00
parent e0f1e2b641
commit 9a2e98ed74
4 changed files with 90 additions and 4 deletions

View File

@ -8,6 +8,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Added
- **Drum-part picker (feedpak 1.17.0 "drums as arrangements").** When a song
carries several drum charts, a **Drum part** selector appears beside the
arrangement switcher (advanced settings) so a player can choose which drummer
to play. Selecting one re-streams that part's tab over the highway WS
(`?drum_part=<id>`, mirroring the arrangement switch); the choice persists
across an arrangement change, and the picker reflects the server's
authoritative part (unknown/absent selection falls back to the primary). The
row hides for single-drum and non-drum songs, so nothing changes there. Builds
on the loader below; no plugin change needed — the drum renderer just draws
whatever tab streams.
- **Multiple drum parts (feedpak 1.17.0 "drums as arrangements").** The sloppak
loader now reads `type: drums` arrangement entries carrying per-arrangement
`drum_tab` file pointers — a song can ship several drum charts (a second

View File

@ -1150,7 +1150,7 @@ window.feedBack.on('song:ready', () => {
let _arrBusyGen = 0;
let _arrBusyTimeout = null;
async function changeArrangement(index) {
async function changeArrangement(index, drumPart) {
if (currentFilename) {
// Tear down any pending fresh-load credits before switching: the
// no-count-in hold timer would otherwise fire togglePlay() against the
@ -1276,11 +1276,38 @@ async function changeArrangement(index) {
_resetSectionPracticeLog();
invalidateParentCount();
window.highway.reconnect(currentFilename, index);
// Carry the selected drum part across the re-stream. An explicit
// `drumPart` (a drum-part switch, from changeDrumPart) wins; otherwise
// preserve the current picker selection so an ARRANGEMENT switch keeps
// the chosen part (drum parts are song-level, not per-arrangement).
const part = drumPart !== undefined
? drumPart
: (document.getElementById('drum-part-select')?.value || '');
window.highway.reconnect(currentFilename, index, part);
window.feedBack.emit('arrangement:changed', { index, filename: currentFilename });
}
}
// Switch which drum part plays (feedpak 1.17.0 "drums as arrangements"). A part
// switch re-streams the same song with a different drum tab — the same
// transition as an arrangement switch — so it delegates to changeArrangement
// with the CURRENT arrangement held and the new part applied. Wired to
// #drum-part-select's onchange; the select is populated + shown by
// highway.js's song_info handler only when the song has 2+ drum parts.
async function changeDrumPart(partId) {
if (!currentFilename) return;
let index = 0;
const si = window.highway && typeof window.highway.getSongInfo === 'function'
? window.highway.getSongInfo() : null;
if (si && typeof si.arrangement_index === 'number' && si.arrangement_index >= 0) {
index = si.arrangement_index;
} else {
const arrSel = document.getElementById('arr-select');
if (arrSel && arrSel.value !== '') index = Number(arrSel.value) || 0;
}
return changeArrangement(index, partId);
}
// Restart the current song from the beginning (or from loop A when an AB
// loop is armed). Uses the canonical _audioSeek funnel only — never touches
// audio.currentTime directly and never reloads via playSong().
@ -2325,7 +2352,7 @@ configureHost({
Object.assign(window, {
_confirmDialog, _getArrangementNamingMode, _libraryLocalFilename, _librarySongArtUrl,
_librarySongId, _onHeaderClick, _onNamingModeChange, _trapFocusInModal,
changeArrangement, checkPluginUpdates, clearLibFilters, clearLoop,
changeArrangement, changeDrumPart, checkPluginUpdates, clearLibFilters, clearLoop,
deleteSelectedLoop, esc, exportDiagnostics, exportSettings, filterFavorites,
filterLibrary, fullRescanLibrary, goFavPage, handleSliderInput,
hideScanBanner, importSettings, loadPlugins, loadSavedLoop,

View File

@ -2298,6 +2298,31 @@ function createHighway() {
sel.appendChild(opt);
}
}
// Drum-part picker (feedpak 1.17.0 "drums as
// arrangements"): a song can carry several drum
// charts. Populate the picker beside the
// arrangement switcher; show it only when there
// are 2+ parts to choose between. `drum_parts`
// is always present (empty for non-drum songs),
// so a single-drum / no-drum song hides it. The
// currently-streaming part is marked selected by
// the `drum_tab` handler below (authoritative
// `part_id`), so we don't guess here.
{
const dpSel = document.getElementById('drum-part-select');
if (dpSel) {
const parts = Array.isArray(msg.drum_parts) ? msg.drum_parts : [];
dpSel.textContent = '';
for (const p of parts) {
const opt = document.createElement('option');
opt.value = p.id;
opt.textContent = p.name || p.id;
dpSel.appendChild(opt);
}
const dpRow = document.getElementById('v3-drum-part-row');
if (dpRow) dpRow.classList.toggle('hidden', parts.length <= 1);
}
}
}
// Plugin context API — broadcast current song state
if (window.feedBack) {
@ -2380,7 +2405,22 @@ function createHighway() {
name: (typeof msg.name === 'string' && msg.name) ? msg.name : 'Drums',
kit: Array.isArray(msg.kit) ? msg.kit : [],
hits: [],
// Which drum part this stream carries (feedpak
// 1.17.0). Present only for multi-part packs;
// null otherwise. Plugins can read it via
// bundle.drumTab.part_id.
part_id: (typeof msg.part_id === 'string' && msg.part_id) ? msg.part_id : null,
};
// Reflect the authoritative streaming part in the
// picker (the server resolves an unknown/absent
// selection to the primary, so this keeps the
// dropdown honest even after a fallback).
if (hwState.drumTab.part_id) {
const dpSel = document.getElementById('drum-part-select');
if (dpSel && dpSel.value !== hwState.drumTab.part_id) {
dpSel.value = hwState.drumTab.part_id;
}
}
break;
case 'drum_hits':
if (hwState.drumTab && Array.isArray(msg.data)) {
@ -2773,7 +2813,7 @@ function createHighway() {
localStorage.setItem('showFingerHints', String(hwState._showFingerHints));
},
reconnect(filename, arrangement) {
reconnect(filename, arrangement, drumPart) {
// Close old WS but keep audio + animation running
if (hwState.ws) { hwState.ws.close(); hwState.ws = null; }
hwState.ready = false;
@ -2799,6 +2839,11 @@ function createHighway() {
_resetChordRenderState();
const wsParams = new URLSearchParams();
if (arrangement !== undefined) wsParams.set('arrangement', arrangement);
// Multiple drum parts (feedpak 1.17.0 "drums as arrangements"):
// carry the selected part id so the WS streams ITS drum tab. Empty
// / undefined → the primary part (server default), i.e. today's
// one-drum behavior for any pack the picker never touched.
if (drumPart) wsParams.set('drum_part', drumPart);
let namingMode = 'smart';
if (typeof window._getArrangementNamingMode === 'function') {
const v = window._getArrangementNamingMode();

View File

@ -1194,6 +1194,10 @@
<button id="arr-default-pin" type="button" onclick="pinCurrentArrangementDefault()" aria-pressed="false" aria-label="Select an arrangement to make it the default" class="v3-pop-pin" title="Select an arrangement to make it the default"></button>
</span>
</div>
<div class="v3-pop-row hidden" id="v3-drum-part-row">
<span class="v3-pop-label">Drum part</span>
<select id="drum-part-select" onchange="changeDrumPart(this.value)" class="v3-pop-select max-w-[130px]" title="Which drum chart to play — a song can carry several"></select>
</div>
<div class="v3-pop-row">
<span class="v3-pop-label" id="mastery-slider-label">Difficulty</span>
<span class="flex items-center gap-2">