Skip to content

Commit c2f5626

Browse files
committed
Correct MIDI Studio V2 playback contract for rendered preview and future real MIDI synthesis - PR_26146_004-midi-studio-v2-real-playback-foundation
1 parent 29a1c0d commit c2f5626

7 files changed

Lines changed: 123 additions & 30 deletions

File tree

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
# PR_26146_004-midi-studio-v2-real-playback-foundation Validation
2+
3+
## Scope
4+
5+
- Repaired MIDI Studio V2 playback messaging so `.mid` files are treated as musical instruction data, not browser-playable audio.
6+
- Updated rendered preview behavior to use browser media playback only for manifest-owned rendered OGG/MP3/WAV targets.
7+
- Added explicit live MIDI synthesis status: `NOT IMPLEMENTED` until a real shared JavaScript MIDI parser/synth/instrument path exists.
8+
- Preserved first-class Workspace V2 tool scope and did not add MIDI input, recording, fallback songs, DAW editing, piano-roll editing, or fake playback.
9+
10+
## Playback Contract
11+
12+
- Manifest root `music.songs` stores song metadata.
13+
- `sourceMidi` points to a `.mid` musical instruction file.
14+
- `rendered` targets point to runtime audio assets that may be previewed through browser `Audio`.
15+
- Future real MIDI playback requires shared `src/` MIDI parser, synth, and instrument rendering capability before the tool can claim live MIDI playback.
16+
17+
## Status Behavior
18+
19+
- Rendered preview success logs `OK Rendered preview started ...`.
20+
- Instruction-only playback attempts log `WARN Live MIDI synthesis not implemented.`.
21+
- Songs without rendered targets fail with `FAIL No rendered audio target ... and no live MIDI engine is available ...`.
22+
23+
## Validation
24+
25+
- PASS: `node --check tests/playwright/tools/MidiStudioV2.spec.mjs`
26+
- PASS: `node --check tools/midi-studio-v2/js/MidiStudioV2App.js`
27+
- PASS: `node --check tools/midi-studio-v2/js/services/MidiPlaybackService.js`
28+
- PASS: `node --check tools/midi-studio-v2/js/controls/PlaybackControl.js`
29+
- PASS: `npx.cmd playwright test tests/playwright/tools/MidiStudioV2.spec.mjs --project=playwright --workers=1 --reporter=line`
30+
- PASS: `npx.cmd playwright test tests/playwright/tools/WorkspaceManagerV2.spec.mjs --project=playwright --workers=1 --reporter=line -g "registers Workspace Manager V2 from the tools index"`
31+
- PASS: `git diff --check`
32+
33+
## Environment Notes
34+
35+
- Initial Playwright Chromium install attempt failed when writing to the default user cache path.
36+
- Retried Chromium install with `PLAYWRIGHT_BROWSERS_PATH` set to `%TEMP%\ms-playwright`.
37+
- A TLS validation error was resolved by adding `NODE_OPTIONS=--use-system-ca`.
38+
- Targeted Playwright validation was run only after Chromium was available.
39+
40+
## Explicit Skips
41+
42+
- SKIP: full samples smoke test. Samples alignment is out of scope for this docs/tool repair.
43+
- SKIP: full Workspace V2 suite. Only the touched registration/handoff test was required and run.

tests/playwright/tools/MidiStudioV2.spec.mjs

