Skip to content

Commit a7b7552

Browse files
committed
Improve MIDI Studio V2 canvas note editing hover, paint, erase, and selection flow - PR_26146_056-midi-studio-v2-canvas-note-editing-flow
1 parent 15de451 commit a7b7552

5 files changed

Lines changed: 396 additions & 21 deletions

File tree

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
# PR_26146_056 MIDI Studio V2 Canvas Note Editing Flow Validation
2+
3+
Result: PASS
4+
5+
## Scope
6+
7+
- Continued from PR_26146_055.
8+
- Focused only on MIDI Studio V2 canvas Octave Timeline editing workflow.
9+
- Preserved DOM tabs, instruments, import/save/export controls, diagnostics, canonical song model, manifest import, multiple songs, GM controls, Play/Stop, and PR055 unwired visibility behavior.
10+
11+
## Implementation Summary
12+
13+
- Added canvas hover-cell feedback with renderer state, dataset markers, and controlled canvas redraws.
14+
- Added pointer-driven click-to-toggle behavior with immediate canvas/canonical model updates.
15+
- Added drag-paint and drag-erase flows across beats and rows while preserving multi-note/chord storage.
16+
- Added selected-cell dataset markers and preserved selected-cell details without dialogs.
17+
- Kept playback reading edited canonical song data.
18+
- Added short Preview Synth auditions for painted notes when audio is available.
19+
- Added actionable WARN logging when audio audition is unavailable while keeping the edit.
20+
- Avoided full instrument/control rerender for note edits by syncing the edited grid result into the existing canvas renderer.
21+
22+
## Validation Commands
23+
24+
```powershell
25+
node --check tools/midi-studio-v2/js/controls/OctaveTimelineCanvasRenderer.js
26+
node --check tools/midi-studio-v2/js/controls/InstrumentGridControl.js
27+
node --check tools/midi-studio-v2/js/MidiStudioV2App.js
28+
node --check tests/playwright/tools/MidiStudioV2.spec.mjs
29+
npx playwright test tests/playwright/tools/MidiStudioV2.spec.mjs --grep "canvas octave timeline edits canonical data|canvas note editing flow|canvas note editing warns" --reporter=line
30+
git diff --check
31+
```
32+
33+
## Validation Results
34+
35+
- Changed-file syntax checks: PASS.
36+
- Targeted MIDI Studio V2 Playwright tests: PASS, 3 passed.
37+
- `git diff --check`: PASS.
38+
- Full samples smoke test: not run per PR instruction.
39+
40+
## Playwright Coverage
41+
42+
The targeted Playwright run proves:
43+
44+
- Canvas cells expose hover feedback.
45+
- Click paints and erases/toggles notes.
46+
- Drag-paint adds multiple notes.
47+
- Drag-erase removes multiple notes.
48+
- Edited notes update canonical song/timeline data.
49+
- Playback uses edited visible/canonical note data.
50+
- Selected note/cell indication appears.
51+
- Unavailable audio audition reports WARN and does not block editing.
52+
- Play and Stop still work.
53+
54+
## Notes
55+
56+
- No SoundFont playback was added.
57+
- No export rendering was added.
58+
- No MIDI recording/input was added.

tests/playwright/tools/MidiStudioV2.spec.mjs

Lines changed: 190 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -440,6 +440,73 @@ async function clickCanvasCell(page, rowToken, stepIndex) {
440440
await page.mouse.click(point.x, point.y);
441441
}
442442

