Skip to content

Commit 12d0ed4

Browse files
committed
Add shared MIDI parser foundation and extended MIDI source analysis for MIDI Studio V2 - PR_26146_007-midi-studio-v2-shared-midi-parser-foundation
1 parent b62ad42 commit 12d0ed4

4 files changed

Lines changed: 320 additions & 6 deletions

File tree

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
# PR_26146_007-midi-studio-v2-shared-midi-parser-foundation Validation
2+
3+
## Scope
4+
5+
- Continued from PR_26146_006.
6+
- Expanded the shared MIDI parser under `src/engine/audio/MidiSourceMetadataParser.js`.
7+
- Kept MIDI parsing ownership in shared `src/` runtime capability instead of tool-local parsing.
8+
- Extended MIDI Studio V2 source details to display estimated duration, tempo summary, time signature summary, and note/event counts.
9+
- Preserved MIDI Studio V2 rendered OGG/MP3/WAV preview behavior, header-action layout behavior, source inspection flow, and manifest schema.
10+
- Did not implement live synthesis, piano-roll editing, or DAW sequencing.
11+
12+
## Shared Parser Capability
13+
14+
- Parses Standard MIDI File header metadata.
15+
- Parses declared track chunks.
16+
- Walks track events with variable-length delta times and running-status handling.
17+
- Extracts tempo meta events.
18+
- Extracts time signature meta events.
19+
- Counts note-on, note-off, MIDI channel, meta, and system events.
20+
- Estimates duration from ticks-per-quarter-note and tempo events.
21+
- Rejects corrupt or truncated MIDI visibly; no silent fallback parsing is used.
22+
23+
## Validation
24+
25+
- PASS: `node --check src/engine/audio/MidiSourceMetadataParser.js`
26+
- PASS: `node --check tools/midi-studio-v2/js/controls/MidiSourceDetailsControl.js`
27+
- PASS: `node --check tests/playwright/tools/MidiStudioV2.spec.mjs`
28+
- PASS: `npx.cmd playwright test tests/playwright/tools/MidiStudioV2.spec.mjs --project=playwright --workers=1 --reporter=line`
29+
- PASS: `git diff --check`
30+
31+
## Playwright Coverage
32+
33+
- PASS: valid MIDI metadata extraction displays format, tracks, TPQN, and parsed track chunks.
34+
- PASS: tempo extraction displays `120 BPM at tick 0`.
35+
- PASS: time signature extraction displays `4/4 at tick 0`.
36+
- PASS: duration estimation displays `0.5 seconds`.
37+
- PASS: note/event counts display note-on, note-off, MIDI, and meta event counts.
38+
- PASS: corrupt MIDI rejects with actionable `FAIL` status.
39+
- PASS: corrupt MIDI does not leave partial source metadata rendered.
40+
- PASS: existing rendered preview behavior still uses rendered OGG.
41+
- PASS: existing header-action layout behavior still passes.
42+
43+
## Explicit Skips
44+
45+
- SKIP: Workspace Manager V2 registration/handoff test because Workspace Manager files were not touched.
46+
- SKIP: full samples smoke test because sample JSON alignment is out of scope.

