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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,16 @@ 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.
- **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
one-off "drums" slot. Mute / solo / volume on the drums strip behave exactly like
a pitched track's — including in multi-track ("play all") playback, where the drum
kit now follows its own strip — and the drum grid's guide claps follow that strip
too. With a single drum chart you won't see a difference (its durable track
identity is unchanged, so delete/undo, rename, and pairing all work as before);
this is the wiring that lets *several* drum charts each get their own strip.

- **Drums are now a selectable part in the arrangement switcher.** Pick
**"🥁 Drums"** from the part dropdown to open the drum editor — exactly like
switching to Lead, Rhythm, or Bass. The drum chart is no longer a mode tucked
Expand Down
43 changes: 27 additions & 16 deletions src/audio.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +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 { arrKind } from './instrument.js';
import { _recState } from './midi-record.js';
import { notes } from './notes.js';
Expand Down Expand Up @@ -1702,19 +1703,24 @@ function _guidePitchedEvents() {
// identical. Drum parts clap (GM percussion is a follow-up).
//
// The band roster: one entry per mixable part, in strip order — the SAME
// keys the mixer panel uses ('arr:<idx>' / 'drums'), so the strips and the
// engine can never disagree about who is who.
// `arr:<idx>` keys the mixer panel uses (the drums arrangement included), so
// the strips and the engine can never disagree about who is who.
function _bandPartsPure(arrangements, drumTab) {
const out = [];
(arrangements || []).forEach((a, i) => {
// The drums arrangement plays through the `'drums'` band key (from
// drumTab) below, not as an `arr:<idx>` part — skip it so it doesn't add
// a phantom (empty-note) band entry.
// 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 (drumTab && Array.isArray(drumTab.hits) && drumTab.hits.length) {
out.push({ key: 'drums', idx: -1, name: 'Drums' });
// 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' });
}
return out;
}
Expand Down Expand Up @@ -1966,7 +1972,8 @@ export function _stemCatchupPure(playStartTime, playStartWall, currentTime, rate
// playing node, drop its gain node, and — the one that bites — delete its
// 'audio:<id>' entry from S.partMix. That entry is counted by the whole-map
// solo rule, so a stale SOLO left behind by a removed stem would silence every
// live track. Mirrors the drum-delete path (delete S.partMix.drums).
// live track. Same hazard the arrangement-delete path guards against by
// renumbering the arr:<idx> keys (see _partMixDropArrangementPure).
export function _pruneStaleStems(liveIds) {
for (const id of [...playingStemSources.keys()]) {
if (liveIds.has(id)) continue;
Expand Down Expand Up @@ -2335,10 +2342,19 @@ function _guideTick() {
const target = _ensurePartGain(part.key);
if (!target) continue;
const arr = part.idx >= 0 ? S.arrangements[part.idx] : null;
// A drum-ENCODED arrangement (created/imported/legacy "Drums" part —
// no pitch, so _bandPartPitchedEvents returns []) claps its rhythm
// through this part's gain, else it voices neither GM nor clap and
// goes silent (review #280 follow-up; GM percussion here is a follow-up).
// The drum-grid arrangement (type:"drums") voices real GM percussion
// from the drum tab through this part's gain (review #282). 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).
if (part.key === 'drums' || (arr && isDrumArrangement(arr))) {
_drumKitVoicesInWindow(from, to, target, 1);
continue;
}
// A drum-ENCODED pitched part (a legacy "Drums"-named arrangement with
// real notes, not type:"drums") claps its rhythm through this part's
// gain, else it voices neither GM nor clap and goes silent (review
// #280 follow-up; GM percussion here is a follow-up).
if (arr && arrKind(arr) === 'drums') {
const times = _guideSanitizeTimesPure((arr.notes || []).map(n => n.time));
for (const t of _guideClapTimesInWindowPure(times, from, to)) {
Expand All @@ -2349,11 +2365,6 @@ function _guideTick() {
}
continue;
}
// The drum-grid sidecar plays real GM percussion (review #282).
if (part.key === 'drums') {
_drumKitVoicesInWindow(from, to, target, 1);
continue;
}
const gm = editorGmVoiceFor(_gmKindPure(arrKind(arr)));
const ready = gm !== null && gmPresetReady(gm);
if (gm !== null && !ready) ensureGmPreset(gm, S.audioCtx); // clap while it loads
Expand Down
4 changes: 2 additions & 2 deletions src/host.js
Original file line number Diff line number Diff line change
Expand Up @@ -211,8 +211,8 @@ export const host = {
/** Mixer strip keys in Tracks-column row order (mixer follows a reorder). */
mixerTrackOrder: () => [],
/**
* Per-part strip state BY KEY ('arr:<idx>' / 'drums') for band-mode
* MIDI playback: {audible, vol 0..1} with the whole-map solo rule.
* Per-part strip state BY KEY ('arr:<idx>', the drums arrangement included)
* for band-mode MIDI playback: {audible, vol 0..1} with the whole-map solo rule.
* Owned by src/mixer-panel.js; inert default = every part at unity.
*/
partStripState: () => ({ audible: true, vol: 1 }),
Expand Down
39 changes: 22 additions & 17 deletions src/mixer-panel.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
// (recording / guide / click) and the edit blip.
//
// This module owns the CANONICAL per-part mix state, `S.partMix` — a map from
// part key ('arr:<idx>' for arrangements, 'drums' for the drum tab) to
// part key ('arr:<idx>' for arrangements, the drums arrangement included) to
// { vol, mute, solo }. Today the only per-part sound is the guide voice (claps
// follow the active editing surface), so mute/solo/volume gate and scale the
// guide claps for the part being edited; the Parts-gutter M/S/A (§2.5) and
Expand All @@ -25,15 +25,17 @@
// Part mute/solo/volume is SESSION state — it resets with the loaded song
// (create.js / file-ops.js clear `S.partMix` when they install arrangements).
// ════════════════════════════════════════════════════════════════════
import { drumArrangementIndex } from './drum-arrangement.js';
import { host } from './host.js';
import { S, editGen } from './state.js';
import { _editorEscHtml, setStatus } from './ui.js';
import { isDrumArrangement } from './drum-arrangement.js';

/* @pure:mixer-panel:start */
// One strip per part: every arrangement, plus the drum tab as its own strip
// (drums are a song-level sidecar, not an arrangement) — the same list shape
// as the Parts view, keyed the way S.currentArr addresses parts (by index).
// One strip per part, keyed the way S.currentArr addresses parts (by index).
// The drums arrangement is an ordinary `type:"drums"` entry in `arrangements`
// now (its strip is `arr:<idx>` like any other part — no `'drums'` singleton),
// so a single pass over the arrangements covers it. `drumTab` is unused here;
// it stays in the signature so callers match the sibling roster builders.
export function _mixerPartsPure(arrangements, drumTab, stems, removedSourceIds, master) {
const parts = [];
// The master mix leads the audio band as its own channel strip (keyed
Expand All @@ -56,17 +58,11 @@ export function _mixerPartsPure(arrangements, drumTab, stems, removedSourceIds,
parts.push({ key: 'audio:' + id, name: stem.name || id, kind: 'audio' });
}
(arrangements || []).forEach((arr, i) => {
// The drums arrangement gets its OWN 'drums' strip below (from drumTab),
// not an 'arr:<idx>' strip — skip it here so drums don't show twice.
if (isDrumArrangement(arr)) return;
parts.push({
key: 'arr:' + i,
name: (arr && arr.name) || 'Track ' + (i + 1),
});
});
if (drumTab && Array.isArray(drumTab.hits) && drumTab.hits.length) {
parts.push({ key: 'drums', name: 'Drums' });
}
return parts;
}
// Fader positions run 0..110: 0..100 is linear to unity, 101..110 adds
Expand Down Expand Up @@ -140,11 +136,19 @@ export function _mixerPartAudiblePure(partMix, key) {
if (key === 'audio:master') return true;
return _mixerAnySoloPure(partMix) ? st.solo : true;
}
// The mix key of the ACTIVE editing surface: the drums arrangement's channel
// while the drum grid is open (`arr:<drumIdx>` — currentArr itself stays on a
// pitched arrangement, #337), else the current pitched arrangement.
export function _mixerActivePartKeyPure(drumEditMode, currentArr, drumIdx) {
return (drumEditMode && Number(drumIdx) >= 0)
? 'arr:' + drumIdx
: 'arr:' + (Number(currentArr) || 0);
}
// What the guide-clap scheduler needs for the ACTIVE editing surface: claps
// follow the drum grid in drum mode, the current arrangement otherwise, so
// that surface's part decides whether (and how loud) the claps sound.
export function _mixerClapStatePure(partMix, drumEditMode, currentArr) {
const key = drumEditMode ? 'drums' : 'arr:' + (Number(currentArr) || 0);
export function _mixerClapStatePure(partMix, drumEditMode, currentArr, drumIdx) {
const key = _mixerActivePartKeyPure(drumEditMode, currentArr, drumIdx);
return {
audible: _mixerPartAudiblePure(partMix, key),
vol: _mixerGainForFaderPure(_mixerPartStatePure(partMix, key).vol),
Expand All @@ -158,7 +162,7 @@ export function _mixerOpenFromStoredPure(raw) {

// The host-hook target audio.js consults per scheduled clap voice.
export function _mixerClapState() {
return _mixerClapStatePure(S.partMix, S.drumEditMode, S.currentArr);
return _mixerClapStatePure(S.partMix, S.drumEditMode, S.currentArr, drumArrangementIndex(S.arrangements));
}

// Band mode's per-KEY twin (host.partStripState): {audible, vol 0..1} for
Expand Down Expand Up @@ -199,7 +203,8 @@ function _selectedStripKeyPure() {
if (!selected) return '';
if (selected.type === 'audio') return 'audio:' + selected.sourceId;
if (selected.type === 'transcription') {
if (selected.targetId === 'drums') return 'drums';
// The drums arrangement resolves through the same id→index path as any
// other part (its id is 'drums', so targetId 'drums' → its arr:<idx>).
const idx = (S.arrangements || [])
.findIndex((arr, i) => String((arr && arr.id) || ('arr:' + i)) === selected.targetId);
return idx >= 0 ? 'arr:' + idx : '';
Expand Down Expand Up @@ -263,7 +268,7 @@ export function _mixerMeterPeakPure(key, levels, activeAudioId, activePart) {
}

function _meterPeakForKey(key, levels) {
const activePart = S.drumEditMode ? 'drums' : 'arr:' + (Number(S.currentArr) || 0);
const activePart = _mixerActivePartKeyPure(S.drumEditMode, S.currentArr, drumArrangementIndex(S.arrangements));
return _mixerMeterPeakPure(key, levels, S.activeAudioSourceId, activePart);
}

Expand All @@ -278,7 +283,7 @@ export function _mixerMeterInputPure(key, levels, activeAudioId, activePart, pla
}

function _meterInputForKey(key, levels) {
const activePart = S.drumEditMode ? 'drums' : 'arr:' + (Number(S.currentArr) || 0);
const activePart = _mixerActivePartKeyPure(S.drumEditMode, S.currentArr, drumArrangementIndex(S.arrangements));
return _mixerMeterInputPure(key, levels, S.activeAudioSourceId, activePart,
host.playAllTracksEnabled());
}
Expand Down
5 changes: 3 additions & 2 deletions src/state.js
Original file line number Diff line number Diff line change
Expand Up @@ -59,8 +59,9 @@ export const S = {
partsViewMode: false,
drumSel: new Set(),

// Per-part mix state (mixer panel, B6) — 'arr:<idx>' / 'drums' →
// { vol, mute, solo }. Session-scoped UI state (never the pack): the
// Per-part mix state (mixer panel, B6) — 'arr:<idx>' (the drums
// arrangement included) → { vol, mute, solo }. Session-scoped UI state
// (never the pack): the
// canonical source for part mute/solo/volume that the mixer strips,
// the guide-clap gate (via host.partClapState) and the future
// Parts-gutter M/S/A all read. Reset when a song is installed.
Expand Down
Loading
Loading