Skip to content

Commit 37aa63a

Browse files
committed
Add MIDI Studio V2 MIDI source inspection and status clear polish - PR_26146_005-midi-studio-v2-midi-source-inspection
1 parent c2f5626 commit 37aa63a

10 files changed

Lines changed: 365 additions & 2 deletions

File tree

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
# PR_26146_005-midi-studio-v2-midi-source-inspection Validation
2+
3+
## Scope
4+
5+
- Continued from PR_26146_004 rendered-preview foundation.
6+
- Added explicit MIDI source inspection for selected `sourceMidi` paths.
7+
- Added a shared `src/engine/audio` Standard MIDI File metadata parser for header and track chunk validation.
8+
- Added MIDI Studio V2 source-inspection UI and status handling without claiming synthesized live playback.
9+
- Preserved rendered OGG/MP3/WAV browser preview behavior.
10+
- Tightened MIDI Studio V2 Status Clear horizontal padding through an external CSS override.
11+
12+
## MIDI Source Contract
13+
14+
- `.mid` remains instruction data, not playable browser audio.
15+
- Source inspection loads the selected song's `sourceMidi` only when the user requests inspection.
16+
- Parsed metadata displays format, track count, ticks-per-quarter-note, parsed track chunks, and validation status.
17+
- Missing, failed-load, or malformed `.mid` sources clear stale inspection metadata and log actionable `FAIL` status.
18+
- Live MIDI synthesis remains `NOT IMPLEMENTED`; no MIDI input, recording, DAW, piano-roll, hidden fallback MIDI, or default songs were added.
19+
20+
## Lanes
21+
22+
- runtime/tool: MIDI Studio V2 source inspection UI, failure states, rendered preview preservation, and status layout.
23+
- engine/shared audio: small Standard MIDI File metadata parser syntax and exercised browser coverage through MIDI Studio V2.
24+
- integration: skipped because Workspace Manager V2 registration and handoff files were not touched.
25+
- samples: SKIP because sample JSON alignment and sample launch validation are out of scope.
26+
27+
## Validation
28+
29+
- PASS: `node --check src/engine/audio/MidiSourceMetadataParser.js`
30+
- PASS: `node --check tools/midi-studio-v2/js/services/MidiSourceInspectionService.js`
31+
- PASS: `node --check tools/midi-studio-v2/js/controls/MidiSourceDetailsControl.js`
32+
- PASS: `node --check tools/midi-studio-v2/js/MidiStudioV2App.js`
33+
- PASS: `node --check tools/midi-studio-v2/js/bootstrap.js`
34+
- PASS: `node --check tests/playwright/tools/MidiStudioV2.spec.mjs`
35+
- PASS: MIDI Studio V2 HTML inline scan found no inline `<script>`, `<style>`, or inline event handlers.
36+
- PASS: `npx.cmd playwright test tests/playwright/tools/MidiStudioV2.spec.mjs --project=playwright --workers=1 --reporter=line`
37+
- PASS: `git diff --check`
38+
39+
## Playwright Coverage
40+
41+
- PASS: valid `.mid` source metadata display.
42+
- PASS: missing `.mid` source actionable failure without stale source details.
43+
- PASS: invalid `.mid` source actionable failure without partial metadata render.
44+
- PASS: rendered preview still uses rendered OGG path and does not use `.mid` playback.
45+
- PASS: Status Clear button carries MIDI Studio padding override and computes zero left/right padding.
46+
- PASS: invalid payload rejection before render remains covered.
47+
48+
## Explicit Skips
49+
50+
- SKIP: Workspace Manager V2 registration/handoff test because Workspace Manager files were not touched.
51+
- SKIP: full samples smoke test because samples are out of scope for this tool-focused PR.
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
export class MidiSourceMetadataParser {
2+
parse(arrayBuffer) {
3+
if (!(arrayBuffer instanceof ArrayBuffer)) {
4+
return { ok: false, message: "MIDI source bytes are unavailable." };
5+
}
6+
const view = new DataView(arrayBuffer);
7+
if (view.byteLength < 14) {
8+
return { ok: false, message: "MIDI source is too small to contain a Standard MIDI File header." };
9+
}
10+
if (this.readAscii(view, 0, 4) !== "MThd") {
11+
return { ok: false, message: "MIDI source is missing the MThd header chunk." };
12+
}
13+
const headerLength = view.getUint32(4);
14+
if (headerLength < 6) {
15+
return { ok: false, message: `MIDI header length ${headerLength} is invalid; expected at least 6 bytes.` };
16+
}
17+
const trackOffset = 8 + headerLength;
18+
if (trackOffset > view.byteLength) {
19+
return { ok: false, message: "MIDI header extends beyond the available source bytes." };
20+
}
21+
const format = view.getUint16(8);
22+
const trackCount = view.getUint16(10);
23+
const division = view.getUint16(12);
24+
if ((division & 0x8000) !== 0) {
25+
return { ok: false, message: "MIDI source uses SMPTE time division; ticks-per-quarter-note metadata is unavailable." };
26+
}
27+
const tracks = this.readTracks(view, trackOffset, trackCount);
28+
if (!tracks.ok) {
29+
return tracks;
30+
}
31+
return {
32+
format,
33+
ok: true,
34+
ticksPerQuarterNote: division,
35+
trackCount,
36+
tracks: tracks.tracks,
37+
validationStatus: `Valid Standard MIDI File header with ${tracks.tracks.length} declared track chunk${tracks.tracks.length === 1 ? "" : "s"}.`
38+
};
39+
}
40+
41+
readTracks(view, offset, trackCount) {
42+
const tracks = [];
43+
let cursor = offset;
44+
while (cursor < view.byteLength && tracks.length < trackCount) {
45+
if (cursor + 8 > view.byteLength) {
46+
return { ok: false, message: "MIDI track chunk header is truncated." };
47+
}
48+
const chunkType = this.readAscii(view, cursor, 4);
49+
const length = view.getUint32(cursor + 4);
50+
const dataOffset = cursor + 8;
51+
const nextCursor = dataOffset + length;
52+
if (nextCursor > view.byteLength) {
53+
return { ok: false, message: `MIDI track chunk ${tracks.length + 1} length exceeds source bytes.` };
54+
}
55+
if (chunkType !== "MTrk") {
56+
return { ok: false, message: `MIDI chunk ${tracks.length + 1} is ${chunkType || "unknown"}; expected MTrk.` };
57+
}
58+
tracks.push({ length, offset: dataOffset });
59+
cursor = nextCursor;
60+
}
61+
if (tracks.length !== trackCount) {
62+
return { ok: false, message: `MIDI header declares ${trackCount} tracks but ${tracks.length} track chunks were found.` };
63+
}
64+
return { ok: true, tracks };
65+
}
66+
67+
readAscii(view, offset, length) {
68+
if (offset + length > view.byteLength) {
69+
return "";
70+
}
71+
let value = "";
72+
for (let index = 0; index < length; index += 1) {
73+
value += String.fromCharCode(view.getUint8(offset + index));
74+
}
75+
return value;
76+
}
77+
}

