Skip to content

Commit ca380e0

Browse files
committed
Add aligned multi-instrument grid editor foundation for MIDI Studio V2 - PR_26146_011-midi-studio-v2-multi-instrument-grid
1 parent 3193311 commit ca380e0

8 files changed

Lines changed: 647 additions & 0 deletions

File tree

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
# PR_26146_011-midi-studio-v2-multi-instrument-grid Validation
2+
3+
## Summary
4+
- Added a shared `InstrumentGridParser` under `src/engine/audio/` for section/bar/beat/subdivision parsing.
5+
- Added an advanced MIDI Studio V2 multi-instrument grid editor with aligned lanes for chords, bass, pad, lead, and drums.
6+
- Kept guided Song Sheet fields as the primary/default workflow.
7+
- Grid normalization emits reusable section, cell, and timeline event structures without adding live synthesis, piano-roll editing, DAW sequencing, MIDI input, or fallback notes.
8+
9+
## Lanes Executed
10+
- Engine/shared audio parser: `InstrumentGridParser` syntax and targeted runtime exercise through MIDI Studio V2 Playwright.
11+
- Runtime/tool: MIDI Studio V2 advanced grid input, aligned rendering, validation logging, Song Sheet coexistence, MIDI inspection preservation, and rendered export header preservation.
12+
- Contract/static: external-only HTML guard and `git diff --check`.
13+
14+
## Lanes Skipped
15+
- Workspace Manager V2 registration/handoff: SKIP because this PR did not touch Workspace Manager files.
16+
- Samples: SKIP because sample JSON alignment is out of scope.
17+
- Full samples smoke: SKIP per BUILD instruction.
18+
19+
## Validation Commands
20+
- PASS: `node --check src/engine/audio/InstrumentGridParser.js`
21+
- PASS: `node --check tools/midi-studio-v2/js/MidiStudioV2App.js`
22+
- PASS: `node --check tools/midi-studio-v2/js/bootstrap.js`
23+
- PASS: `node --check tools/midi-studio-v2/js/controls/InstrumentGridControl.js`
24+
- PASS: `node --check tests/playwright/tools/MidiStudioV2.spec.mjs`
25+
- PASS: `Select-String -Path tools/midi-studio-v2/index.html -Pattern '<script(?![^>]*src=)|<style|\son[a-z]+\s*='`
26+
- PASS: `npx.cmd playwright test tests/playwright/tools/MidiStudioV2.spec.mjs --project=playwright --workers=1 --reporter=line`
27+
- PASS: `git diff --check`
28+
29+
## Playwright Coverage
30+
- PASS: 21 targeted MIDI Studio V2 tests.
31+
- Covered aligned grid rendering with section headers, bars, beats, and lane rows.
32+
- Covered beat/bar alignment consistency across chords, bass, pad, lead, and drums.
33+
- Covered note/chord/drum entry per lane and normalized timeline structures from the shared parser.
34+
- Covered invalid note failure without partial grid render.
35+
- Covered unsupported subdivision failure.
36+
- Covered malformed lane bar-count failure.
37+
- Covered invalid drum token warning with skipped invalid drum event.
38+
- Covered coexistence with guided Song Sheet parsing.
39+
- Covered existing MIDI source inspection, rendered preview, rendered export header actions, accordion/header behavior, and invalid payload rejection.
40+
41+
## Notes
42+
- One early Playwright assertion used computed `grid-column-end`; Chromium returned `auto` for the shorthand. The test was corrected to assert the applied section span from the grid cell style before the final passing run.
43+
- Browser availability was already satisfied through `PLAYWRIGHT_BROWSERS_PATH=$env:TEMP\ms-playwright`; no Chromium install was required.
Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,221 @@
1+
const CHORD_PATTERN = /^[A-G](?:#|b)?(?:m|min|maj|dim|aug|sus2|sus4|7|maj7|m7|min7|dim7|add9)?$/;
2+
const DRUM_TOKENS = new Set(["kick", "snare", "hat", "ride", "clap", "tom", "crash", "perc"]);
3+
const LANE_NAMES = ["chords", "bass", "pad", "lead", "drums"];
4+
const NOTE_PATTERN = /^[A-G](?:#|b)?[0-8]$/;
5+
const REST_TOKENS = new Set(["", "-", ".", "rest"]);
6+
const SUPPORTED_SUBDIVISIONS = new Set([1, 2, 4]);
7+
8+
export class InstrumentGridParser {
9+
parse(input = {}) {
10+
const sections = this.parseSections(input.sections);
11+
if (!sections.ok) {
12+
return sections;
13+
}
14+
const beatsPerBar = this.parsePositiveInteger(input.beatsPerBar, "beats per bar");
15+
if (!beatsPerBar.ok) {
16+
return beatsPerBar;
17+
}
18+
const subdivision = this.parsePositiveInteger(input.subdivision, "timing subdivision");
19+
if (!subdivision.ok) {
20+
return subdivision;
21+
}
22+
if (!SUPPORTED_SUBDIVISIONS.has(subdivision.value)) {
23+
return {
24+
message: `Unsupported timing subdivision: ${subdivision.value}. Use 1, 2, or 4.`,
25+
ok: false
26+
};
27+
}
28+
const barCount = sections.value.reduce((total, section) => total + section.bars, 0);
29+
const stepsPerBar = beatsPerBar.value * subdivision.value;
30+
const timeline = [];
31+
const warnings = [];
32+
const laneCells = {};
33+
for (const lane of LANE_NAMES) {
34+
const parsedLane = this.parseLane({
35+
barCount,
36+
beatsPerBar: beatsPerBar.value,
37+
lane,
38+
sections: sections.value,
39+
source: input.lanes?.[lane],
40+
stepsPerBar,
41+
subdivision: subdivision.value
42+
});
43+
if (!parsedLane.ok) {
44+
return parsedLane;
45+
}
46+
laneCells[lane] = parsedLane.cells;
47+
timeline.push(...parsedLane.events);
48+
warnings.push(...parsedLane.warnings);
49+
}
50+
const normalizedSections = sections.value.map((section) => ({
51+
...section,
52+
beatsPerBar: beatsPerBar.value,
53+
subdivision: subdivision.value,
54+
steps: section.bars * stepsPerBar
55+
}));
56+
return {
57+
barCount,
58+
beatCount: barCount * beatsPerBar.value,
59+
beatsPerBar: beatsPerBar.value,
60+
cells: laneCells,
61+
chordCount: timeline.filter((event) => event.kind === "chord").length,
62+
drumCount: timeline.filter((event) => event.kind === "drum").length,
63+
eventCount: timeline.length,
64+
lanes: LANE_NAMES,
65+
noteCount: timeline.filter((event) => event.kind === "note").length,
66+
ok: true,
67+
sectionSummary: normalizedSections.map((section) => `${section.label}: ${section.bars} bar${section.bars === 1 ? "" : "s"}`).join("; "),
68+
sections: normalizedSections,
69+
subdivision: subdivision.value,
70+
timeline,
71+
totalSteps: barCount * stepsPerBar,
72+
warningSummary: warnings.length ? warnings.join("; ") : "none",
73+
warnings
74+
};
75+
}
76+
77+
parseSections(source) {
78+
const text = String(source || "").trim();
79+
if (!text) {
80+
return { message: "Malformed bar counts. Enter sections as label:bars, for example intro:1, loop:2.", ok: false };
81+
}
82+
const sections = [];
83+
let startBar = 1;
84+
for (const part of text.split(",")) {
85+
const match = part.trim().match(/^([A-Za-z][A-Za-z0-9_-]*)\s*:\s*(\d+)$/);
86+
if (!match) {
87+
return { message: `Malformed bar counts in section entry: ${part.trim() || "(empty)"}. Use label:bars.`, ok: false };
88+
}
89+
const bars = Number(match[2]);
90+
if (!Number.isInteger(bars) || bars <= 0) {
91+
return { message: `Malformed bar counts for section ${match[1]}. Bars must be a positive integer.`, ok: false };
92+
}
93+
sections.push({
94+
bars,
95+
label: match[1],
96+
startBar
97+
});
98+
startBar += bars;
99+
}
100+
return { ok: true, value: sections };
101+
}
102+
103+
parsePositiveInteger(value, label) {
104+
const number = Number(value);
105+
if (!Number.isInteger(number) || number <= 0) {
106+
return { message: `Invalid ${label}. Enter a positive whole number.`, ok: false };
107+
}
108+
return { ok: true, value: number };
109+
}
110+
111+
parseLane({ barCount, beatsPerBar, lane, sections, source, stepsPerBar, subdivision }) {
112+
const text = String(source || "").trim();
113+
const bars = text ? text.split("|").map((bar) => bar.trim()) : [];
114+
if (bars.length && bars.length !== barCount) {
115+
return {
116+
message: `Malformed bar counts for ${lane}. Expected ${barCount} bar${barCount === 1 ? "" : "s"} from the section map, received ${bars.length}.`,
117+
ok: false
118+
};
119+
}
120+
const cells = [];
121+
const events = [];
122+
const warnings = [];
123+
for (let barIndex = 0; barIndex < barCount; barIndex += 1) {
124+
const tokens = bars[barIndex] ? bars[barIndex].split(/\s+/).filter(Boolean) : [];
125+
if (tokens.length && tokens.length !== stepsPerBar) {
126+
return {
127+
message: `Malformed bar counts for ${lane} bar ${barIndex + 1}. Expected ${stepsPerBar} beat slot${stepsPerBar === 1 ? "" : "s"}, received ${tokens.length}.`,
128+
ok: false
129+
};
130+
}
131+
for (let stepIndex = 0; stepIndex < stepsPerBar; stepIndex += 1) {
132+
const token = tokens[stepIndex] || "";
133+
const normalized = this.normalizeToken({ barIndex, beatsPerBar, lane, sections, stepIndex, subdivision, token });
134+
if (!normalized.ok) {
135+
return normalized;
136+
}
137+
warnings.push(...normalized.warnings);
138+
cells.push(normalized.cell);
139+
events.push(...normalized.events);
140+
}
141+
}
142+
return { cells, events, ok: true, warnings };
143+
}
144+
145+
normalizeToken({ barIndex, beatsPerBar, lane, sections, stepIndex, subdivision, token }) {
146+
const value = token.trim();
147+
const timing = this.timingForCell({ barIndex, beatsPerBar, sections, stepIndex, subdivision });
148+
const cell = {
149+
bar: timing.bar,
150+
beat: timing.beat,
151+
lane,
152+
section: timing.section,
153+
subdivision: timing.subdivision,
154+
subdivisionStep: timing.subdivisionStep,
155+
token: value
156+
};
157+
if (REST_TOKENS.has(value.toLowerCase())) {
158+
return { cell, events: [], ok: true, warnings: [] };
159+
}
160+
if (lane === "drums") {
161+
return this.normalizeDrums({ cell, timing, value });
162+
}
163+
if (lane === "chords") {
164+
if (!CHORD_PATTERN.test(value)) {
165+
return { message: `Invalid chord token "${value}" in ${lane} at bar ${timing.bar}, beat ${timing.beat}.`, ok: false };
166+
}
167+
return { cell, events: [this.eventForCell({ cell, kind: "chord", value })], ok: true, warnings: [] };
168+
}
169+
if (lane === "pad" && CHORD_PATTERN.test(value)) {
170+
return { cell, events: [this.eventForCell({ cell, kind: "chord", value })], ok: true, warnings: [] };
171+
}
172+
if (!NOTE_PATTERN.test(value)) {
173+
return { message: `Invalid note token "${value}" in ${lane} at bar ${timing.bar}, beat ${timing.beat}. Use notes like C4 or rests (-).`, ok: false };
174+
}
175+
return { cell, events: [this.eventForCell({ cell, kind: "note", value })], ok: true, warnings: [] };
176+
}
177+
178+
normalizeDrums({ cell, timing, value }) {
179+
const parts = value.toLowerCase().split("+").map((part) => part.trim()).filter(Boolean);
180+
const invalid = parts.filter((part) => !DRUM_TOKENS.has(part));
181+
if (invalid.length) {
182+
return {
183+
cell,
184+
events: [],
185+
ok: true,
186+
warnings: [`Invalid drum token "${invalid.join("+")}" at bar ${timing.bar}, beat ${timing.beat}; token was skipped.`]
187+
};
188+
}
189+
return {
190+
cell,
191+
events: parts.map((part) => this.eventForCell({ cell, kind: "drum", value: part })),
192+
ok: true,
193+
warnings: []
194+
};
195+
}
196+
197+
eventForCell({ cell, kind, value }) {
198+
return {
199+
bar: cell.bar,
200+
beat: cell.beat,
201+
durationBeats: 1 / cell.subdivision,
202+
kind,
203+
lane: cell.lane,
204+
section: cell.section,
205+
subdivision: cell.subdivision,
206+
value
207+
};
208+
}
209+
210+
timingForCell({ barIndex, beatsPerBar, sections, stepIndex, subdivision }) {
211+
const bar = barIndex + 1;
212+
const section = sections.find((entry) => bar >= entry.startBar && bar < entry.startBar + entry.bars)?.label || "unknown";
213+
return {
214+
bar,
215+
beat: Math.floor(stepIndex / subdivision) + 1,
216+
section,
217+
subdivision,
218+
subdivisionStep: (stepIndex % subdivision) + 1
219+
};
220+
}
221+
}

0 commit comments

Comments
 (0)