Lines changed: 32 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,22 @@ const validManifest = {
5757
notes: "Short encounter loop."
5858
},
5959
tags: ["combat"]
60+
},
61+
{
62+
id: "source-only",
63+
name: "Source Only",
64+
sourceMidi: "assets/music/midi/source-only.mid",
65+
instrumentSet: "General MIDI",
66+
rendered: {},
67+
defaultRuntimeFormat: "ogg",
68+
loop: { enabled: false },
69+
director: {
70+
mood: "mysterious",
71+
intensity: "low",
72+
usage: ["debug"],
73+
notes: "Instruction-only MIDI with no rendered preview target."
74+
},
75+
tags: ["instruction-only"]
6076
}
6177
]
6278
}
@@ -93,14 +109,14 @@ function installMockAudio(page) {
93109
async function openMidiStudio(page, routePayload = validManifest) {
94110
const server = await startRepoServer();
95111
await installMockAudio(page);
96-
await page.route("**/midi-fixture.game.manifest.json", async (route) => {
112+
await page.route((url) => url.pathname === "/midi-fixture.game.manifest.json", async (route) => {
97113
await route.fulfill({
98114
contentType: "application/json",
99115
body: JSON.stringify(routePayload)
100116
});
101117
});
102118
await workspaceV2CoverageReporter.start(page);
103-
await page.goto(`${server.baseUrl}/tools/midi-studio-v2/index.html?manifestPath=/midi-fixture.game.manifest.json`, { waitUntil: "networkidle" });
119+
await page.goto(`${server.baseUrl}/tools/midi-studio-v2/index.html?manifestPath=/midi-fixture.game.manifest.json`, { waitUntil: "domcontentloaded" });
104120
return server;
105121
}
106122

@@ -112,15 +128,18 @@ test.describe("MIDI Studio V2", () => {
112128
test("launches and renders a valid multi-song manifest payload", async ({ page }) => {
113129
const server = await openMidiStudio(page);
114130
try {
115-
await expect(page.locator("body[data-tool-id='midi-studio-v2']")).toBeVisible();
131+
await expect(page.locator("body")).toHaveAttribute("data-tool-id", "midi-studio-v2");
116132
await expect(page.locator("[data-midi-studio-header]")).toContainText("MIDI Studio V2");
117-
await expect(page.locator("#songList [data-song-id]")).toHaveCount(2);
133+
await expect(page.locator("#songList [data-song-id]")).toHaveCount(3);
118134
await expect(page.locator('[data-song-id="theme-main"]')).toHaveAttribute("aria-pressed", "true");
119135
await expect(page.locator("#songSourceField")).toHaveValue("assets/music/midi/theme-main.mid");
120136
await expect(page.locator("#instrumentSetField")).toHaveValue("General MIDI");
121137
await expect(page.locator("#renderedTargets")).toContainText("theme-main.ogg");
122138
await expect(page.locator("#directorPanel")).toContainText("heroic");
123-
await expect(page.locator("#statusLog")).toHaveValue(/OK Loaded 2 MIDI songs/);
139+
await expect(page.locator("#playButton")).toHaveText("Play Rendered Preview");
140+
await expect(page.locator("#playbackState")).toContainText("Live MIDI synthesis: NOT IMPLEMENTED");
141+
await expect(page.locator("#statusLog")).toHaveValue(/OK Loaded 3 MIDI songs/);
142+
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\./);
124143
} finally {
125144
await workspaceV2CoverageReporter.stop(page);
126145
await server.close();
@@ -144,12 +163,13 @@ test.describe("MIDI Studio V2", () => {
144163
}
145164
});
146165

147-
test("shows actionable failure for a selected song with missing MIDI source", async ({ page }) => {
166+
test("shows actionable failure when no rendered target and no live MIDI engine exist", async ({ page }) => {
148167
const server = await openMidiStudio(page);
149168
try {
150-
await page.locator('[data-song-id="combat-light"]').click();
169+
await page.locator('[data-song-id="source-only"]').click();
151170
await page.locator("#playButton").click();
152-
await expect(page.locator("#statusLog")).toHaveValue(/FAIL Missing MIDI source for Light Combat\. Add music\.songs\[\]\.sourceMidi in game\.manifest\.json\./);
171+
await expect(page.locator("#statusLog")).toHaveValue(/WARN Live MIDI synthesis not implemented\./);
172+
await expect(page.locator("#statusLog")).toHaveValue(/FAIL No rendered audio target is available for Source Only, and no live MIDI engine is available\./);
153173
await expect(page.locator("#stopButton")).toBeDisabled();
154174
await expect(page.locator("#playButton")).toBeEnabled();
155175
} finally {
@@ -165,15 +185,15 @@ test.describe("MIDI Studio V2", () => {
165185
await page.locator("#playButton").click();
166186
await expect(page.locator("#playButton")).toBeDisabled();
167187
await expect(page.locator("#stopButton")).toBeEnabled();
168-
await expect(page.locator("#playbackState")).toContainText("Playing preview: Main Theme");
169-
await expect(page.locator("#statusLog")).toHaveValue(/OK Preview play started for Main Theme/);
188+
await expect(page.locator("#playbackState")).toContainText("Playing rendered preview: Main Theme");
189+
await expect(page.locator("#statusLog")).toHaveValue(/OK Rendered preview started for Main Theme: assets\/music\/rendered\/theme-main\.ogg\./);
170190
expect(await page.evaluate(() => window.__midiStudioAudioEvents)).toEqual([
171-
{ action: "play", loop: true, src: "assets/music/midi/theme-main.mid" }
191+
{ action: "play", loop: true, src: "assets/music/rendered/theme-main.ogg" }
172192
]);
173193
await page.locator("#stopButton").click();
174194
await expect(page.locator("#stopButton")).toBeDisabled();
175195
await expect(page.locator("#playButton")).toBeEnabled();
176-
await expect(page.locator("#playbackState")).toContainText("Stopped: Main Theme");
196+
await expect(page.locator("#playbackState")).toContainText("Stopped rendered preview: Main Theme");
177197
} finally {
178198
await workspaceV2CoverageReporter.stop(page);
179199
await server.close();

tools/midi-studio-v2/README.md

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,9 @@ Current scope:
77
- renders multiple songs from `game.manifest.json` root `music.songs`;
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;
10-
- attempts browser media preview for selected `.mid` sources;
10+
- treats `sourceMidi` as a `.mid` instruction file path, not browser-playable audio;
11+
- uses browser media preview only for rendered OGG/MP3/WAV targets;
12+
- reports live MIDI synthesis as not implemented until shared MIDI parser/synth/instrument capability exists;
1113
- reports missing source paths and unsupported preview playback visibly;
1214
- rejects invalid payloads before render.
1315

@@ -18,4 +20,9 @@ Out of scope:
1820
- piano-roll or DAW editing;
1921
- hidden fallback songs or sample data.
2022

21-
Future `src/` follow-up: add shared MIDI parsing/synthesis/rendering capability when live MIDI preview and rendered WAV/MP3/OGG export move beyond this first usable metadata surface.
23+
Playback distinction:
24+
25+
- manifest root `music.songs` stores song metadata;
26+
- `sourceMidi` points to a `.mid` musical instruction file;
27+
- `rendered` targets point to runtime audio assets that browser `Audio` may preview;
28+
- future real MIDI playback requires shared `src/` MIDI parser, synth, and instrument capability.

tools/midi-studio-v2/index.html

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ <h2 class="tools-platform-frame__eyebrow">First-Class Tools Surface V2</h2>
2929
</div>
3030
</div>
3131
<div class="tools-platform-frame__topline">
32-
<p class="tools-platform-frame__description">Inspect multi-song MIDI metadata, preview source files when the browser can play them, and verify rendered gameplay targets.</p>
32+
<p class="tools-platform-frame__description">Inspect multi-song MIDI metadata, preview rendered audio targets, and track future live MIDI synthesis readiness.</p>
3333
</div>
3434
</div>
3535
</section>
@@ -70,7 +70,7 @@ <h2 class="tools-platform-frame__eyebrow">First-Class Tools Surface V2</h2>
7070
</button>
7171
<div id="songSourceContent" class="accordion-v2__content midi-studio-v2__stack">
7272
<div class="tool-starter__field tool-starter__field--not-editable">
73-
<span>.mid source</span>
73+
<span>.mid instruction source</span>
7474
<input id="songSourceField" type="text" readonly value="No song selected">
7575
</div>
7676
<div class="tool-starter__field tool-starter__field--not-editable">
@@ -84,12 +84,12 @@ <h2 class="tools-platform-frame__eyebrow">First-Class Tools Surface V2</h2>
8484
<section class="tool-starter__panel tool-starter__panel--center" aria-label="MIDI preview">
8585
<section class="accordion-v2 tool-starter__accordion is-open" data-accordion-v2-open="true">
8686
<button class="accordion-v2__header" type="button" aria-expanded="true" aria-controls="playbackContent">
87-
<span>Playback Preview</span>
87+
<span>Rendered Preview</span>
8888
<span class="accordion-v2__icon" aria-hidden="true">+</span>
8989
</button>
9090
<div id="playbackContent" class="accordion-v2__content">
9191
<div class="midi-studio-v2__playback-controls">
92-
<button id="playButton" type="button" disabled>Play</button>
92+
<button id="playButton" type="button" disabled>Play Rendered Preview</button>
9393
<button id="stopButton" type="button" disabled>Stop</button>
9494
<label class="tool-starter__toggle" for="loopToggle">
9595
<input id="loopToggle" type="checkbox">

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

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ export class MidiStudioV2App {
8282
this.selectedSongId = this.payload.activeSongId;
8383
this.render();
8484
this.statusLog.ok(`Loaded ${this.payload.songs.length} MIDI song${this.payload.songs.length === 1 ? "" : "s"} from ${sourceLabel} via ${normalized.sourceKind}.`);
85+
this.statusLog.warn("Live MIDI synthesis not implemented. sourceMidi is musical instruction data; rendered OGG/MP3/WAV targets are used for preview and gameplay audio.");
8586
return true;
8687
}
8788

@@ -112,14 +113,17 @@ export class MidiStudioV2App {
112113

113114
async playSelectedSong() {
114115
const song = this.selectedSong();
115-
const result = await this.playback.play(song, { loop: this.playbackControl.loopEnabled() });
116+
const result = await this.playback.playRenderedPreview(song, { loop: this.playbackControl.loopEnabled() });
116117
if (!result.ok) {
117118
this.playbackControl.setStopped(song);
119+
if (result.liveMidiNotImplemented) {
120+
this.statusLog.warn("Live MIDI synthesis not implemented.");
121+
}
118122
this.statusLog.fail(result.message);
119123
return;
120124
}
121125
this.playbackControl.setPlaying(song);
122-
this.statusLog.ok(`Preview play started for ${song.name}. Live MIDI playback is for tools, preview, and debugging; prefer rendered OGG/MP3 for gameplay.`);
126+
this.statusLog.ok(`Rendered preview started for ${song.name}: ${result.path}.`);
123127
}
124128

125129
stopPlayback() {

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

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,18 +24,18 @@ export class PlaybackControl {
2424
setSelected(song) {
2525
this.playButton.disabled = !song;
2626
this.stopButton.disabled = true;
27-
this.stateOutput.textContent = song ? `Ready: ${song.name}` : "No song selected.";
27+
this.stateOutput.textContent = song ? `Rendered preview ready: ${song.name}. Live MIDI synthesis: NOT IMPLEMENTED.` : "No song selected.";
2828
}
2929

3030
setPlaying(song) {
3131
this.playButton.disabled = true;
3232
this.stopButton.disabled = false;
33-
this.stateOutput.textContent = `Playing preview: ${song.name}`;
33+
this.stateOutput.textContent = `Playing rendered preview: ${song.name}`;
3434
}
3535

3636
setStopped(song) {
3737
this.playButton.disabled = !song;
3838
this.stopButton.disabled = true;
39-
this.stateOutput.textContent = song ? `Stopped: ${song.name}` : "No song selected.";
39+
this.stateOutput.textContent = song ? `Stopped rendered preview: ${song.name}. Live MIDI synthesis: NOT IMPLEMENTED.` : "No song selected.";
4040
}
4141
}

tools/midi-studio-v2/js/services/MidiPlaybackService.js

Lines changed: 26 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,31 +6,50 @@ export class MidiPlaybackService {
66
this.activeTrackId = "";
77
}
88

9-
async play(song, { loop = false } = {}) {
10-
const sourceMidi = String(song?.sourceMidi || "").trim();
9+
renderedPreviewSource(song) {
10+
const rendered = song?.rendered || {};
11+
const preferredFormat = String(song?.defaultRuntimeFormat || "ogg").trim().toLowerCase();
12+
const orderedFormats = [
13+
preferredFormat,
14+
"ogg",
15+
"mp3",
16+
"wav"
17+
].filter((format, index, formats) => format && formats.indexOf(format) === index);
18+
for (const format of orderedFormats) {
19+
const path = String(rendered[format] || "").trim();
20+
if (path) {
21+
return { format, path };
22+
}
23+
}
24+
return null;
25+
}
26+
27+
async playRenderedPreview(song, { loop = false } = {}) {
1128
if (!song?.id) {
1229
return { ok: false, message: "No MIDI song is selected." };
1330
}
14-
if (!sourceMidi) {
31+
const previewSource = this.renderedPreviewSource(song);
32+
if (!previewSource) {
1533
return {
1634
ok: false,
17-
message: `Missing MIDI source for ${song.name}. Add music.songs[].sourceMidi in game.manifest.json.`
35+
liveMidiNotImplemented: true,
36+
message: `No rendered audio target is available for ${song.name}, and no live MIDI engine is available. Add rendered OGG/MP3/WAV targets or implement shared src MIDI parser/synth/instrument capability.`
1837
};
1938
}
2039
if (!this.mediaTracks.isSupported()) {
2140
return { ok: false, message: "Media playback is unavailable in this browser." };
2241
}
2342
const trackId = `midi-studio-v2:${song.id}`;
24-
this.mediaTracks.register(trackId, { loop, src: sourceMidi, volume: 1 });
43+
this.mediaTracks.register(trackId, { loop, src: previewSource.path, volume: 1 });
2544
const played = await this.mediaTracks.play(trackId);
2645
if (!played) {
2746
return {
2847
ok: false,
29-
message: this.mediaTracks.lastError || `MIDI preview failed for ${sourceMidi}. Browser MIDI playback may be unsupported; use rendered OGG/MP3 for gameplay.`
48+
message: this.mediaTracks.lastError || `Rendered ${previewSource.format.toUpperCase()} preview failed for ${previewSource.path}.`
3049
};
3150
}
3251
this.activeTrackId = trackId;
33-
return { ok: true, trackId };
52+
return { format: previewSource.format, ok: true, path: previewSource.path, trackId };
3453
}
3554

3655
stop() {

0 commit comments

Comments
 (0)