Skip to content

Commit 3a86cfa

Browse files
committed
Add normalized MIDI event timeline and channel/instrument analysis for MIDI Studio V2 - PR_26146_008-midi-studio-v2-normalized-event-timeline
1 parent 12d0ed4 commit 3a86cfa

4 files changed

Lines changed: 307 additions & 6 deletions

File tree

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
# PR_26146_008-midi-studio-v2-normalized-event-timeline Validation
2+
3+
## Scope
4+
5+
- Continued from PR_26146_007.
6+
- Extended shared MIDI parsing under `src/engine/audio/MidiSourceMetadataParser.js`.
7+
- Added normalized note pairing, channel summaries, program-change summaries, measure/bar timing, track activity summaries, warning summaries, and loop-safe duration output.
8+
- Extended MIDI Studio V2 source details to display the new shared parser metadata.
9+
- Preserved rendered OGG/MP3/WAV preview behavior and existing header-action layout behavior.
10+
- Did not add live synthesis, DAW editing, piano-roll editing, MIDI input, or MIDI recording.
11+
12+
## Shared Parser Behavior
13+
14+
- Generates normalized note-on/note-off pairs.
15+
- Tracks channel event totals and normalized note counts.
16+
- Extracts program changes as instrument/program summary data.
17+
- Keeps tempo and time signature timelines from the shared parser.
18+
- Estimates bars/measures from ticks-per-quarter-note and time signature metadata.
19+
- Uses the same parsed max tick for loop-safe duration output.
20+
- Emits visible warnings for malformed note pairs, skipped system/SysEx events, and empty tracks.
21+
- Rejects corrupt MIDI before rendering partial metadata.
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: MIDI Studio V2 HTML inline scan found no inline `<script>`, `<style>`, or inline event handlers.
29+
- PASS: `npx.cmd playwright test tests/playwright/tools/MidiStudioV2.spec.mjs --project=playwright --workers=1 --reporter=line`
30+
- PASS: `git diff --check`
31+
32+
## Playwright Coverage
33+
34+
- PASS: normalized event timeline generation displays normalized note count.
35+
- PASS: program-change extraction displays instrument/program summary.
36+
- PASS: channel summary displays channel note and event totals.
37+
- PASS: malformed note pair warnings are visible.
38+
- PASS: unsupported system/SysEx event warning is visible.
39+
- PASS: empty track warning is visible.
40+
- PASS: corrupt MIDI rejects before partial metadata render.
41+
- PASS: existing rendered preview behavior still uses rendered OGG.
42+
- PASS: existing header-action layout behavior still passes.
43+
44+
## Coverage Artifacts
45+
46+
- Updated `docs/dev/reports/playwright_v8_coverage_report.txt`.
47+
- Updated `docs/dev/reports/coverage_changed_js_guardrail.txt`.
48+
49+
## Explicit Skips
50+
51+
- SKIP: Workspace Manager V2 registration/handoff test because Workspace Manager files were not touched.
52+
- SKIP: full samples smoke test because sample JSON alignment is out of scope.

src/engine/audio/MidiSourceMetadataParser.js

