Skip to content

Commit 3d00f1c

Browse files
committed
Compact MIDI Studio V2 timeline instruments and add duplicate instrument action - PR_26146_066-midi-studio-v2-timeline-instrument-compact-duplicate
1 parent 46a1866 commit 3d00f1c

7 files changed

Lines changed: 343 additions & 36 deletions

File tree

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
# PR_26146_066 MIDI Studio V2 Timeline Instrument Compact Duplicate Validation
2+
3+
## Scope
4+
5+
PASS - Continued from PR_26146_065 and kept this PR focused on MIDI Studio V2 Octave Timeline quick instrument controls plus Instruments tab setting ownership.
6+
7+
## Implementation
8+
9+
- PASS - Octave Timeline Instruments accordion now uses 10px top and bottom margins on the accordion and content.
10+
- PASS - Octave Timeline quick instrument rows are compact and contain only active instrument selection plus mute, solo, and hide/show controls.
11+
- PASS - Added icon-only Duplicate control to the Octave Timeline Instruments header before the Add and X controls.
12+
- PASS - Duplicate copies the selected instrument lane data, GM type, GM patch, volume, pan, visibility, mute, and solo state into a unique new instrument id/name.
13+
- PASS - Duplicated instrument becomes the shared selected instrument and updates the canonical song model.
14+
- PASS - Instruments > Instrument Settings no longer exposes Mute default or Solo default fields.
15+
- PASS - GM Type and GM Instrument editing remain owned by the Instruments tab.
16+
17+
## Validation
18+
19+
- PASS - `node --check tools/midi-studio-v2/js/controls/InstrumentGridControl.js`
20+
- PASS - `node --check tools/midi-studio-v2/js/MidiStudioV2App.js`
21+
- PASS - `node --check tools/midi-studio-v2/js/bootstrap.js`
22+
- PASS - `node --check tests/playwright/tools/MidiStudioV2.spec.mjs`
23+
- PASS - `rg --pcre2 -n "<script(?![^>]*src=)|<style\\b|\\sstyle=|\\son[a-z]+=" tools/midi-studio-v2/index.html` returned no inline script/style/event handler matches.
24+
- PASS - `npx playwright test tests/playwright/tools/MidiStudioV2.spec.mjs --project=playwright --grep "PR066"` passed 1 test.
25+
- PASS - `npx playwright test tests/playwright/tools/MidiStudioV2.spec.mjs --project=playwright --grep "PR0(60|61|62|63|64|65|66)"` passed 7 tests.
26+
- PASS - `git diff --check`
27+
- PASS - `npm run codex:review-artifacts`
28+
29+
## Notes
30+
31+
- Full samples smoke test was not run per instruction.
32+
- Earlier PR066 validation attempts found and fixed the timeline margin specificity, row stretch compactness, and the test setup for Play/Stop after duplicating a hidden/muted lane.

tests/playwright/tools/MidiStudioV2.spec.mjs

