Skip to content

Commit 40cad07

Browse files
committed
Move MIDI Studio V2 octave timeline rendering to a canvas-backed editor surface - PR_26146_051-midi-studio-v2-canvas-octave-timeline-foundation
1 parent 0924b38 commit 40cad07

5 files changed

Lines changed: 798 additions & 235 deletions

File tree

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
# PR_26146_051-midi-studio-v2-canvas-octave-timeline-foundation Validation
2+
3+
Status: PASS
4+
5+
## Scope
6+
7+
- Continued from `PR_26146_050`.
8+
- Moved the MIDI Studio V2 Octave Timeline visual/edit surface to a canvas-backed renderer.
9+
- Kept NAV, tabs, instrument controls, dropdowns, buttons, import/save/export controls, diagnostics, and status/logging in DOM.
10+
- Preserved the canonical selected song arrangement and parsed grid result as the source of truth.
11+
- Canvas hit testing maps pointer location to row pitch/percussion value, bar/beat step, and the selected instrument lane.
12+
- Canvas clicks update the existing lane source text and then sync back into the canonical song model.
13+
- Playback continues to read the parsed canonical grid data.
14+
- Playback playhead/header highlighting now updates canvas state instead of DOM grid cell classes.
15+
16+
## Validation Commands
17+
18+
```powershell
19+
node --check tools/midi-studio-v2/js/controls/OctaveTimelineCanvasRenderer.js
20+
node --check tools/midi-studio-v2/js/controls/InstrumentGridControl.js
21+
node --check tests/playwright/tools/MidiStudioV2.spec.mjs
22+
npx playwright test tests/playwright/tools/MidiStudioV2.spec.mjs -g "canvas octave timeline edits canonical data and drives playback without DOM grid repaint" --reporter=list --workers=1 --timeout=60000
23+
git diff --check
24+
```
25+
26+
## Results
27+
28+
- PASS: changed-file JavaScript syntax checks passed.
29+
- PASS: targeted MIDI Studio V2 Playwright coverage passed, `1 passed`.
30+
- PASS: `git diff --check` exited successfully. Git reported only LF/CRLF notices for touched text files.
31+
- PASS: Playwright V8 coverage artifacts were refreshed:
32+
- `docs/dev/reports/playwright_v8_coverage_report.txt`
33+
- `docs/dev/reports/coverage_changed_js_guardrail.txt`
34+
- PASS: coverage guardrail reports no changed runtime JS warnings.
35+
36+
## Playwright Proof
37+
38+
The targeted MIDI Studio V2 Playwright test validates:
39+
40+
- Octave Timeline renders a visible canvas-backed surface.
41+
- Old DOM octave note cells and DOM timing-header cells are not present in the editing surface.
42+
- instrument dropdowns, Play, and manifest import remain DOM controls.
43+
- canvas keyboard axis renders with sampled non-empty pixels and exposes black/white key rows from the renderer state.
44+
- clicking a canvas note position toggles the selected instrument note.
45+
- the edit appears in `lastInstrumentGridResult.timeline`.
46+
- the same edit appears in the canonical selected song `studioArrangement.lanes`.
47+
- playback receives the edited canonical grid data.
48+
- Preview Synth starts and Stop returns Play/Stop controls to the expected state.
49+
- playhead/header state advances on the canvas without adding/removing DOM grid repaint classes.
50+
- vertical and horizontal scrolling remain usable.
51+
- zoom controls update canvas cell sizing.
52+
53+
## Lanes
54+
55+
- tool runtime: executed through targeted MIDI Studio V2 Playwright because the affected surface is a tool runtime/editor interaction.
56+
- rendering surface: executed through browser canvas rendering and pixel/state assertions.
57+
- engine: skipped; this PR does not change shared engine rendering, audio, input, parser, or timing services.
58+
- integration: skipped; launch-specific NAV behavior is preserved, but no Workspace handoff contract changed.
59+
- samples: SKIP by explicit PR instruction. Full samples smoke test was not run.
60+
- recovery/UAT: covered only for the targeted MIDI Studio V2 canvas timeline slice named by this PR.
61+
62+
## Manual Validation Notes
63+
64+
1. Open `tools/midi-studio-v2/index.html`.
65+
2. Import the MIDI Studio V2 UAT manifest.
66+
3. Confirm Studio opens to Octave Timeline and the center timeline is canvas-backed while left/right controls remain DOM.
67+
4. Select Lead, click a note cell in the canvas, and confirm selected song JSON/details update through the canonical arrangement.
68+
5. Press Play and confirm the edited note is audible in Preview Synth playback.
69+
6. Confirm playhead/header highlight advances in the canvas without DOM note-cell repaint.
70+
7. Scroll vertically and horizontally in the timeline, then use zoom in/out.
71+
8. Press Stop and confirm playback stops and Play is enabled.
72+
73+
## Out Of Scope
74+
75+
- SoundFont playback.
76+
- export rendering.
77+
- MIDI recording/input.
78+
- full samples smoke test.