443+
async function hoverCanvasCell(page, rowToken, stepIndex) {
444+
await scrollCanvasCellIntoView(page, rowToken, stepIndex);
445+
const point = await page.evaluate((target) => window.__midiStudioV2App.instrumentGrid.timelineCanvasCellCenter(target.rowToken, target.stepIndex), { rowToken, stepIndex });
446+
expect(point).toBeTruthy();
447+
await page.mouse.move(point.x, point.y);
448+
}
449+
450+
async function dragCanvasCells(page, fromRowToken, fromStepIndex, toRowToken, toStepIndex) {
451+
await scrollCanvasCellIntoView(page, fromRowToken, fromStepIndex);
452+
const points = await page.evaluate((target) => ({
453+
from: window.__midiStudioV2App.instrumentGrid.timelineCanvasCellCenter(target.fromRowToken, target.fromStepIndex),
454+
to: window.__midiStudioV2App.instrumentGrid.timelineCanvasCellCenter(target.toRowToken, target.toStepIndex)
455+
}), { fromRowToken, fromStepIndex, toRowToken, toStepIndex });
456+
expect(points.from).toBeTruthy();
457+
expect(points.to).toBeTruthy();
458+
await page.mouse.move(points.from.x, points.from.y);
459+
await page.mouse.down();
460+
await page.mouse.move(points.to.x, points.to.y, { steps: 8 });
461+
await page.mouse.up();
462+
}
463+
464+
async function canvasCellPixel(page, rowToken, stepIndex) {
465+
return page.evaluate((target) => {
466+
const canvas = document.querySelector("[data-octave-timeline-canvas='true']");
467+
const point = window.__midiStudioV2App.instrumentGrid.timelineCanvasCellCenter(target.rowToken, target.stepIndex);
468+
const ratio = canvas.width / canvas.clientWidth;
469+
const sample = canvas.getContext("2d").getImageData(Math.round((point.x - canvas.getBoundingClientRect().left) * ratio), Math.round((point.y - canvas.getBoundingClientRect().top) * ratio), 1, 1).data;
470+
return Array.from(sample);
471+
}, { rowToken, stepIndex });
472+
}
473+
474+
async function emptyCanvasRun(page, { lane = "lead", length = 3 } = {}) {
475+
return page.evaluate(({ lane, length }) => {
476+
const app = window.__midiStudioV2App;
477+
const result = app.lastInstrumentGridResult;
478+
const state = app.instrumentGrid.timelineCanvasState();
479+
const rows = state.rows.map((row) => row.value);
480+
const hasNote = (rowToken, stepIndex) => result.timeline.some((event) => event.lane === lane
481+
&& event.stepIndex === stepIndex
482+
&& app.instrumentGrid.rowsForEvent(event).includes(rowToken));
483+
for (const rowToken of rows) {
484+
for (let stepIndex = 1; stepIndex <= result.totalSteps - length; stepIndex += 1) {
485+
let empty = true;
486+
for (let offset = 0; offset < length; offset += 1) {
487+
if (hasNote(rowToken, stepIndex + offset)) {
488+
empty = false;
489+
break;
490+
}
491+
}
492+
if (empty) {
493+
return { rowToken, stepIndex };
494+
}
495+
}
496+
}
497+
return null;
498+
}, { lane, length });
499+
}
500+
501+
async function hasCanvasNote(page, lane, rowToken, stepIndex) {
502+
return page.evaluate((target) => {
503+
const app = window.__midiStudioV2App;
504+
return app.lastInstrumentGridResult.timeline.some((event) => event.lane === target.lane
505+
&& event.stepIndex === target.stepIndex
506+
&& app.instrumentGrid.rowsForEvent(event).includes(target.rowToken));
507+
}, { lane, rowToken, stepIndex });
508+
}
509+
443510
function instrumentRow(page, lane) {
444511
return page.locator(`.midi-studio-v2__instrument-row[data-lane="${lane}"]`);
445512
}
@@ -738,7 +805,7 @@ test.describe("MIDI Studio V2", () => {
738805
expect(keyboardAxisPixel.slice(0, 3).some((channel) => channel > 0)).toBe(true);
739806

740807
await clickCanvasCell(page, "C6", 2);
741-
await expect(page.locator("#statusLog")).toHaveValue(/OK Toggled C6 for Lead; visible timeline playback data updated\./);
808+
await expect(page.locator("#statusLog")).toHaveValue(/OK Painted C6 for Lead across the timeline; playback data updated\./);
742809
const canonicalEdit = await page.evaluate(() => {
743810
const app = window.__midiStudioV2App;
744811
return {
@@ -809,7 +876,7 @@ test.describe("MIDI Studio V2", () => {
809876
expect(scrollEvidence.scrollLeft).toBeGreaterThan(0);
810877
expect(scrollEvidence.scrollTop).toBeGreaterThan(0);
811878
expect(scrollEvidence.datasetScrollLeft).toBe(String(scrollEvidence.scrollLeft));
812-
expect(scrollEvidence.topScrollLeft).toBe(scrollEvidence.scrollLeft);
879+
expect(Math.abs(scrollEvidence.topScrollLeft - scrollEvidence.scrollLeft)).toBeLessThanOrEqual(1);
813880

814881
const zoomBefore = (await canvasTimelineState(page)).cellSize;
815882
await page.locator("#instrumentGridZoomInButton").click();
@@ -827,6 +894,127 @@ test.describe("MIDI Studio V2", () => {
827894
}
828895
});
829896

897+
test("canvas note editing flow supports hover click drag paint erase and playback", async ({ page }) => {
898+
const server = await openMidiStudioForImport(page);
899+
try {
900+
await page.locator("#toolImportManifestInput").setInputFiles(uatManifestPath);
901+
await selectMidiStudioTab(page, "instruments");
902+
await selectInstrumentRow(page, "lead");
903+
await selectMidiStudioTab(page, "studio");
904+
await waitForCanvasRender(page);
905+
await expect(octaveTimelineCanvas(page)).toBeVisible();
906+
await expect(page.locator(".midi-studio-v2__octave-note-cell")).toHaveCount(0);
907+
908+
const target = await emptyCanvasRun(page, { lane: "lead", length: 4 });
909+
expect(target).toBeTruthy();
910+
const hoverBefore = await canvasCellPixel(page, target.rowToken, target.stepIndex);
911+
await hoverCanvasCell(page, target.rowToken, target.stepIndex);
912+
await expect(octaveTimelineCanvas(page)).toHaveAttribute("data-hover-row-token", target.rowToken);
913+
await expect(octaveTimelineCanvas(page)).toHaveAttribute("data-hover-step-index", String(target.stepIndex));
914+
await expect.poll(() => canvasTimelineState(page).then((state) => state.hoverCell)).toEqual({
915+
rowToken: target.rowToken,
916+
stepIndex: target.stepIndex
917+
});
918+
const hoverAfter = await canvasCellPixel(page, target.rowToken, target.stepIndex);
919+
expect(hoverAfter).not.toEqual(hoverBefore);
920+
921+
await page.evaluate(() => {
922+
window.__midiStudioPreviewSynthEvents = [];
923+
});
924+
await clickCanvasCell(page, target.rowToken, target.stepIndex);
925+
await expect(page.locator("#statusLog")).toHaveValue(/OK Painted .* for Lead across the timeline; playback data updated\./);
926+
expect(await hasCanvasNote(page, "lead", target.rowToken, target.stepIndex)).toBe(true);
927+
await expect(octaveTimelineCanvas(page)).toHaveAttribute("data-selected-row-token", target.rowToken);
928+
await expect(octaveTimelineCanvas(page)).toHaveAttribute("data-selected-step-index", String(target.stepIndex));
929+
await expect(page.locator("#timelineSelectionDetails")).toContainText("Selected cell");
930+
await expect(page.locator("#timelineSelectionDetails")).toContainText(`${target.rowToken} / step ${target.stepIndex + 1}`);
931+
await expect.poll(() => page.evaluate(() => window.__midiStudioPreviewSynthEvents.some((event) => event.action === "oscillator-start"))).toBe(true);
932+
933+
await clickCanvasCell(page, target.rowToken, target.stepIndex);
934+
await expect(page.locator("#statusLog")).toHaveValue(/OK Erased .* for Lead across the timeline; playback data updated\./);
935+
expect(await hasCanvasNote(page, "lead", target.rowToken, target.stepIndex)).toBe(false);
936+
937+
await page.evaluate(() => {
938+
window.__midiStudioPreviewSynthEvents = [];
939+
});
940+
await dragCanvasCells(page, target.rowToken, target.stepIndex, target.rowToken, target.stepIndex + 2);
941+
await expect(page.locator("#statusLog")).toHaveValue(/OK Painted .* for Lead across the timeline; playback data updated\./);
942+
for (let stepIndex = target.stepIndex; stepIndex <= target.stepIndex + 2; stepIndex += 1) {
943+
expect(await hasCanvasNote(page, "lead", target.rowToken, stepIndex)).toBe(true);
944+
}
945+
await expect(octaveTimelineCanvas(page)).toHaveAttribute("data-selected-row-token", target.rowToken);
946+
await expect(octaveTimelineCanvas(page)).toHaveAttribute("data-selected-step-index", String(target.stepIndex + 2));
947+
await expect.poll(() => page.evaluate(() => window.__midiStudioPreviewSynthEvents.some((event) => event.action === "oscillator-start"))).toBe(true);
948+
949+
const canonicalPainted = await page.evaluate((paintTarget) => {
950+
const app = window.__midiStudioV2App;
951+
return [paintTarget.stepIndex, paintTarget.stepIndex + 1, paintTarget.stepIndex + 2].map((stepIndex) => ({
952+
stepIndex,
953+
token: app.instrumentGrid.tokenForLaneStep("lead", stepIndex),
954+
visibleInGrid: app.lastInstrumentGridResult.timeline.some((event) => event.lane === "lead"
955+
&& event.stepIndex === stepIndex
956+
&& app.instrumentGrid.rowsForEvent(event).includes(paintTarget.rowToken))
957+
}));
958+
}, target);
959+
expect(canonicalPainted.every((entry) => entry.visibleInGrid && entry.token.includes(target.rowToken))).toBe(true);
960+
961+
await page.evaluate((paintTarget) => {
962+
const app = window.__midiStudioV2App;
963+
const originalPlayGridRange = app.previewSynth.playGridRange.bind(app.previewSynth);
964+
app.__lastPreviewGridValues = [];
965+
app.previewSynth.playGridRange = async (options) => {
966+
app.__lastPreviewGridValues = options.grid.timeline
967+
.filter((event) => event.lane === "lead"
968+
&& event.stepIndex >= paintTarget.stepIndex
969+
&& event.stepIndex <= paintTarget.stepIndex + 2)
970+
.map((event) => event.value);
971+
return originalPlayGridRange(options);
972+
};
973+
window.__midiStudioPreviewSynthEvents = [];
974+
}, target);
975+
await page.locator("#playButton").click();
976+
await expect(page.locator("#stopButton")).toBeEnabled();
977+
await expect(page.locator("#playButton")).toBeDisabled();
978+
await expect.poll(() => page.evaluate(() => window.__midiStudioV2App.__lastPreviewGridValues)).toContain(target.rowToken);
979+
await expect.poll(() => page.evaluate(() => window.__midiStudioPreviewSynthEvents.some((event) => event.action === "oscillator-start"))).toBe(true);
980+
await page.locator("#stopButton").click();
981+
await expect(page.locator("#stopButton")).toBeDisabled();
982+
await expect(page.locator("#playButton")).toBeEnabled();
983+
984+
await dragCanvasCells(page, target.rowToken, target.stepIndex, target.rowToken, target.stepIndex + 2);
985+
await expect(page.locator("#statusLog")).toHaveValue(/OK Erased .* for Lead across the timeline; playback data updated\./);
986+
for (let stepIndex = target.stepIndex; stepIndex <= target.stepIndex + 2; stepIndex += 1) {
987+
expect(await hasCanvasNote(page, "lead", target.rowToken, stepIndex)).toBe(false);
988+
}
989+
} finally {
990+
await workspaceV2CoverageReporter.stop(page);
991+
await server.close();
992+
}
993+
});
994+
995+
test("canvas note editing warns when note audition audio is unavailable without blocking edits", async ({ page }) => {
996+
const server = await openMidiStudioForImport(page, { webAudio: false });
997+
try {
998+
await page.locator("#toolImportManifestInput").setInputFiles(uatManifestPath);
999+
await selectMidiStudioTab(page, "instruments");
1000+
await selectInstrumentRow(page, "lead");
1001+
await selectMidiStudioTab(page, "studio");
1002+
await waitForCanvasRender(page);
1003+
1004+
const target = await emptyCanvasRun(page, { lane: "lead", length: 2 });
1005+
expect(target).toBeTruthy();
1006+
await clickCanvasCell(page, target.rowToken, target.stepIndex);
1007+
await expect(page.locator("#statusLog")).toHaveValue(/OK Painted .* for Lead across the timeline; playback data updated\./);
1008+
await expect(page.locator("#statusLog")).toHaveValue(/WARN Preview Synth note audition unavailable: Preview Synth audio unavailable: Web Audio AudioContext is not available\. Use a browser with Web Audio support\. Editing was kept\./);
1009+
expect(await hasCanvasNote(page, "lead", target.rowToken, target.stepIndex)).toBe(true);
1010+
await expect(octaveTimelineCanvas(page)).toHaveAttribute("data-selected-row-token", target.rowToken);
1011+
await expect(octaveTimelineCanvas(page)).toHaveAttribute("data-selected-step-index", String(target.stepIndex));
1012+
} finally {
1013+
await workspaceV2CoverageReporter.stop(page);
1014+
await server.close();
1015+
}
1016+
});
1017+
8301018
test("octave grid density supports icon controls and simultaneous chord editing", async ({ page }) => {
8311019
const server = await openMidiStudioForImport(page);
8321020
try {

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

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -834,7 +834,7 @@ export class MidiStudioV2App {
834834
return;
835835
}
836836
this.setCurrentInstrumentGridResult(result);
837-
if (["add-lane", "delete-lane", "delete-selected-note", "duplicate-selected-note", "paint-notes", "toggle-note"].includes(detail.action)) {
837+
if (["add-lane", "delete-lane"].includes(detail.action)) {
838838
this.instrumentGrid.render(result);
839839
} else {
840840
this.instrumentGrid.syncEditedGridResult(result);
@@ -851,11 +851,43 @@ export class MidiStudioV2App {
851851
this.statusLog.ok(`Duplicated selected ${detail.rowToken || "note"} for ${detail.laneLabel || "instrument"}; playback data updated.`);
852852
} else if (detail.action === "paint-notes") {
853853
this.statusLog.ok(`Painted ${detail.rowToken || "notes"} for ${detail.laneLabel || "instrument"} across the timeline; playback data updated.`);
854+
} else if (detail.action === "erase-notes") {
855+
this.statusLog.ok(`Erased ${detail.rowToken || "notes"} for ${detail.laneLabel || "instrument"} across the timeline; playback data updated.`);
854856
} else if (detail.action === "toggle-note") {
855857
this.statusLog.ok(`Toggled ${detail.rowToken || detail.note || "note"} for ${detail.laneLabel || "instrument"}; visible timeline playback data updated.`);
856858
} else {
857859
this.statusLog.ok(`Edited ${detail.laneLabel || "instrument"} note cell; playback data updated.`);
858860
}
861+
if (detail.audition) {
862+
void this.auditionEditedTimelineCell(result, detail);
863+
}
864+
this.updateAudioDiagnostics();
865+
}
866+
867+
async auditionEditedTimelineCell(result, detail = {}) {
868+
const stepIndex = Number(detail.auditionStepIndex);
869+
if (!result?.ok || !Number.isInteger(stepIndex)) {
870+
return;
871+
}
872+
const audition = await this.previewSynth.playGridRange({
873+
endStep: stepIndex,
874+
grid: result,
875+
label: `${detail.rowToken || "note"} step ${stepIndex + 1}`,
876+
laneSettings: this.instrumentGrid.previewLaneSettings(),
877+
loop: false,
878+
mode: "note audition",
879+
startStep: stepIndex,
880+
tempoBpm: this.previewTempoBpm()
881+
});
882+
if (!audition.ok) {
883+
this.statusLog.warn(`Preview Synth note audition unavailable: ${audition.message} Editing was kept.`);
884+
this.updateAudioDiagnostics();
885+
return;
886+
}
887+
if (audition.warnings?.length) {
888+
this.statusLog.warn(`Preview Synth note audition warnings: ${audition.warnings.join("; ")}`);
889+
}
890+
this.statusLog.info(`Auditioned edited ${detail.rowToken || "note"} for ${detail.laneLabel || "instrument"}.`);
859891
this.updateAudioDiagnostics();
860892
}
861893

0 commit comments

Comments
 (0)