Skip to content

Commit c096ea6

Browse files
committed
Repair MIDI Studio V2 fullscreen, editable song details, and preview instrument behavior - PR_26146_026-midi-studio-v2-uat-usability-and-sound-repair
1 parent f34a1c7 commit c096ea6

13 files changed

Lines changed: 838 additions & 193 deletions
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
# PR_26146_026 MIDI Studio V2 UAT Usability And Sound Repair Validation
2+
3+
## Result
4+
5+
PASS.
6+
7+
MIDI Studio V2 now preserves the import manifest workflow while repairing the PR_26146_026 UAT blockers:
8+
9+
- Fullscreen uses the shared tool fullscreen pattern and browser Fullscreen API path through `requestFullscreen`.
10+
- Fullscreen preserves transport controls, Stop All Audio, recovery/status controls, and visible timeline instrument rows.
11+
- Visible Role text was removed from instrument rows; the selected preview instrument is the row identity.
12+
- The UAT manifest first song now proves more than six manifest lanes by rendering the additional `mallets` lane.
13+
- Preview Synth renders drum events through a buffer/noise percussion path instead of pitched oscillator tones.
14+
- Selected Song Details fields are editable for title, BPM, key, style, loop settings, source MIDI, runtime format, instrument set, and tags.
15+
- Song Sheet now lives under Selected Song Details with an HR divider, and the duplicate Song Sheet Summary panel was removed.
16+
- Manifest import, multiple songs, timeline editing, timeline playback, instrument dropdowns, volume/pan icons, bar/beat playhead, Stop All Audio, and honest export WARN behavior were preserved.
17+
18+
## Files Changed
19+
20+
- `tools/midi-studio-v2/index.html`
21+
- `tools/midi-studio-v2/styles/midiStudioV2.css`
22+
- `tools/midi-studio-v2/js/MidiStudioV2App.js`
23+
- `tools/midi-studio-v2/js/controls/ToolShellControl.js`
24+
- `tools/midi-studio-v2/js/controls/SongDetailsControl.js`
25+
- `tools/midi-studio-v2/js/controls/InstrumentGridControl.js`
26+
- `tools/midi-studio-v2/js/services/MidiStudioStateSerializer.js`
27+
- `src/engine/audio/InstrumentGridParser.js`
28+
- `src/engine/audio/PreviewInstrumentPacks.js`
29+
- `src/engine/audio/PreviewSynthEngine.js`
30+
- `tests/fixtures/midi-studio-v2/uat-midi-studio-v2.game.manifest.json`
31+
- `tests/playwright/tools/MidiStudioV2.spec.mjs`
32+
33+
## Validation Commands
34+
35+
- PASS: `node --check` for changed MIDI Studio V2 JS, changed engine audio JS, and the Playwright spec.
36+
- PASS: UAT manifest JSON parse.
37+
- PASS: external-only HTML guard for `tools/midi-studio-v2/index.html`.
38+
- PASS: dynamic lane parser smoke for additional manifest lanes and drum tokens.
39+
- PASS: targeted MIDI Studio V2 Playwright affected lane:
40+
- `imports UAT manifest`
41+
- `keeps selected song details`
42+
- `applies Preview Synth instruments`
43+
- `expands and restores`
44+
- `keeps guided Song Sheet workflow primary`
45+
- PASS: additional targeted Song Sheet parser regression lane:
46+
- `parses guided Song Sheet`
47+
- `shows guided Song Sheet warnings`
48+
- `rejects invalid guided Song Sheet`
49+
- `rejects malformed raw Song Sheet`
50+
- `keeps MIDI source inspection`
51+
- PASS: `git diff --check`
52+
53+
## Playwright Proof Points
54+
55+
- UAT manifest JSON imports through Import JSON Manifest.
56+
- Multiple manifest songs are listed and selectable.
57+
- Camptown Races UAT Reel, Frog Hop Nursery Rhyme UAT, and Coal Mine Descent still populate timeline rows.
58+
- All selected-song manifest lanes render, including the additional `mallets` lane.
59+
- Role field is absent from instrument rows.
60+
- Drums schedule `buffer-start` percussion events while non-drum instruments still schedule oscillator tones.
61+
- Selected Song Details fields edit the selected song and synchronize visible Song Sheet fields.
62+
- Song Sheet is nested under Selected Song Details, and `#songSheetSummaryContent` is absent.
63+
- Fullscreen enters the shared `tools-platform-fullscreen-active` shell, marks the summary as fullscreen, keeps the timeline rows visible, and restores cleanly.
64+
- Play starts audible preview state and Stop All Audio clears playback state.
65+
66+
## Notes
67+
68+
- Full samples smoke was not run per request.
69+
- Sample JSON was not modified.
70+
- Preview Synth remains an approximate Web Audio audition path. SoundFont or real-instrument playback remains future work and is surfaced as a WARN rather than represented as realistic instrument rendering.
71+
- Export rendering remains intentionally WARN-only.
72+
- The V8 coverage guardrail is advisory only and reports low function coverage for `MidiStudioV2App.js`, with executed browser lines collected for all changed runtime JS files.