src/engine/audio/MidiSourceMetadataParser.js

Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,11 +28,26 @@ export class MidiSourceMetadataParser {
2828
if (!tracks.ok) {
2929
return tracks;
3030
}
31+
const events = this.readTrackEvents(view, tracks.tracks);
32+
if (!events.ok) {
33+
return events;
34+
}
35+
const durationSeconds = this.estimateDurationSeconds({
36+
maxTick: events.maxTick,
37+
tempoEvents: events.tempoEvents,
38+
ticksPerQuarterNote: division
39+
});
3140
return {
41+
durationSeconds,
3242
format,
43+
eventCounts: events.eventCounts,
3344
ok: true,
3445
ticksPerQuarterNote: division,
3546
trackCount,
47+
tempoEvents: events.tempoEvents,
48+
tempoSummary: this.formatTempoSummary(events.tempoEvents),
49+
timeSignatureEvents: events.timeSignatureEvents,
50+
timeSignatureSummary: this.formatTimeSignatureSummary(events.timeSignatureEvents),
3651
tracks: tracks.tracks,
3752
validationStatus: `Valid Standard MIDI File header with ${tracks.tracks.length} declared track chunk${tracks.tracks.length === 1 ? "" : "s"}.`
3853
};
@@ -64,6 +79,215 @@ export class MidiSourceMetadataParser {
6479
return { ok: true, tracks };
6580
}
6681

82+
readTrackEvents(view, tracks) {
83+
const eventCounts = {
84+
meta: 0,
85+
midi: 0,
86+
noteOff: 0,
87+
noteOn: 0,
88+
system: 0
89+
};
90+
const tempoEvents = [];
91+
const timeSignatureEvents = [];
92+
let maxTick = 0;
93+
for (let trackIndex = 0; trackIndex < tracks.length; trackIndex += 1) {
94+
const track = tracks[trackIndex];
95+
let absoluteTick = 0;
96+
let cursor = track.offset;
97+
let runningStatus = 0;
98+
const endOffset = track.offset + track.length;
99+
while (cursor < endOffset) {
100+
const delta = this.readVariableLengthQuantity(view, cursor, endOffset);
101+
if (!delta.ok) {
102+
return { ok: false, message: `MIDI track ${trackIndex + 1} has invalid delta time: ${delta.message}` };
103+
}
104+
cursor = delta.nextOffset;
105+
absoluteTick += delta.value;
106+
maxTick = Math.max(maxTick, absoluteTick);
107+
if (cursor >= endOffset) {
108+
return { ok: false, message: `MIDI track ${trackIndex + 1} ended after delta time without an event.` };
109+
}
110+
let status = view.getUint8(cursor);
111+
if (status < 0x80) {
112+
if (!runningStatus) {
113+
return { ok: false, message: `MIDI track ${trackIndex + 1} uses running status before any channel status byte.` };
114+
}
115+
status = runningStatus;
116+
} else {
117+
cursor += 1;
118+
runningStatus = status < 0xf0 ? status : 0;
119+
}
120+
if (status === 0xff) {
121+
const meta = this.readMetaEvent(view, cursor, endOffset, absoluteTick, trackIndex);
122+
if (!meta.ok) {
123+
return meta;
124+
}
125+
cursor = meta.nextOffset;
126+
eventCounts.meta += 1;
127+
if (meta.tempoEvent) {
128+
tempoEvents.push(meta.tempoEvent);
129+
}
130+
if (meta.timeSignatureEvent) {
131+
timeSignatureEvents.push(meta.timeSignatureEvent);
132+
}
133+
continue;
134+
}
135+
if (status === 0xf0 || status === 0xf7) {
136+
const systemEvent = this.readSystemEvent(view, cursor, endOffset, trackIndex);
137+
if (!systemEvent.ok) {
138+
return systemEvent;
139+
}
140+
cursor = systemEvent.nextOffset;
141+
eventCounts.system += 1;
142+
continue;
143+
}
144+
const channelEvent = this.readChannelEvent(view, cursor, endOffset, status, trackIndex);
145+
if (!channelEvent.ok) {
146+
return channelEvent;
147+
}
148+
cursor = channelEvent.nextOffset;
149+
eventCounts.midi += 1;
150+
if (channelEvent.noteOn) {
151+
eventCounts.noteOn += 1;
152+
}
153+
if (channelEvent.noteOff) {
154+
eventCounts.noteOff += 1;
155+
}
156+
}
157+
}
158+
tempoEvents.sort((left, right) => left.tick - right.tick);
159+
timeSignatureEvents.sort((left, right) => left.tick - right.tick);
160+
return { eventCounts, maxTick, ok: true, tempoEvents, timeSignatureEvents };
161+
}
162+
163+
readMetaEvent(view, offset, endOffset, tick, trackIndex) {
164+
if (offset >= endOffset) {
165+
return { ok: false, message: `MIDI track ${trackIndex + 1} has a truncated meta event type.` };
166+
}
167+
const type = view.getUint8(offset);
168+
const length = this.readVariableLengthQuantity(view, offset + 1, endOffset);
169+
if (!length.ok) {
170+
return { ok: false, message: `MIDI track ${trackIndex + 1} has invalid meta event length: ${length.message}` };
171+
}
172+
const dataOffset = length.nextOffset;
173+
const nextOffset = dataOffset + length.value;
174+
if (nextOffset > endOffset) {
175+
return { ok: false, message: `MIDI track ${trackIndex + 1} meta event length exceeds track bytes.` };
176+
}
177+
if (type === 0x51) {
178+
if (length.value !== 3) {
179+
return { ok: false, message: `MIDI track ${trackIndex + 1} tempo event length ${length.value} is invalid; expected 3 bytes.` };
180+
}
181+
const microsecondsPerQuarterNote = (view.getUint8(dataOffset) << 16) | (view.getUint8(dataOffset + 1) << 8) | view.getUint8(dataOffset + 2);
182+
return {
183+
nextOffset,
184+
ok: true,
185+
tempoEvent: {
186+
bpm: this.roundBpm(60000000 / microsecondsPerQuarterNote),
187+
microsecondsPerQuarterNote,
188+
tick
189+
}
190+
};
191+
}
192+
if (type === 0x58) {
193+
if (length.value < 4) {
194+
return { ok: false, message: `MIDI track ${trackIndex + 1} time signature event is truncated.` };
195+
}
196+
const numerator = view.getUint8(dataOffset);
197+
const denominator = 2 ** view.getUint8(dataOffset + 1);
198+
return {
199+
nextOffset,
200+
ok: true,
201+
timeSignatureEvent: {
202+
denominator,
203+
numerator,
204+
tick
205+
}
206+
};
207+
}
208+
return { nextOffset, ok: true };
209+
}
210+
211+
readSystemEvent(view, offset, endOffset, trackIndex) {
212+
const length = this.readVariableLengthQuantity(view, offset, endOffset);
213+
if (!length.ok) {
214+
return { ok: false, message: `MIDI track ${trackIndex + 1} has invalid system event length: ${length.message}` };
215+
}
216+
const nextOffset = length.nextOffset + length.value;
217+
if (nextOffset > endOffset) {
218+
return { ok: false, message: `MIDI track ${trackIndex + 1} system event length exceeds track bytes.` };
219+
}
220+
return { nextOffset, ok: true };
221+
}
222+
223+
readChannelEvent(view, offset, endOffset, status, trackIndex) {
224+
const command = status & 0xf0;
225+
const dataLength = command === 0xc0 || command === 0xd0 ? 1 : 2;
226+
if (status < 0x80 || status > 0xef) {
227+
return { ok: false, message: `MIDI track ${trackIndex + 1} has unsupported event status 0x${status.toString(16)}.` };
228+
}
229+
if (offset + dataLength > endOffset) {
230+
return { ok: false, message: `MIDI track ${trackIndex + 1} channel event is truncated.` };
231+
}
232+
const data2 = dataLength === 2 ? view.getUint8(offset + 1) : 0;
233+
return {
234+
nextOffset: offset + dataLength,
235+
noteOff: command === 0x80 || (command === 0x90 && data2 === 0),
236+
noteOn: command === 0x90 && data2 > 0,
237+
ok: true
238+
};
239+
}
240+
241+
readVariableLengthQuantity(view, offset, endOffset) {
242+
let value = 0;
243+
for (let count = 0; count < 4; count += 1) {
244+
if (offset + count >= endOffset) {
245+
return { ok: false, message: "variable-length quantity is truncated." };
246+
}
247+
const byte = view.getUint8(offset + count);
248+
value = (value << 7) | (byte & 0x7f);
249+
if ((byte & 0x80) === 0) {
250+
return { nextOffset: offset + count + 1, ok: true, value };
251+
}
252+
}
253+
return { ok: false, message: "variable-length quantity exceeds 4 bytes." };
254+
}
255+
256+
estimateDurationSeconds({ maxTick, tempoEvents, ticksPerQuarterNote }) {
257+
let seconds = 0;
258+
let tick = 0;
259+
let microsecondsPerQuarterNote = 500000;
260+
tempoEvents.forEach((event) => {
261+
if (event.tick > tick) {
262+
seconds += ((event.tick - tick) / ticksPerQuarterNote) * (microsecondsPerQuarterNote / 1000000);
263+
tick = event.tick;
264+
}
265+
microsecondsPerQuarterNote = event.microsecondsPerQuarterNote;
266+
});
267+
if (maxTick > tick) {
268+
seconds += ((maxTick - tick) / ticksPerQuarterNote) * (microsecondsPerQuarterNote / 1000000);
269+
}
270+
return Number(seconds.toFixed(3));
271+
}
272+
273+
formatTempoSummary(tempoEvents) {
274+
if (!tempoEvents.length) {
275+
return "Default 120 BPM";
276+
}
277+
return tempoEvents.map((event) => `${event.bpm} BPM at tick ${event.tick}`).join("; ");
278+
}
279+
280+
formatTimeSignatureSummary(timeSignatureEvents) {
281+
if (!timeSignatureEvents.length) {
282+
return "not declared";
283+
}
284+
return timeSignatureEvents.map((event) => `${event.numerator}/${event.denominator} at tick ${event.tick}`).join("; ");
285+
}
286+
287+
roundBpm(value) {
288+
return Number(value.toFixed(2)).toString();
289+
}
290+
67291
readAscii(view, offset, length) {
68292
if (offset + length > view.byteLength) {
69293
return "";

tests/playwright/tools/MidiStudioV2.spec.mjs

Lines changed: 42 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -85,13 +85,28 @@ const validMidiBytes = Buffer.from([
8585
0x00, 0x02,
8686
0x01, 0xe0,
8787
0x4d, 0x54, 0x72, 0x6b,
88-
0x00, 0x00, 0x00, 0x04,
88+
0x00, 0x00, 0x00, 0x13,
89+
0x00, 0xff, 0x51, 0x03, 0x07, 0xa1, 0x20,
90+
0x00, 0xff, 0x58, 0x04, 0x04, 0x02, 0x18, 0x08,
8991
0x00, 0xff, 0x2f, 0x00,
9092
0x4d, 0x54, 0x72, 0x6b,
91-
0x00, 0x00, 0x00, 0x04,
93+
0x00, 0x00, 0x00, 0x0d,
94+
0x00, 0x90, 0x3c, 0x40,
95+
0x83, 0x60, 0x80, 0x3c, 0x40,
9296
0x00, 0xff, 0x2f, 0x00
9397
]);
9498

99+
const corruptMidiBytes = Buffer.from([
100+
0x4d, 0x54, 0x68, 0x64,
101+
0x00, 0x00, 0x00, 0x06,
102+
0x00, 0x00,
103+
0x00, 0x01,
104+
0x01, 0xe0,
105+
0x4d, 0x54, 0x72, 0x6b,
106+
0x00, 0x00, 0x00, 0x01,
107+
0x00
108+
]);
109+
95110
function installMockAudio(page) {
96111
return page.addInitScript(() => {
97112
window.__midiStudioAudioEvents = [];
@@ -204,6 +219,28 @@ test.describe("MIDI Studio V2", () => {
204219
await expect(page.locator("#midiSourceDetails")).toContainText("2");
205220
await expect(page.locator("#midiSourceDetails")).toContainText("Ticks per quarter note");
206221
await expect(page.locator("#midiSourceDetails")).toContainText("480");
222+
await expect(page.locator("#midiSourceDetails")).toContainText("Estimated duration");
223+
await expect(page.locator("#midiSourceDetails")).toContainText("0.5 seconds");
224+
await expect(page.locator("#midiSourceDetails")).toContainText("Tempo summary");
225+
await expect(page.locator("#midiSourceDetails")).toContainText("120 BPM at tick 0");
226+
await expect(page.locator("#midiSourceDetails")).toContainText("Time signature summary");
227+
await expect(page.locator("#midiSourceDetails")).toContainText("4/4 at tick 0");
228+
await expect(page.locator("#midiSourceDetails")).toContainText("Note on events");
229+
await expect(page.locator("#midiSourceDetails")).toContainText("Note off events");
230+
await expect(page.locator("#midiSourceDetails")).toContainText("MIDI events");
231+
await expect(page.locator("#midiSourceDetails")).toContainText("Meta events");
232+
expect(await page.locator("#midiSourceDetails div").evaluateAll((rows) => Object.fromEntries(rows.map((row) => [
233+
row.querySelector("dt")?.textContent || "",
234+
row.querySelector("dd")?.textContent || ""
235+
])))).toMatchObject({
236+
"Estimated duration": "0.5 seconds",
237+
"Meta events": "4",
238+
"MIDI events": "2",
239+
"Note off events": "1",
240+
"Note on events": "1",
241+
"Tempo summary": "120 BPM at tick 0",
242+
"Time signature summary": "4/4 at tick 0"
243+
});
207244
await expect(page.locator("#statusLog")).toHaveValue(/OK MIDI source inspected for Main Theme: format 1, 2 tracks, 480 TPQN\./);
208245
} finally {
209246
await workspaceV2CoverageReporter.stop(page);
@@ -229,15 +266,15 @@ test.describe("MIDI Studio V2", () => {
229266
}
230267
});
231268

232-
test("shows actionable failure for invalid MIDI source bytes without partial render", async ({ page }) => {
269+
test("shows actionable failure for corrupt MIDI source bytes without partial render", async ({ page }) => {
233270
const server = await openMidiStudio(page, validManifest, {
234-
"assets/music/midi/theme-main.mid": Buffer.from([0x4e, 0x6f])
271+
"assets/music/midi/theme-main.mid": corruptMidiBytes
235272
});
236273
try {
237274
await page.locator("#inspectMidiSourceButton").click();
238275
await expect(page.locator("#midiSourceDetails")).toContainText("MIDI source validation failed");
239276
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/);
277+
await expect(page.locator("#statusLog")).toHaveValue(/FAIL MIDI source validation failed for assets\/music\/midi\/theme-main\.mid: MIDI track 1 ended after delta time without an event\./);
241278
} finally {
242279
await workspaceV2CoverageReporter.stop(page);
243280
await server.close();

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

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,14 @@ function sourceDetailRows(details) {
1010
["Format", details.format],
1111
["Track count", details.trackCount],
1212
["Ticks per quarter note", details.ticksPerQuarterNote],
13-
["Parsed track chunks", details.tracks.length]
13+
["Parsed track chunks", details.tracks.length],
14+
["Estimated duration", `${details.durationSeconds} seconds`],
15+
["Tempo summary", details.tempoSummary],
16+
["Time signature summary", details.timeSignatureSummary],
17+
["Note on events", details.eventCounts.noteOn],
18+
["Note off events", details.eventCounts.noteOff],
19+
["MIDI events", details.eventCounts.midi],
20+
["Meta events", details.eventCounts.meta]
1421
];
1522
}
1623

0 commit comments

Comments
 (0)