Skip to content

Commit e68a3ca

Browse files
committed
Add song-sheet parsing and section normalization foundation for MIDI Studio V2 - PR_26146_009-midi-studio-v2-song-sheet-foundation
1 parent 3a86cfa commit e68a3ca

8 files changed

Lines changed: 374 additions & 0 deletions

File tree

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
# PR_26146_009-midi-studio-v2-song-sheet-foundation Validation
2+
3+
## Scope
4+
5+
- Continued from PR_26146_008.
6+
- Added shared Song Sheet parsing capability under `src/engine/audio/SongSheetParser.js`.
7+
- Added MIDI Studio V2 Song Sheet input panel and generated Song Sheet Summary display.
8+
- Supported `tempo`, `key`, `style`, section labels, chord progressions, and `[loop]` loop sections.
9+
- Normalized parsed structures into reusable section/timeline chord objects.
10+
- Preserved imported MIDI inspection, normalized MIDI parsing, rendered OGG/MP3/WAV preview behavior, and header-action layout behavior.
11+
- Did not add live synthesis, DAW sequencing, piano-roll editing, MIDI recording, MIDI input, hidden fallback sheets, or hidden generated music.
12+
13+
## Song Sheet Behavior
14+
15+
- Valid sheets parse into tempo, key, style, section summary, bar count, chord count, loop sections, and estimated duration.
16+
- Invalid chords produce visible `WARN` status and are skipped from normalized chord timelines.
17+
- Empty sections produce visible `WARN` status.
18+
- Unsupported syntax and malformed sheet structure produce visible `FAIL` status without partial section summary.
19+
20+
## Validation
21+
22+
- PASS: `node --check src/engine/audio/SongSheetParser.js`
23+
- PASS: `node --check tools/midi-studio-v2/js/controls/SongSheetControl.js`
24+
- PASS: `node --check tools/midi-studio-v2/js/MidiStudioV2App.js`
25+
- PASS: `node --check tools/midi-studio-v2/js/bootstrap.js`
26+
- PASS: `node --check tests/playwright/tools/MidiStudioV2.spec.mjs`
27+
- PASS: MIDI Studio V2 HTML inline scan found no inline `<script>`, `<style>`, or inline event handlers.
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 Song Sheet parsing.
34+
- PASS: section parsing and loop-section summary.
35+
- PASS: invalid chord warnings.
36+
- PASS: empty section warnings.
37+
- PASS: malformed Song Sheet rejection without partial section summary.
38+
- PASS: estimated duration display.
39+
- PASS: coexistence of MIDI source inspection, rendered preview, and Song Sheet workflow.
40+
- PASS: invalid payload rejection before render remains covered.
41+
42+
## Coverage Artifacts
43+
44+
- Updated `docs/dev/reports/playwright_v8_coverage_report.txt`.
45+
- Updated `docs/dev/reports/coverage_changed_js_guardrail.txt`.
46+
47+
## Explicit Skips
48+
49+
- SKIP: Workspace Manager V2 registration/handoff test because Workspace Manager files were not touched.
50+
- SKIP: full samples smoke test because sample JSON alignment is out of scope.
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
const CHORD_PATTERN = /^[A-G](?:#|b)?(?:m|min|maj|dim|aug|sus2|sus4|7|maj7|m7|min7|dim7|add9)?$/;
2+
const DIRECTIVES = new Set(["key", "style", "tempo"]);
3+
4+
export class SongSheetParser {
5+
parse(sourceText) {
6+
const text = String(sourceText || "").trim();
7+
if (!text) {
8+
return { ok: false, message: "Song Sheet is empty. Add tempo, key, style, and at least one section." };
9+
}
10+
const lines = String(sourceText || "").split(/\r?\n/);
11+
const metadata = { key: "", style: "", tempo: 120 };
12+
const sections = [];
13+
const warnings = [];
14+
let activeSection = null;
15+
for (let index = 0; index < lines.length; index += 1) {
16+
const lineNumber = index + 1;
17+
const line = lines[index].trim();
18+
if (!line) {
19+
continue;
20+
}
21+
if (line.startsWith("[") || line.endsWith("]")) {
22+
if (!line.startsWith("[") || !line.endsWith("]")) {
23+
return { ok: false, message: `Malformed section header on line ${lineNumber}: ${line}` };
24+
}
25+
const label = line.slice(1, -1).trim();
26+
if (!/^[A-Za-z][A-Za-z0-9_-]*$/.test(label)) {
27+
return { ok: false, message: `Malformed section label on line ${lineNumber}: ${label || "(empty)"}` };
28+
}
29+
activeSection = {
30+
bars: 0,
31+
chords: [],
32+
label,
33+
lineNumber,
34+
loop: label.toLowerCase() === "loop",
35+
timeline: []
36+
};
37+
sections.push(activeSection);
38+
continue;
39+
}
40+
if (line.includes("=")) {
41+
if (activeSection) {
42+
return { ok: false, message: `Unsupported directive inside section ${activeSection.label} on line ${lineNumber}: ${line}` };
43+
}
44+
const parsedDirective = this.parseDirective(line, lineNumber);
45+
if (!parsedDirective.ok) {
46+
return parsedDirective;
47+
}
48+
metadata[parsedDirective.key] = parsedDirective.value;
49+
continue;
50+
}
51+
if (!activeSection && line.includes(":")) {
52+
return { ok: false, message: `Unsupported Song Sheet syntax on line ${lineNumber}: ${line}` };
53+
}
54+
if (!activeSection) {
55+
return { ok: false, message: `Chord progression on line ${lineNumber} must appear inside a [section].` };
56+
}
57+
const chords = line.split(/\s+/).filter(Boolean);
58+
chords.forEach((chord) => {
59+
if (!CHORD_PATTERN.test(chord)) {
60+
warnings.push(`Invalid chord "${chord}" in section ${activeSection.label} on line ${lineNumber}; chord was skipped.`);
61+
return;
62+
}
63+
activeSection.timeline.push({
64+
bar: activeSection.timeline.length + 1,
65+
chord,
66+
durationBars: 1,
67+
section: activeSection.label
68+
});
69+
activeSection.chords.push(chord);
70+
});
71+
}
72+
if (!sections.length) {
73+
return { ok: false, message: "Song Sheet must include at least one [section]." };
74+
}
75+
sections.forEach((section) => {
76+
section.bars = section.chords.length;
77+
if (!section.chords.length) {
78+
warnings.push(`Section ${section.label} is empty.`);
79+
}
80+
});
81+
const bars = sections.reduce((total, section) => total + section.bars, 0);
82+
const chordCount = sections.reduce((total, section) => total + section.chords.length, 0);
83+
const estimatedDurationSeconds = Number(((bars * 4 * 60) / metadata.tempo).toFixed(3));
84+
return {
85+
bars,
86+
chordCount,
87+
estimatedDurationSeconds,
88+
key: metadata.key || "not declared",
89+
ok: true,
90+
sections,
91+
sectionSummary: sections.map((section) => `${section.label}: ${section.bars} bars, ${section.chords.length} chords${section.loop ? ", loop" : ""}`).join("; "),
92+
style: metadata.style || "not declared",
93+
tempo: metadata.tempo,
94+
timeline: sections.flatMap((section) => section.timeline),
95+
warnings,
96+
warningSummary: warnings.length ? warnings.join("; ") : "none"
97+
};
98+
}
99+
100+
parseDirective(line, lineNumber) {
101+
const match = line.match(/^([A-Za-z]+)\s*=\s*(.+)$/);
102+
if (!match) {
103+
return { ok: false, message: `Unsupported directive syntax on line ${lineNumber}: ${line}` };
104+
}
105+
const key = match[1].toLowerCase();
106+
const value = match[2].trim();
107+
if (!DIRECTIVES.has(key)) {
108+
return { ok: false, message: `Unsupported Song Sheet directive on line ${lineNumber}: ${key}` };
109+
}
110+
if (key === "tempo") {
111+
const tempo = Number(value);
112+
if (!Number.isFinite(tempo) || tempo <= 0) {
113+
return { ok: false, message: `Invalid tempo on line ${lineNumber}: ${value}` };
114+
}
115+
return { key, ok: true, value: tempo };
116+
}
117+
return { key, ok: true, value };
118+
}
119+
}

tests/playwright/tools/MidiStudioV2.spec.mjs

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -335,6 +335,108 @@ test.describe("MIDI Studio V2", () => {
335335
}
336336
});
337337