src/engine/audio/InstrumentGridParser.js

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
const CHORD_PATTERN = /^[A-G](?:#|b)?(?:m|min|maj|dim|aug|sus2|sus4|7|maj7|m7|min7|dim7|add9)?$/;
22
const DRUM_TOKENS = new Set(["kick", "snare", "hat", "ride", "clap", "tom", "crash", "perc"]);
3-
const LANE_NAMES = ["chords", "bass", "pad", "lead", "drums"];
3+
const DEFAULT_LANE_NAMES = ["chords", "bass", "pad", "lead", "drums"];
44
const NOTE_PATTERN = /^[A-G](?:#|b)?[0-8]$/;
55
const REST_TOKENS = new Set(["", "-", ".", "rest"]);
66
const SUPPORTED_SUBDIVISIONS = new Set([1, 2, 4, 8, 16]);
@@ -47,7 +47,8 @@ export class InstrumentGridParser {
4747
const timeline = [];
4848
const warnings = [];
4949
const laneCells = {};
50-
for (const lane of LANE_NAMES) {
50+
const laneNames = this.laneNamesFor(input.lanes);
51+
for (const lane of laneNames) {
5152
const parsedLane = this.parseLane({
5253
barCount,
5354
beatsPerBar: beatsPerBar.value,
@@ -88,7 +89,7 @@ export class InstrumentGridParser {
8889
chordCount: timeline.filter((event) => event.kind === "chord").length,
8990
drumCount: timeline.filter((event) => event.kind === "drum").length,
9091
eventCount: timeline.length,
91-
lanes: LANE_NAMES,
92+
lanes: laneNames,
9293
noteCount: timeline.filter((event) => event.kind === "note").length,
9394
ok: true,
9495
sectionSummary: normalizedSections.map((section) => `${section.label}: ${section.bars} bar${section.bars === 1 ? "" : "s"}`).join("; "),
@@ -171,6 +172,17 @@ export class InstrumentGridParser {
171172
return { ok: true, value: number };
172173
}
173174

175+
laneNamesFor(lanes = {}) {
176+
const names = DEFAULT_LANE_NAMES.slice();
177+
Object.keys(lanes || {}).forEach((lane) => {
178+
const normalized = String(lane || "").trim();
179+
if (normalized && !names.includes(normalized)) {
180+
names.push(normalized);
181+
}
182+
});
183+
return names;
184+
}
185+
174186
parseLane({ barCount, beatsPerBar, generatedSource, lane, sections, source, stepsPerBar, subdivision }) {
175187
const text = String(source || "").trim();
176188
const bars = text ? text.split("|").map((bar) => bar.trim()) : [];
@@ -225,7 +237,7 @@ export class InstrumentGridParser {
225237
if (REST_TOKENS.has(value.toLowerCase())) {
226238
return { cell, events: [], ok: true, warnings: [] };
227239
}
228-
if (lane === "drums") {
240+
if (lane === "drums" || this.isDrumToken(value)) {
229241
return this.normalizeDrums({ cell, timing, value });
230242
}
231243
if (lane === "chords") {
@@ -262,6 +274,11 @@ export class InstrumentGridParser {
262274
};
263275
}
264276

277+
isDrumToken(value) {
278+
const parts = String(value || "").toLowerCase().split("+").map((part) => part.trim()).filter(Boolean);
279+
return parts.length > 0 && parts.every((part) => DRUM_TOKENS.has(part));
280+
}
281+
265282
eventForCell({ cell, kind, value }) {
266283
return {
267284
bar: cell.bar,

src/engine/audio/PreviewInstrumentPacks.js

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,18 +2,21 @@ export const PREVIEW_INSTRUMENT_PACKS = [
22
{
33
id: "retro-square-lead",
44
label: "Retro Square Lead",
5+
synthRole: "lead",
56
volume: 0.075,
67
waveform: "square"
78
},
89
{
910
id: "retro-pulse-lead",
1011
label: "Retro Pulse Lead",
12+
synthRole: "lead",
1113
volume: 0.07,
1214
waveform: "square"
1315
},
1416
{
1517
id: "synth-bass",
1618
label: "Synth Bass",
19+
synthRole: "bass",
1720
transposeSemitones: -12,
1821
volume: 0.095,
1922
waveform: "triangle"
@@ -22,19 +25,22 @@ export const PREVIEW_INSTRUMENT_PACKS = [
2225
id: "warm-pad",
2326
label: "Warm Pad",
2427
durationScale: 1.3,
28+
synthRole: "pad",
2529
volume: 0.045,
2630
waveform: "sine"
2731
},
2832
{
2933
id: "basic-drums",
3034
label: "Basic Drums",
31-
volume: 0.12,
32-
waveform: "square"
35+
synthRole: "percussion",
36+
volume: 0.16,
37+
waveform: "noise"
3338
},
3439
{
3540
id: "ambient-pad",
3641
label: "Ambient Pad",
3742
durationScale: 1.55,
43+
synthRole: "pad",
3844
transposeSemitones: 12,
3945
volume: 0.035,
4046
waveform: "sine"

src/engine/audio/PreviewSynthEngine.js

Lines changed: 70 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,17 @@ const DRUM_FREQUENCIES = {
2828
tom: 150
2929
};
3030

31+
const DRUM_DURATIONS = {
32+
clap: 0.1,
33+
crash: 0.32,
34+
hat: 0.055,
35+
kick: 0.14,
36+
perc: 0.09,
37+
ride: 0.22,
38+
snare: 0.12,
39+
tom: 0.16
40+
};
41+
3142
function audioContextCtor(windowRef) {
3243
return windowRef?.AudioContext || windowRef?.webkitAudioContext || null;
3344
}
@@ -155,7 +166,14 @@ export class PreviewSynthEngine {
155166
}
156167
return;
157168
}
158-
if (this.frequenciesForEvent(event, instrument).length > 0) {
169+
if (event.kind === "drum" && instrument.synthRole !== "percussion") {
170+
const key = `drum-pack:${event.lane}`;
171+
if (!warningKeys.has(key)) {
172+
warnings.push(`Preview instrument "${instrument.label}" is not a percussion pack for ${event.lane}; Preview Synth will use a noise-based percussion approximation.`);
173+
warningKeys.add(key);
174+
}
175+
}
176+
if (this.canScheduleEvent(event, instrument)) {
159177
events.push({ ...event, previewInstrument: instrument });
160178
}
161179
});
@@ -171,6 +189,14 @@ export class PreviewSynthEngine {
171189
events.forEach((event) => {
172190
const offsetSeconds = Math.max(0, (event.stepIndex - startStep) * secondsPerStep);
173191
const instrument = event.previewInstrument;
192+
if (event.kind === "drum") {
193+
this.scheduleDrumHit({
194+
context,
195+
event,
196+
startTime: now + offsetSeconds
197+
});
198+
return;
199+
}
174200
const durationScale = Number(instrument?.durationScale || 1);
175201
const durationSeconds = Math.max(0.06, Number(event.durationBeats || 1) * secondsPerBeat * 0.82 * durationScale);
176202
this.frequenciesForEvent(event, instrument).forEach((frequency, index) => {
@@ -201,7 +227,46 @@ export class PreviewSynthEngine {
201227
gainNode.connect(context.destination);
202228
oscillator.start(startTime);
203229
oscillator.stop(endTime);
204-
this.nodes.push({ gainNode, oscillator });
230+
this.nodes.push({ gainNode, source: oscillator });
231+
}
232+
233+
scheduleDrumHit({ context, event, startTime }) {
234+
if (typeof context.createBuffer !== "function" || typeof context.createBufferSource !== "function") {
235+
const frequency = DRUM_FREQUENCIES[String(event.value || "").toLowerCase()];
236+
if (frequency) {
237+
this.scheduleTone({ context, durationSeconds: 0.08, event, frequency, startTime });
238+
}
239+
return;
240+
}
241+
const drumName = String(event.value || "").toLowerCase();
242+
const durationSeconds = DRUM_DURATIONS[drumName] || 0.1;
243+
const sampleRate = Number(context.sampleRate || 44100);
244+
const frameCount = Math.max(1, Math.floor(sampleRate * durationSeconds));
245+
const buffer = context.createBuffer(1, frameCount, sampleRate);
246+
const samples = buffer.getChannelData(0);
247+
for (let index = 0; index < frameCount; index += 1) {
248+
const decay = 1 - (index / frameCount);
249+
samples[index] = (Math.random() * 2 - 1) * decay * decay;
250+
}
251+
const source = context.createBufferSource();
252+
const gainNode = context.createGain();
253+
const volume = this.volumeForEvent(event, event.previewInstrument);
254+
source.buffer = buffer;
255+
gainNode.gain.setValueAtTime(0.0001, startTime);
256+
gainNode.gain.linearRampToValueAtTime(volume, startTime + 0.004);
257+
gainNode.gain.linearRampToValueAtTime(0.0001, startTime + durationSeconds);
258+
source.connect(gainNode);
259+
gainNode.connect(context.destination);
260+
source.start(startTime);
261+
source.stop(startTime + durationSeconds);
262+
this.nodes.push({ gainNode, source });
263+
}
264+
265+
canScheduleEvent(event, instrument = null) {
266+
if (event.kind === "drum") {
267+
return Boolean(DRUM_FREQUENCIES[String(event.value || "").toLowerCase()]);
268+
}
269+
return this.frequenciesForEvent(event, instrument).length > 0;
205270
}
206271

207272
frequenciesForEvent(event, instrument = null) {
@@ -236,14 +301,14 @@ export class PreviewSynthEngine {
236301
this.window.clearInterval(this.loopTimer);
237302
this.loopTimer = null;
238303
}
239-
this.nodes.forEach(({ oscillator, gainNode }) => {
304+
this.nodes.forEach(({ source, gainNode }) => {
240305
try {
241-
oscillator.stop();
306+
source.stop();
242307
} catch {
243308
// Already stopped or never started.
244309
}
245310
try {
246-
oscillator.disconnect();
311+
source.disconnect();
247312
gainNode.disconnect();
248313
} catch {
249314
// Some test doubles do not implement disconnect.

tests/fixtures/midi-studio-v2/uat-midi-studio-v2.game.manifest.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,13 +63,15 @@
6363
"bass": "G2 G2 C2 G2 | D2 D2 G2 G2 | G2 G2 C2 G2 | D2 C2 G2 G2",
6464
"pad": "G - C - | D - G - | G - C - | D C G -",
6565
"lead": "G4 G4 E4 G4 | A4 G4 E4 - | B4 B4 A4 B4 | D5 B4 G4 -",
66+
"mallets": "B4 - C5 - | A4 - G4 - | B4 C5 D5 - | G4 - B4 -",
6667
"drums": "kick hat snare hat | kick hat snare hat | kick hat snare hat | kick hat snare hat"
6768
},
6869
"previewInstruments": {
6970
"chords": "warm-pad",
7071
"bass": "synth-bass",
7172
"pad": "ambient-pad",
7273
"lead": "retro-pulse-lead",
74+
"mallets": "retro-square-lead",
7375
"drums": "basic-drums"
7476
}
7577
}

0 commit comments

Comments
 (0)