Skip to content

Commit 1a0da18

Browse files
committed
Make MIDI Studio V2 note edits persist and drive playback from canonical song data - PR_26146_047-midi-studio-v2-note-edit-persistence-and-playback-truth
1 parent 6875965 commit 1a0da18

7 files changed

Lines changed: 283 additions & 0 deletions

File tree

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
# PR_26146_047 MIDI Studio V2 Note Edit Persistence And Playback Truth Validation
2+
3+
Status: PASS
4+
5+
## Scope
6+
- Continued from PR_26146_046.
7+
- Kept octave timeline edits as the source of truth for the canonical selected song model.
8+
- Added visible project dirty state for edited note data.
9+
- Added Save Project behavior that serializes the canonical tool payload, updates JSON Details, clears dirty state, and reports saved song/note counts.
10+
- Added Reset Song Edits behavior that restores the selected song from the imported manifest baseline.
11+
- Preserved one Studio Octave Timeline, MIDI Import tab ownership, piano-style keyboard grid, GM instrument controls, Play/Stop, section behavior, export Type + Save, and no SoundFont/export-rendering scope.
12+
13+
## Changed-File Syntax Checks
14+
PASS:
15+
16+
```text
17+
node --check tools/midi-studio-v2/js/MidiStudioV2App.js
18+
node --check tools/midi-studio-v2/js/bootstrap.js
19+
node --check tools/midi-studio-v2/js/controls/ActionNavControl.js
20+
node --check tests/playwright/tools/MidiStudioV2.spec.mjs
21+
```
22+
23+
Additional guards:
24+
25+
```text
26+
HTML external-only guard passed
27+
CSS brace check passed
28+
```
29+
30+
## Targeted Playwright Validation
31+
PASS:
32+
33+
```text
34+
npx.cmd playwright test tests/playwright/tools/MidiStudioV2.spec.mjs -g "persists octave note edits into canonical song data, playback, save, and reset|fast octave note editing supports drag painting keyboard shortcuts selection and timeline scroll sync|derives primary song, instrument, grid, playback, and diagnostics views from the canonical selected song|imports a local MIDI source from the MIDI Import tab without default HTTP 404 noise|renders timing ruler, section navigation, and loop region visualization" --config=codex_playwright_system_chrome.config.cjs --reporter=list --workers=1 --timeout=60000
35+
```
36+
37+
Result:
38+
39+
```text
40+
Running 5 tests using 1 worker
41+
ok 1 fast octave note editing supports drag painting keyboard shortcuts selection and timeline scroll sync
42+
ok 2 persists octave note edits into canonical song data, playback, save, and reset
43+
ok 3 imports a local MIDI source from the MIDI Import tab without default HTTP 404 noise
44+
ok 4 derives primary song, instrument, grid, playback, and diagnostics views from the canonical selected song
45+
ok 5 renders timing ruler, section navigation, and loop region visualization
46+
5 passed
47+
```
48+
49+
## Required Assertions Covered
50+
- PASS: editing a note updates canonical `song.studioArrangement.lanes`.
51+
- PASS: playback uses the edited grid passed into Preview Synth.
52+
- PASS: JSON Details reflects edited notes immediately after edit and after Save Project.
53+
- PASS: visible dirty state appears after edit.
54+
- PASS: switching songs and returning preserves edited notes during the current session.
55+
- PASS: Save Project reports saved song count and editable note event count.
56+
- PASS: Reset Song Edits restores the imported song state.
57+
- PASS: Play and Stop still work through targeted playback tests.
58+
59+
## Diff Hygiene
60+
PASS:
61+
62+
```text
63+
git diff --check
64+
```
65+
66+
Git reported line-ending normalization warnings for touched files, but no whitespace errors.
67+
68+
## Not Run
69+
- Full samples smoke test was not run, per PR instructions.
70+
71+
## Result
72+
PR_26146_047 is marked PASS. Playback did not ignore edited grid state in targeted validation.

tests/playwright/tools/MidiStudioV2.spec.mjs

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -960,6 +960,74 @@ test.describe("MIDI Studio V2", () => {
960960
}
961961
});
962962

