Skip to content

Commit c0c3f90

Browse files
committed
Add snapping controls and generated instrument lane helpers for MIDI Studio V2 - PR_26146_012-midi-studio-v2-grid-snapping-and-lane-generation
1 parent ca380e0 commit c0c3f90

8 files changed

Lines changed: 516 additions & 17 deletions

File tree

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
# PR_26146_012 MIDI Studio V2 Grid Snapping And Lane Generation Validation
2+
3+
## Scope
4+
5+
- Extended the shared instrument grid parser with 1/1, 1/2, 1/4, 1/8, and 1/16 timing subdivisions.
6+
- Added generated lane helpers for bass, pad, arpeggio, and basic drums from chord-grid input.
7+
- Preserved generated/manual cell ownership so manual edits override generated cells without hidden regeneration.
8+
- Added a visible snap indicator, generator actions, generated/manual cell styling, and status/logging for success, skipped empty bars, unsupported chord patterns, and invalid note generation.
9+
- Preserved MIDI source inspection, guided Song Sheet, rendered target export header behavior, and no-live-synthesis scope.
10+
11+
## Validation
12+
13+
- PASS: `node --check src/engine/audio/InstrumentGridParser.js`
14+
- PASS: `node --check tools/midi-studio-v2/js/controls/InstrumentGridControl.js`
15+
- PASS: `node --check tools/midi-studio-v2/js/MidiStudioV2App.js`
16+
- PASS: `node --check tools/midi-studio-v2/js/bootstrap.js`
17+
- PASS: `node --check tests/playwright/tools/MidiStudioV2.spec.mjs`
18+
- INITIAL BLOCKED: `npx playwright test tests/playwright/tools/MidiStudioV2.spec.mjs` was blocked by PowerShell execution policy for `npx.ps1`.
19+
- INITIAL BLOCKED: `cmd /c npx.cmd playwright test tests/playwright/tools/MidiStudioV2.spec.mjs` found Chromium missing at the default user cache path.
20+
- INITIAL BLOCKED: `cmd /c npx.cmd playwright install chromium` could not create the default Playwright cache outside the writable workspace.
21+
- PASS: `cmd /c "set PLAYWRIGHT_BROWSERS_PATH=0&& npx.cmd playwright install chromium"`
22+
- PASS: `cmd /c "set PLAYWRIGHT_BROWSERS_PATH=0&& npx.cmd playwright test tests/playwright/tools/MidiStudioV2.spec.mjs"` passed 25 tests.
23+
- PASS: `git diff --check` completed with only existing line-ending normalization warnings.
24+
- PASS: `Select-String -Path tools/midi-studio-v2/index.html -Pattern '<script(?![^>]*src=)|<style|\son[a-z]+\s*='` returned no inline script/style/event handler matches.
25+
26+
## Playwright Coverage
27+
28+
Targeted MIDI Studio V2 Playwright coverage validates:
29+
30+
- tool launch and valid multi-song manifest render
31+
- selected song details, rendered preview, MIDI source inspection, and existing header-action layout behavior
32+
- snap subdivision switching and active snap indicator
33+
- beat/bar alignment after snapping changes
34+
- generated bass, pad, arpeggio, and drum lanes
35+
- generated vs manual cell override behavior
36+
- invalid chord/unsupported generation warnings
37+
- empty-bar skip handling
38+
- normalization into shared timeline structures
39+
- invalid payload rejection before render
40+
41+
Workspace Manager V2 registration/handoff was not run because Workspace Manager files were not touched.
42+
43+
Full samples smoke test was skipped per request. Samples decision: SKIP because sample JSON alignment is out of scope.

src/engine/audio/InstrumentGridParser.js