Lines changed: 191 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -37,19 +37,37 @@ export class MidiSourceMetadataParser {
3737
tempoEvents: events.tempoEvents,
3838
ticksPerQuarterNote: division
3939
});
40+
const measureTiming = this.createMeasureTiming({
41+
maxTick: events.maxTick,
42+
ticksPerQuarterNote: division,
43+
timeSignatureEvents: events.timeSignatureEvents
44+
});
4045
return {
46+
channelSummary: this.formatChannelSummary(events.channels),
47+
channels: events.channels,
4148
durationSeconds,
4249
format,
4350
eventCounts: events.eventCounts,
51+
instrumentSummary: this.formatInstrumentSummary(events.programChanges),
52+
loopSafeDurationSeconds: durationSeconds,
53+
measureTiming,
54+
measureTimingSummary: this.formatMeasureTimingSummary(measureTiming),
55+
normalizedNotes: events.normalizedNotes,
56+
normalizedNoteCount: events.normalizedNotes.length,
4457
ok: true,
58+
programChanges: events.programChanges,
4559
ticksPerQuarterNote: division,
4660
trackCount,
4761
tempoEvents: events.tempoEvents,
4862
tempoSummary: this.formatTempoSummary(events.tempoEvents),
4963
timeSignatureEvents: events.timeSignatureEvents,
5064
timeSignatureSummary: this.formatTimeSignatureSummary(events.timeSignatureEvents),
65+
trackActivity: events.trackActivity,
66+
trackActivitySummary: this.formatTrackActivitySummary(events.trackActivity),
5167
tracks: tracks.tracks,
52-
validationStatus: `Valid Standard MIDI File header with ${tracks.tracks.length} declared track chunk${tracks.tracks.length === 1 ? "" : "s"}.`
68+
validationStatus: `Valid Standard MIDI File header with ${tracks.tracks.length} declared track chunk${tracks.tracks.length === 1 ? "" : "s"}.`,
69+
warningSummary: this.formatWarnings(events.warnings),
70+
warnings: events.warnings
5371
};
5472
}
5573

@@ -87,11 +105,27 @@ export class MidiSourceMetadataParser {
87105
noteOn: 0,
88106
system: 0
89107
};
108+
const channelMap = new Map();
109+
const openNotes = new Map();
110+
const normalizedNotes = [];
111+
const programChanges = [];
90112
const tempoEvents = [];
113+
const trackActivity = tracks.map((track, index) => ({
114+
eventCount: 0,
115+
index: index + 1,
116+
length: track.length,
117+
metaEvents: 0,
118+
noteEvents: 0,
119+
notes: 0,
120+
programChanges: 0,
121+
systemEvents: 0
122+
}));
91123
const timeSignatureEvents = [];
124+
const warnings = [];
92125
let maxTick = 0;
93126
for (let trackIndex = 0; trackIndex < tracks.length; trackIndex += 1) {
94127
const track = tracks[trackIndex];
128+
const activity = trackActivity[trackIndex];
95129
let absoluteTick = 0;
96130
let cursor = track.offset;
97131
let runningStatus = 0;
@@ -124,6 +158,8 @@ export class MidiSourceMetadataParser {
124158
}
125159
cursor = meta.nextOffset;
126160
eventCounts.meta += 1;
161+
activity.eventCount += 1;
162+
activity.metaEvents += 1;
127163
if (meta.tempoEvent) {
128164
tempoEvents.push(meta.tempoEvent);
129165
}
@@ -139,6 +175,9 @@ export class MidiSourceMetadataParser {
139175
}
140176
cursor = systemEvent.nextOffset;
141177
eventCounts.system += 1;
178+
activity.eventCount += 1;
179+
activity.systemEvents += 1;
180+
warnings.push(`Track ${trackIndex + 1} contains unsupported system/SysEx event 0x${status.toString(16)} at tick ${absoluteTick}; event was skipped for normalized timeline output.`);
142181
continue;
143182
}
144183
const channelEvent = this.readChannelEvent(view, cursor, endOffset, status, trackIndex);
@@ -147,17 +186,62 @@ export class MidiSourceMetadataParser {
147186
}
148187
cursor = channelEvent.nextOffset;
149188
eventCounts.midi += 1;
189+
activity.eventCount += 1;
190+
this.updateChannelSummary(channelMap, channelEvent);
191+
if (channelEvent.programChange) {
192+
activity.programChanges += 1;
193+
programChanges.push({
194+
channel: channelEvent.channel,
195+
program: channelEvent.data1,
196+
tick: absoluteTick,
197+
track: trackIndex + 1
198+
});
199+
}
150200
if (channelEvent.noteOn) {
151201
eventCounts.noteOn += 1;
202+
activity.noteEvents += 1;
203+
this.openNote(openNotes, channelEvent, absoluteTick, trackIndex);
152204
}
153205
if (channelEvent.noteOff) {
154206
eventCounts.noteOff += 1;
207+
activity.noteEvents += 1;
208+
const note = this.closeNote(openNotes, channelEvent, absoluteTick, trackIndex);
209+
if (note) {
210+
activity.notes += 1;
211+
normalizedNotes.push(note);
212+
} else {
213+
warnings.push(`Track ${trackIndex + 1} has note-off without matching note-on for channel ${channelEvent.channel}, note ${channelEvent.noteNumber} at tick ${absoluteTick}.`);
214+
}
155215
}
156216
}
157217
}
218+
openNotes.forEach((entries) => {
219+
entries.forEach((entry) => {
220+
warnings.push(`Track ${entry.track} has note-on without matching note-off for channel ${entry.channel}, note ${entry.noteNumber} at tick ${entry.startTick}.`);
221+
});
222+
});
223+
trackActivity.forEach((activity) => {
224+
if (!activity.noteEvents && !activity.programChanges && !activity.systemEvents && activity.metaEvents <= 1) {
225+
warnings.push(`Track ${activity.index} has no note, program, tempo, or time signature activity.`);
226+
}
227+
});
228+
const channels = Array.from(channelMap.values()).sort((left, right) => left.channel - right.channel);
229+
normalizedNotes.sort((left, right) => left.startTick - right.startTick || left.channel - right.channel || left.noteNumber - right.noteNumber);
230+
programChanges.sort((left, right) => left.tick - right.tick || left.channel - right.channel);
158231
tempoEvents.sort((left, right) => left.tick - right.tick);
159232
timeSignatureEvents.sort((left, right) => left.tick - right.tick);
160-
return { eventCounts, maxTick, ok: true, tempoEvents, timeSignatureEvents };
233+
return {
234+
channels,
235+
eventCounts,
236+
maxTick,
237+
normalizedNotes,
238+
ok: true,
239+
programChanges,
240+
tempoEvents,
241+
timeSignatureEvents,
242+
trackActivity,
243+
warnings
244+
};
161245
}
162246

