Skip to content

Commit 90003f3

Browse files
committed
Close out MIDI Studio V2 playable preview MVP with audio diagnostics - PR_26146_018-midi-studio-v2-playable-mvp-closeout
1 parent 8f3f0d8 commit 90003f3

10 files changed

Lines changed: 352 additions & 24 deletions

File tree

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
# PR_26146_018 MIDI Studio V2 Playable MVP Closeout Validation
2+
3+
## Summary
4+
- Added a primary `Load Example And Play` action that loads explicit demo data, assigns preview instruments, generates missing lanes, normalizes the grid, unlocks/resumes Web Audio, starts Preview Synth playback, and starts playhead movement.
5+
- Added `Stop All Audio` to stop rendered preview and scheduled Preview Synth oscillators while resetting active playback UI state.
6+
- Added visible Audio Diagnostics for audio context state, selected song/section, playable note count, active/muted/soloed lanes, preview instrument packs, and last playback error.
7+
- Preserved rendered OGG/MP3/WAV preview/export honesty, imported MIDI inspection, snapping, lane generation, mute/solo filtering, loop/section transport, and no hidden fallback songs.
8+
9+
## Validation
10+
- PASS: changed-file JavaScript syntax checks with `node --check`.
11+
- PASS: installed Chromium for Playwright under repo-local `tmp/.ms-playwright` after the default user cache path was blocked and the CDN needed `NODE_OPTIONS=--use-system-ca`.
12+
- PASS: targeted MIDI Studio V2 Playwright suite with repo-local browser cache: `36 passed (3.9m)`.
13+
- PASS: `git diff --check` completed successfully. Output only included existing line-ending warnings for files Git will normalize on touch.
14+
15+
## Commands
16+
- `node --check src/engine/audio/PreviewSynthEngine.js`
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 tools/midi-studio-v2/js/controls/InstrumentGridControl.js`
21+
- `node --check tools/midi-studio-v2/js/controls/AudioDiagnosticsControl.js`
22+
- `node --check tests/playwright/tools/MidiStudioV2.spec.mjs`
23+
- `npx.cmd playwright test tests/playwright/tools/MidiStudioV2.spec.mjs` (initial run failed because Chromium was missing)
24+
- `npx.cmd playwright install chromium` (failed because default browser cache was outside writable workspace)
25+
- `$env:PLAYWRIGHT_BROWSERS_PATH='tmp/.ms-playwright'; npx.cmd playwright install chromium` (failed due certificate chain verification)
26+
- `$env:PLAYWRIGHT_BROWSERS_PATH='tmp/.ms-playwright'; $env:NODE_OPTIONS='--use-system-ca'; npx.cmd playwright install chromium`
27+
- `$env:PLAYWRIGHT_BROWSERS_PATH='tmp/.ms-playwright'; npx.cmd playwright test tests/playwright/tools/MidiStudioV2.spec.mjs`
28+
- `git diff --check`
29+
30+
## Skipped
31+
- Full samples smoke test: SKIP per BUILD request because sample JSON alignment is out of scope.
32+
33+
## Package
34+
- Created repo-structured delta ZIP: `tmp/PR_26146_018-midi-studio-v2-playable-mvp-closeout_delta.zip`.
35+
- Removed temporary repo-local Playwright browser cache after validation; rerunning locally may require the install command again if Chromium is absent.

src/engine/audio/PreviewSynthEngine.js

Lines changed: 34 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,8 @@ function chordNotes(chordName) {
5151
export class PreviewSynthEngine {
5252
constructor({ windowRef = globalThis } = {}) {
5353
this.context = null;
54+
this.lastError = "";
55+
this.lastPlayback = null;
5456
this.loopTimer = null;
5557
this.nodes = [];
5658
this.playing = false;
@@ -64,20 +66,19 @@ export class PreviewSynthEngine {
6466
async playGridRange({ endStep, grid, label, laneSettings = {}, loop = false, mode = "section", startStep, tempoBpm = 120 } = {}) {
6567
this.stop();
6668
if (!grid?.ok) {
67-
return { message: "Preview Synth needs a normalized instrument grid before playback.", ok: false, reason: "missing-grid" };
69+
return this.fail("Preview Synth needs a normalized instrument grid before playback.", "missing-grid");
6870
}
6971
const contextResult = await this.ensureContext();
7072
if (!contextResult.ok) {
7173
return contextResult;
7274
}
7375
const playable = this.playableEventsForRange(grid, startStep, endStep, laneSettings);
7476
if (!playable.events.length) {
75-
return {
76-
message: `No playable Preview Synth notes found for ${mode} ${label || "(unnamed)"}. Generate or enter chords, bass, pad, lead, or drum cells before playing.`,
77-
ok: false,
78-
warnings: playable.warnings,
79-
reason: "no-playable-notes"
80-
};
77+
return this.fail(
78+
`No playable Preview Synth notes found for ${mode} ${label || "(unnamed)"}. Generate or enter chords, bass, pad, lead, or drum cells before playing.`,
79+
"no-playable-notes",
80+
{ warnings: playable.warnings }
81+
);
8182
}
8283
const safeTempo = Number.isFinite(Number(tempoBpm)) && Number(tempoBpm) > 0 ? Number(tempoBpm) : 120;
8384
const secondsPerBeat = 60 / safeTempo;
@@ -90,6 +91,13 @@ export class PreviewSynthEngine {
9091
this.scheduleEvents({ context: contextResult.context, events: playable.events, secondsPerBeat, secondsPerStep, startStep });
9192
}, cycleSeconds * 1000);
9293
}
94+
this.lastError = "";
95+
this.lastPlayback = {
96+
activeLanes: playable.activeLanes,
97+
eventCount: playable.events.length,
98+
label,
99+
mode
100+
};
93101
return {
94102
activeLanes: playable.activeLanes,
95103
eventCount: playable.events.length,
@@ -105,31 +113,19 @@ export class PreviewSynthEngine {
105113
async ensureContext() {
106114
const AudioContextCtor = audioContextCtor(this.window);
107115
if (!AudioContextCtor) {
108-
return {
109-
message: "Preview Synth audio unavailable: Web Audio AudioContext is not available. Use a browser with Web Audio support.",
110-
ok: false,
111-
reason: "audio-unavailable"
112-
};
116+
return this.fail("Preview Synth audio unavailable: Web Audio AudioContext is not available. Use a browser with Web Audio support.", "audio-unavailable");
113117
}
114118
try {
115119
this.context = this.context || new AudioContextCtor();
116120
if (typeof this.context.resume === "function") {
117121
await this.context.resume();
118122
}
119123
if (this.context.state === "suspended") {
120-
return {
121-
message: "Preview Synth audio is still suspended after the play gesture. Click Play Section or Play Loop again and check browser audio permissions.",
122-
ok: false,
123-
reason: "audio-suspended"
124-
};
124+
return this.fail("Preview Synth audio is still suspended after the play gesture. Click Play Section or Play Loop again and check browser audio permissions.", "audio-suspended");
125125
}
126126
return { context: this.context, ok: true };
127127
} catch (error) {
128-
return {
129-
message: `Preview Synth audio could not start after the play gesture: ${error.message || "browser audio permission blocked playback"}.`,
130-
ok: false,
131-
reason: "audio-resume-failed"
132-
};
128+
return this.fail(`Preview Synth audio could not start after the play gesture: ${error.message || "browser audio permission blocked playback"}.`, "audio-resume-failed");
133129
}
134130
}
135131

@@ -256,15 +252,31 @@ export class PreviewSynthEngine {
256252
const stoppedCount = this.nodes.length;
257253
this.nodes = [];
258254
this.playing = false;
255+
this.lastPlayback = null;
259256
return stoppedCount;
260257
}
261258

262259
getSnapshot() {
263260
return {
264261
activeNodeCount: this.nodes.length,
262+
audioContextState: this.context?.state || "not created",
263+
lastError: this.lastError || "none",
264+
lastPlayback: this.lastPlayback,
265265
loopActive: Boolean(this.loopTimer),
266266
playing: this.playing,
267267
supported: this.isSupported()
268268
};
269269
}
270+
271+
fail(message, reason, extra = {}) {
272+
this.lastError = message;
273+
this.lastPlayback = null;
274+
this.playing = false;
275+
return {
276+
message,
277+
ok: false,
278+
reason,
279+
...extra
280+
};
281+
}
270282
}

tests/playwright/tools/MidiStudioV2.spec.mjs

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -280,6 +280,13 @@ async function fillInstrumentGrid(page, {
280280
await page.locator("#instrumentGridDrumsInput").fill(drums);
281281
}
282282

283+
async function audioDiagnosticsRows(page) {
284+
return page.locator("#audioDiagnostics div").evaluateAll((rows) => Object.fromEntries(rows.map((row) => [
285+
row.querySelector("dt")?.textContent || "",
286+
row.querySelector("dd")?.textContent || ""
287+
])));
288+
}
289+
283290
test.describe("MIDI Studio V2", () => {
284291
test.afterAll(async () => {
285292
await workspaceV2CoverageReporter.writeReport();
@@ -301,10 +308,14 @@ test.describe("MIDI Studio V2", () => {
301308
const renderedHeader = page.locator('.accordion-v2__header[aria-controls="renderedTargetsContent"]');
302309
await expect(renderedHeader).toContainText("Rendered Export Targets");
303310
await expect(renderedHeader.locator("#exportWavButton")).toHaveCount(0);
311+
await expect(page.locator(".midi-studio-v2__tool-menu #loadExampleAndPlayButton")).toBeVisible();
312+
await expect(page.locator(".midi-studio-v2__tool-menu #stopAllAudioButton")).toBeVisible();
304313
await expect(page.locator(".midi-studio-v2__tool-menu #exportWavButton")).toBeVisible();
305314
await expect(page.locator(".midi-studio-v2__tool-menu #exportMp3Button")).toBeVisible();
306315
await expect(page.locator(".midi-studio-v2__tool-menu #exportOggButton")).toBeVisible();
307316
await expect(page.locator("#midiSourceDetails")).toContainText("No MIDI source inspected.");
317+
await expect(page.locator("#audioDiagnostics")).toContainText("Audio context state");
318+
await expect(page.locator("#audioDiagnostics")).toContainText("Playable note count");
308319
await expect(page.locator("#playbackState")).toContainText("Live MIDI synthesis: NOT IMPLEMENTED");
309320
await expect(page.locator("#statusLog")).toHaveValue(/OK Loaded 3 MIDI songs/);
310321
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\./);
@@ -360,6 +371,11 @@ test.describe("MIDI Studio V2", () => {
360371
await expect(page.locator("#previewInstrumentLeadSelect")).toHaveValue("retro-pulse-lead");
361372
await expect(page.locator("#previewInstrumentPadSelect")).toHaveValue("ambient-pad");
362373
await expect(page.locator("#statusLog")).toHaveValue(/OK Loaded explicit demo test song data\. Demo paths are declared for UAT only; they are not hidden fallback assets\./);
374+
await expect(page.locator("#instrumentGridSectionSelect")).toContainText("intro");
375+
await page.locator("#playSectionButton").click();
376+
await expect(page.locator("#statusLog")).toHaveValue(/OK Preview Synth started for section intro with \d+ playable events\./);
377+
expect(await page.evaluate(() => window.__midiStudioPreviewSynthEvents.some((event) => event.action === "oscillator-start"))).toBe(true);
378+
await page.locator("#stopTimingPreviewButton").click();
363379

364380
await page.locator("#generateBassFromChordsButton").click();
365381
await page.locator("#generatePadFromChordsButton").click();
@@ -393,6 +409,44 @@ test.describe("MIDI Studio V2", () => {
393409
}
394410
});
395411

412+
test("loads example and starts audible Preview Synth MVP playback with diagnostics", async ({ page }) => {
413+
const server = await openMidiStudio(page);
414+
try {
415+
await page.locator("#loadExampleAndPlayButton").click();
416+
await expect(page.locator("#statusLog")).toHaveValue(/OK Load Example And Play started\./);
417+
await expect(page.locator("#statusLog")).toHaveValue(/OK Load Example And Play loaded explicit demo data\./);
418+
await expect(page.locator("#statusLog")).toHaveValue(/OK Load Example And Play assigned Preview Synth instruments\./);
419+
await expect(page.locator("#statusLog")).toHaveValue(/OK Demo grid has \d+ playable Preview Synth notes after lane generation\./);
420+
await expect(page.locator("#statusLog")).toHaveValue(/OK Preview Synth started for section intro with \d+ playable events\./);
421+
await expect(page.locator("#statusLog")).toHaveValue(/OK Load Example And Play started audible Preview Synth playback and moved the playhead\./);
422+
await expect(page.locator("#instrumentGridTransportState")).toContainText("Playing section Preview Synth timing preview: intro");
423+
expect(await page.evaluate(() => window.__midiStudioPreviewSynthEvents.some((event) => event.action === "resume"))).toBe(true);
424+
expect(await page.evaluate(() => window.__midiStudioPreviewSynthEvents.some((event) => event.action === "oscillator-start"))).toBe(true);
425+
await expect(page.locator(".midi-studio-v2__grid-cell--playhead-active")).not.toHaveAttribute("data-step-index", "0");
426+
const diagnostics = await audioDiagnosticsRows(page);
427+
expect(Number(diagnostics["Playable note count"])).toBeGreaterThan(0);
428+
expect(diagnostics).toMatchObject({
429+
"Audio context state": "running",
430+
"Last playback error": "none",
431+
"Selected section": "intro",
432+
"Selected song": "Demo Test Song"
433+
});
434+
expect(diagnostics["Active lanes"]).toMatch(/bass|chords|drums|lead|pad/);
435+
expect(diagnostics["Current preview instrument pack"]).toContain("lead: Retro Pulse Lead");
436+
expect(await page.evaluate(() => window.__midiStudioV2App.previewSynth.getSnapshot().playing)).toBe(true);
437+
438+
await page.locator("#stopAllAudioButton").click();
439+
await expect(page.locator("#statusLog")).toHaveValue(/OK Stop All Audio completed\. Cleared \d+ scheduled oscillators and reset Preview Synth state\./);
440+
await expect(page.locator("#instrumentGridTransportState")).toContainText("Preview Synth timing preview stopped.");
441+
const stoppedDiagnostics = await audioDiagnosticsRows(page);
442+
expect(stoppedDiagnostics["Active lanes"]).toBe("none");
443+
expect(await page.evaluate(() => window.__midiStudioV2App.previewSynth.getSnapshot().playing)).toBe(false);
444+
} finally {
445+
await workspaceV2CoverageReporter.stop(page);
446+
await server.close();
447+
}
448+
});
449+
396450
test("selects multiple songs and updates source, director, and rendered targets", async ({ page }) => {
397451
const server = await openMidiStudio(page);
398452
try {
@@ -854,6 +908,7 @@ Am F`);
854908
const beforeStep = await page.locator(".midi-studio-v2__grid-cell--playhead-active").getAttribute("data-step-index");
855909
await page.locator("#playSectionButton").click();
856910
await expect(page.locator("#statusLog")).toHaveValue(/FAIL No playable Preview Synth notes found for section intro\. Generate or enter chords, bass, pad, lead, or drum cells before playing\./);
911+
await expect(page.locator("#audioDiagnostics")).toContainText("No playable Preview Synth notes found for section intro.");
857912
await expect(page.locator(".midi-studio-v2__grid-cell--playhead-active")).toHaveAttribute("data-step-index", beforeStep || "0");
858913
expect(await page.evaluate(() => window.__midiStudioPreviewSynthEvents.some((event) => event.action === "oscillator-start"))).toBe(false);
859914
} finally {
@@ -869,6 +924,7 @@ Am F`);
869924
await page.locator("#normalizeInstrumentGridButton").click();
870925
await page.locator("#playSectionButton").click();
871926
await expect(page.locator("#statusLog")).toHaveValue(/FAIL Preview Synth audio unavailable: Web Audio AudioContext is not available\. Use a browser with Web Audio support\./);
927+
await expect(page.locator("#audioDiagnostics")).toContainText("Preview Synth audio unavailable");
872928
expect(await page.evaluate(() => window.__midiStudioV2App.previewSynth.getSnapshot())).toMatchObject({
873929
playing: false,
874930
supported: false
@@ -895,6 +951,7 @@ Am F`);
895951
await page.locator("#playSectionButton").click();
896952
await expect(page.locator("#statusLog")).toHaveValue(/OK Preview Synth started for section intro with \d+ playable events\./);
897953
await expect(page.locator('.midi-studio-v2__grid-cell--lane-active[data-lane="bass"]')).toHaveCount(0);
954+
expect((await audioDiagnosticsRows(page))["Muted lanes"]).toBe("bass");
898955
await page.locator("#stopTimingPreviewButton").click();
899956