tests/playwright/tools/MidiStudioV2.spec.mjs

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -403,6 +403,35 @@ function octaveNoteBlock(page, lane) {
403403
return page.locator(`.midi-studio-v2__octave-note-cell[data-note-lanes~="${lane}"]`);
404404
}
405405

406+
function octaveTimelineCanvas(page) {
407+
return page.locator("[data-octave-timeline-canvas='true']");
408+
}
409+
410+
async function canvasTimelineState(page) {
411+
return page.evaluate(() => window.__midiStudioV2App.instrumentGrid.timelineCanvasState());
412+
}
413+
414+
async function waitForCanvasRender(page) {
415+
await page.waitForFunction(() => Number(document.querySelector("[data-octave-timeline-canvas='true']")?.dataset.renderFrame || 0) > 0);
416+
}
417+
418+
async function scrollCanvasCellIntoView(page, rowToken, stepIndex) {
419+
await page.locator("#instrumentGridOutput").evaluate((output, target) => {
420+
const state = window.__midiStudioV2App.instrumentGrid.timelineCanvasState();
421+
const rowIndex = state.rows.findIndex((row) => row.value === target.rowToken);
422+
output.scrollLeft = Math.max(0, state.axisWidth + target.stepIndex * state.cellSize - output.clientWidth / 2);
423+
output.scrollTop = Math.max(0, state.headerHeight + rowIndex * state.cellSize - output.clientHeight / 2);
424+
output.dispatchEvent(new Event("scroll"));
425+
}, { rowToken, stepIndex });
426+
}
427+
428+
async function clickCanvasCell(page, rowToken, stepIndex) {
429+
await scrollCanvasCellIntoView(page, rowToken, stepIndex);
430+
const point = await page.evaluate((target) => window.__midiStudioV2App.instrumentGrid.timelineCanvasCellCenter(target.rowToken, target.stepIndex), { rowToken, stepIndex });
431+
expect(point).toBeTruthy();
432+
await page.mouse.click(point.x, point.y);
433+
}
434+
406435
function instrumentRow(page, lane) {
407436
return page.locator(`.midi-studio-v2__instrument-row[data-lane="${lane}"]`);
408437
}
@@ -635,6 +664,136 @@ test.describe("MIDI Studio V2", () => {
635664
}
636665
});
637666

