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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
`editor_track_session` schema is bumped to v3 (purely additive — v2 trees carry
no regions and need no migration). Rendering, playback, and build are unchanged
and land in later steps.
- **A song can hold several drum parts.** The payoff of the drums-as-arrangement
arc below: with drums already present, **+ Track ▸ Drums** adds *another* drum
part (a second drummer, an aux-percussion layer), and a GP/MIDI drum import is
**added** as a new Drums track instead of replacing the one you have. Each part
is its own **🥁 entry in the part dropdown** — pick one to open *its* grid — with
its own Tracks row, mixer strip, and multi-track playback channel (two parts
hitting the same drum at the same instant both sound). Deleting a part removes
just that part (undoable, exact); renaming follows the part. Saving persists
every part: the first as the song-level drum tab today's game plays (packs stay
fully backward-compatible — older readers simply see that one), the rest as
`type: drums` arrangement entries per feedpak-spec 1.17.0, and they all come
back on reload. Create-mode compose sessions keep the one-part rule for now
(their build path persists a single drum tab).

- **The drums track is now an ordinary mixer / Tracks channel.** Building on the
drums-as-arrangement work below, the drum chart's mixer strip and Tracks mix now
use the same per-arrangement channel address every other part does, instead of a
Expand Down
15 changes: 11 additions & 4 deletions docs/USER-GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,10 +127,10 @@ defaults** puts everything back.
## 3. Play and navigate