900957
await page.locator("#previewMuteBassToggle").uncheck();
@@ -905,6 +962,9 @@ Am F`);
905962
expect(await page.locator(".midi-studio-v2__grid-cell--lane-active").evaluateAll((cells) => (
906963
cells.every((cell) => cell.dataset.lane === "lead")
907964
))).toBe(true);
965+
const soloDiagnostics = await audioDiagnosticsRows(page);
966+
expect(soloDiagnostics["Active lanes"]).toBe("lead");
967+
expect(soloDiagnostics["Soloed lanes"]).toBe("lead");
908968
await page.locator("#stopTimingPreviewButton").click();
909969

910970
await page.locator("#previewSoloLeadToggle").uncheck();

tools/midi-studio-v2/index.html

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,9 @@ <h2 class="tools-platform-frame__eyebrow">First-Class Tools Surface V2</h2>
3939

4040
<nav class="tool-starter__menu tool-starter__tool__menu midi-studio-v2__tool-menu" aria-label="Tool actions" data-launch-mode-nav="tool">
4141
<button id="toolImportManifestButton" type="button">Import Manifest</button>
42+
<button id="loadExampleAndPlayButton" class="midi-studio-v2__primary-action" type="button">Load Example And Play</button>
4243
<button id="useExampleButton" type="button">Use Example Test Song</button>
44+
<button id="stopAllAudioButton" type="button">Stop All Audio</button>
4345
<label class="midi-studio-v2__compact-field" for="renderedExportTargetTypeSelect">
4446
<span>Export target type</span>
4547
<select id="renderedExportTargetTypeSelect">
@@ -457,6 +459,16 @@ <h2 class="tools-platform-frame__eyebrow">First-Class Tools Surface V2</h2>
457459
</div>
458460
</section>
459461

462+
<section class="accordion-v2 tool-starter__accordion is-open" data-accordion-v2-open="true">
463+
<button class="accordion-v2__header" type="button" aria-expanded="true" aria-controls="audioDiagnosticsContent">
464+
<span>Audio Diagnostics</span>
465+
<span class="accordion-v2__icon" aria-hidden="true">+</span>
466+
</button>
467+
<div id="audioDiagnosticsContent" class="accordion-v2__content">
468+
<dl id="audioDiagnostics" class="midi-studio-v2__details"></dl>
469+
</div>
470+
</section>
471+
460472
<section class="accordion-v2 tool-starter__accordion is-open" data-accordion-v2-open="true">
461473
<div class="accordion-v2__header tool-starter__status-accordion-header" aria-expanded="true" aria-controls="statusLogContent">
462474
<span>Status</span>

0 commit comments

Comments
 (0)