667+
test("canvas octave timeline edits canonical data and drives playback without DOM grid repaint", async ({ page }) => {
668+
const server = await openMidiStudioForImport(page);
669+
try {
670+
await page.locator("#toolImportManifestInput").setInputFiles(uatManifestPath);
671+
await expect(page.locator('[data-midi-studio-tab="studio"]')).toHaveAttribute("aria-selected", "true");
672+
await expect(page.locator("#instrumentGridHeading")).toHaveText("Octave Timeline");
673+
await expect(page.locator("#instrumentGridOutput")).toHaveAttribute("data-timeline-renderer", "canvas");
674+
await expect(octaveTimelineCanvas(page)).toBeVisible();
675+
await expect(page.locator(".midi-studio-v2__octave-note-cell")).toHaveCount(0);
676+
await expect(page.locator(".midi-studio-v2__grid-cell--timing-header")).toHaveCount(0);
677+
await expect(instrumentTypeSelect(page, "lead")).toHaveJSProperty("tagName", "SELECT");
678+
await expect(instrumentSelect(page, "lead")).toHaveJSProperty("tagName", "SELECT");
679+
await expect(page.locator("#playButton")).toHaveJSProperty("tagName", "BUTTON");
680+
await expect(page.locator("#toolImportManifestInput")).toHaveJSProperty("tagName", "INPUT");
681+
682+
await selectInstrumentRow(page, "lead");
683+
await waitForCanvasRender(page);
684+
const initialCanvasState = await canvasTimelineState(page);
685+
expect(initialCanvasState.rows.some((row) => row.value === "C6")).toBe(true);
686+
expect(initialCanvasState.rows.some((row) => row.keyKind === "black")).toBe(true);
687+
expect(initialCanvasState.rows.some((row) => row.keyKind === "white")).toBe(true);
688+
expect(initialCanvasState.totalSteps).toBeGreaterThan(0);
689+
expect(initialCanvasState.noteCount).toBeGreaterThan(0);
690+
691+
const keyboardAxisPixel = await page.evaluate(() => {
692+
const canvas = document.querySelector("[data-octave-timeline-canvas='true']");
693+
const state = window.__midiStudioV2App.instrumentGrid.timelineCanvasState();
694+
const rowIndex = state.rows.findIndex((row) => row.value === "C6");
695+
const ratio = canvas.width / canvas.clientWidth;
696+
const sample = canvas.getContext("2d").getImageData(
697+
Math.round(10 * ratio),
698+
Math.round((state.headerHeight + rowIndex * state.cellSize + state.cellSize / 2) * ratio),
699+
1,
700+
1
701+
).data;
702+
return Array.from(sample);
703+
});
704+
expect(keyboardAxisPixel[3]).toBeGreaterThan(0);
705+
expect(keyboardAxisPixel.slice(0, 3).some((channel) => channel > 0)).toBe(true);
706+
707+
await clickCanvasCell(page, "C6", 2);
708+
await expect(page.locator("#statusLog")).toHaveValue(/OK Toggled C6 for Lead; visible timeline playback data updated\./);
709+
const canonicalEdit = await page.evaluate(() => {
710+
const app = window.__midiStudioV2App;
711+
return {
712+
gridHasEdit: app.lastInstrumentGridResult.timeline.some((event) => event.lane === "lead" && event.stepIndex === 2 && event.value === "C6"),
713+
leadLane: app.selectedSong().studioArrangement.lanes.lead,
714+
selectedCell: app.instrumentGrid.timelineCanvasState().selectedCell
715+
};
716+
});
717+
expect(canonicalEdit.gridHasEdit).toBe(true);
718+
expect(canonicalEdit.leadLane).toContain("C6");
719+
expect(canonicalEdit.selectedCell).toEqual({ rowToken: "C6", stepIndex: 2 });
720+
721+
await page.locator("#instrumentGridOutput").evaluate((output) => {
722+
window.__midiStudioGridClassMutations = [];
723+
window.__midiStudioGridClassObserver = new MutationObserver((records) => {
724+
records.forEach((record) => {
725+
window.__midiStudioGridClassMutations.push(record.target.className || "");
726+
});
727+
});
728+
window.__midiStudioGridClassObserver.observe(output, {
729+
attributeFilter: ["class"],
730+
attributes: true,
731+
subtree: true
732+
});
733+
});
734+
await page.evaluate(() => {
735+
const app = window.__midiStudioV2App;
736+
const originalPlayGridRange = app.previewSynth.playGridRange.bind(app.previewSynth);
737+
app.__lastPreviewGridValues = [];
738+
app.previewSynth.playGridRange = async (options) => {
739+
app.__lastPreviewGridValues = options.grid.timeline
740+
.filter((event) => event.lane === "lead")
741+
.map((event) => event.value);
742+
return originalPlayGridRange(options);
743+
};
744+
window.__midiStudioPreviewSynthEvents = [];
745+
});
746+
await page.locator("#playButton").click();
747+
await expect(page.locator("#stopButton")).toBeEnabled();
748+
await expect(page.locator("#playButton")).toBeDisabled();
749+
expect(await page.evaluate(() => window.__midiStudioV2App.__lastPreviewGridValues)).toContain("C6");
750+
expect(await page.evaluate(() => window.__midiStudioPreviewSynthEvents.some((event) => event.action === "oscillator-start"))).toBe(true);
751+
await page.waitForFunction(() => window.__midiStudioV2App.instrumentGrid.playheadStep > 0);
752+
const playbackCanvasState = await canvasTimelineState(page);
753+
expect(playbackCanvasState.playheadStep).toBeGreaterThan(0);
754+
await expect(octaveTimelineCanvas(page)).toHaveAttribute("data-playhead-step", String(playbackCanvasState.playheadStep));
755+
await expect(page.locator(".midi-studio-v2__grid-cell--playhead-active")).toHaveCount(0);
756+
expect(await page.evaluate(() => window.__midiStudioGridClassMutations.filter((className) => String(className).includes("midi-studio-v2__grid-cell")).length)).toBe(0);
757+
758+
const scrollEvidence = await page.locator("#instrumentGridOutput").evaluate((output) => {
759+
output.style.width = "320px";
760+
output.style.maxWidth = "320px";
761+
output.scrollLeft = 240;
762+
output.scrollTop = 190;
763+
output.dispatchEvent(new Event("scroll"));
764+
const topScrollbar = output.querySelector(".midi-studio-v2__timeline-scroll-proxy");
765+
return {
766+
canScrollHorizontal: output.scrollWidth > output.clientWidth,
767+
canScrollVertical: output.scrollHeight > output.clientHeight,
768+
datasetScrollLeft: output.dataset.timelineScrollLeft,
769+
scrollLeft: Math.round(output.scrollLeft),
770+
scrollTop: Math.round(output.scrollTop),
771+
topScrollLeft: Math.round(topScrollbar?.scrollLeft || 0)
772+
};
773+
});
774+
expect(scrollEvidence.canScrollHorizontal).toBe(true);
775+
expect(scrollEvidence.canScrollVertical).toBe(true);
776+
expect(scrollEvidence.scrollLeft).toBeGreaterThan(0);
777+
expect(scrollEvidence.scrollTop).toBeGreaterThan(0);
778+
expect(scrollEvidence.datasetScrollLeft).toBe(String(scrollEvidence.scrollLeft));
779+
expect(scrollEvidence.topScrollLeft).toBe(scrollEvidence.scrollLeft);
780+
781+
const zoomBefore = (await canvasTimelineState(page)).cellSize;
782+
await page.locator("#instrumentGridZoomInButton").click();
783+
await expect.poll(() => canvasTimelineState(page).then((state) => state.cellSize)).toBeGreaterThan(zoomBefore);
784+
await page.locator("#instrumentGridZoomOutButton").click();
785+
await expect.poll(() => canvasTimelineState(page).then((state) => state.cellSize)).toBe(zoomBefore);
786+
787+
await page.locator("#stopButton").click();
788+
await expect(page.locator("#stopButton")).toBeDisabled();
789+
await expect(page.locator("#playButton")).toBeEnabled();
790+
await expect(page.locator("#playbackState")).toContainText("Stopped audible preview");
791+
} finally {
792+
await workspaceV2CoverageReporter.stop(page);
793+
await server.close();
794+
}
795+
});
796+
638797
test("octave grid density supports icon controls and simultaneous chord editing", async ({ page }) => {
639798
const server = await openMidiStudioForImport(page);
640799
try {

0 commit comments

Comments
 (0)