163247
readMetaEvent(view, offset, endOffset, tick, trackIndex) {
@@ -222,19 +306,83 @@ export class MidiSourceMetadataParser {
222306

223307
readChannelEvent(view, offset, endOffset, status, trackIndex) {
224308
const command = status & 0xf0;
309+
const channel = (status & 0x0f) + 1;
225310
const dataLength = command === 0xc0 || command === 0xd0 ? 1 : 2;
226311
if (status < 0x80 || status > 0xef) {
227312
return { ok: false, message: `MIDI track ${trackIndex + 1} has unsupported event status 0x${status.toString(16)}.` };
228313
}
229314
if (offset + dataLength > endOffset) {
230315
return { ok: false, message: `MIDI track ${trackIndex + 1} channel event is truncated.` };
231316
}
317+
const data1 = view.getUint8(offset);
232318
const data2 = dataLength === 2 ? view.getUint8(offset + 1) : 0;
233319
return {
320+
channel,
321+
command,
322+
data1,
323+
data2,
234324
nextOffset: offset + dataLength,
325+
noteNumber: data1,
235326
noteOff: command === 0x80 || (command === 0x90 && data2 === 0),
236327
noteOn: command === 0x90 && data2 > 0,
237-
ok: true
328+
ok: true,
329+
programChange: command === 0xc0
330+
};
331+
}
332+
333+
updateChannelSummary(channelMap, event) {
334+
if (!channelMap.has(event.channel)) {
335+
channelMap.set(event.channel, {
336+
channel: event.channel,
337+
eventCount: 0,
338+
noteCount: 0,
339+
programs: []
340+
});
341+
}
342+
const channel = channelMap.get(event.channel);
343+
channel.eventCount += 1;
344+
if (event.noteOn) {
345+
channel.noteCount += 1;
346+
}
347+
if (event.programChange && !channel.programs.includes(event.data1)) {
348+
channel.programs.push(event.data1);
349+
channel.programs.sort((left, right) => left - right);
350+
}
351+
}
352+
353+
openNote(openNotes, event, absoluteTick, trackIndex) {
354+
const key = `${event.channel}:${event.noteNumber}`;
355+
const entries = openNotes.get(key) || [];
356+
entries.push({
357+
channel: event.channel,
358+
noteNumber: event.noteNumber,
359+
startTick: absoluteTick,
360+
track: trackIndex + 1,
361+
velocity: event.data2
362+
});
363+
openNotes.set(key, entries);
364+
}
365+
366+
closeNote(openNotes, event, absoluteTick, trackIndex) {
367+
const key = `${event.channel}:${event.noteNumber}`;
368+
const entries = openNotes.get(key) || [];
369+
const started = entries.shift();
370+
if (!entries.length) {
371+
openNotes.delete(key);
372+
} else {
373+
openNotes.set(key, entries);
374+
}
375+
if (!started) {
376+
return null;
377+
}
378+
return {
379+
channel: event.channel,
380+
durationTicks: Math.max(0, absoluteTick - started.startTick),
381+
endTick: absoluteTick,
382+
noteNumber: event.noteNumber,
383+
startTick: started.startTick,
384+
track: trackIndex + 1,
385+
velocity: started.velocity
238386
};
239387
}
240388

@@ -270,6 +418,17 @@ export class MidiSourceMetadataParser {
270418
return Number(seconds.toFixed(3));
271419
}
272420

421+
createMeasureTiming({ maxTick, ticksPerQuarterNote, timeSignatureEvents }) {
422+
const signature = timeSignatureEvents[0] || { denominator: 4, numerator: 4, tick: 0 };
423+
const ticksPerBar = ticksPerQuarterNote * signature.numerator * (4 / signature.denominator);
424+
return {
425+
estimatedBars: ticksPerBar ? Number((maxTick / ticksPerBar).toFixed(2)) : 0,
426+
maxTick,
427+
ticksPerBar,
428+
timeSignature: `${signature.numerator}/${signature.denominator}`
429+
};
430+
}
431+
273432
formatTempoSummary(tempoEvents) {
274433
if (!tempoEvents.length) {
275434
return "Default 120 BPM";
@@ -284,6 +443,35 @@ export class MidiSourceMetadataParser {
284443
return timeSignatureEvents.map((event) => `${event.numerator}/${event.denominator} at tick ${event.tick}`).join("; ");
285444
}
286445

446+
formatChannelSummary(channels) {
447+
if (!channels.length) {
448+
return "No channel events";
449+
}
450+
return channels.map((channel) => `Ch ${channel.channel}: ${channel.noteCount} notes, ${channel.eventCount} events`).join("; ");
451+
}
452+
453+
formatInstrumentSummary(programChanges) {
454+
if (!programChanges.length) {
455+
return "No program changes";
456+
}
457+
return programChanges.map((event) => `Ch ${event.channel} program ${event.program} at tick ${event.tick}`).join("; ");
458+
}
459+
460+
formatMeasureTimingSummary(measureTiming) {
461+
return `${measureTiming.estimatedBars} bars at ${measureTiming.timeSignature}`;
462+
}
463+
464+
formatTrackActivitySummary(trackActivity) {
465+
if (!trackActivity.length) {
466+
return "No tracks";
467+
}
468+
return trackActivity.map((track) => `Track ${track.index}: ${track.notes} notes, ${track.eventCount} events`).join("; ");
469+
}
470+
471+
formatWarnings(warnings) {
472+
return warnings.length ? warnings.join("; ") : "none";
473+
}
474+
287475
roundBpm(value) {
288476
return Number(value.toFixed(2)).toString();
289477
}

0 commit comments

Comments
 (0)