Skip to content

Commit 8f3f0d8

Browse files
committed
Add preview instrument packs and lane mute/solo controls for MIDI Studio V2 - PR_26146_017-midi-studio-v2-preview-instrument-packs
1 parent 293d12d commit 8f3f0d8

9 files changed

Lines changed: 491 additions & 38 deletions

File tree

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
# PR_26146_017-midi-studio-v2-preview-instrument-packs Validation
2+
3+
## Scope
4+
5+
- Added shared Preview Synth instrument pack definitions for Retro Square Lead, Retro Pulse Lead, Synth Bass, Warm Pad, Basic Drums, and Ambient Pad.
6+
- Added visible lane-level preview instrument dropdowns for chords, bass, pad, lead, and drums.
7+
- Added lane mute and solo controls with status logging.
8+
- Added active playback lane highlighting while Preview Synth timing preview runs.
9+
- Preserved generated/manual lane distinction, snapping, playhead, loops, sections, MIDI inspection, export status honesty, and invalid payload rejection.
10+
- Did not implement SoundFont playback, rendered export, MIDI recording, or MIDI input.
11+
12+
## Validation
13+
14+
- PASS: changed-file syntax checks with `node --check`.
15+
- PASS: MIDI Studio V2 inline HTML check for inline scripts, styles, and event handlers returned no matches.
16+
- PASS: targeted MIDI Studio V2 Playwright suite: `35 passed`.
17+
- PASS: `git diff --check` completed successfully. Git reported line-ending warnings for existing LF/CRLF normalization only.
18+
- SKIP: full samples smoke test per request. Samples decision: SKIP because sample JSON alignment is out of scope.
19+
20+
## Lanes
21+
22+
- Executed: engine/audio shared runtime, because Preview Synth instrument pack behavior lives under `src/engine/audio/`.
23+
- Executed: recovery/UAT tool runtime, because MIDI Studio V2 preview controls, lane filtering, status, and visual highlighting changed.
24+
- Skipped: samples, because sample JSON alignment remains out of scope.
25+
26+
## Manual Check
27+
28+
1. Open MIDI Studio V2.
29+
2. Choose `Use Example Test Song`.
30+
3. Confirm preview instrument dropdowns are populated and assigned for each lane.
31+
4. Generate or normalize grid lanes, then test Play Section and Play Loop.
32+
5. Toggle lane mute and solo controls and confirm status logs plus active lane highlighting match the selected lanes.
33+
6. Clear a lane instrument selection and confirm Preview Synth reports an actionable warning without using a hidden fallback instrument.
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
export const PREVIEW_INSTRUMENT_PACKS = [
2+
{
3+
id: "retro-square-lead",
4+
label: "Retro Square Lead",
5+
volume: 0.075,
6+
waveform: "square"
7+
},
8+
{
9+
id: "retro-pulse-lead",
10+
label: "Retro Pulse Lead",
11+
volume: 0.07,
12+
waveform: "square"
13+
},
14+
{
15+
id: "synth-bass",
16+
label: "Synth Bass",
17+
transposeSemitones: -12,
18+
volume: 0.095,
19+
waveform: "triangle"
20+
},
21+
{
22+
id: "warm-pad",
23+
label: "Warm Pad",
24+
durationScale: 1.3,
25+
volume: 0.045,
26+
waveform: "sine"
27+
},
28+
{
29+
id: "basic-drums",
30+
label: "Basic Drums",
31+
volume: 0.12,
32+
waveform: "square"
33+
},
34+
{
35+
id: "ambient-pad",
36+
label: "Ambient Pad",
37+
durationScale: 1.55,
38+
transposeSemitones: 12,
39+
volume: 0.035,
40+
waveform: "sine"
41+
}
42+
];
43+
44+
export function previewInstrumentById(id) {
45+
return PREVIEW_INSTRUMENT_PACKS.find((instrument) => instrument.id === id) || null;
46+
}

src/engine/audio/PreviewSynthEngine.js