963+
test("persists octave note edits into canonical song data, playback, save, and reset", async ({ page }) => {
964+
const server = await openMidiStudioForImport(page);
965+
try {
966+
await page.locator("#toolImportManifestInput").setInputFiles(uatManifestPath);
967+
await selectInstrumentRow(page, "lead");
968+
await expect(page.locator("#projectDirtyState")).toHaveText("Saved");
969+
await expect(page.locator("body")).toHaveAttribute("data-midi-studio-dirty", "false");
970+
const originalLeadLane = await page.evaluate(() => window.__midiStudioV2App.selectedSong().studioArrangement.lanes.lead);
971+
await expect(octaveCell(page, "C6", 2)).not.toHaveAttribute("data-note-lanes", /lead/);
972+
973+
await octaveCell(page, "C6", 2).click();
974+
await expect(octaveCell(page, "C6", 2)).toHaveAttribute("data-note-lanes", /lead/);
975+
await expect(page.locator("#projectDirtyState")).toHaveText("Unsaved changes");
976+
await expect(page.locator("body")).toHaveAttribute("data-midi-studio-dirty", "true");
977+
const editedState = await page.evaluate(() => {
978+
const app = window.__midiStudioV2App;
979+
return {
980+
currentGridHasEdit: app.lastInstrumentGridResult.timeline.some((event) => event.lane === "lead" && event.stepIndex === 2 && event.value === "C6"),
981+
json: document.querySelector("#inspectorOutput").textContent,
982+
leadLane: app.selectedSong().studioArrangement.lanes.lead
983+
};
984+
});
985+
expect(editedState.currentGridHasEdit).toBe(true);
986+
expect(editedState.leadLane).toContain("C6");
987+
expect(editedState.json).toContain('"lead"');
988+
expect(editedState.json).toContain("C6");
989+
990+
await page.locator('[data-song-id="frog-hop-nursery-rhyme"]').click();
991+
await page.locator('[data-song-id="camptown-races-uat-reel"]').click();
992+
await selectInstrumentRow(page, "lead");
993+
await expect(octaveCell(page, "C6", 2)).toHaveAttribute("data-note-lanes", /lead/);
994+
expect(await page.evaluate(() => window.__midiStudioV2App.selectedSong().studioArrangement.lanes.lead)).toContain("C6");
995+
996+
await page.evaluate(() => {
997+
const app = window.__midiStudioV2App;
998+
const originalPlayGridRange = app.previewSynth.playGridRange.bind(app.previewSynth);
999+
app.__lastPreviewGridValues = [];
1000+
app.previewSynth.playGridRange = async (options) => {
1001+
app.__lastPreviewGridValues = options.grid.timeline
1002+
.filter((event) => event.lane === "lead" && event.stepIndex === 2)
1003+
.map((event) => event.value);
1004+
return originalPlayGridRange(options);
1005+
};
1006+
});
1007+
await page.locator("#playButton").click();
1008+
await expect(page.locator("#stopButton")).toBeEnabled();
1009+
expect(await page.evaluate(() => window.__midiStudioV2App.__lastPreviewGridValues)).toContain("C6");
1010+
await page.locator("#stopButton").click();
1011+
await expect(page.locator("#playButton")).toBeEnabled();
1012+
1013+
await page.locator("#saveProjectButton").click();
1014+
await expect(page.locator("#statusLog")).toHaveValue(/OK Save Project completed: 3 songs saved with \d+ editable note events\./);
1015+
await expect(page.locator("#projectDirtyState")).toHaveText("Saved");
1016+
await expect(page.locator("body")).toHaveAttribute("data-midi-studio-dirty", "false");
1017+
await expect(page.locator("#inspectorOutput")).toContainText('"schema": "html-js-gaming.tool-state"');
1018+
await expect(page.locator("#inspectorOutput")).toContainText("C6");
1019+
1020+
await page.locator("#resetSongEditsButton").click();
1021+
await expect(page.locator("#statusLog")).toHaveValue(/OK Reset Song Edits restored Camptown Races UAT Reel to imported manifest state\./);
1022+
await expect(octaveCell(page, "C6", 2)).not.toHaveAttribute("data-note-lanes", /lead/);
1023+
await expect(page.locator("#projectDirtyState")).toHaveText("Saved");
1024+
expect(await page.evaluate(() => window.__midiStudioV2App.selectedSong().studioArrangement.lanes.lead)).toBe(originalLeadLane);
1025+
} finally {
1026+
await workspaceV2CoverageReporter.stop(page);
1027+
await server.close();
1028+
}
1029+
});
1030+
9631031
test("octave timeline freezes compact headers and note labels while active cells stay textless", async ({ page }) => {
9641032
const server = await openMidiStudioForImport(page);
9651033
try {

tools/midi-studio-v2/index.html

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,9 @@ <h2 class="tools-platform-frame__eyebrow">First-Class Tools Surface V2</h2>
4444
<button id="playButton" type="button" disabled>Play</button>
4545
<button id="stopButton" type="button" disabled>Stop</button>
4646
<button id="stopAllAudioButton" type="button">Stop All Audio</button>
47+
<button id="saveProjectButton" type="button" disabled>Save Project</button>
48+
<button id="resetSongEditsButton" type="button" disabled>Reset Song Edits</button>
49+
<span id="projectDirtyState" class="midi-studio-v2__dirty-state" data-midi-studio-dirty-state="clean">Saved</span>
4750
<div class="midi-studio-v2__export-save-controls">
4851
<label class="midi-studio-v2__compact-field" for="renderedExportTargetTypeSelect">
4952
<span>Type</span>

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

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import { readFileText } from "../../../src/engine/persistence/FilePersistenceService.js";
2+
import { deepClone } from "../../../src/shared/json/clone.js";
3+
import { notifyWorkspaceToolDirty } from "../../../src/tools/common/WorkspaceDirtyNotifier.js";
24

35
const TOOL_ID = "midi-studio-v2";
46
const NOTE_NAMES = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"];
@@ -36,6 +38,9 @@ export class MidiStudioV2App {
3638
this.instrumentGrid = instrumentGrid;
3739
this.instrumentGridParser = instrumentGridParser;
3840
this.instrumentGridResults = new WeakMap();
41+
this.importedSongBaselines = new Map();
42+
this.isDirty = false;
43+
this.lastSavedToolState = null;
3944
this.manifestLoader = manifestLoader;
4045
this.midiSourceDetails = midiSourceDetails;
4146
this.midiSourceInspection = midiSourceInspection;
@@ -85,6 +90,8 @@ export class MidiStudioV2App {
8590
onToolCopyJson: () => this.copyJson(),
8691
onToolExportToolState: () => this.exportToolState(),
8792
onToolImportManifest: (file) => this.importManifestFile(file),
93+
onResetSongEdits: () => this.resetSelectedSongEdits(),
94+
onSaveProject: () => this.saveProject(),
8895
onWorkspaceCopyManifest: () => this.statusLog.info("Workspace copy is owned by Workspace Manager V2."),
8996
onWorkspaceExportManifest: () => this.statusLog.info("Workspace export is owned by Workspace Manager V2."),
9097
onWorkspaceImportManifest: () => this.statusLog.info("Workspace import is owned by Workspace Manager V2.")
@@ -141,6 +148,9 @@ export class MidiStudioV2App {
141148

142149
renderEmpty() {
143150
this.payload = null;
151+
this.importedSongBaselines = new Map();
152+
this.lastSavedToolState = null;
153+
this.setDirtyState(false);
144154
this.songList.render([], "");
145155
this.details.render(null, null);
146156
this.directorPanel.render(null, {});
@@ -171,6 +181,9 @@ export class MidiStudioV2App {
171181
return false;
172182
}
173183
this.payload = normalized.payload;
184+
this.importedSongBaselines = new Map(this.payload.songs.map((song) => [song.id, deepClone(song)]));
185+
this.lastSavedToolState = null;
186+
this.setDirtyState(false);
174187
this.render();
175188
this.applySelectedSongArrangement("active manifest song");
176189
this.statusLog.ok(`Loaded ${this.payload.songs.length} MIDI song${this.payload.songs.length === 1 ? "" : "s"} from ${sourceLabel} via ${normalized.sourceKind}.`);
@@ -259,6 +272,27 @@ export class MidiStudioV2App {
259272
};
260273
}
261274

275+
setDirtyState(isDirty) {
276+
this.isDirty = isDirty === true;
277+
this.actionNav.setDirtyState(this.isDirty);
278+
this.window.document.body.dataset.midiStudioDirty = this.isDirty ? "true" : "false";
279+
}
280+
281+
markDirty({ changedKeys = ["data.songs"], reason = "midi-studio-song-updated" } = {}) {
282+
this.setDirtyState(true);
283+
const result = notifyWorkspaceToolDirty({
284+
changedKeys,
285+
payload: this.payload,
286+
reason,
287+
toolId: TOOL_ID,
288+
windowRef: this.window
289+
});
290+
if (!result.ok) {
291+
this.statusLog.warn(`Workspace dirty state not updated: ${result.message}`);
292+
}
293+
return result;
294+
}
295+
262296
get selectedSongId() {
263297
return this.payload?.activeSongId || "";
264298
}
@@ -420,6 +454,7 @@ export class MidiStudioV2App {
420454
this.payload.songs.push(song);
421455
this.payload.activeSongId = song.id;
422456
}
457+
this.importedSongBaselines.set(song.id, deepClone(song));
423458
this.render();
424459
this.midiSourceDetails.render(result);
425460
this.applySelectedSongArrangement("local MIDI import");
@@ -655,6 +690,7 @@ export class MidiStudioV2App {
655690
this.instrumentGrid.syncEditedGridResult(result);
656691
}
657692
this.syncSelectedArrangementFromGridInput(input);
693+
this.markDirty({ changedKeys: ["data.songs.studioArrangement"], reason: "midi-studio-note-grid-edited" });
658694
if (detail.action === "add-lane") {
659695
this.statusLog.ok(`Added instrument row ${detail.laneLabel || detail.lane}; playback data updated.`);
660696
} else if (detail.action === "delete-lane") {
@@ -686,6 +722,31 @@ export class MidiStudioV2App {
686722
this.details.showJson(song);
687723
}
688724

725+
gridInputFromArrangement(arrangement) {
726+
return {
727+
bass: arrangement.lanes?.bass || "",
728+
beatsPerBar: arrangement.beatsPerBar,
729+
chords: arrangement.lanes?.chords || "",
730+
drums: arrangement.lanes?.drums || "",
731+
lanes: arrangement.lanes || {},
732+
lead: arrangement.lanes?.lead || "",
733+
pad: arrangement.lanes?.pad || "",
734+
previewInstruments: arrangement.previewInstruments || {},
735+
sections: arrangement.sections,
736+
subdivision: arrangement.subdivision
737+
};
738+
}
739+
740+
editableNoteCount(payload = this.payload) {
741+
return (payload?.songs || []).reduce((count, song) => {
742+
if (!song.studioArrangement) {
743+
return count;
744+
}
745+
const result = this.instrumentGridParser.parse(this.gridInputFromArrangement(song.studioArrangement));
746+
return result.ok ? count + result.timeline.length : count;
747+
}, 0);
748+
}
749+
689750
syncSelectedArrangementFromSongSheetResult(result) {
690751
const song = this.selectedSong();
691752
if (!song?.studioArrangement || !result?.ok) {
@@ -1026,6 +1087,48 @@ export class MidiStudioV2App {
10261087
this.statusLog.ok("MIDI Studio V2 toolState preview written to JSON Details.");
10271088
}
10281089

1090+
saveProject() {
1091+
if (!this.payload) {
1092+
this.statusLog.fail("Save Project failed: no MIDI Studio V2 payload is loaded.");
1093+
return;
1094+
}
1095+
try {
1096+
const toolState = this.serializer.createToolState(this.payload);
1097+
JSON.stringify(toolState);
1098+
this.lastSavedToolState = toolState;
1099+
this.details.showJson(toolState);
1100+
this.setDirtyState(false);
1101+
const noteCount = this.editableNoteCount();
1102+
this.statusLog.ok(`Save Project completed: ${this.payload.songs.length} song${this.payload.songs.length === 1 ? "" : "s"} saved with ${noteCount} editable note event${noteCount === 1 ? "" : "s"}.`);
1103+
} catch (error) {
1104+
this.statusLog.fail(`Save Project failed: ${error.message}`);
1105+
}
1106+
}
1107+
1108+
resetSelectedSongEdits() {
1109+
const song = this.selectedSong();
1110+
if (!song) {
1111+
this.statusLog.fail("Reset Song Edits failed: no MIDI song is selected.");
1112+
return;
1113+
}
1114+
const baseline = this.importedSongBaselines.get(song.id);
1115+
if (!baseline) {
1116+
this.statusLog.fail(`Reset Song Edits failed: imported state is unavailable for ${song.name || song.id}.`);
1117+
return;
1118+
}
1119+
const index = this.payload.songs.findIndex((candidate) => candidate.id === song.id);
1120+
if (index < 0) {
1121+
this.statusLog.fail(`Reset Song Edits failed: ${song.id} is not in the active MIDI payload.`);
1122+
return;
1123+
}
1124+
this.stopPlayback({ log: false });
1125+
this.payload.songs[index] = deepClone(baseline);
1126+
this.render();
1127+
this.applySelectedSongArrangement("Reset Song Edits");
1128+
this.setDirtyState(false);
1129+
this.statusLog.ok(`Reset Song Edits restored ${baseline.name || baseline.id} to imported manifest state.`);
1130+
}
1131+
10291132
async copyJson() {
10301133
if (!this.payload) {
10311134
this.statusLog.fail("Copy blocked: no MIDI Studio V2 payload is loaded.");

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,10 @@ window.addEventListener("DOMContentLoaded", () => {
4242
accordions: Array.from(document.querySelectorAll(".accordion-v2"), (section) => new AccordionSection(section)),
4343
actionNav: new ActionNavControl({
4444
nowPlayingLabel: requireElement("#nowPlayingLabel"),
45+
projectDirtyState: requireElement("#projectDirtyState"),
4546
returnToWorkspaceButton: requireElement("#returnToWorkspaceButton"),
47+
resetSongEditsButton: requireElement("#resetSongEditsButton"),
48+
saveProjectButton: requireElement("#saveProjectButton"),
4649
stopAllAudioButton: requireElement("#stopAllAudioButton"),
4750
toolCopyJsonButton: requireElement("#toolCopyJsonButton"),
4851
toolExportToolStateButton: requireElement("#toolExportToolStateButton"),

tools/midi-studio-v2/js/controls/ActionNavControl.js

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,10 @@ export class ActionNavControl {
22
constructor({
33
locationRef = window.location,
44
nowPlayingLabel,
5+
projectDirtyState,
56
returnToWorkspaceButton,
7+
resetSongEditsButton,
8+
saveProjectButton,
69
stopAllAudioButton,
710
toolCopyJsonButton,
811
toolExportToolStateButton,
@@ -17,7 +20,10 @@ export class ActionNavControl {
1720
}) {
1821
this.location = locationRef;
1922
this.nowPlayingLabel = nowPlayingLabel;
23+
this.projectDirtyState = projectDirtyState;
2024
this.returnToWorkspaceButton = returnToWorkspaceButton;
25+
this.resetSongEditsButton = resetSongEditsButton;
26+
this.saveProjectButton = saveProjectButton;
2127
this.stopAllAudioButton = stopAllAudioButton;
2228
this.toolCopyJsonButton = toolCopyJsonButton;
2329
this.toolExportToolStateButton = toolExportToolStateButton;
@@ -35,6 +41,8 @@ export class ActionNavControl {
3541
onToolCopyJson,
3642
onToolExportToolState,
3743
onToolImportManifest,
44+
onResetSongEdits,
45+
onSaveProject,
3846
onStopAllAudio,
3947
onWorkspaceCopyManifest,
4048
onWorkspaceExportManifest,
@@ -43,6 +51,8 @@ export class ActionNavControl {
4351
this.applyLaunchMode();
4452
this.toolImportManifestButton.addEventListener("click", () => this.toolImportManifestInput.click());
4553
this.toolImportManifestInput.addEventListener("change", () => onToolImportManifest(this.toolImportManifestInput.files?.[0] || null));
54+
this.saveProjectButton.addEventListener("click", onSaveProject);
55+
this.resetSongEditsButton.addEventListener("click", onResetSongEdits);
4656
this.stopAllAudioButton.addEventListener("click", onStopAllAudio);
4757
this.toolCopyJsonButton.addEventListener("click", onToolCopyJson);
4858
this.toolExportToolStateButton.addEventListener("click", onToolExportToolState);
@@ -69,6 +79,16 @@ export class ActionNavControl {
6979
setToolActionsEnabled(isEnabled) {
7080
this.toolCopyJsonButton.disabled = !isEnabled;
7181
this.toolExportToolStateButton.disabled = !isEnabled;
82+
this.saveProjectButton.disabled = !isEnabled;
83+
this.resetSongEditsButton.disabled = !isEnabled;
84+
}
85+
86+
setDirtyState(isDirty) {
87+
if (!this.projectDirtyState) {
88+
return;
89+
}
90+
this.projectDirtyState.dataset.midiStudioDirtyState = isDirty ? "dirty" : "clean";
91+
this.projectDirtyState.textContent = isDirty ? "Unsaved changes" : "Saved";
7292
}
7393

7494
setNowPlaying(song, { playing = false } = {}) {

0 commit comments

Comments
 (0)