Lines changed: 145 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -585,6 +585,7 @@ async function visibleMidiStudioControlOwnership(page, activeTabId) {
585585
addSongButton: { canonical: "music.songs[] / tools.midi-studio-v2.activeSongId", kind: "canonical-action", owner: "Song Setup", wired: "wired" },
586586
clearStatusButton: { canonical: "diagnostic status log", kind: "action", owner: "Diagnostics", wired: "wired" },
587587
closeInstrumentPanelButton: { canonical: "accordion view state", kind: "view-state", owner: "Instruments", wired: "wired" },
588+
duplicateInstrumentRowButton: { canonical: "music.songs[].studioArrangement.lanes / previewLaneSettings", kind: "canonical-action", owner: "Octave Timeline", wired: "wired" },
588589
futureAutosaveButton: { canonical: "future editing history", kind: "unwired", owner: "Song Setup", wired: "unwired" },
589590
futureEnableMidiInputButton: { canonical: "future MIDI input", kind: "unwired", owner: "MIDI Import", wired: "unwired" },
590591
futureMidiDeviceSelect: { canonical: "future MIDI input", kind: "unwired", owner: "MIDI Import", wired: "unwired" },
@@ -643,6 +644,8 @@ async function visibleMidiStudioControlOwnership(page, activeTabId) {
643644
toolCopyJsonButton: { canonical: "serialized midi-studio-v2 tool state", kind: "action", owner: "Diagnostics", wired: "wired" },
644645
toolExportToolStateButton: { canonical: "serialized midi-studio-v2 tool state", kind: "action", owner: "Export", wired: "wired" },
645646
toolImportManifestButton: { canonical: "imported game manifest / midi-studio-v2 payload", kind: "canonical-action", owner: "Global NAV", wired: "wired" },
647+
timelineAddInstrumentRowButton: { canonical: "music.songs[].studioArrangement.lanes / previewLaneSettings", kind: "canonical-action", owner: "Octave Timeline", wired: "wired" },
648+
timelineCloseInstrumentPanelButton: { canonical: "accordion view state", kind: "view-state", owner: "Octave Timeline", wired: "wired" },
646649
workspaceCopyManifestButton: { canonical: "workspace manifest proxy", kind: "unwired", owner: "Workspace NAV", wired: "unwired" },
647650
workspaceExportManifestButton: { canonical: "workspace manifest proxy", kind: "unwired", owner: "Workspace NAV", wired: "unwired" },
648651
workspaceImportManifestButton: { canonical: "workspace manifest proxy", kind: "unwired", owner: "Workspace NAV", wired: "unwired" }
@@ -3040,10 +3043,9 @@ test.describe("MIDI Studio V2", () => {
30403043
await instrumentSelect(page, "bass").selectOption("gm-electric-bass-finger");
30413044
await setInputValue(page, "#previewVolumeBassInput", "0.65");
30423045
await setInputValue(page, "#previewPanBassInput", "-0.4");
3043-
await setCheckboxValue(page, "#previewMuteBassToggle", true);
3044-
expect(await page.evaluate(() => window.__midiStudioV2App.selectedSong().studioArrangement.previewLaneSettings.muted.bass)).toBe(true);
3045-
await setCheckboxValue(page, "#previewMuteBassToggle", false);
3046-
await setCheckboxValue(page, "#previewSoloBassToggle", true);
3046+
await expect(editor).not.toContainText("Mute default");
3047+
await expect(editor).not.toContainText("Solo default");
3048+
await expect(editor.locator("#previewMuteBassToggle, #previewSoloBassToggle")).toHaveCount(0);
30473049
await setInputValue(page, "#previewOctaveLowBassInput", "2");
30483050
await setInputValue(page, "#previewOctaveHighBassInput", "4");
30493051
await setInputValue(page, "#previewTransposeBassInput", "12");
@@ -3058,11 +3060,9 @@ test.describe("MIDI Studio V2", () => {
30583060
duration: settings.durations.bass,
30593061
instrument: settings.instruments.bass,
30603062
instrumentType: settings.instrumentTypes.bass,
3061-
muted: settings.muted.bass,
30623063
octaveRange: settings.octaveRanges.bass,
30633064
pan: settings.pans.bass,
30643065
selectedInstrumentId: app.instrumentGrid.selectedInstrumentId,
3065-
soloed: settings.soloed.bass,
30663066
transpose: settings.transposes.bass,
30673067
velocity: settings.velocities.bass,
30683068
volume: settings.volumes.bass
@@ -3072,11 +3072,9 @@ test.describe("MIDI Studio V2", () => {
30723072
duration: 1.5,
30733073
instrument: "gm-electric-bass-finger",
30743074
instrumentType: "Bass",
3075-
muted: false,
30763075
octaveRange: { high: 4, low: 2 },
30773076
pan: -0.4,
30783077
selectedInstrumentId: "bass",
3079-
soloed: true,
30803078
transpose: 12,
30813079
velocity: 96,
30823080
volume: 0.65
@@ -3518,10 +3516,8 @@ test.describe("MIDI Studio V2", () => {
35183516
"music.songs[].studioArrangement.previewLaneSettings.durations",
35193517
"music.songs[].studioArrangement.previewLaneSettings.instruments",
35203518
"music.songs[].studioArrangement.previewLaneSettings.instrumentTypes",
3521-
"music.songs[].studioArrangement.previewLaneSettings.muted",
35223519
"music.songs[].studioArrangement.previewLaneSettings.octaveRanges",
35233520
"music.songs[].studioArrangement.previewLaneSettings.pans",
3524-
"music.songs[].studioArrangement.previewLaneSettings.soloed",
35253521
"music.songs[].studioArrangement.previewLaneSettings.transposes",
35263522
"music.songs[].studioArrangement.previewLaneSettings.velocities",
35273523
"music.songs[].studioArrangement.previewLaneSettings.volumes",
@@ -3560,19 +3556,17 @@ test.describe("MIDI Studio V2", () => {
35603556

35613557
await selectMidiStudioTab(page, "instruments");
35623558
await setInputValue(page, "#previewVolumeLeadInput", "0.8");
3563-
await setCheckboxValue(page, "#previewMuteLeadToggle", true);
3564-
await setCheckboxValue(page, "#previewSoloLeadToggle", true);
35653559
expect(await page.evaluate(() => {
35663560
const settings = window.__midiStudioV2App.selectedSong().studioArrangement.previewLaneSettings;
35673561
return {
3568-
muted: settings.muted.lead,
3569-
soloed: settings.soloed.lead,
35703562
volume: settings.volumes.lead
35713563
};
3572-
})).toEqual({ muted: true, soloed: true, volume: 0.8 });
3564+
})).toEqual({ volume: 0.8 });
35733565

35743566
await selectMidiStudioTab(page, "studio");
35753567
await waitForCanvasRender(page);
3568+
await timelineQuickInstrumentRow(page, "lead").locator("[data-timeline-quick-mute='lead']").click();
3569+
await timelineQuickInstrumentRow(page, "lead").locator("[data-timeline-quick-solo='lead']").click();
35763570
await expect(timelineQuickInstrumentRow(page, "lead").locator("[data-timeline-quick-mute='lead']")).toHaveAttribute("aria-pressed", "true");
35773571
await expect(timelineQuickInstrumentRow(page, "lead").locator("[data-timeline-quick-solo='lead']")).toHaveAttribute("aria-pressed", "true");
35783572
await timelineQuickInstrumentRow(page, "lead").locator("[data-timeline-quick-mute='lead']").click();
@@ -3601,6 +3595,142 @@ test.describe("MIDI Studio V2", () => {
36013595
}
36023596
});
36033597

3598+
test("keeps PR066 timeline Instruments compact and duplicates selected instruments", async ({ page }) => {
3599+
await page.setViewportSize({ width: 1600, height: 900 });
3600+
const server = await openMidiStudioForImport(page);
3601+
try {
3602+
await page.locator("#toolImportManifestInput").setInputFiles(uatManifestPath);
3603+
3604+
await selectMidiStudioTab(page, "instruments");
3605+
await selectInstrumentRow(page, "lead");
3606+
await page.locator("#previewDisplayNameLeadInput").fill("Lead Copy Source");
3607+
await instrumentTypeSelect(page, "lead").selectOption("Bass");
3608+
await instrumentSelect(page, "lead").selectOption("gm-electric-bass-finger");
3609+
await setInputValue(page, "#previewVolumeLeadInput", "0.6");
3610+
await setInputValue(page, "#previewPanLeadInput", "-0.3");
3611+
await expect(page.locator("#selectedInstrumentEditor")).not.toContainText("Mute default");
3612+
await expect(page.locator("#selectedInstrumentEditor")).not.toContainText("Solo default");
3613+
await expect(page.locator("#selectedInstrumentEditor [id^='previewMute'], #selectedInstrumentEditor [id^='previewSolo']")).toHaveCount(0);
3614+
3615+
const beforeDuplicate = await page.evaluate(() => {
3616+
const app = window.__midiStudioV2App;
3617+
const song = app.selectedSong();
3618+
const settings = song.studioArrangement.previewLaneSettings;
3619+
return {
3620+
leadLane: song.studioArrangement.lanes.lead,
3621+
settings: {
3622+
displayName: settings.displayNames.lead,
3623+
instrument: settings.instruments.lead,
3624+
instrumentType: settings.instrumentTypes.lead,
3625+
pan: settings.pans.lead,
3626+
volume: settings.volumes.lead
3627+
}
3628+
};
3629+
});
3630+
expect(beforeDuplicate.settings).toEqual({
3631+
displayName: "Lead Copy Source",
3632+
instrument: "gm-electric-bass-finger",
3633+
instrumentType: "Bass",
3634+
pan: -0.3,
3635+
volume: 0.6
3636+
});
3637+
3638+
await selectMidiStudioTab(page, "studio");
3639+
await waitForCanvasRender(page);
3640+
await expect(page.locator(".midi-studio-v2__timeline-instrument-accordion")).toBeVisible();
3641+
const timelineInstrumentLayout = await page.locator(".midi-studio-v2__timeline-instrument-accordion").evaluate((section) => {
3642+
const content = section.querySelector("#timelineInstrumentQuickContent");
3643+
const rows = Array.from(section.querySelectorAll(".midi-studio-v2__quick-instrument-row"));
3644+
const header = section.querySelector(".midi-studio-v2__timeline-instrument-accordion-header");
3645+
const duplicate = header.querySelector("#duplicateInstrumentRowButton");
3646+
const add = header.querySelector("#timelineAddInstrumentRowButton");
3647+
const close = header.querySelector("#timelineCloseInstrumentPanelButton");
3648+
const duplicateRect = duplicate.getBoundingClientRect();
3649+
const addRect = add.getBoundingClientRect();
3650+
const closeRect = close.getBoundingClientRect();
3651+
const rowRects = rows.map((row) => row.getBoundingClientRect());
3652+
return {
3653+
addAfterDuplicate: addRect.left > duplicateRect.right,
3654+
closeAfterAdd: closeRect.left > addRect.right,
3655+
contentMarginBottom: getComputedStyle(content).marginBottom,
3656+
contentMarginTop: getComputedStyle(content).marginTop,
3657+
duplicateText: duplicate.textContent.trim(),
3658+
rowHeights: rowRects.map((rect) => rect.height),
3659+
sectionMarginBottom: getComputedStyle(section).marginBottom,
3660+
sectionMarginTop: getComputedStyle(section).marginTop
3661+
};
3662+
});
3663+
expect(timelineInstrumentLayout.sectionMarginTop).toBe("10px");
3664+
expect(timelineInstrumentLayout.sectionMarginBottom).toBe("10px");
3665+
expect(timelineInstrumentLayout.contentMarginTop).toBe("10px");
3666+
expect(timelineInstrumentLayout.contentMarginBottom).toBe("10px");
3667+
expect(timelineInstrumentLayout.duplicateText).toBe("");
3668+
expect(timelineInstrumentLayout.addAfterDuplicate).toBe(true);
3669+
expect(timelineInstrumentLayout.closeAfterAdd).toBe(true);
3670+
expect(timelineInstrumentLayout.rowHeights.every((height) => height <= 28)).toBe(true);
3671+
await expect(page.locator("#duplicateInstrumentRowButton")).toHaveAttribute("aria-label", "Duplicate selected instrument");
3672+
await expect(page.locator("#duplicateInstrumentRowButton")).toHaveAttribute("title", "Duplicate selected instrument");
3673+
3674+
await timelineQuickInstrumentRow(page, "lead").locator("[data-timeline-quick-mute='lead']").click();
3675+
await timelineQuickInstrumentRow(page, "lead").locator("[data-timeline-quick-solo='lead']").click();
3676+
await timelineQuickInstrumentRow(page, "lead").locator("[data-toggle-instrument-visibility='lead']").click();
3677+
await page.locator("#duplicateInstrumentRowButton").click();
3678+
await expect(page.locator("#statusLog")).toHaveValue(/OK Duplicated instrument row Lead as Lead Copy; playback data updated\./);
3679+
3680+
const duplicateState = await page.evaluate((leadLaneSource) => {
3681+
const app = window.__midiStudioV2App;
3682+
const song = app.selectedSong();
3683+
const settings = song.studioArrangement.previewLaneSettings;
3684+
const selected = app.instrumentGrid.selectedInstrumentId;
3685+
return {
3686+
copiedLaneData: song.studioArrangement.lanes[selected] === leadLaneSource,
3687+
duplicateDisplayName: settings.displayNames[selected],
3688+
duplicateInstrument: settings.instruments[selected],
3689+
duplicateInstrumentType: settings.instrumentTypes[selected],
3690+
duplicatePan: settings.pans[selected],
3691+
duplicateVisible: settings.visible[selected],
3692+
duplicateMuted: settings.muted[selected],
3693+
duplicateSoloed: settings.soloed[selected],
3694+
duplicateVolume: settings.volumes[selected],
3695+
hasUniqueId: selected !== "lead" && Object.hasOwn(song.studioArrangement.lanes, selected),
3696+
selected
3697+
};
3698+
}, beforeDuplicate.leadLane);
3699+
expect(duplicateState).toEqual({
3700+
copiedLaneData: true,
3701+
duplicateDisplayName: "Lead Copy Source Copy",
3702+
duplicateInstrument: "gm-electric-bass-finger",
3703+
duplicateInstrumentType: "Bass",
3704+
duplicatePan: -0.3,
3705+
duplicateVisible: false,
3706+
duplicateMuted: true,
3707+
duplicateSoloed: true,
3708+
duplicateVolume: 0.6,
3709+
hasUniqueId: true,
3710+
selected: "lead-copy"
3711+
});
3712+
await expect(timelineQuickInstrumentRow(page, "lead-copy")).toHaveClass(/is-selected/);
3713+
3714+
await selectMidiStudioTab(page, "instruments");
3715+
await expect(instrumentRow(page, "lead-copy")).toHaveClass(/is-selected/);
3716+
await expect(page.locator("#selectedInstrumentEditor")).not.toContainText("Mute default");
3717+
await expect(page.locator("#selectedInstrumentEditor")).not.toContainText("Solo default");
3718+
3719+
await selectMidiStudioTab(page, "studio");
3720+
await timelineQuickInstrumentRow(page, "lead-copy").locator("[data-timeline-quick-mute='lead-copy']").click();
3721+
await timelineQuickInstrumentRow(page, "lead-copy").locator("[data-toggle-instrument-visibility='lead-copy']").click();
3722+
await page.locator("#playButton").click();
3723+
await expect(page.locator("#playButton")).toBeDisabled();
3724+
await expect(page.locator("#stopButton")).toBeEnabled();
3725+
await page.locator("#stopButton").click();
3726+
await expect(page.locator("#stopButton")).toBeDisabled();
3727+
await expect(page.locator("#playButton")).toBeEnabled();
3728+
} finally {
3729+
await workspaceV2CoverageReporter.stop(page);
3730+
await server.close();
3731+
}
3732+
});
3733+
36043734
test("derives primary song, instrument, grid, playback, and diagnostics views from the canonical selected song", async ({ page }) => {
36053735
const server = await openMidiStudioForImport(page);
36063736
try {

tools/midi-studio-v2/index.html

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -78,11 +78,16 @@ <h2 class="tools-platform-frame__eyebrow">First-Class Tools Surface V2</h2>
7878
<div id="songList" class="midi-studio-v2__song-list" role="listbox" aria-label="MIDI songs"></div>
7979
</div>
8080
</section>
81-
<section class="accordion-v2 tool-starter__accordion is-open" data-accordion-v2-open="true" data-midi-studio-tab-panel="studio">
82-
<button class="accordion-v2__header" type="button" aria-expanded="true" aria-controls="timelineInstrumentQuickContent">
81+
<section class="accordion-v2 tool-starter__accordion is-open midi-studio-v2__timeline-instrument-accordion" data-accordion-v2-open="true" data-midi-studio-tab-panel="studio">
82+
<div class="accordion-v2__header midi-studio-v2__instrument-accordion-header midi-studio-v2__timeline-instrument-accordion-header" aria-expanded="true" aria-controls="timelineInstrumentQuickContent">
8383
<span>Instruments</span>
84+
<span class="midi-studio-v2__instrument-header-actions">
85+
<button id="duplicateInstrumentRowButton" class="midi-studio-v2__instrument-header-button midi-studio-v2__duplicate-instrument-button" type="button" aria-label="Duplicate selected instrument" title="Duplicate selected instrument"></button>
86+
<button id="timelineAddInstrumentRowButton" class="midi-studio-v2__instrument-header-button" type="button" aria-label="Add instrument" title="Add instrument">Add</button>
87+
<button id="timelineCloseInstrumentPanelButton" class="midi-studio-v2__instrument-header-button" type="button" aria-label="Collapse Octave Timeline Instruments panel" title="Collapse Instruments panel">X</button>
88+
</span>
8489
<span class="accordion-v2__icon" aria-hidden="true">+</span>
85-
</button>
90+
</div>
8691
<div id="timelineInstrumentQuickContent" class="accordion-v2__content midi-studio-v2__timeline-instruments">
8792
<div id="timelineInstrumentQuickList" class="midi-studio-v2__timeline-instrument-list" aria-label="Octave Timeline quick instrument controls"></div>
8893
</div>

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -948,7 +948,7 @@ export class MidiStudioV2App {
948948
return;
949949
}
950950
this.setCurrentInstrumentGridResult(result);
951-
if (["add-lane", "delete-lane"].includes(detail.action)) {
951+
if (["add-lane", "delete-lane", "duplicate-lane"].includes(detail.action)) {
952952
this.instrumentGrid.render(result);
953953
} else {
954954
this.instrumentGrid.syncEditedGridResult(result);
@@ -957,6 +957,8 @@ export class MidiStudioV2App {
957957
this.markDirty({ changedKeys: ["data.songs.studioArrangement"], reason: "midi-studio-note-grid-edited" });
958958
if (detail.action === "add-lane") {
959959
this.statusLog.ok(`Added instrument row ${detail.laneLabel || detail.lane}; playback data updated.`);
960+
} else if (detail.action === "duplicate-lane") {
961+
this.statusLog.ok(`Duplicated instrument row ${detail.sourceLaneLabel || detail.sourceLane || "instrument"} as ${detail.laneLabel || detail.lane}; playback data updated.`);
960962
} else if (detail.action === "delete-lane") {
961963
this.statusLog.ok(`Deleted instrument row ${detail.laneLabel || detail.lane}; playback data updated.`);
962964
} else if (detail.action === "delete-selected-note") {

0 commit comments

Comments
 (0)