Lines changed: 61 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { previewInstrumentById } from "./PreviewInstrumentPacks.js";
2+
13
const CHORD_TONES = {
24
A: ["A", "C#", "E"],
35
Am: ["A", "C", "E"],
@@ -59,7 +61,7 @@ export class PreviewSynthEngine {
5961
return Boolean(audioContextCtor(this.window));
6062
}
6163

62-
async playGridRange({ endStep, grid, label, loop = false, mode = "section", startStep, tempoBpm = 120 } = {}) {
64+
async playGridRange({ endStep, grid, label, laneSettings = {}, loop = false, mode = "section", startStep, tempoBpm = 120 } = {}) {
6365
this.stop();
6466
if (!grid?.ok) {
6567
return { message: "Preview Synth needs a normalized instrument grid before playback.", ok: false, reason: "missing-grid" };
@@ -68,11 +70,12 @@ export class PreviewSynthEngine {
6870
if (!contextResult.ok) {
6971
return contextResult;
7072
}
71-
const playableEvents = this.playableEventsForRange(grid, startStep, endStep);
72-
if (!playableEvents.length) {
73+
const playable = this.playableEventsForRange(grid, startStep, endStep, laneSettings);
74+
if (!playable.events.length) {
7375
return {
7476
message: `No playable Preview Synth notes found for ${mode} ${label || "(unnamed)"}. Generate or enter chords, bass, pad, lead, or drum cells before playing.`,
7577
ok: false,
78+
warnings: playable.warnings,
7679
reason: "no-playable-notes"
7780
};
7881
}
@@ -81,19 +84,21 @@ export class PreviewSynthEngine {
8184
const secondsPerStep = secondsPerBeat / grid.subdivision;
8285
const cycleSeconds = Math.max((endStep - startStep + 1) * secondsPerStep, secondsPerStep);
8386
this.playing = true;
84-
this.scheduleEvents({ context: contextResult.context, events: playableEvents, secondsPerBeat, secondsPerStep, startStep });
87+
this.scheduleEvents({ context: contextResult.context, events: playable.events, secondsPerBeat, secondsPerStep, startStep });
8588
if (loop) {
8689
this.loopTimer = this.window.setInterval(() => {
87-
this.scheduleEvents({ context: contextResult.context, events: playableEvents, secondsPerBeat, secondsPerStep, startStep });
90+
this.scheduleEvents({ context: contextResult.context, events: playable.events, secondsPerBeat, secondsPerStep, startStep });
8891
}, cycleSeconds * 1000);
8992
}
9093
return {
91-
eventCount: playableEvents.length,
94+
activeLanes: playable.activeLanes,
95+
eventCount: playable.events.length,
9296
label,
9397
mode,
9498
ok: true,
9599
soundFontPlayback: false,
96-
synthName: "Preview Synth"
100+
synthName: "Preview Synth",
101+
warnings: playable.warnings
97102
};
98103
}
99104

@@ -128,22 +133,51 @@ export class PreviewSynthEngine {
128133
}
129134
}
130135

131-
playableEventsForRange(grid, startStep = 0, endStep = 0) {
132-
return (grid.timeline || []).filter((event) => {
136+
playableEventsForRange(grid, startStep = 0, endStep = 0, laneSettings = {}) {
137+
const instruments = laneSettings.instruments || {};
138+
const muted = laneSettings.muted || {};
139+
const soloed = laneSettings.soloed || {};
140+
const soloedLanes = Object.entries(soloed).filter((entry) => entry[1]).map(([lane]) => lane);
141+
const warnings = [];
142+
const warningKeys = new Set();
143+
const events = [];
144+
(grid.timeline || []).forEach((event) => {
133145
const stepIndex = Number(event.stepIndex);
134-
return Number.isFinite(stepIndex)
135-
&& stepIndex >= startStep
136-
&& stepIndex <= endStep
137-
&& this.frequenciesForEvent(event).length > 0;
146+
if (!Number.isFinite(stepIndex) || stepIndex < startStep || stepIndex > endStep) {
147+
return;
148+
}
149+
if (muted[event.lane] || (soloedLanes.length && !soloedLanes.includes(event.lane))) {
150+
return;
151+
}
152+
const instrumentId = String(instruments[event.lane] || "").trim();
153+
const instrument = previewInstrumentById(instrumentId);
154+
if (!instrument) {
155+
const key = `missing:${event.lane}`;
156+
if (!warningKeys.has(key)) {
157+
warnings.push(`Missing preview instrument selection for ${event.lane}. Choose a Preview Synth instrument before playback.`);
158+
warningKeys.add(key);
159+
}
160+
return;
161+
}
162+
if (this.frequenciesForEvent(event, instrument).length > 0) {
163+
events.push({ ...event, previewInstrument: instrument });
164+
}
138165
});
166+
return {
167+
activeLanes: Array.from(new Set(events.map((event) => event.lane))),
168+
events,
169+
warnings
170+
};
139171
}
140172

141173
scheduleEvents({ context, events, secondsPerBeat, secondsPerStep, startStep }) {
142174
const now = context.currentTime;
143175
events.forEach((event) => {
144176
const offsetSeconds = Math.max(0, (event.stepIndex - startStep) * secondsPerStep);
145-
const durationSeconds = Math.max(0.06, Number(event.durationBeats || 1) * secondsPerBeat * 0.82);
146-
this.frequenciesForEvent(event).forEach((frequency, index) => {
177+
const instrument = event.previewInstrument;
178+
const durationScale = Number(instrument?.durationScale || 1);
179+
const durationSeconds = Math.max(0.06, Number(event.durationBeats || 1) * secondsPerBeat * 0.82 * durationScale);
180+
this.frequenciesForEvent(event, instrument).forEach((frequency, index) => {
147181
this.scheduleTone({
148182
context,
149183
durationSeconds,
@@ -158,8 +192,8 @@ export class PreviewSynthEngine {
158192
scheduleTone({ context, durationSeconds, event, frequency, startTime }) {
159193
const oscillator = context.createOscillator();
160194
const gainNode = context.createGain();
161-
const waveform = this.waveformForEvent(event);
162-
const volume = this.volumeForEvent(event);
195+
const waveform = this.waveformForEvent(event, event.previewInstrument);
196+
const volume = this.volumeForEvent(event, event.previewInstrument);
163197
const endTime = startTime + durationSeconds;
164198
oscillator.type = waveform;
165199
oscillator.frequency.setValueAtTime(frequency, startTime);
@@ -174,33 +208,31 @@ export class PreviewSynthEngine {
174208
this.nodes.push({ gainNode, oscillator });
175209
}
176210

177-
frequenciesForEvent(event) {
211+
frequenciesForEvent(event, instrument = null) {
212+
const transposeFactor = 2 ** (Number(instrument?.transposeSemitones || 0) / 12);
178213
if (event.kind === "drum") {
179214
const frequency = DRUM_FREQUENCIES[String(event.value || "").toLowerCase()];
180-
return frequency ? [frequency] : [];
215+
return frequency ? [frequency * transposeFactor] : [];
181216
}
182217
if (event.kind === "chord") {
183-
return chordNotes(event.value).map((note) => noteFrequency(note)).filter(Boolean);
218+
return chordNotes(event.value).map((note) => noteFrequency(note)).filter(Boolean).map((frequency) => frequency * transposeFactor);
184219
}
185220
if (event.kind === "note") {
186221
const frequency = noteFrequency(event.value);
187-
return frequency ? [frequency] : [];
222+
return frequency ? [frequency * transposeFactor] : [];
188223
}
189224
return [];
190225
}
191226

192-
waveformForEvent(event) {
227+
waveformForEvent(event, instrument = null) {
193228
if (event.kind === "drum") {
194-
return event.value === "kick" || event.value === "tom" ? "sine" : "square";
229+
return event.value === "kick" || event.value === "tom" ? "sine" : instrument?.waveform || "square";
195230
}
196-
return event.lane === "lead" ? "sawtooth" : event.lane === "bass" ? "triangle" : "sine";
231+
return instrument?.waveform || "sine";
197232
}
198233

199-
volumeForEvent(event) {
200-
if (event.kind === "drum") {
201-
return 0.12;
202-
}
203-
return event.kind === "chord" ? 0.045 : 0.075;
234+
volumeForEvent(event, instrument = null) {
235+
return Number(instrument?.volume || (event.kind === "chord" ? 0.045 : 0.075));
204236
}
205237

206238
stop() {

tests/playwright/tools/MidiStudioV2.spec.mjs

Lines changed: 63 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -322,6 +322,20 @@ test.describe("MIDI Studio V2", () => {
322322
await expect(page.locator("#instrumentGridSubdivisionInput option")).toContainText(["1/1", "1/2", "1/4", "1/8", "1/16"]);
323323
await expect(page.locator("#instrumentGridLaneTypeSelect option")).toContainText(["Chords", "Bass", "Pad", "Lead", "Drums"]);
324324
await expect(page.locator("#renderedExportTargetTypeSelect option")).toContainText(["WAV", "MP3", "OGG"]);
325+
await expect(page.locator("#previewInstrumentLeadSelect option")).toContainText([
326+
"Choose preview instrument",
327+
"Retro Square Lead",
328+
"Retro Pulse Lead",
329+
"Synth Bass",
330+
"Warm Pad",
331+
"Basic Drums",
332+
"Ambient Pad"
333+
]);
334+
await expect(page.locator("#previewInstrumentChordsSelect")).toHaveValue("warm-pad");
335+
await expect(page.locator("#previewInstrumentBassSelect")).toHaveValue("synth-bass");
336+
await expect(page.locator("#previewInstrumentPadSelect")).toHaveValue("warm-pad");
337+
await expect(page.locator("#previewInstrumentLeadSelect")).toHaveValue("retro-square-lead");
338+
await expect(page.locator("#previewInstrumentDrumsSelect")).toHaveValue("basic-drums");
325339
await expect(page.locator("#instrumentGridSectionSelect")).toContainText("No section selected");
326340
await expect(page.locator("#instrumentGridTransportState")).toContainText("No section selected. Normalize grid data before testing section timing.");
327341
await expect(page.locator("#howToTestContent")).toContainText("Step 1: choose style/key/tempo");
@@ -343,6 +357,8 @@ test.describe("MIDI Studio V2", () => {
343357
await expect(page.locator("#songSheetStyleInput")).toHaveValue("retro-arcade");
344358
await expect(page.locator("#instrumentGridSectionsInput")).toHaveValue("intro:1, loop:1, victory:1");
345359
await expect(page.locator("#instrumentGridChordsInput")).toHaveValue("Am F C G | Am F C G | C G F Am");
360+
await expect(page.locator("#previewInstrumentLeadSelect")).toHaveValue("retro-pulse-lead");
361+
await expect(page.locator("#previewInstrumentPadSelect")).toHaveValue("ambient-pad");
346362
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\./);
347363

348364
await page.locator("#generateBassFromChordsButton").click();
@@ -365,6 +381,7 @@ test.describe("MIDI Studio V2", () => {
365381
await expect(page.locator("#statusLog")).toHaveValue(/Preview Synth uses temporary oscillator instruments for grid audition only; SoundFont playback is not implemented\./);
366382
await expect(page.locator("#instrumentGridTransportState")).toContainText("Playing loop Preview Synth timing preview: loop to victory");
367383
expect(await page.evaluate(() => window.__midiStudioPreviewSynthEvents.some((event) => event.action === "oscillator-start"))).toBe(true);
384+
await expect(page.locator(".midi-studio-v2__grid-cell--lane-active")).not.toHaveCount(0);
368385
await page.locator("#exportOggButton").click();
369386
await expect(page.locator("#statusLog")).toHaveValue(/WARN Export rendering not implemented for OGG\. Planned target: assets\/music\/demo\/demo-test-song\.ogg\./);
370387
await page.locator('[data-song-id="demo-missing-target"]').click();
@@ -671,7 +688,10 @@ Am F`);
671688
test("keeps beat bar alignment consistent across grid lanes", async ({ page }) => {
672689
const server = await openMidiStudio(page);
673690
try {
674-
await fillInstrumentGrid(page);
691+
await fillInstrumentGrid(page, {
692+
bass: "A2 E2 F2 C2 | C3 B2 G2 E2",
693+
lead: "E4 G4 A4 B4 | C5 B4 G4 E4"
694+
});
675695
await page.locator("#normalizeInstrumentGridButton").click();
676696
expect(await page.locator(".midi-studio-v2__instrument-grid").evaluate((grid) => getComputedStyle(grid).gridTemplateColumns.split(" ").length)).toBe(9);
677697
expect(await page.locator(".midi-studio-v2__grid-cell--section").evaluateAll((cells) => cells.map((cell) => cell.style.gridColumn))).toEqual(["span 4", "span 4"]);
@@ -812,7 +832,7 @@ Am F`);
812832
expect(await page.evaluate(() => window.__midiStudioPreviewSynthEvents.some((event) => event.action === "oscillator-start"))).toBe(true);
813833
await page.locator("#stopTimingPreviewButton").click();
814834
await expect(page.locator("#instrumentGridTransportState")).toContainText("Preview Synth timing preview stopped.");
815-
await expect(page.locator("#statusLog")).toHaveValue(/OK Preview Synth timing preview stopped\. Cleared \d+ scheduled oscillators\./);
835+
await expect(page.locator("#statusLog")).toHaveValue(/OK Preview playback stopped\. Cleared \d+ scheduled oscillators\./);
816836
expect(await page.evaluate(() => window.__midiStudioV2App.previewSynth.getSnapshot().playing)).toBe(false);
817837
} finally {
818838
await workspaceV2CoverageReporter.stop(page);
@@ -859,6 +879,47 @@ Am F`);
859879
}
860880
});
861881

882+
test("applies Preview Synth instruments, mute, solo, and missing-instrument warnings", async ({ page }) => {
883+
const server = await openMidiStudio(page);
884+
try {
885+
await fillInstrumentGrid(page, {
886+
bass: "A2 E2 F2 C2 | C3 B2 G2 E2",
887+
lead: "E4 G4 A4 B4 | C5 B4 G4 E4"
888+
});
889+
await page.locator("#previewInstrumentLeadSelect").selectOption("retro-pulse-lead");
890+
await expect(page.locator("#statusLog")).toHaveValue(/OK Preview instrument selected for Lead: Retro Pulse Lead\./);
891+
await page.locator("#normalizeInstrumentGridButton").click();
892+
893+
await page.locator("#previewMuteBassToggle").check();
894+
await expect(page.locator("#statusLog")).toHaveValue(/WARN Lane muted: Bass\./);
895+
await page.locator("#playSectionButton").click();
896+
await expect(page.locator("#statusLog")).toHaveValue(/OK Preview Synth started for section intro with \d+ playable events\./);
897+
await expect(page.locator('.midi-studio-v2__grid-cell--lane-active[data-lane="bass"]')).toHaveCount(0);
898+
await page.locator("#stopTimingPreviewButton").click();
899+
900+
await page.locator("#previewMuteBassToggle").uncheck();
901+
await page.locator("#previewSoloLeadToggle").check();
902+
await expect(page.locator("#statusLog")).toHaveValue(/OK Lane soloed: Lead\./);
903+
await page.locator("#playSectionButton").click();
904+
await expect(page.locator(".midi-studio-v2__grid-cell--lane-active")).not.toHaveCount(0);
905+
expect(await page.locator(".midi-studio-v2__grid-cell--lane-active").evaluateAll((cells) => (
906+
cells.every((cell) => cell.dataset.lane === "lead")
907+
))).toBe(true);
908+
await page.locator("#stopTimingPreviewButton").click();
909+
910+
await page.locator("#previewSoloLeadToggle").uncheck();
911+
await page.locator("#previewInstrumentLeadSelect").selectOption("");
912+
await expect(page.locator("#statusLog")).toHaveValue(/WARN Missing preview instrument selection for Lead\. Choose a Preview Synth instrument before playback\./);
913+
await page.locator("#playSectionButton").click();
914+
await expect(page.locator("#statusLog")).toHaveValue(/WARN Preview Synth warnings: Missing preview instrument selection for lead\. Choose a Preview Synth instrument before playback\./);
915+
await expect(page.locator("#statusLog")).toHaveValue(/OK Preview Synth started for section intro with \d+ playable events\./);
916+
expect(await page.evaluate(() => window.__midiStudioPreviewSynthEvents.some((event) => event.action === "oscillator-start"))).toBe(true);
917+
} finally {
918+
await workspaceV2CoverageReporter.stop(page);
919+
await server.close();
920+
}
921+
});
922+
862923
test("reports invalid section and invalid loop handling", async ({ page }) => {
863924
const server = await openMidiStudio(page);
864925
try {

0 commit comments

Comments
 (0)