- **Space** plays/stops from the playhead.
- **Follow playhead** (`Shift+L`) keeps the view with the playhead during
playback. By default the view jumps ahead a page when the playhead reaches
the edge; turn on **View ▸ Scroll in Play** to pin the playhead and glide the
timeline under it instead (Logic's continuous-scroll manner).
- **Follow playhead** (`Shift+L`) keeps the view with the playhead during
playback. By default the view jumps ahead a page when the playhead reaches
the edge; turn on **View ▸ Scroll in Play** to pin the playhead and glide the
timeline under it instead (Logic's continuous-scroll manner).
- **Loop A/B** (`Alt+B`) compares the recording against your guide so you can
hear whether the chart matches the take.
- **Count-in** adds a bar of clicks before playback so you can catch the entry.
Expand Down Expand Up @@ -376,6 +376,13 @@ piano roll, with each row labeled by its GM note number (the familiar DAW
drum-roll layout). The **drum limb lint** flags hits that would need three
hands — advisory only.

A song can hold **several drum parts** (a second drummer, an aux-percussion
layer): with drums already present, **+ Track ▸ Drums** adds another, and a
GP/MIDI drum import is added as a new Drums track instead of replacing. Each
part is its own 🥁 entry in the part dropdown — pick one to open *its* grid —
with its own Tracks row and mixer channel. All parts save with the song; the
first part is the one the game plays today.

---

## 8. Structure — sections, phrases, anchors, handshapes, tones
Expand Down
282 changes: 280 additions & 2 deletions routes.py

Large diffs are not rendered by default.

77 changes: 56 additions & 21 deletions src/arrangement.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { _editorEscHtml, _editorPromptText, setStatus } from './ui.js';
import { flattenChords } from './chords.js';
import { KEYS_PATTERN } from './keys.js';
import { _arrTypeKind, _typeKind } from './instrument.js';
import { clampAwayFromDrums, isDrumArrangement, pitchedArrangementCount, pitchedIndexOf, syncDrumArrangement } from './drum-arrangement.js';
import { addDrumArrangement, clampAwayFromDrums, findDrumArrangement, isDrumArrangement, pitchedArrangementCount, pitchedIndexOf, syncDrumArrangement } from './drum-arrangement.js';
import { _recState } from './midi-record.js';
import { _maybeOfferMidiTempoMap, _showDrumImportUnmappedModal } from './import.js';
import { host } from './host.js';
Expand Down Expand Up @@ -301,14 +301,31 @@ export function editorShowAddDrumsModal() {
document.getElementById('editor-add-drums-status').textContent = '';
const fileInput = document.getElementById('editor-add-drums-gp');
if (fileInput) fileInput.value = '';
// Show the "will replace" notice only when a drum_tab already lives on
// the sloppak so the user knows what's about to happen.
// Notice when a drum tab already exists: in a saved sloppak session the
// import ADDS another Drums track (multiple drum parts); in a create-mode
// session (which can only persist one part) it still REPLACES.
const existingEl = document.getElementById('editor-add-drums-existing');
if (existingEl) {
existingEl.classList.toggle('hidden', !S.drumTab);
if (S.drumTab) {
existingEl.textContent = _canAddAnotherDrums()
? 'This song already has drums — this import will be added as another Drums track.'
: 'This song already has a drum track — importing will replace it.';
}
}
}

// Can a SECOND (third, …) drum part be added — i.e. does a drum import ADD
// another part (vs replace the existing tab)? True only when the primary part
// is materialized as a type:"drums" arrangement in a non-create sloppak
// session — the create flow's build (create_sloppak) persists a single
// drum_tab, so create mode keeps the legacy one-part replace semantics.
// (Exported for new-track.js's plan gate + the tests.)
export function _canAddAnotherDrums() {
return !!(S.drumTab && !S.createMode && S.format === 'sloppak'
&& findDrumArrangement(S.arrangements));
}

export function editorHideAddDrumsModal() {
document.getElementById('editor-add-drums-modal').classList.add('hidden');
}
Expand All @@ -317,23 +334,33 @@ export function editorHideAddDrumsModal() {
// tab is pure client state until save (S.drumTab + S.drumTabDirty — the
// same stash the GP/MIDI import path uses), and the create flow's
// init_drums seeds the identical empty shape, so no backend call is
// needed. Refuses when a drum tab already exists: replacing goes through
// the import modal, which warns.
// needed. When drums already exist, ADDS another drum part (a song can
// hold several) — except in create mode, whose build persists one part.
export function editorAddEmptyDrums() {
if (!S.sessionId || S.format !== 'sloppak') return false;
if (S.drumTab) {
setStatus('This song already has a Drums track — open it with 🥁 Edit Drums, or import to replace it.');
if (S.drumTab && !_canAddAnotherDrums()) {
// Create-mode (or an unmaterialized legacy tab): still one part max.
setStatus('This song already has a Drums track — open it with 🥁 Edit Drums. Save the song to add more drum parts.');
return false;
}
S.drumTab = { version: 1, name: 'Drums', kit: [], hits: [] };
const tab = { version: 1, name: 'Drums', kit: [], hits: [] };
if (S.drumTab) {
// A second (third, …) part: append its own type:"drums" arrangement
// and make the fresh part the active grid target.
addDrumArrangement(S, tab);
}
S.drumTab = tab;
S.drumTabDirty = true;
S.drumSel = new Set();
syncDrumArrangement(S); // materialize the type:"drums" arrangement
// Materialize beside a pitched part only — a drums-only session must not
// put a drums arrangement at index 0, where the default currentArr would
// land on it (the tab stays a legacy off-array singleton there).
if (pitchedArrangementCount(S.arrangements) > 0) syncDrumArrangement(S);
markSessionDirty();
host.updateArrangementSelector();
host.updateStatus();
host.draw();
setStatus('Added empty Drums track — 🥁 Edit Drums to add hits; save to commit.');
setStatus(`Added empty ${tab.name} track — 🥁 Edit Drums to add hits; save to commit.`);
return true;
}

Expand Down Expand Up @@ -451,28 +478,36 @@ export async function editorDoAddDrums() {
return;
}

// Stash on session state; the next save_song ships it as
// `drum_tab` and the backend writes drum_tab.json + manifest key.
// Normalize hits: ensure sorted by t so drum-editor hit-testing and
// dragging work correctly, and clear any stale selection so indices
// from the old tab don't point into the new hits array.
S.drumTab = data.drum_tab;
if (S.drumTab && Array.isArray(S.drumTab.hits)) {
S.drumTab.hits.sort((a, b) => (a.t || 0) - (b.t || 0));
// Stash on session state; the next save_song ships it (primary as
// `drum_tab`, extra parts as `drum_parts`) and the backend writes the
// drum tab JSONs + manifest keys. Normalize hits: ensure sorted by t
// so drum-editor hit-testing and dragging work correctly, and clear
// any stale selection so indices from the old tab don't point into
// the new hits array. When drums already exist (saved sloppak), the
// import ADDS another drum part; create mode still replaces.
const tab = data.drum_tab;
if (tab && Array.isArray(tab.hits)) {
tab.hits.sort((a, b) => (a.t || 0) - (b.t || 0));
}
const added = _canAddAnotherDrums();
if (added) addDrumArrangement(S, tab); // its own type:"drums" arrangement
S.drumTab = tab; // the imported part is now the grid target
S.drumTabDirty = true; // user-imported — persist on next save
S.drumSel = new Set();
syncDrumArrangement(S); // reflect the imported tab in S.arrangements[]
// Reflect the imported tab in S.arrangements[] — beside a pitched
// part only (a drums-only session must not put drums at index 0).
if (pitchedArrangementCount(S.arrangements) > 0) syncDrumArrangement(S);

editorHideAddDrumsModal();
const hitCount = Array.isArray(data.drum_tab.hits)
? data.drum_tab.hits.length : 0;
const unmapped = Array.isArray(data.unmapped) ? data.unmapped : [];
const droppedCount = unmapped.reduce((s, u) => s + Math.max(0, Number(u.count) || 0), 0);
const what = added ? `Added drum track “${tab.name}”` : 'Drum tab imported';
if (droppedCount > 0) {
setStatus(`Drum tab imported (${hitCount} hits, ${droppedCount} unmapped — see dialog) — save to persist`);
setStatus(`${what} (${hitCount} hits, ${droppedCount} unmapped — see dialog) — save to persist`);
} else {
setStatus(`Drum tab imported (${hitCount} hits) — save to persist`);
setStatus(`${what} (${hitCount} hits) — save to persist`);
}
// Refresh the toolbar drum button (text/colour) and canvas so the
// user immediately sees the "⟳ Drums (N)" state without waiting for
Expand Down
56 changes: 33 additions & 23 deletions src/audio.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ import { host } from './host.js';
import { _pickOnsetsPure, _spectralFluxOnsetsPlan, _spectralFluxStep } from './onsets.js';
import { _tourNoteAction } from './tour.js';
import { _rollMidiForNote, _rollPitchCtx, _rollPitchCtxFor, midiToFreq } from './keys.js';
import { drumArrangementIndex, isDrumArrangement } from './drum-arrangement.js';
import { isDrumArrangement } from './drum-arrangement.js';
import { arrKind } from './instrument.js';
import { _recState } from './midi-record.js';
import { notes } from './notes.js';
Expand Down Expand Up @@ -1707,20 +1707,24 @@ function _guidePitchedEvents() {
// the strips and the engine can never disagree about who is who.
function _bandPartsPure(arrangements, drumTab) {
const out = [];
let anyDrumArr = false;
(arrangements || []).forEach((a, i) => {
// The drums arrangement is appended below with the drum tab as its
// payload (its own notes are empty) — skip the plain arr pass so it
// isn't added twice.
if (a && a.type === 'drums') return;
if (a) out.push({ key: 'arr:' + i, idx: i, name: a.name || ('Track ' + (i + 1)) });
if (!a) return;
if (a.type === 'drums') {
// A drum PART (a song can hold several): each plays ITS OWN tab's
// kit through its own `arr:<idx>` channel — the SAME key its mixer
// strip uses. An empty part (no hits yet) schedules nothing.
anyDrumArr = true;
if (!(a.drumTab && Array.isArray(a.drumTab.hits) && a.drumTab.hits.length)) return;
out.push({ key: 'arr:' + i, idx: i, name: a.name || 'Drums' });
return;
}
out.push({ key: 'arr:' + i, idx: i, name: a.name || ('Track ' + (i + 1)) });
});
if (drumTab && Array.isArray(drumTab.hits) && drumTab.hits.length) {
// The drum part rides its arrangement's own `arr:<idx>` channel now
// (PR2b) — the SAME key the mixer strip uses, so the strip and the
// engine agree. `idx` points at the drums arrangement so the scheduler
// resolves it; fall back to the legacy key if it isn't materialized.
const di = drumArrangementIndex(arrangements);
out.push({ key: di >= 0 ? 'arr:' + di : 'drums', idx: di, name: 'Drums' });
if (!anyDrumArr && drumTab && Array.isArray(drumTab.hits) && drumTab.hits.length) {
// Legacy unmaterialized tab (create-mode compose): the old singleton
// band entry, keyed by the legacy 'drums' strip key.
out.push({ key: 'drums', idx: -1, name: 'Drums' });
}
return out;
}
Expand Down Expand Up @@ -2176,21 +2180,24 @@ function _bandPartPitchedEvents(idx) {
})));
}

// Voice the drum-tab hits in [from, to) as the GM KIT through `target`
// Voice a drum tab's hits in [from, to) as the GM KIT through `target`
// (null = the guide bus): each piece plays its one-shot (kick, snare, hats,
// toms, cymbals — DRUM_PIECE_GM_NOTE), lazily loaded on first sight; a hit
// whose sound isn't ready yet ticks instead (the never-silent rule). The
// dedupe key is piece-scoped: kick + snare on the same millisecond BOTH
// sound. Used by band mode (per-part gain target) and the drum-edit guide.
function _drumKitVoicesInWindow(from, to, target, scale) {
const hits = (S.drumTab && Array.isArray(S.drumTab.hits)) ? S.drumTab.hits : [];
// dedupe key is piece-scoped AND part-scoped (`keyPrefix`): kick + snare on
// the same millisecond BOTH sound, and TWO drum parts hitting the same piece
// on the same millisecond both sound too. Used by band mode (per-part gain
// target + each part's OWN tab) and the drum-edit guide (defaults: the
// active tab, the legacy 'drums' prefix).
function _drumKitVoicesInWindow(from, to, target, scale, tab = S.drumTab, keyPrefix = 'drums') {
const hits = (tab && Array.isArray(tab.hits)) ? tab.hits : [];
if (!hits.length) return;
const bus = _ensureMasterBus();
const tgt = target || (bus && bus.guideGain);
if (!tgt) return;
for (const h of hits) {
if (!h || !Number.isFinite(h.t) || h.t < from || h.t >= to) continue;
const key = _bandFiredKeyPure('drums:' + (h.p || ''), h.t);
const key = _bandFiredKeyPure(keyPrefix + ':' + (h.p || ''), h.t);
if (_bandFiredKeys.has(key)) continue;
_bandFiredKeys.add(key);
const note = DRUM_PIECE_GM_NOTE[h.p];
Expand Down Expand Up @@ -2343,12 +2350,15 @@ function _guideTick() {
if (!target) continue;
const arr = part.idx >= 0 ? S.arrangements[part.idx] : null;
// The drum-grid arrangement (type:"drums") voices real GM percussion
// from the drum tab through this part's gain (review #282). Its own
// from ITS OWN drum tab through this part's gain (review #282) —
// with several drum parts, each voices its own hits, part-scoped
// dedupe so two parts hitting the same piece both sound. Its own
// notes are empty, so it must be caught BEFORE the clap-notes path
// below. `part.key === 'drums'` is the defensive fallback for an
// un-materialized tab (di < 0 in the roster).
// below. `part.key === 'drums'` is the legacy fallback for an
// un-materialized tab (create-mode compose; idx -1 in the roster).
if (part.key === 'drums' || (arr && isDrumArrangement(arr))) {
_drumKitVoicesInWindow(from, to, target, 1);
_drumKitVoicesInWindow(from, to, target, 1,
(arr && arr.drumTab) || S.drumTab, part.key);
continue;
}
// A drum-ENCODED pitched part (a legacy "Drums"-named arrangement with
Expand Down
12 changes: 8 additions & 4 deletions src/create.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
_updateTonesButtonVisibility,
} from './annotation-lanes.js';
import { _handshapesAreDirty, flattenChords, reconstructChords } from './chords.js';
import { isDrumArrangement, syncDrumArrangement } from './drum-arrangement.js';
import { isDrumArrangement, pitchedArrangementCount, syncDrumArrangement } from './drum-arrangement.js';
import { EditHistory } from './history.js';
import { host } from './host.js';
import { isKeysMode, updatePianoRange } from './keys.js';
Expand Down Expand Up @@ -1675,7 +1675,7 @@
// Left in place rather than deleted, because deleting them is a separate change
// from the bug fix that made them redundant. They arrived with the same
// half-wired Create-New redesign (977ec65, #45).
function _populateCreateArrButtons() {

Check warning on line 1678 in src/create.js

View workflow job for this annotation

GitHub Actions / lint

'_populateCreateArrButtons' is defined but never used
const wrap = document.getElementById('editor-create-arr-buttons');
if (!wrap) return;
wrap.replaceChildren();
Expand Down Expand Up @@ -1876,7 +1876,7 @@
createState.lastSync = { ...createState.lastSync, ...data };
}
return data;
} catch (e) {

Check warning on line 1879 in src/create.js

View workflow job for this annotation

GitHub Actions / lint

'e' is defined but never used. Allowed unused caught errors must match /^_/u
return null;
}
}
Expand Down Expand Up @@ -2416,9 +2416,13 @@
S.drumTabDirty = !!S.drumTab;
S.drumEditMode = false;
S.drumSel = new Set();
// Materialize the type:"drums" arrangement now (like loadCDLC / +Drums), so a
// freshly imported drum song has it immediately — not only after build+reopen.
syncDrumArrangement(S);
// Materialize the drums as a type:"drums" arrangement here too (the load
// path has done so since the drums-as-arrangements foundation) so the
// switcher's 🥁 option and the drums mixer strip exist in create-mode
// sessions as well — but ONLY beside a pitched part: a drums-only import
// must not put a drums arrangement at index 0, where the default
// currentArr would land on it (it stays a legacy off-array tab instead).
if (pitchedArrangementCount(S.arrangements) > 0) syncDrumArrangement(S);
// The DAW track-session feature is stacked independently. Its host hook is
// inert on this branch, but when present it receives every create-time
// source immediately so stems do not appear only after a save/reopen.
Expand Down
Loading
Loading