From cbd66ab4f600550e24bf0544a0a11c475048b13b Mon Sep 17 00:00:00 2001 From: Xiaohan-Tian <157918347+Xiaohan-Tian@users.noreply.github.com> Date: Sun, 17 May 2026 12:55:59 -0700 Subject: [PATCH 01/17] fix: VSCode debug option in macOS --- .vscode/tasks.json | 12 ++++++------ package-lock.json | 4 ++-- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 2bd38c4..ee2e83f 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -2,15 +2,15 @@ "version": "2.0.0", "tasks": [ { - "type": "npm", - "script": "dev", + "type": "shell", "label": "npm: dev", "detail": "vite", "isBackground": true, + "command": "source ~/.nvm/nvm.sh && nvm use 20 && npm run dev", "options": { "shell": { "executable": "/bin/zsh", - "args": ["-c", "source ~/.nvm/nvm.sh && nvm use 20 && npm run dev"] + "args": ["-c"] } }, "problemMatcher": { @@ -25,11 +25,11 @@ } }, { - "type": "npm", - "script": "dev", + "type": "shell", "label": "npm: dev (Windows)", "detail": "vite", "isBackground": true, + "command": "npm run dev", "options": { "shell": { "executable": "cmd.exe", @@ -48,4 +48,4 @@ } } ] -} \ No newline at end of file +} diff --git a/package-lock.json b/package-lock.json index 7a0f770..b15f8ea 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "K.G.Studio", - "version": "0.16.0-build.20260510", + "version": "0.17.3-build.20260515", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "K.G.Studio", - "version": "0.16.0-build.20260510", + "version": "0.17.3-build.20260515", "dependencies": { "@breezystack/lamejs": "^1.2.7", "class-transformer": "^0.5.1", From 9c197ecd080556ffe1ced25f579238d2a2dbf825 Mon Sep 17 00:00:00 2001 From: Xiaohan-Tian <157918347+Xiaohan-Tian@users.noreply.github.com> Date: Sun, 17 May 2026 13:06:47 -0700 Subject: [PATCH 02/17] feat: persist track mute/solo status --- src/components/track/TrackInfoItem.tsx | 25 +++--- src/core/KGProject.ts | 2 +- src/core/audio-interface/KGAudioInterface.ts | 12 ++- .../commands/track/UpdateTrackCommand.test.ts | 87 +++++++++++++++++++ src/core/commands/track/UpdateTrackCommand.ts | 56 +++++++++++- src/core/io/KGProjectStorage.test.ts | 17 ++++ .../project-upgrader/KGProjectUpgrader.ts | 5 ++ .../project-upgrader/upgradeToV10.test.ts | 2 +- .../project-upgrader/upgradeToV11.test.ts | 45 ++++++++++ src/core/project-upgrader/upgradeToV11.ts | 21 +++++ src/core/project-upgrader/upgradeToV8.test.ts | 2 +- src/core/track/KGTrack.ts | 24 +++++ src/stores/projectStore.ts | 8 ++ 13 files changed, 285 insertions(+), 21 deletions(-) create mode 100644 src/core/commands/track/UpdateTrackCommand.test.ts create mode 100644 src/core/project-upgrader/upgradeToV11.test.ts create mode 100644 src/core/project-upgrader/upgradeToV11.ts diff --git a/src/components/track/TrackInfoItem.tsx b/src/components/track/TrackInfoItem.tsx index 7722b67..244ac72 100644 --- a/src/components/track/TrackInfoItem.tsx +++ b/src/components/track/TrackInfoItem.tsx @@ -96,8 +96,8 @@ const TrackInfoItem: React.FC = ({ const volumeInputRef = useRef(null); // Local flag to track slider interaction; not used for rendering const isAdjustingVolumeRef = useRef(false); - const [muted, setMuted] = useState(false); - const [solo, setSolo] = useState(false); + const [muted, setMuted] = useState(track.getMuted()); + const [solo, setSolo] = useState(track.getSolo()); // Close dropdown when clicking outside useEffect(() => { @@ -135,11 +135,10 @@ const TrackInfoItem: React.FC = ({ setVolume(track.getVolume()); }, [allTracks, track]); - // Sync mute/solo UI with audio interface state on track/project changes + // Sync mute/solo UI with the track model on track/project changes useEffect(() => { - const audioInterface = KGAudioInterface.instance(); - setMuted(audioInterface.getTrackMuted(track.getId().toString())); - setSolo(audioInterface.getTrackSolo(track.getId().toString())); + setMuted(track.getMuted()); + setSolo(track.getSolo()); }, [allTracks, track]); // Handle track name edit within the component @@ -252,22 +251,20 @@ const TrackInfoItem: React.FC = ({ e.stopPropagation(); const next = !muted; setMuted(next); - try { - KGAudioInterface.instance().setTrackMute(track.getId().toString(), next); - } catch (err) { + useProjectStore.getState().updateTrackProperties(track.getId(), { muted: next }).catch(err => { + setMuted(track.getMuted()); console.error('Failed to toggle mute:', err); - } + }); }; const handleToggleSolo = (e: React.MouseEvent) => { e.stopPropagation(); const next = !solo; setSolo(next); - try { - KGAudioInterface.instance().setTrackSolo(track.getId().toString(), next); - } catch (err) { + useProjectStore.getState().updateTrackProperties(track.getId(), { solo: next }).catch(err => { + setSolo(track.getSolo()); console.error('Failed to toggle solo:', err); - } + }); }; // Handle track click diff --git a/src/core/KGProject.ts b/src/core/KGProject.ts index f73f921..c3d28aa 100644 --- a/src/core/KGProject.ts +++ b/src/core/KGProject.ts @@ -53,7 +53,7 @@ export class KGProject { @WithDefault(0) private projectStructureVersion: number = 0; - public static readonly CURRENT_PROJECT_STRUCTURE_VERSION: number = 10; + public static readonly CURRENT_PROJECT_STRUCTURE_VERSION: number = 11; @Expose() @Type(() => KGTrack, { diff --git a/src/core/audio-interface/KGAudioInterface.ts b/src/core/audio-interface/KGAudioInterface.ts index bf785ea..180f88c 100644 --- a/src/core/audio-interface/KGAudioInterface.ts +++ b/src/core/audio-interface/KGAudioInterface.ts @@ -242,11 +242,13 @@ export class KGAudioInterface { console.log(`Creating audio bus for track ${trackId} with instrument ${instrumentType}`); // Create new audio bus - // Initialize with track's stored volume if available + // Initialize with track's stored mix state if available const project = KGCore.instance().getCurrentProject(); const track = project.getTracks().find(t => t.getId().toString() === trackId); const initialVolume = track ? track.getVolume() : AUDIO_INTERFACE_CONSTANTS.DEFAULT_TRACK_VOLUME; - const audioBus = await KGAudioBus.create(instrumentType, initialVolume, 0); + const initialMuted = track ? track.getMuted() : false; + const initialSolo = track ? track.getSolo() : false; + const audioBus = await KGAudioBus.create(instrumentType, initialVolume, 0, initialMuted, initialSolo); // Connect to master gain if available, otherwise to destination if (this.masterGain) { @@ -299,7 +301,11 @@ export class KGAudioInterface { try { console.log(`Creating audio player bus for track ${trackId}`); - const playerBus = await KGAudioPlayerBus.create(volume, 0); + const project = KGCore.instance().getCurrentProject(); + const track = project.getTracks().find(t => t.getId().toString() === trackId); + const initialMuted = track ? track.getMuted() : false; + const initialSolo = track ? track.getSolo() : false; + const playerBus = await KGAudioPlayerBus.create(volume, 0, initialMuted, initialSolo); if (this.masterGain) { playerBus.connect(this.masterGain); diff --git a/src/core/commands/track/UpdateTrackCommand.test.ts b/src/core/commands/track/UpdateTrackCommand.test.ts new file mode 100644 index 0000000..5a97edc --- /dev/null +++ b/src/core/commands/track/UpdateTrackCommand.test.ts @@ -0,0 +1,87 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { KGCore } from '../../KGCore'; +import { KGProject } from '../../KGProject'; +import { KGAudioInterface } from '../../audio-interface/KGAudioInterface'; +import { KGTrack } from '../../track/KGTrack'; +import { UpdateTrackCommand } from './UpdateTrackCommand'; + +vi.mock('../../KGCore', () => ({ + KGCore: { + instance: vi.fn(), + }, +})); + +vi.mock('../../audio-interface/KGAudioInterface', () => ({ + KGAudioInterface: { + instance: vi.fn(), + }, +})); + +describe('UpdateTrackCommand', () => { + let track: KGTrack; + let project: KGProject; + const mockCore = { + getCurrentProject: vi.fn(), + }; + const mockAudioInterface = { + setTrackVolume: vi.fn(), + setTrackInstrument: vi.fn(), + setTrackMute: vi.fn(), + setTrackSolo: vi.fn(), + }; + + beforeEach(() => { + vi.clearAllMocks(); + track = new KGTrack('Track 1', 1); + project = new KGProject('Test', 32, 0, 125, undefined, undefined, undefined, undefined, undefined, 1, [track], 11); + mockCore.getCurrentProject.mockReturnValue(project); + vi.mocked(KGCore.instance).mockReturnValue(mockCore as unknown as KGCore); + vi.mocked(KGAudioInterface.instance).mockReturnValue(mockAudioInterface as unknown as KGAudioInterface); + }); + + it('updates muted state and propagates to the audio interface', () => { + const command = new UpdateTrackCommand(1, { muted: true }); + + command.execute(); + + expect(track.getMuted()).toBe(true); + expect(mockAudioInterface.setTrackMute).toHaveBeenCalledWith('1', true); + expect(command.getChangedProperties()).toEqual(new Set(['muted'])); + }); + + it('updates solo state and propagates to the audio interface', () => { + const command = new UpdateTrackCommand(1, { solo: true }); + + command.execute(); + + expect(track.getSolo()).toBe(true); + expect(mockAudioInterface.setTrackSolo).toHaveBeenCalledWith('1', true); + expect(command.getChangedProperties()).toEqual(new Set(['solo'])); + }); + + it('restores muted and solo state on undo', () => { + track.setMuted(true); + track.setSolo(true); + const command = new UpdateTrackCommand(1, { muted: false, solo: false }); + + command.execute(); + command.undo(); + + expect(track.getMuted()).toBe(true); + expect(track.getSolo()).toBe(true); + expect(mockAudioInterface.setTrackMute).toHaveBeenLastCalledWith('1', true); + expect(mockAudioInterface.setTrackSolo).toHaveBeenLastCalledWith('1', true); + }); + + it('treats unchanged mute and solo values as no-ops', () => { + const command = new UpdateTrackCommand(1, { muted: false, solo: false }); + + command.execute(); + + expect(track.getMuted()).toBe(false); + expect(track.getSolo()).toBe(false); + expect(mockAudioInterface.setTrackMute).not.toHaveBeenCalled(); + expect(mockAudioInterface.setTrackSolo).not.toHaveBeenCalled(); + expect(command.getChangedProperties()).toEqual(new Set()); + }); +}); diff --git a/src/core/commands/track/UpdateTrackCommand.ts b/src/core/commands/track/UpdateTrackCommand.ts index 0a3cc84..df61e67 100644 --- a/src/core/commands/track/UpdateTrackCommand.ts +++ b/src/core/commands/track/UpdateTrackCommand.ts @@ -12,6 +12,8 @@ export interface TrackUpdateProperties { instrument?: InstrumentType; // Only applies to MIDI tracks type?: TrackType; volume?: number; + muted?: boolean; + solo?: boolean; } /** @@ -47,6 +49,8 @@ export class UpdateTrackCommand extends KGCommand { name: this.targetTrack.getName(), type: this.targetTrack.getType(), volume: this.targetTrack.getVolume(), + muted: this.targetTrack.getMuted(), + solo: this.targetTrack.getSolo(), }; // Store original instrument if it's a MIDI track @@ -103,6 +107,32 @@ export class UpdateTrackCommand extends KGCommand { updatedProperties.push(`volume: ${originalVolume} → ${newVolume}`); } + if (this.newProperties.muted !== undefined && this.newProperties.muted !== this.originalProperties.muted) { + const newMuted = this.newProperties.muted; + const originalMuted = this.originalProperties.muted; + + this.targetTrack.setMuted(newMuted); + + const audioInterface = KGAudioInterface.instance(); + audioInterface.setTrackMute(this.trackId.toString(), newMuted); + + this.changedProperties.add('muted'); + updatedProperties.push(`muted: ${originalMuted} → ${newMuted}`); + } + + if (this.newProperties.solo !== undefined && this.newProperties.solo !== this.originalProperties.solo) { + const newSolo = this.newProperties.solo; + const originalSolo = this.originalProperties.solo; + + this.targetTrack.setSolo(newSolo); + + const audioInterface = KGAudioInterface.instance(); + audioInterface.setTrackSolo(this.trackId.toString(), newSolo); + + this.changedProperties.add('solo'); + updatedProperties.push(`solo: ${originalSolo} → ${newSolo}`); + } + if (updatedProperties.length > 0) { console.log(`Updated track ${this.trackId}: ${updatedProperties.join(', ')}`); } else { @@ -156,6 +186,24 @@ export class UpdateTrackCommand extends KGCommand { restoredProperties.push(`volume: ${this.originalProperties.volume}`); } + if (this.changedProperties.has('muted') && this.originalProperties.muted !== undefined) { + this.targetTrack.setMuted(this.originalProperties.muted); + + const audioInterface = KGAudioInterface.instance(); + audioInterface.setTrackMute(this.trackId.toString(), this.originalProperties.muted); + + restoredProperties.push(`muted: ${this.originalProperties.muted}`); + } + + if (this.changedProperties.has('solo') && this.originalProperties.solo !== undefined) { + this.targetTrack.setSolo(this.originalProperties.solo); + + const audioInterface = KGAudioInterface.instance(); + audioInterface.setTrackSolo(this.trackId.toString(), this.originalProperties.solo); + + restoredProperties.push(`solo: ${this.originalProperties.solo}`); + } + console.log(`Restored track ${this.trackId}: ${restoredProperties.join(', ')}`); } @@ -175,6 +223,12 @@ export class UpdateTrackCommand extends KGCommand { if (this.newProperties.volume !== undefined) { updatedProps.push('volume'); } + if (this.newProperties.muted !== undefined) { + updatedProps.push('muted'); + } + if (this.newProperties.solo !== undefined) { + updatedProps.push('solo'); + } if (updatedProps.length === 1) { return `Update track "${trackName}" ${updatedProps[0]}`; @@ -219,4 +273,4 @@ export class UpdateTrackCommand extends KGCommand { public getChangedProperties(): Set { return new Set(this.changedProperties); } -} \ No newline at end of file +} diff --git a/src/core/io/KGProjectStorage.test.ts b/src/core/io/KGProjectStorage.test.ts index 85b382f..a20a707 100644 --- a/src/core/io/KGProjectStorage.test.ts +++ b/src/core/io/KGProjectStorage.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; import { KGProjectStorage, DuplicateEntryError } from './KGProjectStorage'; import { KGProject } from '../KGProject'; +import { KGTrack } from '../track/KGTrack'; // --- OPFS mock infrastructure --- @@ -121,6 +122,22 @@ describe('KGProjectStorage', () => { expect(loaded!.getBpm()).toBe(120); }); + it('preserves track mute and solo state when saving and loading', async () => { + const track = new KGTrack('Track 1', 1); + track.setMuted(true); + track.setSolo(true); + const project = new KGProject('My Song', 16, 0, 120, undefined, undefined, undefined, undefined, undefined, 1, [track], 11); + + await storage.save('My Song', project); + + const loaded = await storage.load('My Song'); + + expect(loaded).not.toBeNull(); + expect(loaded!.getTracks()).toHaveLength(1); + expect(loaded!.getTracks()[0].getMuted()).toBe(true); + expect(loaded!.getTracks()[0].getSolo()).toBe(true); + }); + it('creates meta.json and media/ directory on save', async () => { const project = createTestProject('My Song'); await storage.save('My Song', project); diff --git a/src/core/project-upgrader/KGProjectUpgrader.ts b/src/core/project-upgrader/KGProjectUpgrader.ts index bafc84c..b70b365 100644 --- a/src/core/project-upgrader/KGProjectUpgrader.ts +++ b/src/core/project-upgrader/KGProjectUpgrader.ts @@ -9,6 +9,7 @@ import { upgradeToV7 } from './upgradeToV7'; import { upgradeToV8 } from './upgradeToV8'; import { upgradeToV9 } from './upgradeToV9'; import { upgradeToV10 } from './upgradeToV10'; +import { upgradeToV11 } from './upgradeToV11'; /** * Upgrade the given project to the latest structure version, one version at a time. @@ -68,6 +69,10 @@ export function upgradeProjectToLatest(project: KGProject): KGProject { workingProject = upgradeToV10(workingProject); break; } + case 11: { + workingProject = upgradeToV11(workingProject); + break; + } default: { // If an upgrader is missing, throw to prevent loading incompatible structures throw new Error(`No upgrader found for project structure version ${nextVersion}`); diff --git a/src/core/project-upgrader/upgradeToV10.test.ts b/src/core/project-upgrader/upgradeToV10.test.ts index 3e7b28c..643c18d 100644 --- a/src/core/project-upgrader/upgradeToV10.test.ts +++ b/src/core/project-upgrader/upgradeToV10.test.ts @@ -26,7 +26,7 @@ describe('upgradeToV10', () => { const upgraded = upgradeProjectToLatest(project); - expect(upgraded.getProjectStructureVersion()).toBe(10); + expect(upgraded.getProjectStructureVersion()).toBe(11); expect(upgraded.getTracks()[0].getVolumeAutomation()).toHaveLength(1); }); }); diff --git a/src/core/project-upgrader/upgradeToV11.test.ts b/src/core/project-upgrader/upgradeToV11.test.ts new file mode 100644 index 0000000..abb1832 --- /dev/null +++ b/src/core/project-upgrader/upgradeToV11.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest'; +import { KGProject } from '../KGProject'; +import { KGTrack } from '../track/KGTrack'; +import { upgradeProjectToLatest } from './KGProjectUpgrader'; +import { upgradeToV11 } from './upgradeToV11'; + +describe('upgradeToV11', () => { + it('initializes missing mute and solo flags on legacy tracks', () => { + const track = new KGTrack('Legacy Track', 1); + delete (track as unknown as { muted?: unknown }).muted; + delete (track as unknown as { solo?: unknown }).solo; + const project = new KGProject('Test', 32, 0, 125, undefined, undefined, undefined, undefined, undefined, 1, [track], 10); + + upgradeToV11(project); + + expect(track.getMuted()).toBe(false); + expect(track.getSolo()).toBe(false); + expect(project.getProjectStructureVersion()).toBe(11); + }); + + it('preserves existing mute and solo flags', () => { + const track = new KGTrack('Legacy Track', 1); + track.setMuted(true); + track.setSolo(true); + const project = new KGProject('Test', 32, 0, 125, undefined, undefined, undefined, undefined, undefined, 1, [track], 10); + + upgradeToV11(project); + + expect(track.getMuted()).toBe(true); + expect(track.getSolo()).toBe(true); + }); + + it('upgrades legacy projects through the main upgrader path', () => { + const track = new KGTrack('Legacy Track', 1); + delete (track as unknown as { muted?: unknown }).muted; + delete (track as unknown as { solo?: unknown }).solo; + const project = new KGProject('Test', 32, 0, 125, undefined, undefined, undefined, undefined, undefined, 1, [track], 10); + + const upgraded = upgradeProjectToLatest(project); + + expect(upgraded.getProjectStructureVersion()).toBe(11); + expect(upgraded.getTracks()[0].getMuted()).toBe(false); + expect(upgraded.getTracks()[0].getSolo()).toBe(false); + }); +}); diff --git a/src/core/project-upgrader/upgradeToV11.ts b/src/core/project-upgrader/upgradeToV11.ts new file mode 100644 index 0000000..1ede27d --- /dev/null +++ b/src/core/project-upgrader/upgradeToV11.ts @@ -0,0 +1,21 @@ +import { KGProject } from '../KGProject'; + +export function upgradeToV11(project: KGProject): KGProject { + try { + for (const track of project.getTracks()) { + const muted = (track as unknown as { muted?: unknown }).muted; + if (typeof muted !== 'boolean') { + track.setMuted(false); + } + + const solo = (track as unknown as { solo?: unknown }).solo; + if (typeof solo !== 'boolean') { + track.setSolo(false); + } + } + } finally { + project.setProjectStructureVersion(11); + } + + return project; +} diff --git a/src/core/project-upgrader/upgradeToV8.test.ts b/src/core/project-upgrader/upgradeToV8.test.ts index ba6a341..ee13acf 100644 --- a/src/core/project-upgrader/upgradeToV8.test.ts +++ b/src/core/project-upgrader/upgradeToV8.test.ts @@ -57,7 +57,7 @@ describe('upgradeToV8', () => { const upgraded = upgradeProjectToLatest(project); - expect(upgraded.getProjectStructureVersion()).toBe(10); + expect(upgraded.getProjectStructureVersion()).toBe(11); expect((upgraded.getTracks()[0].getRegions()[0] as KGMidiRegion).getPitchBends()).toEqual([]); expect((upgraded.getTracks()[0].getRegions()[0] as KGMidiRegion).getControllerEventsByType()).toHaveLength(128); }); diff --git a/src/core/track/KGTrack.ts b/src/core/track/KGTrack.ts index c3692a1..4ae20c2 100644 --- a/src/core/track/KGTrack.ts +++ b/src/core/track/KGTrack.ts @@ -37,6 +37,14 @@ export class KGTrack { @Expose() @WithDefault(AUDIO_INTERFACE_CONSTANTS.DEFAULT_TRACK_VOLUME) protected volume: number = AUDIO_INTERFACE_CONSTANTS.DEFAULT_TRACK_VOLUME; + + @Expose() + @WithDefault(false) + protected muted: boolean = false; + + @Expose() + @WithDefault(false) + protected solo: boolean = false; @Expose() @Type(() => KGRegion, { @@ -91,6 +99,14 @@ export class KGTrack { return this.volume; } + public getMuted(): boolean { + return this.muted; + } + + public getSolo(): boolean { + return this.solo; + } + // Setters public setName(name: string): void { this.name = name; @@ -120,6 +136,14 @@ export class KGTrack { ); } + public setMuted(muted: boolean): void { + this.muted = muted; + } + + public setSolo(solo: boolean): void { + this.solo = solo; + } + public setRegions(regions: KGRegion[]): void { this.regions = regions; } diff --git a/src/stores/projectStore.ts b/src/stores/projectStore.ts index c8bf4dd..eabd77a 100644 --- a/src/stores/projectStore.ts +++ b/src/stores/projectStore.ts @@ -873,6 +873,14 @@ export const useProjectStore = create((set, get) => { } } + // Reapply restored mute/solo state after all buses exist so solo logic can be + // computed against the full track set. + for (const track of tracks) { + const trackId = track.getId().toString(); + audioInterface.setTrackMute(trackId, track.getMuted()); + audioInterface.setTrackSolo(trackId, track.getSolo()); + } + // Update CSS variables updateTimeSignatureCSS(timeSignature); updateMaxBarsCSS(maxBars); From b5f5e1628e1f9f278b2e5bf8c5bd41f85d9487e0 Mon Sep 17 00:00:00 2001 From: Xiaohan-Tian <157918347+Xiaohan-Tian@users.noreply.github.com> Date: Sun, 17 May 2026 13:09:34 -0700 Subject: [PATCH 03/17] fix: hide zoom level option in piano roll window's sheet music view --- .../piano-roll/PianoRollToolbar.test.tsx | 13 ++++++ .../piano-roll/PianoRollToolbar.tsx | 46 ++++++++++--------- 2 files changed, 37 insertions(+), 22 deletions(-) diff --git a/src/components/piano-roll/PianoRollToolbar.test.tsx b/src/components/piano-roll/PianoRollToolbar.test.tsx index 8e16cf5..7e50a4f 100644 --- a/src/components/piano-roll/PianoRollToolbar.test.tsx +++ b/src/components/piano-roll/PianoRollToolbar.test.tsx @@ -130,10 +130,23 @@ describe('PianoRollToolbar', () => { expect(screen.getByRole('button', { name: 'Sheet Music View' })).toBeInTheDocument(); expect(screen.getByRole('button', { name: /16,48/i })).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Show Entire Track' })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: '1x' })).not.toBeInTheDocument(); expect(screen.queryByRole('button', { name: 'Pointer Tool' })).not.toBeInTheDocument(); expect(screen.queryByRole('button', { name: /Pitch Bend/i })).not.toBeInTheDocument(); }); + it('shows the zoom button outside sheet mode', () => { + render( + + ); + + expect(screen.getByRole('button', { name: '1x' })).toBeInTheDocument(); + }); + it('toggles the full-track sheet scope button', () => { const onSheetMusicTrackScopeToggle = vi.fn(); diff --git a/src/components/piano-roll/PianoRollToolbar.tsx b/src/components/piano-roll/PianoRollToolbar.tsx index b9b6df0..3e8d8c5 100644 --- a/src/components/piano-roll/PianoRollToolbar.tsx +++ b/src/components/piano-roll/PianoRollToolbar.tsx @@ -256,28 +256,30 @@ const PianoRollToolbar: React.FC = ({ )} -
- - {showZoomSlider && ( -
- onZoomChange(parseInt(e.target.value))} - /> - {zoom}x -
- )} -
+ {!sheetMusicViewEnabled && ( +
+ + {showZoomSlider && ( +
+ onZoomChange(parseInt(e.target.value))} + /> + {zoom}x +
+ )} +
+ )} ); From 60f5b75cb205160d94e1361e0428ea63e7f00155 Mon Sep 17 00:00:00 2001 From: Xiaohan-Tian <157918347+Xiaohan-Tian@users.noreply.github.com> Date: Sun, 17 May 2026 13:18:02 -0700 Subject: [PATCH 04/17] fix: playhead click-n-seek offset issue in piano roll window's sheet music view --- .../piano-roll/PianoRollContent.tsx | 1 - .../piano-roll/SheetMusicView.test.tsx | 302 ++++++++++++++++++ src/components/piano-roll/SheetMusicView.tsx | 5 +- 3 files changed, 303 insertions(+), 5 deletions(-) create mode 100644 src/components/piano-roll/SheetMusicView.test.tsx diff --git a/src/components/piano-roll/PianoRollContent.tsx b/src/components/piano-roll/PianoRollContent.tsx index 957f4f1..bae4605 100644 --- a/src/components/piano-roll/PianoRollContent.tsx +++ b/src/components/piano-roll/PianoRollContent.tsx @@ -351,7 +351,6 @@ const PianoRollContent: React.FC = ({ keySignature={sheetKeySignature} instrument={sheetInstrument} quantization={sheetQuantization} - noteScrollRef={noteScrollRef} onMetricsChange={onSheetMeasureMetricsChange ?? NOOP_SHEET_METRICS_CHANGE} /> ) : ( diff --git a/src/components/piano-roll/SheetMusicView.test.tsx b/src/components/piano-roll/SheetMusicView.test.tsx new file mode 100644 index 0000000..789073a --- /dev/null +++ b/src/components/piano-roll/SheetMusicView.test.tsx @@ -0,0 +1,302 @@ +import React from 'react'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import SheetMusicView from './SheetMusicView'; +import { getSheetPlayheadPixel, parseSheetQuantization } from './sheetNotation'; +import type { SheetMeasureMetric } from './sheetNotationTypes'; +import { createMockMidiNote, createMockMidiRegion } from '../../test/utils/mock-data'; + +const setPlayheadPosition = vi.fn(); +const requestMainContentScroll = vi.fn(); + +vi.mock('../../stores/projectStore', () => ({ + useProjectStore: (selector: (state: { + playheadPosition: number; + setPlayheadPosition: typeof setPlayheadPosition; + requestMainContentScroll: typeof requestMainContentScroll; + }) => unknown) => selector({ + playheadPosition: 0, + setPlayheadPosition, + requestMainContentScroll, + }), +})); + +vi.mock('../common', () => ({ + Playhead: ({ pixelPositionOverride }: { pixelPositionOverride?: number }) => ( +
+ ), +})); + +vi.mock('vexflow', () => { + class MockRenderer { + static Backends = { SVG: 'svg' }; + + private readonly host: HTMLElement; + + constructor(host: HTMLElement) { + this.host = host; + } + + resize() {} + + getContext() { + const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + this.host.appendChild(svg); + return {}; + } + } + + class MockStave { + constructor( + _x: number, + _y: number, + _width: number + ) {} + + setBegBarType() { + return this; + } + + setEndBarType() { + return this; + } + + addClef() { + return this; + } + + addKeySignature() { + return this; + } + + addTimeSignature() { + return this; + } + + setContext() { + return this; + } + + draw() { + return this; + } + } + + class MockStaveNote { + constructor(_options: unknown) {} + + isRest() { + return false; + } + + getTieLeftX() { + return 0; + } + + getTieRightX() { + return 0; + } + + getYs() { + return [0]; + } + } + + class MockVoice { + constructor(_options: unknown) {} + + setStrict() { + return this; + } + + addTickables() { + return this; + } + + draw() { + return this; + } + } + + class MockFormatter { + joinVoices() { + return this; + } + + formatToStave() { + return this; + } + } + + class MockBeam { + static generateBeams() { + return []; + } + + setContext() { + return this; + } + + draw() { + return this; + } + } + + return { + Accidental: { applyAccidentals: vi.fn() }, + BarlineType: { SINGLE: 1, NONE: 0 }, + Beam: MockBeam, + Dot: { buildAndAttach: vi.fn() }, + Formatter: MockFormatter, + Renderer: MockRenderer, + Stave: MockStave, + StaveNote: MockStaveNote, + Voice: MockVoice, + }; +}); + +describe('SheetMusicView', () => { + const quantization = parseSheetQuantization('16,48'); + const onMetricsChange = vi.fn(); + + const getLatestMetrics = (): SheetMeasureMetric[] => { + const latestCall = onMetricsChange.mock.calls.at(-1); + expect(latestCall).toBeDefined(); + return latestCall?.[0] as SheetMeasureMetric[]; + }; + + beforeEach(() => { + setPlayheadPosition.mockClear(); + requestMainContentScroll.mockClear(); + onMetricsChange.mockClear(); + }); + + it('maps header clicks in region scope without adding scroll offset', () => { + const activeRegion = createMockMidiRegion({ + startFromBeat: 16, + length: 8, + notes: [], + }); + + render( + + ); + + const header = document.querySelector('.sheet-music-header') as HTMLDivElement; + expect(header).not.toBeNull(); + const metrics = getLatestMetrics(); + const expectedLocalBeat = 6; + const headerPixel = getSheetPlayheadPixel(expectedLocalBeat, metrics); + + Object.defineProperty(header, 'getBoundingClientRect', { + value: () => ({ + left: 100, + top: 0, + right: 540, + bottom: 20, + width: 440, + height: 20, + x: 100, + y: 0, + toJSON: () => ({}), + }), + }); + + fireEvent.click(header, { clientX: 100 + headerPixel }); + + expect(setPlayheadPosition).toHaveBeenCalledTimes(1); + expect(requestMainContentScroll).toHaveBeenCalledTimes(1); + expect(setPlayheadPosition).toHaveBeenCalledWith(22); + expect(requestMainContentScroll).toHaveBeenCalledWith(22); + }); + + it('maps header clicks in track scope to absolute beats', () => { + const activeRegion = createMockMidiRegion({ + startFromBeat: 16, + length: 8, + notes: [ + createMockMidiNote({ id: 'note-1', startBeat: 0, endBeat: 1, pitch: 60 }), + ], + }); + + const anotherRegion = createMockMidiRegion({ + id: 'region-2', + startFromBeat: 24, + length: 4, + notes: [ + createMockMidiNote({ id: 'note-2', startBeat: 0, endBeat: 1, pitch: 67 }), + ], + }); + + render( + + ); + + const header = document.querySelector('.sheet-music-header') as HTMLDivElement; + expect(header).not.toBeNull(); + const metrics = getLatestMetrics(); + const expectedBeat = 3; + const headerPixel = getSheetPlayheadPixel(expectedBeat, metrics); + + Object.defineProperty(header, 'getBoundingClientRect', { + value: () => ({ + left: 100, + top: 0, + right: 300, + bottom: 20, + width: 200, + height: 20, + x: 100, + y: 0, + toJSON: () => ({}), + }), + }); + + fireEvent.click(header, { clientX: 100 + headerPixel }); + + expect(setPlayheadPosition).toHaveBeenCalledTimes(1); + expect(requestMainContentScroll).toHaveBeenCalledTimes(1); + expect(setPlayheadPosition).toHaveBeenCalledWith(3); + expect(requestMainContentScroll).toHaveBeenCalledWith(3); + }); + + it('renders the playhead container', () => { + const activeRegion = createMockMidiRegion(); + + render( + + ); + + expect(screen.getByTestId('playhead')).toBeInTheDocument(); + }); +}); diff --git a/src/components/piano-roll/SheetMusicView.tsx b/src/components/piano-roll/SheetMusicView.tsx index 3620b0c..8329ba0 100644 --- a/src/components/piano-roll/SheetMusicView.tsx +++ b/src/components/piano-roll/SheetMusicView.tsx @@ -26,7 +26,6 @@ interface SheetMusicViewProps { keySignature: KeySignature; instrument: InstrumentType; quantization: SheetQuantization; - noteScrollRef: React.MutableRefObject; onMetricsChange: (metrics: SheetMeasureMetric[]) => void; } @@ -61,7 +60,6 @@ const SheetMusicView: React.FC = ({ keySignature, instrument, quantization, - noteScrollRef, onMetricsChange, }) => { const setPlayheadPosition = useProjectStore(state => state.setPlayheadPosition); @@ -245,7 +243,7 @@ const SheetMusicView: React.FC = ({ } const rect = headerRef.current.getBoundingClientRect(); - const relativeX = event.clientX - rect.left + (noteScrollRef.current?.scrollLeft ?? 0); + const relativeX = event.clientX - rect.left; const metric = metrics.find(candidate => ( relativeX >= candidate.leftPx && relativeX <= candidate.leftPx + candidate.widthPx )); @@ -365,7 +363,6 @@ const arePropsEqual = (previous: SheetMusicViewProps, next: SheetMusicViewProps) previous.quantization.raw === next.quantization.raw && previous.timeSignature.numerator === next.timeSignature.numerator && previous.timeSignature.denominator === next.timeSignature.denominator && - previous.noteScrollRef === next.noteScrollRef && previous.onMetricsChange === next.onMetricsChange ); }; From 288af15ee97c3dd49d74234e99b9c9176180c43a Mon Sep 17 00:00:00 2001 From: Xiaohan-Tian <157918347+Xiaohan-Tian@users.noreply.github.com> Date: Sun, 17 May 2026 13:27:16 -0700 Subject: [PATCH 05/17] fix: when resizing an unselected note, we should clear selected note(s) and select the current note being updated --- src/hooks/useNoteOperations.test.ts | 182 ++++++++++++++++++++++++++++ src/hooks/useNoteOperations.ts | 22 ++++ 2 files changed, 204 insertions(+) create mode 100644 src/hooks/useNoteOperations.test.ts diff --git a/src/hooks/useNoteOperations.test.ts b/src/hooks/useNoteOperations.test.ts new file mode 100644 index 0000000..ae111ea --- /dev/null +++ b/src/hooks/useNoteOperations.test.ts @@ -0,0 +1,182 @@ +import { act, renderHook } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +type SelectableItem = { getId: () => string }; + +const coreState = { + selectedItems: [] as SelectableItem[], + selectionChangedCallbacks: [] as Array<() => void>, + executeCommand: vi.fn(), +}; + +const projectStoreState = { + selectedNoteIds: [] as string[], + clearAllSelections: () => { + coreState.selectedItems = []; + syncSelectionFromCore(); + }, + bumpAutomationRedrawVersion: vi.fn(), + syncSelectionFromCore: () => { + syncSelectionFromCore(); + }, +}; + +const syncSelectionFromCore = () => { + projectStoreState.selectedNoteIds = coreState.selectedItems.map(item => item.getId()); + coreState.selectionChangedCallbacks.forEach(callback => callback()); +}; + +vi.mock('../core/KGCore', () => ({ + KGCore: { + instance: () => ({ + getSelectedItems: () => coreState.selectedItems, + addSelectedItem: (item: SelectableItem) => { + coreState.selectedItems = coreState.selectedItems.filter(selectedItem => selectedItem.getId() !== item.getId()); + coreState.selectedItems.push(item); + syncSelectionFromCore(); + }, + addSelectedItems: (items: SelectableItem[]) => { + const incomingIds = new Set(items.map(item => item.getId())); + coreState.selectedItems = coreState.selectedItems.filter(item => !incomingIds.has(item.getId())); + coreState.selectedItems.push(...items); + syncSelectionFromCore(); + }, + clearSelectedItems: () => { + coreState.selectedItems = []; + syncSelectionFromCore(); + }, + executeCommand: (...args: unknown[]) => coreState.executeCommand(...args), + onSelectionChanged: (callback: () => void) => { + coreState.selectionChangedCallbacks.push(callback); + }, + }), + }, +})); + +vi.mock('../stores/projectStore', () => ({ + useProjectStore: Object.assign(vi.fn(), { + getState: () => projectStoreState, + }), +})); + +import { useNoteOperations } from './useNoteOperations'; +import { KGCore } from '../core/KGCore'; +import { KGPianoRollState } from '../core/state/KGPianoRollState'; +import { ResizeNotesCommand } from '../core/commands'; +import { useProjectStore } from '../stores/projectStore'; +import { createMockMidiNote, createMockMidiRegion, createMockMidiTrack } from '../test/utils/mock-data'; + +describe('useNoteOperations', () => { + beforeEach(() => { + vi.restoreAllMocks(); + coreState.selectedItems = []; + coreState.selectionChangedCallbacks = []; + coreState.executeCommand = vi.fn(); + projectStoreState.selectedNoteIds = []; + projectStoreState.bumpAutomationRedrawVersion.mockReset(); + + vi.spyOn(window, 'getComputedStyle').mockReturnValue({ + getPropertyValue: (property: string) => { + if (property === '--region-grid-beat-width') { + return '40'; + } + + if (property === '--region-piano-key-height') { + return '20'; + } + + return ''; + }, + } as CSSStyleDeclaration); + + KGPianoRollState.instance().setActiveTool('pointer'); + }); + + it('selects the grabbed note before resizing when it was not part of the current selection', () => { + const noteA = createMockMidiNote({ id: 'note-a', startBeat: 0, endBeat: 1, pitch: 60 }); + const noteB = createMockMidiNote({ id: 'note-b', startBeat: 1, endBeat: 2, pitch: 62 }); + const noteC = createMockMidiNote({ id: 'note-c', startBeat: 2, endBeat: 3, pitch: 64 }); + const activeRegion = createMockMidiRegion({ + id: 'region-1', + trackId: '1', + notes: [noteA, noteB, noteC], + }); + const track = createMockMidiTrack({ id: 1, regions: [activeRegion] }); + const updateTrack = vi.fn(); + + noteA.select(); + noteB.select(); + KGCore.instance().addSelectedItems([noteA, noteB]); + + const { result } = renderHook(() => useNoteOperations({ + activeRegion, + timeSignature: { numerator: 4, denominator: 4 }, + updateTrack, + tracks: [track], + pianoGridRef: { current: null }, + })); + + act(() => { + result.current.handleNoteResizeStart(noteC.getId(), 'end', 120); + }); + + expect(KGCore.instance().getSelectedItems().map(item => item.getId())).toEqual([noteC.getId()]); + expect(useProjectStore.getState().selectedNoteIds).toEqual([noteC.getId()]); + expect(noteA.isSelected()).toBe(false); + expect(noteB.isSelected()).toBe(false); + expect(noteC.isSelected()).toBe(true); + + act(() => { + result.current.handleNoteResizeEnd(noteC.getId(), 'end'); + }); + + expect(coreState.executeCommand).toHaveBeenCalledTimes(1); + const resizeCommand = coreState.executeCommand.mock.calls[0][0]; + expect(resizeCommand).toBeInstanceOf(ResizeNotesCommand); + expect((resizeCommand as ResizeNotesCommand).getNoteIdsToResize()).toEqual([noteC.getId()]); + }); + + it('keeps the existing multi-selection when resizing a selected note', () => { + const noteA = createMockMidiNote({ id: 'note-a', startBeat: 0, endBeat: 1, pitch: 60 }); + const noteB = createMockMidiNote({ id: 'note-b', startBeat: 1, endBeat: 2, pitch: 62 }); + const noteC = createMockMidiNote({ id: 'note-c', startBeat: 2, endBeat: 3, pitch: 64 }); + const activeRegion = createMockMidiRegion({ + id: 'region-1', + trackId: '1', + notes: [noteA, noteB, noteC], + }); + const track = createMockMidiTrack({ id: 1, regions: [activeRegion] }); + const updateTrack = vi.fn(); + + noteA.select(); + noteB.select(); + KGCore.instance().addSelectedItems([noteA, noteB]); + + const { result } = renderHook(() => useNoteOperations({ + activeRegion, + timeSignature: { numerator: 4, denominator: 4 }, + updateTrack, + tracks: [track], + pianoGridRef: { current: null }, + })); + + act(() => { + result.current.handleNoteResizeStart(noteA.getId(), 'end', 0); + }); + + expect(KGCore.instance().getSelectedItems().map(item => item.getId())).toEqual([noteA.getId(), noteB.getId()]); + expect(useProjectStore.getState().selectedNoteIds).toEqual([noteA.getId(), noteB.getId()]); + expect(noteA.isSelected()).toBe(true); + expect(noteB.isSelected()).toBe(true); + expect(noteC.isSelected()).toBe(false); + + act(() => { + result.current.handleNoteResizeEnd(noteA.getId(), 'end'); + }); + + expect(coreState.executeCommand).toHaveBeenCalledTimes(1); + const resizeCommand = coreState.executeCommand.mock.calls[0][0]; + expect(resizeCommand).toBeInstanceOf(ResizeNotesCommand); + expect((resizeCommand as ResizeNotesCommand).getNoteIdsToResize()).toEqual([noteA.getId(), noteB.getId()]); + }); +}); diff --git a/src/hooks/useNoteOperations.ts b/src/hooks/useNoteOperations.ts index 3a36b0b..8a7c040 100644 --- a/src/hooks/useNoteOperations.ts +++ b/src/hooks/useNoteOperations.ts @@ -279,6 +279,28 @@ export const useNoteOperations = ({ // Find the note being resized const note = activeRegion.getNotes().find(n => n.getId() === noteId); if (!note) return; + + const selectedNotesInRegion = core.getSelectedItems().filter(item => + item instanceof KGMidiNote && + activeRegion.getNotes().some(regionNote => regionNote.getId() === item.getId()) + ) as KGMidiNote[]; + const isResizedNoteSelected = selectedNotesInRegion.some(selectedNote => selectedNote.getId() === noteId); + + if (!isResizedNoteSelected) { + selectedNotesInRegion.forEach(selectedNote => { + selectedNote.deselect(); + }); + useProjectStore.getState().clearAllSelections(); + + note.select(); + core.addSelectedItem(note); + KGPianoRollState.instance().setLastEditedNoteLength(note.getEndBeat() - note.getStartBeat()); + + const track = tracks.find(t => t.getId().toString() === activeRegion.getTrackId()); + if (track) { + updateTrack(track); + } + } // Store the initial start and end beats initialStartBeatRef.current = note.getStartBeat(); From 5fd19c7b2d66b670ffc017a4ebc854e5c3803d83 Mon Sep 17 00:00:00 2001 From: Xiaohan-Tian <157918347+Xiaohan-Tian@users.noreply.github.com> Date: Sun, 17 May 2026 13:47:02 -0700 Subject: [PATCH 06/17] feat: brighten on-bar and on-C separator lines in piano roll grid --- src/components/piano-roll/PianoRoll.css | 5 +++- src/util/scaleUtil.test.ts | 6 ++++ src/util/scaleUtil.ts | 38 +++++++++++++------------ 3 files changed, 30 insertions(+), 19 deletions(-) diff --git a/src/components/piano-roll/PianoRoll.css b/src/components/piano-roll/PianoRoll.css index 56c8d46..977042a 100644 --- a/src/components/piano-roll/PianoRoll.css +++ b/src/components/piano-roll/PianoRoll.css @@ -593,7 +593,10 @@ width: 100%; height: 100%; position: relative; - background-size: var(--region-grid-beat-width) var(--region-piano-key-height), 100% 100%; + background-size: + var(--region-grid-bar-width) var(--region-piano-key-height), + var(--region-grid-beat-width) var(--region-piano-key-height), + 100% 100%; /* background-image is now set dynamically via React inline styles in PianoGrid component */ } diff --git a/src/util/scaleUtil.test.ts b/src/util/scaleUtil.test.ts index c1c11a0..5938b4e 100644 --- a/src/util/scaleUtil.test.ts +++ b/src/util/scaleUtil.test.ts @@ -387,6 +387,12 @@ describe('scaleUtil', () => { expect(typeof result).toBe('string'); expect(result).toContain('#282828'); expect(result).toContain('#303030'); + expect(result).toContain('#404040 calc(var(--region-grid-bar-width) - 1px)'); + expect(result).toContain('#343434 calc(var(--region-grid-beat-width) - 1px)'); + expect(result).toContain('#303030 calc(var(--region-piano-key-height) * 0)'); + expect(result).toContain('#404040 calc(var(--region-piano-key-height) * 12 - 1px)'); + expect(result).toContain('#282828 calc(var(--region-piano-key-height) * 1)'); + expect(result).toContain('#343434 calc(var(--region-piano-key-height) * 2 - 1px)'); }); it('should generate different backgrounds for different modes', () => { diff --git a/src/util/scaleUtil.ts b/src/util/scaleUtil.ts index 5cb72ac..04e0f37 100644 --- a/src/util/scaleUtil.ts +++ b/src/util/scaleUtil.ts @@ -304,6 +304,9 @@ export const generatePianoGridBackground = ( selectedMode: string, keySignature: KeySignature ): string => { + const majorGridLineColor = '#404040'; + const minorGridLineColor = '#343434'; + // Get root note and scale pitch classes const rootNote = getRootNoteFromKeySignature(keySignature); const modeSteps = getModeSteps(selectedMode); @@ -314,37 +317,36 @@ export const generatePianoGridBackground = ( const pitch = pianoRollIndexToPitch(index); const pitchClass = pitch % 12; const isInScale = scalePitchClasses.includes(pitchClass); + const isCRow = pitchClass === 0; // Calculate row positions using CSS calc() with --region-piano-key-height variable const rowTop = `calc(var(--region-piano-key-height) * ${index})`; const rowBottomMinusOne = `calc(var(--region-piano-key-height) * ${index + 1} - 1px)`; const rowBottom = `calc(var(--region-piano-key-height) * ${index + 1})`; + const rowFillColor = isInScale ? '#303030' : '#282828'; + const horizontalLineColor = isCRow ? majorGridLineColor : minorGridLineColor; // Match the event list palette while preserving scale-aware row distinction. - if (isInScale) { - return ` - #282828 ${rowTop}, - #282828 ${rowBottomMinusOne}, - #3a3a3a ${rowBottomMinusOne}, - #3a3a3a ${rowBottom} - `.trim(); - } else { - return ` - #303030 ${rowTop}, - #303030 ${rowBottomMinusOne}, - #3a3a3a ${rowBottomMinusOne}, - #3a3a3a ${rowBottom} - `.trim(); - } + return ` + ${rowFillColor} ${rowTop}, + ${rowFillColor} ${rowBottomMinusOne}, + ${horizontalLineColor} ${rowBottomMinusOne}, + ${horizontalLineColor} ${rowBottom} + `.trim(); }).join(',\n'); // Return complete background-image with vertical and horizontal gradients - // Note: Vertical beat lines gradient should be preserved from existing CSS + // Major bar lines sit above minor beat lines so bar boundaries remain the primary anchors. return ` + linear-gradient(to right, + transparent calc(var(--region-grid-bar-width) - 1px), + ${majorGridLineColor} calc(var(--region-grid-bar-width) - 1px), + ${majorGridLineColor} var(--region-grid-bar-width) + ), linear-gradient(to right, transparent calc(var(--region-grid-beat-width) - 1px), - #3a3a3a calc(var(--region-grid-beat-width) - 1px), - #3a3a3a var(--region-grid-beat-width) + ${minorGridLineColor} calc(var(--region-grid-beat-width) - 1px), + ${minorGridLineColor} var(--region-grid-beat-width) ), linear-gradient(to bottom, ${horizontalLines}) `; From 47d1d98134a68158d07aa51df3dc9a4995078bf1 Mon Sep 17 00:00:00 2001 From: Xiaohan-Tian <157918347+Xiaohan-Tian@users.noreply.github.com> Date: Sun, 17 May 2026 13:51:43 -0700 Subject: [PATCH 07/17] fix: dropdown list in the main ToolBar is lower than event list panel's dropdown label button --- src/components/Toolbar.css | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/components/Toolbar.css b/src/components/Toolbar.css index 258eebb..aac08f2 100644 --- a/src/components/Toolbar.css +++ b/src/components/Toolbar.css @@ -7,6 +7,8 @@ height: 50px; padding: 0 10px; border-bottom: 1px solid #3a3a3a; + position: relative; + z-index: 2000; } .toolbar-left, From fe1b4fc8aeba65a6db37d73340f6b30a96272110 Mon Sep 17 00:00:00 2001 From: Xiaohan-Tian <157918347+Xiaohan-Tian@users.noreply.github.com> Date: Mon, 18 May 2026 18:28:31 -0700 Subject: [PATCH 08/17] feat: added an option to allow user choose whether to trim leading silence when bouncing audio --- public/config.json | 1 + .../settings/sections/BehaviorSettings.tsx | 25 ++++ src/constants/coreConstants.ts | 2 +- .../audio-interface/KGOfflineRenderer.test.ts | 108 +++++++++++++++++- src/core/audio-interface/KGOfflineRenderer.ts | 3 +- src/core/config-upgrader/KGConfigUpgrader.ts | 5 + .../config-upgrader/upgradeConfigToV4.test.ts | 64 +++++++++++ src/core/config-upgrader/upgradeConfigToV4.ts | 30 +++++ src/core/config/ConfigManager.ts | 2 + 9 files changed, 236 insertions(+), 4 deletions(-) create mode 100644 src/core/config-upgrader/upgradeConfigToV4.test.ts create mode 100644 src/core/config-upgrader/upgradeConfigToV4.ts diff --git a/public/config.json b/public/config.json index 88226c1..5a9763c 100644 --- a/public/config.json +++ b/public/config.json @@ -76,6 +76,7 @@ "default_open": true }, "audio": { + "bounce_starts_from_beat_1": true, "enable_audio_capture_for_screen_sharing": false, "input_device_id": "default", "lookahead_time": 0.05, diff --git a/src/components/settings/sections/BehaviorSettings.tsx b/src/components/settings/sections/BehaviorSettings.tsx index 88f6d05..4688346 100644 --- a/src/components/settings/sections/BehaviorSettings.tsx +++ b/src/components/settings/sections/BehaviorSettings.tsx @@ -14,6 +14,7 @@ const BehaviorSettings: React.FC = () => { const [midiAutomationInterpolationIntervalMs, setMidiAutomationInterpolationIntervalMs] = useState(10); const [playbackDelay, setPlaybackDelay] = useState('200'); const [recordingOffset, setRecordingOffset] = useState('0'); + const [bounceStartsFromBeat1, setBounceStartsFromBeat1] = useState(true); const [enableAudioCapture, setEnableAudioCapture] = useState(false); const [lookaheadValidationErrors, setLookaheadValidationErrors] = useState([]); const [playbackDelayValidationErrors, setPlaybackDelayValidationErrors] = useState([]); @@ -42,6 +43,7 @@ const BehaviorSettings: React.FC = () => { setPlaybackDelay(((playbackDelaySeconds * 1000).toFixed(0))); const recordingOffsetSeconds = (configManager.get('audio.recording_offset') as number) ?? 0; setRecordingOffset(((recordingOffsetSeconds * 1000).toFixed(0))); + setBounceStartsFromBeat1((configManager.get('audio.bounce_starts_from_beat_1') as boolean) ?? true); setEnableAudioCapture((configManager.get('audio.enable_audio_capture_for_screen_sharing') as boolean) ?? false); }; @@ -158,6 +160,12 @@ const BehaviorSettings: React.FC = () => { await configManager.set('audio.enable_audio_capture_for_screen_sharing', boolValue); }; + const handleBounceStartsFromBeat1Change = async (value: string) => { + const boolValue = value === 'yes'; + setBounceStartsFromBeat1(boolValue); + await configManager.set('audio.bounce_starts_from_beat_1', boolValue); + }; + return (
@@ -325,6 +333,23 @@ const BehaviorSettings: React.FC = () => {
+
+ + +
+ Yes includes leading silence from the start of the song up to the first rendered region when bouncing WAV/MP3. No trims that leading silence and starts bounce at the first rendered note or audio region. +
+
+