338+
test("parses a valid Song Sheet into section summary metadata", async ({ page }) => {
339+
const server = await openMidiStudio(page);
340+
try {
341+
await page.locator("#songSheetInput").fill(`tempo=132
342+
key=A minor
343+
style=retro-arcade
344+
345+
[intro]
346+
Am F
347+
348+
[loop]
349+
Am F C G`);
350+
await page.locator("#parseSongSheetButton").click();
351+
await expect(page.locator("#songSheetSummary")).toContainText("intro: 2 bars, 2 chords");
352+
await expect(page.locator("#songSheetSummary")).toContainText("loop: 4 bars, 4 chords, loop");
353+
expect(await page.locator("#songSheetSummary div").evaluateAll((rows) => Object.fromEntries(rows.map((row) => [
354+
row.querySelector("dt")?.textContent || "",
355+
row.querySelector("dd")?.textContent || ""
356+
])))).toMatchObject({
357+
"Bars": "6",
358+
"Chord count": "6",
359+
"Estimated duration": "10.909 seconds",
360+
"Key": "A minor",
361+
"Loop sections": "loop",
362+
"Style": "retro-arcade",
363+
"Tempo": "132",
364+
"Warnings": "none"
365+
});
366+
await expect(page.locator("#statusLog")).toHaveValue(/OK Song Sheet parsed: 2 sections, 6 bars, 6 chords\./);
367+
} finally {
368+
await workspaceV2CoverageReporter.stop(page);
369+
await server.close();
370+
}
371+
});
372+
373+
test("shows Song Sheet warnings for invalid chords and empty sections", async ({ page }) => {
374+
const server = await openMidiStudio(page);
375+
try {
376+
await page.locator("#songSheetInput").fill(`tempo=120
377+
key=C major
378+
style=chip
379+
380+
[loop]
381+
C Hm G
382+
383+
[break]`);
384+
await page.locator("#parseSongSheetButton").click();
385+
await expect(page.locator("#songSheetSummary")).toContainText('Invalid chord "Hm"');
386+
await expect(page.locator("#songSheetSummary")).toContainText("Section break is empty.");
387+
await expect(page.locator("#songSheetSummary")).toContainText("loop: 2 bars, 2 chords, loop");
388+
await expect(page.locator("#statusLog")).toHaveValue(/WARN Song Sheet parsed with warnings: Invalid chord "Hm" in section loop/);
389+
await expect(page.locator("#statusLog")).toHaveValue(/OK Song Sheet parsed: 2 sections, 2 bars, 2 chords\./);
390+
} finally {
391+
await workspaceV2CoverageReporter.stop(page);
392+
await server.close();
393+
}
394+
});
395+
396+
test("rejects malformed Song Sheet syntax without partial section summary", async ({ page }) => {
397+
const server = await openMidiStudio(page);
398+
try {
399+
await page.locator("#songSheetInput").fill(`tempo:132
400+
key=A minor
401+
402+
[loop]
403+
Am F`);
404+
await page.locator("#parseSongSheetButton").click();
405+
await expect(page.locator("#songSheetSummary")).toContainText("Unsupported Song Sheet syntax on line 1: tempo:132");
406+
await expect(page.locator("#songSheetSummary")).not.toContainText("loop:");
407+
await expect(page.locator("#statusLog")).toHaveValue(/FAIL Song Sheet rejected: Unsupported Song Sheet syntax on line 1: tempo:132/);
408+
} finally {
409+
await workspaceV2CoverageReporter.stop(page);
410+
await server.close();
411+
}
412+
});
413+
414+
test("keeps MIDI source inspection and rendered preview available after Song Sheet parsing", async ({ page }) => {
415+
const server = await openMidiStudio(page, validManifest, {
416+
"assets/music/midi/theme-main.mid": validMidiBytes
417+
});
418+
try {
419+
await page.locator("#songSheetInput").fill(`tempo=132
420+
key=A minor
421+
style=retro-arcade
422+
423+
[loop]
424+
Am F C G`);
425+
await page.locator("#parseSongSheetButton").click();
426+
await expect(page.locator("#songSheetSummary")).toContainText("loop: 4 bars, 4 chords, loop");
427+
await page.locator("#inspectMidiSourceButton").click();
428+
await expect(page.locator("#midiSourceDetails")).toContainText("Tempo summary");
429+
await page.locator("#playButton").click();
430+
await expect(page.locator("#statusLog")).toHaveValue(/OK Rendered preview started for Main Theme: assets\/music\/rendered\/theme-main\.ogg\./);
431+
expect(await page.evaluate(() => window.__midiStudioAudioEvents)).toEqual([
432+
{ action: "play", loop: false, src: "assets/music/rendered/theme-main.ogg" }
433+
]);
434+
} finally {
435+
await workspaceV2CoverageReporter.stop(page);
436+
await server.close();
437+
}
438+
});
439+
338440
test("shows actionable failure when no rendered target and no live MIDI engine exist", async ({ page }) => {
339441
const server = await openMidiStudio(page);
340442
try {

tools/midi-studio-v2/index.html

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,20 @@ <h2 class="tools-platform-frame__eyebrow">First-Class Tools Surface V2</h2>
8080
<button id="inspectMidiSourceButton" type="button" disabled>Inspect MIDI Source</button>
8181
</div>
8282
</section>
83+
84+
<section class="accordion-v2 tool-starter__accordion is-open" data-accordion-v2-open="true">
85+
<button class="accordion-v2__header" type="button" aria-expanded="true" aria-controls="songSheetContent">
86+
<span>Song Sheet</span>
87+
<span class="accordion-v2__icon" aria-hidden="true">+</span>
88+
</button>
89+
<div id="songSheetContent" class="accordion-v2__content midi-studio-v2__stack">
90+
<label class="tool-starter__field" for="songSheetInput">
91+
<span>Structured song sheet</span>
92+
<textarea id="songSheetInput" class="midi-studio-v2__song-sheet-input" rows="9" placeholder="tempo=132&#10;key=A minor&#10;style=retro-arcade&#10;&#10;[intro]&#10;Am F&#10;&#10;[loop]&#10;Am F C G"></textarea>
93+
</label>
94+
<button id="parseSongSheetButton" type="button">Parse Song Sheet</button>
95+
</div>
96+
</section>
8397
</aside>
8498

8599
<section class="tool-starter__panel tool-starter__panel--center" aria-label="MIDI preview">
@@ -120,6 +134,16 @@ <h2 class="tools-platform-frame__eyebrow">First-Class Tools Surface V2</h2>
120134
<dl id="midiSourceDetails" class="midi-studio-v2__details"></dl>
121135
</div>
122136
</section>
137+
138+
<section class="accordion-v2 tool-starter__accordion is-open" data-accordion-v2-open="true">
139+
<button class="accordion-v2__header" type="button" aria-expanded="true" aria-controls="songSheetSummaryContent">
140+
<span>Song Sheet Summary</span>
141+
<span class="accordion-v2__icon" aria-hidden="true">+</span>
142+
</button>
143+
<div id="songSheetSummaryContent" class="accordion-v2__content">
144+
<dl id="songSheetSummary" class="midi-studio-v2__details"></dl>
145+
</div>
146+
</section>
123147
</section>
124148

125149
<aside class="tool-starter__panel tool-starter__panel--right" aria-label="MIDI output">

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

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ export class MidiStudioV2App {
1616
serializer,
1717
shell,
1818
songList,
19+
songSheet,
20+
songSheetParser,
1921
statusLog,
2022
windowRef = window
2123
}) {
@@ -33,6 +35,8 @@ export class MidiStudioV2App {
3335
this.serializer = serializer;
3436
this.shell = shell;
3537
this.songList = songList;
38+
this.songSheet = songSheet;
39+
this.songSheetParser = songSheetParser;
3640
this.statusLog = statusLog;
3741
this.window = windowRef;
3842
}
@@ -42,6 +46,7 @@ export class MidiStudioV2App {
4246
this.accordions.forEach((accordion) => accordion.mount());
4347
this.statusLog.mount();
4448
this.songList.mount({ onSelect: (songId) => this.selectSong(songId) });
49+
this.songSheet.mount({ onParse: (sourceText) => this.parseSongSheet(sourceText) });
4550
this.midiSourceDetails.mount({ onInspect: () => this.inspectSelectedSource() });
4651
this.playbackControl.mount({
4752
onPlay: () => this.playSelectedSong(),
@@ -74,6 +79,7 @@ export class MidiStudioV2App {
7479
this.directorPanel.render(null, {});
7580
this.midiSourceDetails.render(null);
7681
this.midiSourceDetails.setEnabled(false);
82+
this.songSheet.render(null);
7783
this.playbackControl.setSelected(null);
7884
this.actionNav.setToolActionsEnabled(false);
7985
}
@@ -148,6 +154,19 @@ export class MidiStudioV2App {
148154
this.statusLog.ok(`MIDI source inspected for ${song.name}: format ${result.format}, ${result.trackCount} track${result.trackCount === 1 ? "" : "s"}, ${result.ticksPerQuarterNote} TPQN.`);
149155
}
150156

157+
parseSongSheet(sourceText) {
158+
const result = this.songSheetParser.parse(sourceText);
159+
this.songSheet.render(result);
160+
if (!result.ok) {
161+
this.statusLog.fail(`Song Sheet rejected: ${result.message}`);
162+
return;
163+
}
164+
if (result.warnings.length) {
165+
this.statusLog.warn(`Song Sheet parsed with warnings: ${result.warningSummary}`);
166+
}
167+
this.statusLog.ok(`Song Sheet parsed: ${result.sections.length} section${result.sections.length === 1 ? "" : "s"}, ${result.bars} bars, ${result.chordCount} chords.`);
168+
}
169+
151170
stopPlayback() {
152171
this.playback.stop();
153172
this.playbackControl.setStopped(this.selectedSong());

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,10 @@ import { MidiSourceDetailsControl } from "./controls/MidiSourceDetailsControl.js
77
import { PlaybackControl } from "./controls/PlaybackControl.js";
88
import { SongDetailsControl } from "./controls/SongDetailsControl.js";
99
import { SongListControl } from "./controls/SongListControl.js";
10+
import { SongSheetControl } from "./controls/SongSheetControl.js";
1011
import { StatusLogControl } from "./controls/StatusLogControl.js";
1112
import { ToolShellControl } from "./controls/ToolShellControl.js";
13+
import { SongSheetParser } from "../../../src/engine/audio/SongSheetParser.js";
1214
import { MidiPlaybackService } from "./services/MidiPlaybackService.js";
1315
import { MidiSourceInspectionService } from "./services/MidiSourceInspectionService.js";
1416
import { MidiStudioStateSerializer } from "./services/MidiStudioStateSerializer.js";
@@ -64,6 +66,12 @@ window.addEventListener("DOMContentLoaded", () => {
6466
serializer: new MidiStudioStateSerializer(),
6567
shell: new ToolShellControl(),
6668
songList: new SongListControl({ list: requireElement("#songList") }),
69+
songSheet: new SongSheetControl({
70+
input: requireElement("#songSheetInput"),
71+
parseButton: requireElement("#parseSongSheetButton"),
72+
summary: requireElement("#songSheetSummary")
73+
}),
74+
songSheetParser: new SongSheetParser(),
6775
statusLog,
6876
windowRef: window
6977
});

0 commit comments

Comments
 (0)