Lines changed: 195 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,24 @@ const DRUM_TOKENS = new Set(["kick", "snare", "hat", "ride", "clap", "tom", "cra
33
const LANE_NAMES = ["chords", "bass", "pad", "lead", "drums"];
44
const NOTE_PATTERN = /^[A-G](?:#|b)?[0-8]$/;
55
const REST_TOKENS = new Set(["", "-", ".", "rest"]);
6-
const SUPPORTED_SUBDIVISIONS = new Set([1, 2, 4]);
6+
const SUPPORTED_SUBDIVISIONS = new Set([1, 2, 4, 8, 16]);
7+
8+
const CHORD_TONES = {
9+
A: ["A", "C#", "E"],
10+
Am: ["A", "C", "E"],
11+
B: ["B", "D#", "F#"],
12+
Bm: ["B", "D", "F#"],
13+
C: ["C", "E", "G"],
14+
Cm: ["C", "Eb", "G"],
15+
D: ["D", "F#", "A"],
16+
Dm: ["D", "F", "A"],
17+
E: ["E", "G#", "B"],
18+
Em: ["E", "G", "B"],
19+
F: ["F", "A", "C"],
20+
Fm: ["F", "Ab", "C"],
21+
G: ["G", "B", "D"],
22+
Gm: ["G", "Bb", "D"]
23+
};
724

825
export class InstrumentGridParser {
926
parse(input = {}) {
@@ -21,7 +38,7 @@ export class InstrumentGridParser {
2138
}
2239
if (!SUPPORTED_SUBDIVISIONS.has(subdivision.value)) {
2340
return {
24-
message: `Unsupported timing subdivision: ${subdivision.value}. Use 1, 2, or 4.`,
41+
message: `Unsupported timing subdivision: ${subdivision.value}. Use 1, 2, 4, 8, or 16.`,
2542
ok: false
2643
};
2744
}
@@ -35,6 +52,7 @@ export class InstrumentGridParser {
3552
barCount,
3653
beatsPerBar: beatsPerBar.value,
3754
lane,
55+
generatedSource: input.generatedLanes?.[lane],
3856
sections: sections.value,
3957
source: input.lanes?.[lane],
4058
stepsPerBar,
@@ -67,13 +85,49 @@ export class InstrumentGridParser {
6785
sectionSummary: normalizedSections.map((section) => `${section.label}: ${section.bars} bar${section.bars === 1 ? "" : "s"}`).join("; "),
6886
sections: normalizedSections,
6987
subdivision: subdivision.value,
88+
subdivisionLabel: `1/${subdivision.value}`,
7089
timeline,
7190
totalSteps: barCount * stepsPerBar,
7291
warningSummary: warnings.length ? warnings.join("; ") : "none",
7392
warnings
7493
};
7594
}
7695

96+
generateLane(input = {}, lane) {
97+
if (!["bass", "pad", "lead", "drums"].includes(lane)) {
98+
return { message: `Unsupported generated lane: ${lane}.`, ok: false };
99+
}
100+
const sections = this.parseSections(input.sections);
101+
if (!sections.ok) {
102+
return sections;
103+
}
104+
const beatsPerBar = this.parsePositiveInteger(input.beatsPerBar, "beats per bar");
105+
if (!beatsPerBar.ok) {
106+
return beatsPerBar;
107+
}
108+
const subdivision = this.parsePositiveInteger(input.subdivision, "timing subdivision");
109+
if (!subdivision.ok) {
110+
return subdivision;
111+
}
112+
if (!SUPPORTED_SUBDIVISIONS.has(subdivision.value)) {
113+
return {
114+
message: `Unsupported timing subdivision: ${subdivision.value}. Use 1, 2, 4, 8, or 16.`,
115+
ok: false
116+
};
117+
}
118+
const barCount = sections.value.reduce((total, section) => total + section.bars, 0);
119+
const stepsPerBar = beatsPerBar.value * subdivision.value;
120+
if (lane === "drums") {
121+
return this.generateDrums({ barCount, beatsPerBar: beatsPerBar.value, stepsPerBar, subdivision: subdivision.value });
122+
}
123+
return this.generateFromChords({
124+
barCount,
125+
lane,
126+
source: input.lanes?.chords,
127+
stepsPerBar
128+
});
129+
}
130+
77131
parseSections(source) {
78132
const text = String(source || "").trim();
79133
if (!text) {
@@ -108,9 +162,10 @@ export class InstrumentGridParser {
108162
return { ok: true, value: number };
109163
}
110164

111-
parseLane({ barCount, beatsPerBar, lane, sections, source, stepsPerBar, subdivision }) {
165+
parseLane({ barCount, beatsPerBar, generatedSource, lane, sections, source, stepsPerBar, subdivision }) {
112166
const text = String(source || "").trim();
113167
const bars = text ? text.split("|").map((bar) => bar.trim()) : [];
168+
const generatedBars = this.parseGeneratedBars({ barCount, generatedSource, stepsPerBar });
114169
if (bars.length && bars.length !== barCount) {
115170
return {
116171
message: `Malformed bar counts for ${lane}. Expected ${barCount} bar${barCount === 1 ? "" : "s"} from the section map, received ${bars.length}.`,
@@ -130,7 +185,8 @@ export class InstrumentGridParser {
130185
}
131186
for (let stepIndex = 0; stepIndex < stepsPerBar; stepIndex += 1) {
132187
const token = tokens[stepIndex] || "";
133-
const normalized = this.normalizeToken({ barIndex, beatsPerBar, lane, sections, stepIndex, subdivision, token });
188+
const generatedToken = generatedBars[barIndex]?.[stepIndex] || "";
189+
const normalized = this.normalizeToken({ barIndex, beatsPerBar, generatedToken, lane, sections, stepIndex, subdivision, token });
134190
if (!normalized.ok) {
135191
return normalized;
136192
}
@@ -142,14 +198,16 @@ export class InstrumentGridParser {
142198
return { cells, events, ok: true, warnings };
143199
}
144200

145-
normalizeToken({ barIndex, beatsPerBar, lane, sections, stepIndex, subdivision, token }) {
201+
normalizeToken({ barIndex, beatsPerBar, generatedToken, lane, sections, stepIndex, subdivision, token }) {
146202
const value = token.trim();
147203
const timing = this.timingForCell({ barIndex, beatsPerBar, sections, stepIndex, subdivision });
204+
const tokenSource = this.tokenSource({ generatedToken, value });
148205
const cell = {
149206
bar: timing.bar,
150207
beat: timing.beat,
151208
lane,
152209
section: timing.section,
210+
source: tokenSource,
153211
subdivision: timing.subdivision,
154212
subdivisionStep: timing.subdivisionStep,
155213
token: value
@@ -202,11 +260,143 @@ export class InstrumentGridParser {
202260
kind,
203261
lane: cell.lane,
204262
section: cell.section,
263+
source: cell.source,
205264
subdivision: cell.subdivision,
206265
value
207266
};
208267
}
209268

269+
parseGeneratedBars({ barCount, generatedSource, stepsPerBar }) {
270+
const text = String(generatedSource || "").trim();
271+
const bars = text ? text.split("|").map((bar) => bar.trim()) : [];
272+
if (bars.length !== barCount) {
273+
return [];
274+
}
275+
return bars.map((bar) => {
276+
const tokens = bar ? bar.split(/\s+/).filter(Boolean) : [];
277+
return tokens.length === stepsPerBar ? tokens : [];
278+
});
279+
}
280+
281+
tokenSource({ generatedToken, value }) {
282+
const generated = String(generatedToken || "").trim();
283+
if (REST_TOKENS.has(String(value || "").toLowerCase())) {
284+
return generated && !REST_TOKENS.has(generated.toLowerCase()) ? "manual" : "empty";
285+
}
286+
return generated === value ? "generated" : "manual";
287+
}
288+
289+
generateDrums({ barCount, beatsPerBar, stepsPerBar, subdivision }) {
290+
const bars = [];
291+
for (let barIndex = 0; barIndex < barCount; barIndex += 1) {
292+
const tokens = [];
293+
for (let stepIndex = 0; stepIndex < stepsPerBar; stepIndex += 1) {
294+
if (stepIndex % subdivision !== 0) {
295+
tokens.push("-");
296+
continue;
297+
}
298+
const beat = Math.floor(stepIndex / subdivision) + 1;
299+
tokens.push(beat === 1 ? "kick" : beat === Math.ceil(beatsPerBar / 2) + 1 ? "snare" : "hat");
300+
}
301+
bars.push(tokens.join(" "));
302+
}
303+
return {
304+
generatedCount: barCount * beatsPerBar,
305+
lane: "drums",
306+
message: `Generated Basic Drums for ${barCount} bar${barCount === 1 ? "" : "s"}.`,
307+
ok: true,
308+
skippedEmptyBars: 0,
309+
text: bars.join(" | "),
310+
warningSummary: "none",
311+
warnings: []
312+
};
313+
}
314+
315+
generateFromChords({ barCount, lane, source, stepsPerBar }) {
316+
const text = String(source || "").trim();
317+
const bars = text ? text.split("|").map((bar) => bar.trim()) : Array.from({ length: barCount }, () => "");
318+
if (bars.length !== barCount) {
319+
return {
320+
message: `Malformed bar counts for chord generation. Expected ${barCount} bar${barCount === 1 ? "" : "s"}, received ${bars.filter(Boolean).length || bars.length}.`,
321+
ok: false
322+
};
323+
}
324+
const generatedBars = [];
325+
const warnings = [];
326+
let generatedCount = 0;
327+
let skippedEmptyBars = 0;
328+
bars.forEach((bar, barIndex) => {
329+
const tokens = bar ? bar.split(/\s+/).filter(Boolean) : [];
330+
if (!tokens.length || tokens.every((token) => REST_TOKENS.has(token.toLowerCase()))) {
331+
skippedEmptyBars += 1;
332+
generatedBars.push(Array.from({ length: stepsPerBar }, () => "-").join(" "));
333+
return;
334+
}
335+
if (tokens.length !== stepsPerBar) {
336+
warnings.push(`Skipped bar ${barIndex + 1}: expected ${stepsPerBar} chord slot${stepsPerBar === 1 ? "" : "s"}, received ${tokens.length}.`);
337+
generatedBars.push(Array.from({ length: stepsPerBar }, () => "-").join(" "));
338+
return;
339+
}
340+
const generatedTokens = tokens.map((chord, stepIndex) => {
341+
const generated = this.generateTokenForChord({ chord, lane, stepIndex });
342+
if (!generated.ok) {
343+
warnings.push(`${generated.message} Bar ${barIndex + 1}, slot ${stepIndex + 1} skipped.`);
344+
return "-";
345+
}
346+
generatedCount += 1;
347+
return generated.value;
348+
});
349+
generatedBars.push(generatedTokens.join(" "));
350+
});
351+
const skipMessage = skippedEmptyBars ? ` Skipped ${skippedEmptyBars} empty bar${skippedEmptyBars === 1 ? "" : "s"}.` : "";
352+
return {
353+
generatedCount,
354+
lane,
355+
message: `Generated ${this.generatedLaneLabel(lane)} from chords with ${generatedCount} cell${generatedCount === 1 ? "" : "s"}.${skipMessage}`,
356+
ok: true,
357+
skippedEmptyBars,
358+
text: generatedBars.join(" | "),
359+
warningSummary: warnings.length ? warnings.join("; ") : "none",
360+
warnings
361+
};
362+
}
363+
364+
generateTokenForChord({ chord, lane, stepIndex }) {
365+
const value = String(chord || "").trim();
366+
if (REST_TOKENS.has(value.toLowerCase())) {
367+
return { ok: true, value: "-" };
368+
}
369+
if (!CHORD_PATTERN.test(value)) {
370+
return { message: `Invalid chord "${value}" for lane generation.`, ok: false };
371+
}
372+
const simple = value.match(/^([A-G](?:#|b)?)(m?)$/);
373+
if (!simple) {
374+
return { message: `Unsupported chord pattern "${value}" for lane generation.`, ok: false };
375+
}
376+
const chordName = `${simple[1]}${simple[2] || ""}`;
377+
const tones = CHORD_TONES[chordName];
378+
if (!tones) {
379+
return { message: `Invalid note generation for chord "${value}".`, ok: false };
380+
}
381+
if (lane === "bass") {
382+
return { ok: true, value: `${tones[0]}2` };
383+
}
384+
if (lane === "pad") {
385+
return { ok: true, value };
386+
}
387+
return { ok: true, value: `${tones[stepIndex % tones.length]}4` };
388+
}
389+
390+
generatedLaneLabel(lane) {
391+
return lane === "bass"
392+
? "Bass"
393+
: lane === "pad"
394+
? "Pad"
395+
: lane === "lead"
396+
? "Arpeggio"
397+
: "Basic Drums";
398+
}
399+
210400
timingForCell({ barIndex, beatsPerBar, sections, stepIndex, subdivision }) {
211401
const bar = barIndex + 1;
212402
const section = sections.find((entry) => bar >= entry.startBar && bar < entry.startBar + entry.bars)?.label || "unknown";

0 commit comments

Comments
 (0)