Skip to content

Commit 176119e

Browse files
committed
Add playhead timing, looping, and section navigation to MIDI Studio V2 grid editor - PR_26146_013-midi-studio-v2-playhead-loop-and-sections
1 parent c0c3f90 commit 176119e

8 files changed

Lines changed: 558 additions & 22 deletions

File tree

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
# PR_26146_013 MIDI Studio V2 Playhead Loop And Sections Validation
2+
3+
## Scope
4+
5+
- Added timing-preview playhead rendering aligned to sections, bars, beats, and subdivisions in the multi-instrument grid.
6+
- Added section shortcuts for Intro, Loop, Bridge, Boss, and Victory, plus custom section selection from normalized section labels.
7+
- Added loop-region start/end selection, visualization, and transport actions for Play Section, Play Loop, Stop, and Jump To Section.
8+
- Kept playback timing-preview-only: no audio fallback is started, and live synthesis warning status is reported.
9+
- Preserved snapping/subdivision controls, generated/manual cell distinction, imported MIDI inspection, guided Song Sheet, and rendered export target behavior.
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+
- PASS: `cmd /c "set PLAYWRIGHT_BROWSERS_PATH=0&& npx.cmd playwright test tests/playwright/tools/MidiStudioV2.spec.mjs"` passed 28 tests.
19+
- PASS: `git diff --check` completed with only line-ending normalization warnings.
20+
- 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.
21+
22+
## Playwright Coverage
23+
24+
Targeted MIDI Studio V2 Playwright coverage validates:
25+
26+
- playhead rendering and movement
27+
- section navigation and jump-to-section behavior
28+
- loop-region selection and visualization
29+
- bar numbering, timing ruler, and beat marker rendering
30+
- timing-only playback warning state with no fake audio playback
31+
- subdivision alignment with a moving playhead
32+
- generated/manual lane preservation
33+
- invalid section and invalid loop handling
34+
- existing MIDI inspection, guided Song Sheet, rendered preview/export, snap/generation, and invalid payload behavior
35+
36+
Workspace Manager V2 registration/handoff was not run because Workspace Manager files were not touched.
37+
38+
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: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -65,12 +65,21 @@ export class InstrumentGridParser {
6565
timeline.push(...parsedLane.events);
6666
warnings.push(...parsedLane.warnings);
6767
}
68-
const normalizedSections = sections.value.map((section) => ({
69-
...section,
70-
beatsPerBar: beatsPerBar.value,
71-
subdivision: subdivision.value,
72-
steps: section.bars * stepsPerBar
73-
}));
68+
let sectionStepCursor = 0;
69+
const normalizedSections = sections.value.map((section, index) => {
70+
const steps = section.bars * stepsPerBar;
71+
const normalized = {
72+
...section,
73+
beatsPerBar: beatsPerBar.value,
74+
colorIndex: index % 5,
75+
endStep: sectionStepCursor + steps - 1,
76+
startStep: sectionStepCursor,
77+
subdivision: subdivision.value,
78+
steps
79+
};
80+
sectionStepCursor += steps;
81+
return normalized;
82+
});
7483
return {
7584
barCount,
7685
beatCount: barCount * beatsPerBar.value,

tests/playwright/tools/MidiStudioV2.spec.mjs

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -620,6 +620,88 @@ Am F`);
620620
}
621621
});
622622

623+
test("renders timing ruler, section navigation, and loop region visualization", async ({ page }) => {
624+
const server = await openMidiStudio(page);
625+
try {
626+
await fillInstrumentGrid(page, {
627+
bass: "",
628+
chords: "Am F C G | Dm G C Am | F G Am C | C G F Am | G F C Am",
629+
drums: "",
630+
lead: "",
631+
pad: "",
632+
sections: "intro:1, loop:1, bridge:1, boss:1, victory:1"
633+
});
634+
await page.locator("#normalizeInstrumentGridButton").click();
635+
await expect(page.locator("#instrumentGridOutput")).toContainText("Bar 1");
636+
await expect(page.locator("#instrumentGridOutput")).toContainText("Bar 5");
637+
await expect(page.locator(".midi-studio-v2__grid-cell--bar")).toHaveCount(5);
638+
await expect(page.locator(".midi-studio-v2__grid-cell--ruler").first()).toContainText("1");
639+
await expect(page.locator(".midi-studio-v2__grid-cell--playhead-active")).toHaveAttribute("data-section", "intro");
640+
await expect(page.locator("#instrumentGridSectionSelect")).toContainText("bridge");
641+
await page.locator("#instrumentGridLoopStartSelect").selectOption("loop");
642+
await page.locator("#instrumentGridLoopEndSelect").selectOption("boss");
643+
expect(await page.locator(".midi-studio-v2__grid-cell--loop-region").count()).toBeGreaterThan(0);
644+
await page.locator('[data-section-preset="boss"]').click();
645+
await expect(page.locator("#instrumentGridSectionSelect")).toHaveValue("boss");
646+
await page.locator("#jumpToSectionButton").click();
647+
await expect(page.locator(".midi-studio-v2__grid-cell--playhead-active")).toHaveAttribute("data-section", "boss");
648+
await expect(page.locator("#statusLog")).toHaveValue(/OK Timing playhead jumped to section boss\./);
649+
} finally {
650+
await workspaceV2CoverageReporter.stop(page);
651+
await server.close();
652+
}
653+
});
654+
655+
test("animates timing-only playhead by subdivision and preserves generated manual cells", async ({ page }) => {
656+
const server = await openMidiStudio(page);
657+
try {
658+
await fillInstrumentGrid(page, {
659+
bass: "",
660+
beats: "2",
661+
chords: "Am - F - | C - G -",
662+
drums: "",
663+
lead: "",
664+
pad: "",
665+
subdivision: "2"
666+
});
667+
await page.locator("#generateBassFromChordsButton").click();
668+
await page.locator("#instrumentGridBassInput").fill("A2 E2 F2 - | C2 - G2 -");
669+
await page.locator("#normalizeInstrumentGridButton").click();
670+
await expect(page.locator('[data-lane="bass"][data-source="generated"]')).toHaveCount(4);
671+
await expect(page.locator('[data-lane="bass"][data-source="manual"]')).toHaveCount(1);
672+
await page.locator("#instrumentGridSectionSelect").selectOption("intro");
673+
const beforeStep = await page.locator(".midi-studio-v2__grid-cell--playhead-active").getAttribute("data-step-index");
674+
await page.locator("#playSectionButton").click();
675+
await expect(page.locator("#statusLog")).toHaveValue(/WARN Live playback synthesis not implemented\. Playing timing-preview playhead only; no audio playback was started\./);
676+
await expect(page.locator(".midi-studio-v2__grid-cell--playhead-active")).not.toHaveAttribute("data-step-index", beforeStep || "");
677+
await expect(page.locator(".midi-studio-v2__grid-cell--playhead-active")).toHaveAttribute("data-beat", /1|2/);
678+
await expect(page.locator(".midi-studio-v2__grid-cell--playhead-active")).toHaveAttribute("data-subdivision-step", /1|2/);
679+
await page.locator("#stopTimingPreviewButton").click();
680+
await expect(page.locator("#instrumentGridTransportState")).toContainText("Timing preview stopped.");
681+
await expect(page.locator("#statusLog")).toHaveValue(/OK Timing preview stopped\./);
682+
} finally {
683+
await workspaceV2CoverageReporter.stop(page);
684+
await server.close();
685+
}
686+
});
687+
688+
test("reports invalid section and invalid loop handling", async ({ page }) => {
689+
const server = await openMidiStudio(page);
690+
try {
691+
await fillInstrumentGrid(page);
692+
await page.locator("#normalizeInstrumentGridButton").click();
693+
await page.locator('[data-section-preset="bridge"]').click();
694+
await expect(page.locator("#statusLog")).toHaveValue(/FAIL Instrument grid section not found: Bridge\. Normalize a section map containing that label or choose a listed custom section\./);
695+
await page.locator("#instrumentGridLoopStartSelect").selectOption("loop");
696+
await page.locator("#instrumentGridLoopEndSelect").selectOption("intro");
697+
await page.locator("#playLoopButton").click();
698+
await expect(page.locator("#statusLog")).toHaveValue(/FAIL Instrument grid loop rejected: Invalid loop region: loop starts after intro\./);
699+
} finally {
700+
await workspaceV2CoverageReporter.stop(page);
701+
await server.close();
702+
}
703+
});
704+
623705
test("generates bass pad arpeggio and drum lanes from chord grid", async ({ page }) => {
624706
const server = await openMidiStudio(page);
625707
try {

tools/midi-studio-v2/index.html

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,36 @@ <h2 class="tools-platform-frame__eyebrow">First-Class Tools Surface V2</h2>
225225
<button id="generateBasicDrumsButton" type="button">Generate Basic Drums</button>
226226
<button id="normalizeInstrumentGridButton" type="button">Normalize Grid</button>
227227
</div>
228+
<div class="midi-studio-v2__transport-panel" aria-label="Instrument grid timing preview">
229+
<div class="midi-studio-v2__section-presets" aria-label="Section shortcuts">
230+
<button class="midi-studio-v2__section-preset" type="button" data-section-preset="intro">Intro</button>
231+
<button class="midi-studio-v2__section-preset" type="button" data-section-preset="loop">Loop</button>
232+
<button class="midi-studio-v2__section-preset" type="button" data-section-preset="bridge">Bridge</button>
233+
<button class="midi-studio-v2__section-preset" type="button" data-section-preset="boss">Boss</button>
234+
<button class="midi-studio-v2__section-preset" type="button" data-section-preset="victory">Victory</button>
235+
</div>
236+
<div class="midi-studio-v2__transport-fields">
237+
<label class="tool-starter__field" for="instrumentGridSectionSelect">
238+
<span>Custom section</span>
239+
<select id="instrumentGridSectionSelect"></select>
240+
</label>
241+
<label class="tool-starter__field" for="instrumentGridLoopStartSelect">
242+
<span>Loop start</span>
243+
<select id="instrumentGridLoopStartSelect"></select>
244+
</label>
245+
<label class="tool-starter__field" for="instrumentGridLoopEndSelect">
246+
<span>Loop end</span>
247+
<select id="instrumentGridLoopEndSelect"></select>
248+
</label>
249+
</div>
250+
<div class="midi-studio-v2__transport-actions" aria-label="Grid transport actions">
251+
<button id="jumpToSectionButton" type="button">Jump To Section</button>
252+
<button id="playSectionButton" type="button">Play Section</button>
253+
<button id="playLoopButton" type="button">Play Loop</button>
254+
<button id="stopTimingPreviewButton" type="button">Stop</button>
255+
</div>
256+
<p id="instrumentGridTransportState" class="tool-starter__hint">Timing preview stopped.</p>
257+
</div>
228258
<dl id="instrumentGridSummary" class="midi-studio-v2__details"></dl>
229259
<div id="instrumentGridOutput" class="midi-studio-v2__instrument-grid-output" aria-label="Aligned multi-instrument grid"></div>
230260
</div>

tools/midi-studio-v2/js/MidiStudioV2App.js

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,8 @@ export class MidiStudioV2App {
5656
this.songSheet.mount({ onParse: (sourceText) => this.parseSongSheet(sourceText) });
5757
this.instrumentGrid.mount({
5858
onGenerate: (lane, input) => this.generateInstrumentLane(lane, input),
59-
onNormalize: (input) => this.normalizeInstrumentGrid(input)
59+
onNormalize: (input) => this.normalizeInstrumentGrid(input),
60+
onTransport: (action, detail) => this.handleInstrumentGridTransport(action, detail)
6061
});
6162
this.midiSourceDetails.mount({ onInspect: () => this.inspectSelectedSource() });
6263
this.playbackControl.mount({
@@ -221,6 +222,38 @@ export class MidiStudioV2App {
221222
this.statusLog.ok(generated.message);
222223
}
223224

225+
handleInstrumentGridTransport(action, detail = {}) {
226+
if (action === "invalid-section") {
227+
this.statusLog.fail(`Instrument grid section not found: ${detail.label}. Normalize a section map containing that label or choose a listed custom section.`);
228+
return;
229+
}
230+
if (action === "invalid-loop") {
231+
this.statusLog.fail(`Instrument grid loop rejected: ${detail.message}`);
232+
return;
233+
}
234+
if (action === "play-section") {
235+
this.statusLog.warn("Live playback synthesis not implemented. Playing timing-preview playhead only; no audio playback was started.");
236+
this.statusLog.ok(`Timing preview started for section ${detail.section.label}.`);
237+
return;
238+
}
239+
if (action === "play-loop") {
240+
this.statusLog.warn("Live playback synthesis not implemented. Playing timing-preview playhead only; no audio playback was started.");
241+
this.statusLog.ok(`Timing preview loop started from ${detail.startSection.label} to ${detail.endSection.label}.`);
242+
return;
243+
}
244+
if (action === "jump-section") {
245+
this.statusLog.ok(`Timing playhead jumped to section ${detail.section.label}.`);
246+
return;
247+
}
248+
if (action === "select-section") {
249+
this.statusLog.info(`Timing section selected: ${detail.section.label}.`);
250+
return;
251+
}
252+
if (action === "stop-preview") {
253+
this.statusLog.ok("Timing preview stopped.");
254+
}
255+
}
256+
224257
exportRenderedTarget(format) {
225258
const song = this.selectedSong();
226259
const label = String(format || "").toUpperCase();

tools/midi-studio-v2/js/bootstrap.js

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,13 +63,23 @@ window.addEventListener("DOMContentLoaded", () => {
6363
generateDrumsButton: requireElement("#generateBasicDrumsButton"),
6464
generatePadButton: requireElement("#generatePadFromChordsButton"),
6565
gridOutput: requireElement("#instrumentGridOutput"),
66+
jumpToSectionButton: requireElement("#jumpToSectionButton"),
6667
leadInput: requireElement("#instrumentGridLeadInput"),
68+
loopEndSelect: requireElement("#instrumentGridLoopEndSelect"),
69+
loopStartSelect: requireElement("#instrumentGridLoopStartSelect"),
6770
normalizeButton: requireElement("#normalizeInstrumentGridButton"),
6871
padInput: requireElement("#instrumentGridPadInput"),
72+
playLoopButton: requireElement("#playLoopButton"),
73+
playSectionButton: requireElement("#playSectionButton"),
74+
sectionPresetButtons: Array.from(document.querySelectorAll(".midi-studio-v2__section-preset")),
75+
sectionSelect: requireElement("#instrumentGridSectionSelect"),
6976
sectionsInput: requireElement("#instrumentGridSectionsInput"),
7077
snapIndicator: requireElement("#instrumentGridSnapIndicator"),
78+
stopTimingPreviewButton: requireElement("#stopTimingPreviewButton"),
7179
subdivisionInput: requireElement("#instrumentGridSubdivisionInput"),
72-
summary: requireElement("#instrumentGridSummary")
80+
summary: requireElement("#instrumentGridSummary"),
81+
transportState: requireElement("#instrumentGridTransportState"),
82+
windowRef: window
7383
}),
7484
instrumentGridParser: new InstrumentGridParser(),
7585
manifestLoader: new GameManifestLoader({ windowRef: window }),

0 commit comments

Comments
 (0)