tests/playwright/tools/MidiStudioV2.spec.mjs

Lines changed: 95 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,20 @@ const validManifest = {
7878
}
7979
};
8080

81+
const validMidiBytes = Buffer.from([
82+
0x4d, 0x54, 0x68, 0x64,
83+
0x00, 0x00, 0x00, 0x06,
84+
0x00, 0x01,
85+
0x00, 0x02,
86+
0x01, 0xe0,
87+
0x4d, 0x54, 0x72, 0x6b,
88+
0x00, 0x00, 0x00, 0x04,
89+
0x00, 0xff, 0x2f, 0x00,
90+
0x4d, 0x54, 0x72, 0x6b,
91+
0x00, 0x00, 0x00, 0x04,
92+
0x00, 0xff, 0x2f, 0x00
93+
]);
94+
8195
function installMockAudio(page) {
8296
return page.addInitScript(() => {
8397
window.__midiStudioAudioEvents = [];
@@ -106,7 +120,7 @@ function installMockAudio(page) {
106120
});
107121
}
108122

109-
async function openMidiStudio(page, routePayload = validManifest) {
123+
async function openMidiStudio(page, routePayload = validManifest, midiRoutes = {}) {
110124
const server = await startRepoServer();
111125
await installMockAudio(page);
112126
await page.route((url) => url.pathname === "/midi-fixture.game.manifest.json", async (route) => {
@@ -115,6 +129,18 @@ async function openMidiStudio(page, routePayload = validManifest) {
115129
body: JSON.stringify(routePayload)
116130
});
117131
});
132+
await page.route((url) => url.pathname.endsWith(".mid"), async (route) => {
133+
const requestPath = new URL(route.request().url()).pathname;
134+
const routeEntry = Object.entries(midiRoutes).find(([path]) => requestPath.endsWith(path));
135+
if (!routeEntry) {
136+
await route.fulfill({ status: 404, body: "missing MIDI fixture" });
137+
return;
138+
}
139+
await route.fulfill({
140+
body: routeEntry[1],
141+
contentType: "audio/midi"
142+
});
143+
});
118144
await workspaceV2CoverageReporter.start(page);
119145
await page.goto(`${server.baseUrl}/tools/midi-studio-v2/index.html?manifestPath=/midi-fixture.game.manifest.json`, { waitUntil: "domcontentloaded" });
120146
return server;
@@ -137,6 +163,8 @@ test.describe("MIDI Studio V2", () => {
137163
await expect(page.locator("#renderedTargets")).toContainText("theme-main.ogg");
138164
await expect(page.locator("#directorPanel")).toContainText("heroic");
139165
await expect(page.locator("#playButton")).toHaveText("Play Rendered Preview");
166+
await expect(page.locator("#inspectMidiSourceButton")).toBeEnabled();
167+
await expect(page.locator("#midiSourceDetails")).toContainText("No MIDI source inspected.");
140168
await expect(page.locator("#playbackState")).toContainText("Live MIDI synthesis: NOT IMPLEMENTED");
141169
await expect(page.locator("#statusLog")).toHaveValue(/OK Loaded 3 MIDI songs/);
142170
await expect(page.locator("#statusLog")).toHaveValue(/WARN Live MIDI synthesis not implemented\. sourceMidi is musical instruction data; rendered OGG\/MP3\/WAV targets are used for preview and gameplay audio\./);
@@ -163,6 +191,59 @@ test.describe("MIDI Studio V2", () => {
163191
}
164192
});
165193

194+
test("loads selected MIDI source metadata on request", async ({ page }) => {
195+
const server = await openMidiStudio(page, validManifest, {
196+
"assets/music/midi/theme-main.mid": validMidiBytes
197+
});
198+
try {
199+
await page.locator("#inspectMidiSourceButton").click();
200+
await expect(page.locator("#midiSourceDetails")).toContainText("Valid Standard MIDI File header with 2 declared track chunks.");
201+
await expect(page.locator("#midiSourceDetails")).toContainText("Format");
202+
await expect(page.locator("#midiSourceDetails")).toContainText("1");
203+
await expect(page.locator("#midiSourceDetails")).toContainText("Track count");
204+
await expect(page.locator("#midiSourceDetails")).toContainText("2");
205+
await expect(page.locator("#midiSourceDetails")).toContainText("Ticks per quarter note");
206+
await expect(page.locator("#midiSourceDetails")).toContainText("480");
207+
await expect(page.locator("#statusLog")).toHaveValue(/OK MIDI source inspected for Main Theme: format 1, 2 tracks, 480 TPQN\./);
208+
} finally {
209+
await workspaceV2CoverageReporter.stop(page);
210+
await server.close();
211+
}
212+
});
213+
214+
test("shows actionable failure for missing MIDI source inspection without stale details", async ({ page }) => {
215+
const server = await openMidiStudio(page, validManifest, {
216+
"assets/music/midi/theme-main.mid": validMidiBytes
217+
});
218+
try {
219+
await page.locator("#inspectMidiSourceButton").click();
220+
await expect(page.locator("#midiSourceDetails")).toContainText("480");
221+
await page.locator('[data-song-id="combat-light"]').click();
222+
await page.locator("#inspectMidiSourceButton").click();
223+
await expect(page.locator("#midiSourceDetails")).toContainText("Missing MIDI source for Light Combat.");
224+
await expect(page.locator("#midiSourceDetails")).not.toContainText("480");
225+
await expect(page.locator("#statusLog")).toHaveValue(/FAIL Missing MIDI source for Light Combat\. Add music\.songs\[\]\.sourceMidi in game\.manifest\.json\./);
226+
} finally {
227+
await workspaceV2CoverageReporter.stop(page);
228+
await server.close();
229+
}
230+
});
231+
232+
test("shows actionable failure for invalid MIDI source bytes without partial render", async ({ page }) => {
233+
const server = await openMidiStudio(page, validManifest, {
234+
"assets/music/midi/theme-main.mid": Buffer.from([0x4e, 0x6f])
235+
});
236+
try {
237+
await page.locator("#inspectMidiSourceButton").click();
238+
await expect(page.locator("#midiSourceDetails")).toContainText("MIDI source validation failed");
239+
await expect(page.locator("#midiSourceDetails")).not.toContainText("Ticks per quarter note");
240+
await expect(page.locator("#statusLog")).toHaveValue(/FAIL MIDI source validation failed for assets\/music\/midi\/theme-main\.mid: MIDI source is too small/);
241+
} finally {
242+
await workspaceV2CoverageReporter.stop(page);
243+
await server.close();
244+
}
245+
});
246+
166247
test("shows actionable failure when no rendered target and no live MIDI engine exist", async ({ page }) => {
167248
const server = await openMidiStudio(page);
168249
try {
@@ -200,6 +281,19 @@ test.describe("MIDI Studio V2", () => {
200281
}
201282
});
202283

284+
test("keeps the Status Clear action aligned with MIDI Studio status layout", async ({ page }) => {
285+
const server = await openMidiStudio(page);
286+
try {
287+
const clearButton = page.locator("#clearStatusButton");
288+
await expect(clearButton).toHaveClass(/midi-studio-v2__status-clear-button/);
289+
await expect(clearButton).toHaveCSS("padding-left", "0px");
290+
await expect(clearButton).toHaveCSS("padding-right", "0px");
291+
} finally {
292+
await workspaceV2CoverageReporter.stop(page);
293+
await server.close();
294+
}
295+
});
296+
203297
test("rejects invalid payloads before render", async ({ page }) => {
204298
const server = await openMidiStudio(page, {
205299
music: {

tools/midi-studio-v2/README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ Current scope:
88
- honors `tools.midi-studio-v2.activeSongId` and `directorMode` preferences;
99
- displays `.mid` source paths, instrument sets, rendered WAV/MP3/OGG targets, loop metadata, tags, and Game Music Director notes;
1010
- treats `sourceMidi` as a `.mid` instruction file path, not browser-playable audio;
11+
- inspects selected `.mid` source headers on request and displays format, track count, ticks-per-quarter-note, and validation status;
1112
- uses browser media preview only for rendered OGG/MP3/WAV targets;
1213
- reports live MIDI synthesis as not implemented until shared MIDI parser/synth/instrument capability exists;
1314
- reports missing source paths and unsupported preview playback visibly;
@@ -26,3 +27,4 @@ Playback distinction:
2627
- `sourceMidi` points to a `.mid` musical instruction file;
2728
- `rendered` targets point to runtime audio assets that browser `Audio` may preview;
2829
- future real MIDI playback requires shared `src/` MIDI parser, synth, and instrument capability.
30+
- current source inspection uses a shared `src/engine/audio` MIDI metadata parser for Standard MIDI File headers only; it does not synthesize or schedule notes.

tools/midi-studio-v2/index.html

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ <h2 class="tools-platform-frame__eyebrow">First-Class Tools Surface V2</h2>
7777
<span>Instrument set</span>
7878
<input id="instrumentSetField" type="text" readonly value="No song selected">
7979
</div>
80+
<button id="inspectMidiSourceButton" type="button" disabled>Inspect MIDI Source</button>
8081
</div>
8182
</section>
8283
</aside>
@@ -109,6 +110,16 @@ <h2 class="tools-platform-frame__eyebrow">First-Class Tools Surface V2</h2>
109110
<dl id="songDetails" class="midi-studio-v2__details"></dl>
110111
</div>
111112
</section>
113+
114+
<section class="accordion-v2 tool-starter__accordion is-open" data-accordion-v2-open="true">
115+
<button class="accordion-v2__header" type="button" aria-expanded="true" aria-controls="midiSourceDetailsContent">
116+
<span>MIDI Source Details</span>
117+
<span class="accordion-v2__icon" aria-hidden="true">+</span>
118+
</button>
119+
<div id="midiSourceDetailsContent" class="accordion-v2__content">
120+
<dl id="midiSourceDetails" class="midi-studio-v2__details"></dl>
121+
</div>
122+
</section>
112123
</section>
113124

114125
<aside class="tool-starter__panel tool-starter__panel--right" aria-label="MIDI output">
@@ -146,7 +157,7 @@ <h2 class="tools-platform-frame__eyebrow">First-Class Tools Surface V2</h2>
146157
<button class="accordion-v2__header tool-starter__status-accordion-header" type="button" aria-expanded="true" aria-controls="statusLogContent">
147158
<span>Status</span>
148159
<div class="tool-starter__status-header-actions">
149-
<button id="clearStatusButton" class="tool-starter__status-clear-button" type="button">Clear</button>
160+
<button id="clearStatusButton" class="tool-starter__status-clear-button midi-studio-v2__status-clear-button" type="button">Clear</button>
150161
<span class="accordion-v2__icon" aria-hidden="true">+</span>
151162
</div>
152163
</button>

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

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ export class MidiStudioV2App {
99
details,
1010
directorPanel,
1111
manifestLoader,
12+
midiSourceDetails,
13+
midiSourceInspection,
1214
playback,
1315
playbackControl,
1416
serializer,
@@ -22,6 +24,8 @@ export class MidiStudioV2App {
2224
this.details = details;
2325
this.directorPanel = directorPanel;
2426
this.manifestLoader = manifestLoader;
27+
this.midiSourceDetails = midiSourceDetails;
28+
this.midiSourceInspection = midiSourceInspection;
2529
this.payload = null;
2630
this.playback = playback;
2731
this.playbackControl = playbackControl;
@@ -38,6 +42,7 @@ export class MidiStudioV2App {
3842
this.accordions.forEach((accordion) => accordion.mount());
3943
this.statusLog.mount();
4044
this.songList.mount({ onSelect: (songId) => this.selectSong(songId) });
45+
this.midiSourceDetails.mount({ onInspect: () => this.inspectSelectedSource() });
4146
this.playbackControl.mount({
4247
onPlay: () => this.playSelectedSong(),
4348
onStop: () => this.stopPlayback()
@@ -67,6 +72,8 @@ export class MidiStudioV2App {
6772
this.songList.render([], "");
6873
this.details.render(null, null);
6974
this.directorPanel.render(null, {});
75+
this.midiSourceDetails.render(null);
76+
this.midiSourceDetails.setEnabled(false);
7077
this.playbackControl.setSelected(null);
7178
this.actionNav.setToolActionsEnabled(false);
7279
}
@@ -91,6 +98,8 @@ export class MidiStudioV2App {
9198
this.songList.render(this.payload?.songs || [], this.selectedSongId);
9299
this.details.render(song, this.payload);
93100
this.directorPanel.render(song, this.payload?.directorMode || {});
101+
this.midiSourceDetails.render(null);
102+
this.midiSourceDetails.setEnabled(Boolean(song));
94103
this.playbackControl.setSelected(song);
95104
this.actionNav.setToolActionsEnabled(Boolean(this.payload));
96105
}
@@ -126,6 +135,19 @@ export class MidiStudioV2App {
126135
this.statusLog.ok(`Rendered preview started for ${song.name}: ${result.path}.`);
127136
}
128137

138+
async inspectSelectedSource() {
139+
const song = this.selectedSong();
140+
this.midiSourceDetails.render(null);
141+
const result = await this.midiSourceInspection.inspect(song);
142+
if (!result.ok) {
143+
this.midiSourceDetails.render(result);
144+
this.statusLog.fail(result.message);
145+
return;
146+
}
147+
this.midiSourceDetails.render(result);
148+
this.statusLog.ok(`MIDI source inspected for ${song.name}: format ${result.format}, ${result.trackCount} track${result.trackCount === 1 ? "" : "s"}, ${result.ticksPerQuarterNote} TPQN.`);
149+
}
150+
129151
stopPlayback() {
130152
this.playback.stop();
131153
this.playbackControl.setStopped(this.selectedSong());

0 commit comments

Comments
 (0)