From 4f11fe8e259f6115c75e617a6ace86e03c3e490e Mon Sep 17 00:00:00 2001 From: Xiaohan-Tian <157918347+Xiaohan-Tian@users.noreply.github.com> Date: Wed, 1 Jul 2026 22:31:35 -0700 Subject: [PATCH 1/3] feat: added region color customization feature --- src/components/common/ColorPalettePopup.css | 62 ++++++++++++++ src/components/common/ColorPalettePopup.tsx | 43 ++++++++++ src/components/common/index.ts | 1 + src/components/interfaces.ts | 4 + src/components/piano-roll/PianoRoll.css | 16 ++++ src/components/piano-roll/PianoRoll.tsx | 26 +++++- .../piano-roll/PianoRollToolbar.test.tsx | 29 +++++++ .../piano-roll/PianoRollToolbar.tsx | 34 +++++++- src/components/track/Region.css | 20 ++--- src/components/track/Track.css | 43 ++++++++++ src/components/track/TrackGridItem.tsx | 13 ++- src/components/track/TrackInfoItem.test.tsx | 39 ++++++++- src/components/track/TrackInfoItem.tsx | 61 +++++++++++--- src/constants/regionColorPalette.ts | 11 +++ src/core/KGCore.ts | 22 +++++ .../commands/region/PasteRegionsCommand.ts | 19 +++++ .../commands/region/SplitRegionCommand.ts | 4 + .../region/UpdateRegionCommand.test.ts | 54 ++++++++++++ .../commands/region/UpdateRegionCommand.ts | 19 ++++- .../commands/track/UpdateTrackCommand.test.ts | 23 +++++ src/core/commands/track/UpdateTrackCommand.ts | 20 +++++ src/core/region/KGRegion.ts | 11 +++ src/core/track/KGTrack.ts | 11 +++ src/hooks/useMainContentRegions.ts | 5 ++ src/i18n/messages/en_us.ts | 2 + src/i18n/messages/fr_fr.ts | 2 + src/i18n/messages/zh_cn.ts | 2 + src/i18n/messages/zh_hk.ts | 2 + src/stores/projectStore.ts | 21 ++++- src/util/regionColor.test.ts | 29 +++++++ src/util/regionColor.ts | 83 +++++++++++++++++++ 31 files changed, 700 insertions(+), 31 deletions(-) create mode 100644 src/components/common/ColorPalettePopup.css create mode 100644 src/components/common/ColorPalettePopup.tsx create mode 100644 src/constants/regionColorPalette.ts create mode 100644 src/core/commands/region/UpdateRegionCommand.test.ts create mode 100644 src/util/regionColor.test.ts create mode 100644 src/util/regionColor.ts diff --git a/src/components/common/ColorPalettePopup.css b/src/components/common/ColorPalettePopup.css new file mode 100644 index 0000000..349dec6 --- /dev/null +++ b/src/components/common/ColorPalettePopup.css @@ -0,0 +1,62 @@ +.color-palette-popup { + background: #2d2d2d; + border: 1px solid #444; + border-radius: 6px; + box-shadow: 0 6px 18px rgba(0, 0, 0, 0.35); + padding: 8px; + width: max-content; +} + +.color-palette-popup button { + appearance: none; + -webkit-appearance: none; + border: none; + outline: none; + box-shadow: none; +} + +.color-palette-none { + width: 24px; + height: 24px; + margin: 0 0 2px; + padding: 0; + border-radius: 5px; + background: #383838; + color: #e0e0e0; + cursor: pointer; + font-size: 12px; + display: inline-flex; + align-items: center; + justify-content: center; +} + +.color-palette-none:hover { + background: #444; +} + +.color-palette-none.active { + box-shadow: 0 0 0 2px #ffffff, 0 0 0 3px rgba(0, 0, 0, 0.4); +} + +.color-palette-grid { + display: grid; + grid-template-columns: repeat(24, 24px); + grid-auto-rows: 24px; + gap: 2px; +} + +.color-palette-swatch { + width: 24px; + height: 24px; + border-radius: 5px; + cursor: pointer; + padding: 0; +} + +.color-palette-swatch:hover { + transform: translateY(-1px); +} + +.color-palette-swatch.active { + box-shadow: 0 0 0 2px #ffffff, 0 0 0 3px rgba(0, 0, 0, 0.4); +} diff --git a/src/components/common/ColorPalettePopup.tsx b/src/components/common/ColorPalettePopup.tsx new file mode 100644 index 0000000..69c9877 --- /dev/null +++ b/src/components/common/ColorPalettePopup.tsx @@ -0,0 +1,43 @@ +import React from 'react'; +import { FaUndoAlt } from 'react-icons/fa'; +import { LOGIC_REGION_COLOR_SWATCHES } from '../../constants/regionColorPalette'; +import './ColorPalettePopup.css'; + +interface ColorPalettePopupProps { + selectedColor?: string; + onSelect: (color: string | null) => void; + className?: string; +} + +const ColorPalettePopup: React.FC = ({ + selectedColor, + onSelect, + className = '', +}) => ( +
+ +
+ {LOGIC_REGION_COLOR_SWATCHES.flat().map((color) => ( +
+
+); + +export default ColorPalettePopup; diff --git a/src/components/common/index.ts b/src/components/common/index.ts index faae95c..d9ba3a7 100644 --- a/src/components/common/index.ts +++ b/src/components/common/index.ts @@ -1,4 +1,5 @@ export { default as KGDropdown } from './KGDropdown'; +export { default as ColorPalettePopup } from './ColorPalettePopup'; export { default as Playhead } from './Playhead'; export { default as FileImportModal } from './FileImportModal'; export { default as LoadingOverlay } from './LoadingOverlay'; diff --git a/src/components/interfaces.ts b/src/components/interfaces.ts index e76a18b..eecb52d 100644 --- a/src/components/interfaces.ts +++ b/src/components/interfaces.ts @@ -16,6 +16,10 @@ export interface RegionUI { barNumber: number; length: number; name: string; + color?: string; + trackColor?: string; + effectiveColor?: string; + isAudioRegion?: boolean; } export interface RegionPreviewContentStyle { diff --git a/src/components/piano-roll/PianoRoll.css b/src/components/piano-roll/PianoRoll.css index f3899e4..c4c4e2f 100644 --- a/src/components/piano-roll/PianoRoll.css +++ b/src/components/piano-roll/PianoRoll.css @@ -247,6 +247,22 @@ cursor: default; } +.piano-roll-menu-item-wrapper { + position: relative; +} + +.piano-roll-more-menu { + overflow: visible; + max-height: none; +} + +.piano-roll-region-color-popup { + position: absolute; + top: calc(100% + 6px); + right: 0; + z-index: 1600; +} + .piano-roll-automation-toolbar-group { display: flex; align-items: center; diff --git a/src/components/piano-roll/PianoRoll.tsx b/src/components/piano-roll/PianoRoll.tsx index 312defd..16b9cae 100644 --- a/src/components/piano-roll/PianoRoll.tsx +++ b/src/components/piano-roll/PianoRoll.tsx @@ -101,7 +101,7 @@ const PianoRoll: React.FC = ({ const isAudioWaveform = currentMode === 'audio-waveform'; const isAudioOnly = isAudioWaveform || isSpectrogram; const isHybrid = currentMode === 'hybrid'; - const { maxBars, tracks, updateTrack, timeSignature, showChatBox, showKGOnePanel, showEventListPanel, showInstrumentSelection, keySignature, selectedMode, setSelectedMode, playheadPosition, isPlaying, autoScrollEnabled, bpm, pianoRollScrollRequest, selectedNoteIds, automationRedrawVersion, refreshProjectState, setBpm } = useProjectStore(); + const { maxBars, tracks, updateTrack, updateRegionProperties, timeSignature, showChatBox, showKGOnePanel, showEventListPanel, showInstrumentSelection, keySignature, selectedMode, setSelectedMode, playheadPosition, isPlaying, autoScrollEnabled, bpm, pianoRollScrollRequest, selectedNoteIds, selectedRegionIds, automationRedrawVersion, refreshProjectState, setBpm } = useProjectStore(); const { t } = useI18n(); // Tool state for piano roll @@ -167,6 +167,14 @@ const PianoRoll: React.FC = ({ const activeInstrument = useMemo(() => ( parentMidiTrack instanceof KGMidiTrack ? parentMidiTrack.getInstrument() : 'acoustic_grand_piano' ), [parentMidiTrack]); + const activeEditableRegionId = audioRegion?.getId() ?? activeRegion?.getId() ?? null; + const selectedRegionColor = useMemo(() => { + if (audioRegion) { + return audioRegion.getColor(); + } + + return activeRegion?.getColor(); + }, [activeRegion, audioRegion]); const parsedSheetQuantization = useMemo( () => parseSheetQuantization(sheetQuantization), [sheetQuantization] @@ -492,6 +500,20 @@ const PianoRoll: React.FC = ({ } }; + const handleRegionColorSelect = useCallback(async (color: string | null) => { + const allTrackRegionIds = new Set(tracks.flatMap(track => track.getRegions().map(region => region.getId()))); + const selectedProjectRegionIds = selectedRegionIds.filter(regionId => allTrackRegionIds.has(regionId)); + const targetRegionIds = activeEditableRegionId && selectedProjectRegionIds.includes(activeEditableRegionId) + ? selectedProjectRegionIds + : activeEditableRegionId + ? [activeEditableRegionId] + : []; + + for (const regionId of targetRegionIds) { + await updateRegionProperties(regionId, { color }); + } + }, [activeEditableRegionId, selectedRegionIds, tracks, updateRegionProperties]); + const handleDetectChords = useCallback(async () => { if (!audioRegion && !activeRegion) { await showAlert('Open a MIDI or audio region before detecting chords.'); @@ -1597,6 +1619,8 @@ const PianoRoll: React.FC = ({ detectingChords={isDetectingChords} onDetectTempo={audioRegion ? handleDetectTempo : undefined} detectingTempo={isDetectingTempo} + selectedRegionColor={selectedRegionColor} + onRegionColorSelect={activeEditableRegionId ? (color) => { void handleRegionColorSelect(color); } : undefined} /> diff --git a/src/components/piano-roll/PianoRollToolbar.test.tsx b/src/components/piano-roll/PianoRollToolbar.test.tsx index ba47647..2623a81 100644 --- a/src/components/piano-roll/PianoRollToolbar.test.tsx +++ b/src/components/piano-roll/PianoRollToolbar.test.tsx @@ -30,6 +30,16 @@ vi.mock('../common', () => ({ ); }, + ColorPalettePopup: ({ onSelect }: { onSelect: (value: string | null) => void }) => ( +
+ + +
+ ), })); vi.mock('../../core/KGCore', () => ({ @@ -431,4 +441,23 @@ describe('PianoRollToolbar', () => { expect(screen.getByRole('button', { name: '和声小调' })).toBeInTheDocument(); }); + + it('shows the region color action in the more menu and emits the chosen color', () => { + const onRegionColorSelect = vi.fn(); + + renderWithLocale( + + ); + + fireEvent.click(screen.getByRole('button', { name: 'More options' })); + fireEvent.click(screen.getByText('Region Color...')); + fireEvent.click(screen.getByRole('button', { name: 'Reset color' })); + + expect(onRegionColorSelect).toHaveBeenCalledWith(null); + }); }); diff --git a/src/components/piano-roll/PianoRollToolbar.tsx b/src/components/piano-roll/PianoRollToolbar.tsx index d781c7a..ec0262f 100644 --- a/src/components/piano-roll/PianoRollToolbar.tsx +++ b/src/components/piano-roll/PianoRollToolbar.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { FaMousePointer, FaPencilAlt } from 'react-icons/fa'; import { TbArrowBarToUp } from 'react-icons/tb'; -import { KGDropdown } from '../common'; +import { ColorPalettePopup, KGDropdown } from '../common'; import { KGPianoRollState } from '../../core/state/KGPianoRollState'; import { KGCore } from '../../core/KGCore'; import { @@ -50,6 +50,8 @@ interface PianoRollToolbarProps { detectingChords?: boolean; onDetectTempo?: () => void | Promise; detectingTempo?: boolean; + selectedRegionColor?: string; + onRegionColorSelect?: (color: string | null) => void; } const PianoRollToolbar: React.FC = ({ @@ -92,6 +94,8 @@ const PianoRollToolbar: React.FC = ({ detectingChords = false, onDetectTempo, detectingTempo = false, + selectedRegionColor, + onRegionColorSelect, }) => { const { t } = useI18n(); const showMidiControls = mode !== 'spectrogram' && mode !== 'audio-waveform' && !sheetMusicViewEnabled; @@ -151,6 +155,7 @@ const PianoRollToolbar: React.FC = ({ }, [showZoomSlider]); const [showMoreMenu, setShowMoreMenu] = React.useState(false); + const [showRegionColorPalette, setShowRegionColorPalette] = React.useState(false); const specMenuRef = React.useRef(null); React.useEffect(() => { @@ -158,6 +163,7 @@ const PianoRollToolbar: React.FC = ({ const handleClickOutside = (e: MouseEvent) => { if (specMenuRef.current && !specMenuRef.current.contains(e.target as Node)) { setShowMoreMenu(false); + setShowRegionColorPalette(false); } }; document.addEventListener('mousedown', handleClickOutside); @@ -386,7 +392,29 @@ const PianoRollToolbar: React.FC = ({ ... {showMoreMenu && ( -
+
+ {onRegionColorSelect && ( +
+
setShowRegionColorPalette(open => !open)} + > + {t('pianoRoll.regionColor')} +
+ {showRegionColorPalette && ( +
+ { + onRegionColorSelect(color); + setShowRegionColorPalette(false); + setShowMoreMenu(false); + }} + /> +
+ )} +
+ )} {onDetectChords && (
= ({ if (detectingChords) { return; } + setShowRegionColorPalette(false); setShowMoreMenu(false); void onDetectChords(); }} @@ -409,6 +438,7 @@ const PianoRollToolbar: React.FC = ({ if (detectingTempo) { return; } + setShowRegionColorPalette(false); setShowMoreMenu(false); void onDetectTempo(); }} diff --git a/src/components/track/Region.css b/src/components/track/Region.css index 4c31836..b5b9c77 100644 --- a/src/components/track/Region.css +++ b/src/components/track/Region.css @@ -1,8 +1,8 @@ /* Track Region Styles */ .track-region { position: absolute; - background-color: #4a6b8a; - border: 2px solid #5a7b9a; + background-color: var(--region-header-bg, #4a6b8a); + border: 2px solid var(--region-border-color, #5a7b9a); border-radius: 3px; height: calc(100%); margin: 0px; @@ -48,7 +48,7 @@ } .region-header { - background-color: #5a7b9a; + background-color: var(--region-header-bg, #5a7b9a); color: #fff; padding: 2px 6px; font-size: 11px; @@ -61,19 +61,19 @@ } .track-region:hover .region-header { - background-color: #6a8baa; + background-color: var(--region-header-hover-bg, #6a8baa); } .region-content { height: calc(100% - 18px); - background-color: #87CEFA; /* Light blue */ + background-color: var(--region-content-bg, #4B9A41); /* Default MIDI region color */ width: 100%; position: relative; /* Allow overlayed controls */ overflow: hidden; } .region-content.audio-region-content { - background-color: #90EE90; /* Light green for audio regions */ + background-color: var(--region-content-bg, #39649E); /* Default audio region color */ } .region-preview-content { @@ -91,8 +91,8 @@ /* Audio region overrides */ .track-region.audio-region { - background-color: #3a6b4a; - border-color: #4a8b5a; + background-color: var(--region-header-bg, #3a6b4a); + border-color: var(--region-border-color, #4a8b5a); } .track-region.audio-region.selected { @@ -109,11 +109,11 @@ } .track-region.audio-region .region-header { - background-color: #4a8b5a; + background-color: var(--region-header-bg, #4a8b5a); } .track-region.audio-region:hover .region-header { - background-color: #5a9b6a; + background-color: var(--region-header-hover-bg, #5a9b6a); } /* Left-side button cluster inside region-content */ diff --git a/src/components/track/Track.css b/src/components/track/Track.css index 47a946c..942178c 100644 --- a/src/components/track/Track.css +++ b/src/components/track/Track.css @@ -259,6 +259,49 @@ color: #101010; } +.track-settings-menu { + position: absolute; + top: 100%; + left: 0; + z-index: 10000; + margin-top: 2px; + min-width: 120px; + padding: 4px 0; + border: 1px solid #444; + border-radius: 3px; + background: #2d2d2d; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); +} + +.track-settings-menu-item { + display: flex !important; + align-items: center !important; + justify-content: flex-start !important; + width: 100%; + min-width: 120px; + height: auto !important; + margin: 0 !important; + padding: 6px 10px !important; + border: none !important; + border-radius: 0 !important; + background: transparent !important; + color: #e0e0e0 !important; + font-size: 12px !important; + line-height: 1.2 !important; + white-space: nowrap; +} + +.track-settings-menu-item:hover { + background: #444 !important; +} + +.track-settings-color-popup { + position: absolute; + top: 0; + left: calc(100% + 6px); + z-index: 10001; +} + .track-grid.automation-active { position: relative; } diff --git a/src/components/track/TrackGridItem.tsx b/src/components/track/TrackGridItem.tsx index a4c04e6..f50b922 100644 --- a/src/components/track/TrackGridItem.tsx +++ b/src/components/track/TrackGridItem.tsx @@ -12,6 +12,7 @@ import { useProjectStore } from '../../stores/projectStore'; import { isModifierKeyPressed } from '../../util/osUtil'; import { CHORD_REGION_IMPORT_MIME_TYPE } from '../../util/chordRegionImportUtil'; import { TrackType } from '../../core/track/KGTrack'; +import { buildRegionSurfaceColors, resolveRegionColor } from '../../util/regionColor'; interface RegionResizePreviewBaseline { regionId: string; @@ -795,12 +796,22 @@ const TrackGridItem: React.FC = ({ ); } + const effectiveColor = region.effectiveColor + ?? resolveRegionColor(coreRegion?.getColor(), track.getColor(), !!audioRegion || !!region.isAudioRegion); + const surfaceColors = buildRegionSurfaceColors(effectiveColor); + return ( | null = null; vi.mock('../../stores/projectStore', () => ({ - useProjectStore: (selector?: (state: typeof storeState) => unknown) => ( - selector ? selector(storeState) : storeState + useProjectStore: Object.assign( + (selector?: (state: typeof storeState) => unknown) => ( + selector ? selector(storeState) : storeState + ), + { + getState: () => storeState, + }, ), })); @@ -73,6 +79,7 @@ describe('TrackInfoItem audio import', () => { storeState.removeTrack.mockReset(); storeState.toggleInstrumentSelectionForTrack.mockReset(); storeState.importAudioToTrack.mockReset(); + storeState.updateTrackProperties.mockReset(); storeState.setTrackAutomationView.mockReset(); vi.mocked(showConfirm).mockReset(); }); @@ -127,4 +134,32 @@ describe('TrackInfoItem audio import', () => { translate('track.controls.settings.deleteTrackConfirm', { name: '钢琴' }, 'zh_cn') ); }); + + it('shows the track color entry and clears the color override', async () => { + const audioTrack = new KGAudioTrack('Audio Track', 1); + audioTrack.setTrackIndex(0); + audioTrack.setColor('#3C8AC4'); + storeState.tracks = [audioTrack]; + storeState.updateTrackProperties.mockResolvedValue(undefined); + + render( + + ); + + fireEvent.click(screen.getByRole('button', { name: '更多操作' })); + fireEvent.click(screen.getByRole('button', { name: '轨道颜色...' })); + fireEvent.click(screen.getByRole('button', { name: 'Reset color' })); + + expect(storeState.updateTrackProperties).toHaveBeenCalledWith(1, { color: null }); + }); }); diff --git a/src/components/track/TrackInfoItem.tsx b/src/components/track/TrackInfoItem.tsx index 579aa27..714a797 100644 --- a/src/components/track/TrackInfoItem.tsx +++ b/src/components/track/TrackInfoItem.tsx @@ -7,6 +7,7 @@ import { TbPiano } from 'react-icons/tb'; import { TbDots } from 'react-icons/tb'; import { FaFileAudio } from 'react-icons/fa'; import KGDropdown from '../common/KGDropdown'; +import ColorPalettePopup from '../common/ColorPalettePopup'; import FileImportModal from '../common/FileImportModal'; import { FLUIDR3_INSTRUMENT_MAP } from '../../constants/generalMidiConstants'; import { DEBUG_MODE } from '../../constants/uiConstants'; @@ -91,6 +92,7 @@ const TrackInfoItem: React.FC = ({ const [showSettingsDropdown, setShowSettingsDropdown] = useState(false); const [showAudioImportModal, setShowAudioImportModal] = useState(false); const [showAutomationDropdown, setShowAutomationDropdown] = useState(false); + const [showTrackColorPalette, setShowTrackColorPalette] = useState(false); const settingsDropdownRef = useRef(null); const automationDropdownRef = useRef(null); const suppressDragRef = useRef(false); @@ -115,6 +117,7 @@ const TrackInfoItem: React.FC = ({ !settingsDropdownRef.current.contains(event.target as Node) ) { setShowSettingsDropdown(false); + setShowTrackColorPalette(false); } if ( showAutomationDropdown && @@ -336,6 +339,9 @@ const TrackInfoItem: React.FC = ({ const handleSettingsButtonClick = (e: React.MouseEvent) => { e.stopPropagation(); setShowSettingsDropdown(!showSettingsDropdown); + if (showSettingsDropdown) { + setShowTrackColorPalette(false); + } }; const handleAutomationButtonClick = (e: React.MouseEvent) => { @@ -387,6 +393,17 @@ const TrackInfoItem: React.FC = ({ } }; + const handleTrackColorSelect = async (color: string | null) => { + try { + await useProjectStore.getState().updateTrackProperties(track.getId(), { color }); + } catch (error) { + console.error('Failed to update track color:', error); + } finally { + setShowTrackColorPalette(false); + setShowSettingsDropdown(false); + } + }; + return (
= ({ > -
- -
+ {showSettingsDropdown && ( +
+ + + {showTrackColorPalette && ( +
+ +
+ )} +
+ )}
diff --git a/src/constants/regionColorPalette.ts b/src/constants/regionColorPalette.ts new file mode 100644 index 0000000..2e84859 --- /dev/null +++ b/src/constants/regionColorPalette.ts @@ -0,0 +1,11 @@ +export const DEFAULT_MIDI_REGION_COLOR = '#4B9A41'; +export const DEFAULT_AUDIO_REGION_COLOR = '#39649E'; + +export const LOGIC_REGION_COLOR_SWATCHES: readonly string[][] = [ + ['#B43F1D', '#B65720', '#B97423', '#BE9B2A', '#C3C031', '#A2BF30', '#84BE2E', '#6BBD2D', '#4EBC3A', '#4EBD58', '#4DBD78', '#4DBE9B', '#4CBEC2', '#3C99C0', '#3C8AC4', '#3D77C9', '#3B5ECC', '#3A43CE', '#4E41CA', '#643EC8', '#7A38C6', '#8D2EBE', '#B233BD', '#B33097'], + ['#8E3A1F', '#904C21', '#926124', '#957C27', '#9A972C', '#82962B', '#6C952A', '#599529', '#409435', '#40954D', '#409564', '#3F957D', '#3F9698', '#347B97', '#367099', '#39649E', '#3852A0', '#383EA2', '#463CA0', '#55389D', '#67359D', '#722995', '#8C2D96', '#8C2B7A'], + ['#69331E', '#6B3E20', '#6D4C22', '#706025', '#717126', '#627026', '#546F25', '#476F26', '#336F2E', '#336F3F', '#336E4F', '#337060', '#327070', '#2C5E70', '#2F5672', '#324F75', '#314476', '#323576', '#3B3476', '#463375', '#4F3073', '#57256F', '#68266F', '#69275E'], + ['#48271B', '#492F1C', '#4A381D', '#4C421F', '#4D4C1F', '#444C1E', '#3C4C1E', '#344B1F', '#264B24', '#264B2E', '#264B38', '#274B42', '#264B4C', '#23414B', '#263E4D', '#293A50', '#27324F', '#28294F', '#2E294F', '#342850', '#38254D', '#3D1F4A', '#47204B', '#481F41'], +] as const; + +export const LOGIC_REGION_COLOR_OPTIONS: readonly string[] = LOGIC_REGION_COLOR_SWATCHES.flat(); diff --git a/src/core/KGCore.ts b/src/core/KGCore.ts index b77695f..fe93e4a 100644 --- a/src/core/KGCore.ts +++ b/src/core/KGCore.ts @@ -5,6 +5,7 @@ import { ConfigManager } from './config/ConfigManager'; import { KGProjectStorage } from './io/KGProjectStorage'; import { KGConfigUpgrader } from './config-upgrader/KGConfigUpgrader'; import { KGMidiRegion } from './region/KGMidiRegion'; +import { KGAudioRegion } from './region/KGAudioRegion'; import { KGMidiNote } from './midi/KGMidiNote'; import { KGMidiControllerEvent } from './midi/KGMidiControllerEvent'; import { KGMidiPitchBend } from './midi/KGMidiPitchBend'; @@ -535,6 +536,7 @@ export class KGCore { region.getStartFromBeat(), region.getLength() ); + clonedRegion.setColor(region.getColor()); // Copy all notes within the region const originalNotes = region.getNotes(); @@ -568,6 +570,25 @@ export class KGCore { clonedItems.push(clonedRegion); break; } + + case 'KGAudioRegion': { + const region = item as KGAudioRegion; + const clonedRegion = new KGAudioRegion( + generateUniqueId('KGAudioRegion'), + region.getTrackId(), + region.getTrackIndex(), + region.getName(), + region.getStartFromBeat(), + region.getLength(), + region.getAudioFileId(), + region.getAudioFileName(), + region.getAudioDurationSeconds(), + region.getClipStartOffsetSeconds() + ); + clonedRegion.setColor(region.getColor()); + clonedItems.push(clonedRegion); + break; + } case 'KGRegion': { const region = item as KGRegion; @@ -579,6 +600,7 @@ export class KGCore { region.getStartFromBeat(), region.getLength() ); + clonedRegion.setColor(region.getColor()); clonedItems.push(clonedRegion); break; } diff --git a/src/core/commands/region/PasteRegionsCommand.ts b/src/core/commands/region/PasteRegionsCommand.ts index 0280002..fe97f65 100644 --- a/src/core/commands/region/PasteRegionsCommand.ts +++ b/src/core/commands/region/PasteRegionsCommand.ts @@ -8,6 +8,7 @@ import { KGMidiPitchBend } from '../../midi/KGMidiPitchBend'; import { KGTrack } from '../../track/KGTrack'; import { generateUniqueId } from '../../../util/miscUtil'; import { useProjectStore } from '../../../stores/projectStore'; +import { KGAudioRegion } from '../../region/KGAudioRegion'; /** * Command to paste regions with their notes to a target track at a specific position @@ -71,6 +72,7 @@ export class PasteRegionsCommand extends KGCommand { newPosition, originalRegion.getLength() ); + newRegion.setColor(originalRegion.getColor()); // Copy all notes from the original region const originalNotes = originalRegion.getNotes(); @@ -102,6 +104,22 @@ export class PasteRegionsCommand extends KGCommand { }); console.log(`Created MIDI region "${newRegion.getName()}" with ${originalNotes.length} notes`); + } else if (originalRegion instanceof KGAudioRegion) { + newRegion = new KGAudioRegion( + generateUniqueId('KGAudioRegion'), + targetTrack.getId().toString(), + targetTrack.getTrackIndex(), + `${originalRegion.getName()} (Copy)`, + newPosition, + originalRegion.getLength(), + originalRegion.getAudioFileId(), + originalRegion.getAudioFileName(), + originalRegion.getAudioDurationSeconds(), + originalRegion.getClipStartOffsetSeconds() + ); + newRegion.setColor(originalRegion.getColor()); + + console.log(`Created audio region "${newRegion.getName()}"`); } else { // Fallback for other region types newRegion = new KGRegion( @@ -112,6 +130,7 @@ export class PasteRegionsCommand extends KGCommand { newPosition, originalRegion.getLength() ); + newRegion.setColor(originalRegion.getColor()); console.log(`Created region "${newRegion.getName()}"`); } diff --git a/src/core/commands/region/SplitRegionCommand.ts b/src/core/commands/region/SplitRegionCommand.ts index 2a4775c..414a39f 100644 --- a/src/core/commands/region/SplitRegionCommand.ts +++ b/src/core/commands/region/SplitRegionCommand.ts @@ -93,6 +93,8 @@ export class SplitRegionCommand extends KGCommand { this.splitAtBeat, regionLength - splitOffsetBeats ); + region1.setColor(originalRegion.getColor()); + region2.setColor(originalRegion.getColor()); for (const note of originalRegion.getNotes()) { if (note.getStartBeat() < splitOffsetBeats) { @@ -167,6 +169,7 @@ export class SplitRegionCommand extends KGCommand { originalRegion.getAudioDurationSeconds(), originalRegion.getClipStartOffsetSeconds() ); + this.region1.setColor(originalRegion.getColor()); this.region2 = new KGAudioRegion( generateUniqueId('KGAudioRegion'), @@ -180,6 +183,7 @@ export class SplitRegionCommand extends KGCommand { originalRegion.getAudioDurationSeconds(), originalRegion.getClipStartOffsetSeconds() + splitOffsetSeconds ); + this.region2.setColor(originalRegion.getColor()); } else { throw new Error(`Unsupported region type for splitting: ${originalRegion.getCurrentType()}`); diff --git a/src/core/commands/region/UpdateRegionCommand.test.ts b/src/core/commands/region/UpdateRegionCommand.test.ts new file mode 100644 index 0000000..e92819b --- /dev/null +++ b/src/core/commands/region/UpdateRegionCommand.test.ts @@ -0,0 +1,54 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { KGCore } from '../../KGCore'; +import { KGProject } from '../../KGProject'; +import { KGTrack } from '../../track/KGTrack'; +import { KGMidiRegion } from '../../region/KGMidiRegion'; +import { UpdateRegionCommand } from './UpdateRegionCommand'; + +vi.mock('../../KGCore', () => ({ + KGCore: { + instance: vi.fn(), + }, +})); + +describe('UpdateRegionCommand', () => { + let track: KGTrack; + let region: KGMidiRegion; + let project: KGProject; + const mockCore = { + getCurrentProject: vi.fn(), + }; + + beforeEach(() => { + vi.clearAllMocks(); + track = new KGTrack('Track 1', 1); + region = new KGMidiRegion('region-1', '1', 0, 'Verse', 0, 4); + track.setRegions([region]); + 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); + }); + + it('updates color and restores it on undo', () => { + const command = new UpdateRegionCommand('region-1', { color: '#3D77C9' }); + + command.execute(); + + expect(region.getColor()).toBe('#3D77C9'); + expect(command.getChangedProperties()).toEqual(new Set(['color'])); + + command.undo(); + + expect(region.getColor()).toBeUndefined(); + }); + + it('clears an existing region color override', () => { + region.setColor('#B43F1D'); + const command = new UpdateRegionCommand('region-1', { color: null }); + + command.execute(); + + expect(region.getColor()).toBeUndefined(); + expect(command.getChangedProperties()).toEqual(new Set(['color'])); + }); +}); diff --git a/src/core/commands/region/UpdateRegionCommand.ts b/src/core/commands/region/UpdateRegionCommand.ts index 39e2b52..8077067 100644 --- a/src/core/commands/region/UpdateRegionCommand.ts +++ b/src/core/commands/region/UpdateRegionCommand.ts @@ -8,7 +8,7 @@ import { KGTrack } from '../../track/KGTrack'; */ export interface RegionUpdateProperties { name?: string; - // Future properties can be added here (e.g., color, instrument, etc.) + color?: string | null; } /** @@ -58,6 +58,7 @@ export class UpdateRegionCommand extends KGCommand { // Store original properties for undo this.originalProperties = { name: this.targetRegion.getName(), + color: this.targetRegion.getColor(), }; // Apply updates and track what actually changes @@ -70,6 +71,12 @@ export class UpdateRegionCommand extends KGCommand { updatedProperties.push(`name: "${this.originalProperties.name}" → "${this.newProperties.name}"`); } + if ('color' in this.newProperties && this.newProperties.color !== this.originalProperties.color) { + this.targetRegion.setColor(this.newProperties.color ?? undefined); + this.changedProperties.add('color'); + updatedProperties.push(`color: ${this.originalProperties.color ?? 'none'} → ${this.newProperties.color ?? 'none'}`); + } + if (updatedProperties.length > 0) { console.log(`Updated region ${this.regionId}: ${updatedProperties.join(', ')}`); } else { @@ -91,6 +98,11 @@ export class UpdateRegionCommand extends KGCommand { restoredProperties.push(`name: "${this.originalProperties.name}"`); } + if (this.changedProperties.has('color')) { + this.targetRegion.setColor(this.originalProperties.color ?? undefined); + restoredProperties.push(`color: ${this.originalProperties.color ?? 'none'}`); + } + console.log(`Restored region ${this.regionId}: ${restoredProperties.join(', ')}`); } @@ -101,6 +113,9 @@ export class UpdateRegionCommand extends KGCommand { if (this.newProperties.name !== undefined) { updatedProps.push('name'); } + if ('color' in this.newProperties) { + updatedProps.push('color'); + } if (updatedProps.length === 1) { return `Update region "${regionName}" ${updatedProps[0]}`; @@ -152,4 +167,4 @@ export class UpdateRegionCommand extends KGCommand { public getChangedProperties(): Set { return new Set(this.changedProperties); } -} \ No newline at end of file +} diff --git a/src/core/commands/track/UpdateTrackCommand.test.ts b/src/core/commands/track/UpdateTrackCommand.test.ts index 5a97edc..6302d10 100644 --- a/src/core/commands/track/UpdateTrackCommand.test.ts +++ b/src/core/commands/track/UpdateTrackCommand.test.ts @@ -84,4 +84,27 @@ describe('UpdateTrackCommand', () => { expect(mockAudioInterface.setTrackSolo).not.toHaveBeenCalled(); expect(command.getChangedProperties()).toEqual(new Set()); }); + + it('updates color and restores it on undo', () => { + const command = new UpdateTrackCommand(1, { color: '#3C8AC4' }); + + command.execute(); + + expect(track.getColor()).toBe('#3C8AC4'); + expect(command.getChangedProperties()).toEqual(new Set(['color'])); + + command.undo(); + + expect(track.getColor()).toBeUndefined(); + }); + + it('clears an existing color override', () => { + track.setColor('#B43F1D'); + const command = new UpdateTrackCommand(1, { color: null }); + + command.execute(); + + expect(track.getColor()).toBeUndefined(); + expect(command.getChangedProperties()).toEqual(new Set(['color'])); + }); }); diff --git a/src/core/commands/track/UpdateTrackCommand.ts b/src/core/commands/track/UpdateTrackCommand.ts index df61e67..921d635 100644 --- a/src/core/commands/track/UpdateTrackCommand.ts +++ b/src/core/commands/track/UpdateTrackCommand.ts @@ -14,6 +14,7 @@ export interface TrackUpdateProperties { volume?: number; muted?: boolean; solo?: boolean; + color?: string | null; } /** @@ -51,6 +52,7 @@ export class UpdateTrackCommand extends KGCommand { volume: this.targetTrack.getVolume(), muted: this.targetTrack.getMuted(), solo: this.targetTrack.getSolo(), + color: this.targetTrack.getColor(), }; // Store original instrument if it's a MIDI track @@ -133,6 +135,16 @@ export class UpdateTrackCommand extends KGCommand { updatedProperties.push(`solo: ${originalSolo} → ${newSolo}`); } + if ('color' in this.newProperties && this.newProperties.color !== this.originalProperties.color) { + const newColor = this.newProperties.color ?? undefined; + const originalColor = this.originalProperties.color; + + this.targetTrack.setColor(newColor); + + this.changedProperties.add('color'); + updatedProperties.push(`color: ${originalColor ?? 'none'} → ${newColor}`); + } + if (updatedProperties.length > 0) { console.log(`Updated track ${this.trackId}: ${updatedProperties.join(', ')}`); } else { @@ -204,6 +216,11 @@ export class UpdateTrackCommand extends KGCommand { restoredProperties.push(`solo: ${this.originalProperties.solo}`); } + if (this.changedProperties.has('color')) { + this.targetTrack.setColor(this.originalProperties.color ?? undefined); + restoredProperties.push(`color: ${this.originalProperties.color ?? 'none'}`); + } + console.log(`Restored track ${this.trackId}: ${restoredProperties.join(', ')}`); } @@ -229,6 +246,9 @@ export class UpdateTrackCommand extends KGCommand { if (this.newProperties.solo !== undefined) { updatedProps.push('solo'); } + if ('color' in this.newProperties) { + updatedProps.push('color'); + } if (updatedProps.length === 1) { return `Update track "${trackName}" ${updatedProps[0]}`; diff --git a/src/core/region/KGRegion.ts b/src/core/region/KGRegion.ts index f0181fe..6c9e513 100644 --- a/src/core/region/KGRegion.ts +++ b/src/core/region/KGRegion.ts @@ -27,6 +27,9 @@ export class KGRegion implements Selectable { @Expose() protected length: number = 0; + @Expose() + protected color?: string; + @Expose() protected selected: boolean = false; @@ -66,6 +69,10 @@ export class KGRegion implements Selectable { return this.length; } + public getColor(): string | undefined { + return this.color; + } + // Setters public setId(id: string): void { this.id = id; @@ -91,6 +98,10 @@ export class KGRegion implements Selectable { this.length = length; } + public setColor(color: string | undefined): void { + this.color = color; + } + // interface methods public select(): void { this.selected = true; diff --git a/src/core/track/KGTrack.ts b/src/core/track/KGTrack.ts index 4ae20c2..e7d3311 100644 --- a/src/core/track/KGTrack.ts +++ b/src/core/track/KGTrack.ts @@ -34,6 +34,9 @@ export class KGTrack { @Expose() protected type: TrackType; + @Expose() + protected color?: string; + @Expose() @WithDefault(AUDIO_INTERFACE_CONSTANTS.DEFAULT_TRACK_VOLUME) protected volume: number = AUDIO_INTERFACE_CONSTANTS.DEFAULT_TRACK_VOLUME; @@ -99,6 +102,10 @@ export class KGTrack { return this.volume; } + public getColor(): string | undefined { + return this.color; + } + public getMuted(): boolean { return this.muted; } @@ -136,6 +143,10 @@ export class KGTrack { ); } + public setColor(color: string | undefined): void { + this.color = color; + } + public setMuted(muted: boolean): void { this.muted = muted; } diff --git a/src/hooks/useMainContentRegions.ts b/src/hooks/useMainContentRegions.ts index cb97b6d..6151db5 100644 --- a/src/hooks/useMainContentRegions.ts +++ b/src/hooks/useMainContentRegions.ts @@ -15,6 +15,7 @@ import { DEBUG_MODE } from '../constants'; import { useProjectStore } from '../stores/projectStore'; import { useRegionOperations } from './useRegionOperations'; import { getAudioRegionDisplayLengthBeats } from '../util/globalTrackUtil'; +import { resolveRegionColor } from '../util/regionColor'; const DEFAULT_REGION_CLICK_OPTIONS: RegionClickOptions = { shiftKey: false, @@ -179,6 +180,10 @@ export function useMainContentRegions({ barNumber, length, name: region.getName(), + color: region.getColor(), + trackColor: track.getColor(), + effectiveColor: resolveRegionColor(region.getColor(), track.getColor(), region instanceof KGAudioRegion), + isAudioRegion: region instanceof KGAudioRegion, }); } }); diff --git a/src/i18n/messages/en_us.ts b/src/i18n/messages/en_us.ts index c631f81..a44e376 100644 --- a/src/i18n/messages/en_us.ts +++ b/src/i18n/messages/en_us.ts @@ -327,6 +327,7 @@ export const enUsMessages: TranslationMessages = { 'pianoRoll.curve': 'Curve', 'pianoRoll.zoom': 'Zoom', 'pianoRoll.moreOptions': 'More options', + 'pianoRoll.regionColor': 'Region Color...', 'pianoRoll.quantize.1/1': '1/1', 'pianoRoll.quantize.1/2': '1/2', 'pianoRoll.quantize.1/3': '1/3', @@ -538,6 +539,7 @@ export const enUsMessages: TranslationMessages = { 'track.controls.automation.volume': 'Volume', 'track.controls.automation.pan': 'Pan', 'track.controls.moreActions': 'More actions', + 'track.controls.settings.trackColor': 'Track Color...', 'track.controls.settings.deleteTrack': 'Delete Track', 'track.controls.settings.deleteTrackConfirm': 'Are you sure you want to delete track "{name}"?', 'track.controls.settings.deleteTrackError': 'Failed to delete track. Please try again.', diff --git a/src/i18n/messages/fr_fr.ts b/src/i18n/messages/fr_fr.ts index c40e5ba..60a4806 100644 --- a/src/i18n/messages/fr_fr.ts +++ b/src/i18n/messages/fr_fr.ts @@ -315,6 +315,7 @@ export const frFrMessages: TranslationMessages = { 'pianoRoll.curve': 'Courbe', 'pianoRoll.zoom': 'Zoom', 'pianoRoll.moreOptions': 'Plus d\'options', + 'pianoRoll.regionColor': 'Couleur de la région...', 'pianoRoll.showActiveRegionOnly': 'Afficher seulement la région active', 'pianoRoll.showEntireTrack': 'Afficher toute la piste', 'pianoRoll.detectChords': 'Détecter les accords...', @@ -422,6 +423,7 @@ export const frFrMessages: TranslationMessages = { 'track.controls.automation.volume': 'Volume', 'track.controls.automation.pan': 'Panoramique', 'track.controls.moreActions': 'Autres actions', + 'track.controls.settings.trackColor': 'Couleur de la piste...', 'track.controls.settings.deleteTrack': 'Supprimer la piste', 'track.controls.settings.deleteTrackConfirm': 'Voulez-vous vraiment supprimer la piste « {name} » ?', 'track.controls.settings.deleteTrackError': 'Impossible de supprimer la piste. Veuillez réessayer.', diff --git a/src/i18n/messages/zh_cn.ts b/src/i18n/messages/zh_cn.ts index 18244b4..78e4aaa 100644 --- a/src/i18n/messages/zh_cn.ts +++ b/src/i18n/messages/zh_cn.ts @@ -325,6 +325,7 @@ export const zhCnMessages: TranslationMessages = { 'pianoRoll.curve': '曲线', 'pianoRoll.zoom': '缩放', 'pianoRoll.moreOptions': '更多选项', + 'pianoRoll.regionColor': '区域颜色...', 'pianoRoll.quantize.1/1': '1/1', 'pianoRoll.quantize.1/2': '1/2', 'pianoRoll.quantize.1/3': '1/3', @@ -536,6 +537,7 @@ export const zhCnMessages: TranslationMessages = { 'track.controls.automation.volume': '音量', 'track.controls.automation.pan': '声像', 'track.controls.moreActions': '更多操作', + 'track.controls.settings.trackColor': '轨道颜色...', 'track.controls.settings.deleteTrack': '删除轨道', 'track.controls.settings.deleteTrackConfirm': '确定要删除轨道“{name}”吗?', 'track.controls.settings.deleteTrackError': '删除轨道失败。请重试。', diff --git a/src/i18n/messages/zh_hk.ts b/src/i18n/messages/zh_hk.ts index 4b10f37..b58922d 100644 --- a/src/i18n/messages/zh_hk.ts +++ b/src/i18n/messages/zh_hk.ts @@ -325,6 +325,7 @@ export const zhHkMessages: TranslationMessages = { 'pianoRoll.curve': '曲線', 'pianoRoll.zoom': '縮放', 'pianoRoll.moreOptions': '更多選項', + 'pianoRoll.regionColor': '區域顏色...', 'pianoRoll.quantize.1/1': '1/1', 'pianoRoll.quantize.1/2': '1/2', 'pianoRoll.quantize.1/3': '1/3', @@ -536,6 +537,7 @@ export const zhHkMessages: TranslationMessages = { 'track.controls.automation.volume': '音量', 'track.controls.automation.pan': '聲像', 'track.controls.moreActions': '更多操作', + 'track.controls.settings.trackColor': '音軌顏色...', 'track.controls.settings.deleteTrack': '刪除音軌', 'track.controls.settings.deleteTrackConfirm': '確定要刪除音軌「{name}」嗎?', 'track.controls.settings.deleteTrackError': '刪除音軌失敗。請再試一次。', diff --git a/src/stores/projectStore.ts b/src/stores/projectStore.ts index 8eaa5b2..f9757db 100644 --- a/src/stores/projectStore.ts +++ b/src/stores/projectStore.ts @@ -11,7 +11,7 @@ import { KGPianoRollState } from '../core/state/KGPianoRollState'; import { KGMidiNote } from '../core/midi/KGMidiNote'; import { KGMidiControllerEvent } from '../core/midi/KGMidiControllerEvent'; import { KGRegion } from '../core/region/KGRegion'; -import { AddTrackCommand, AddAudioTrackCommand, RemoveTrackCommand, ReorderTracksCommand, UpdateTrackCommand, type TrackUpdateProperties, PasteRegionsCommand, PasteNotesCommand, ChangeProjectPropertyCommand, ImportAudioCommand } from '../core/commands'; +import { AddTrackCommand, AddAudioTrackCommand, RemoveTrackCommand, ReorderTracksCommand, UpdateTrackCommand, type TrackUpdateProperties, PasteRegionsCommand, PasteNotesCommand, ChangeProjectPropertyCommand, ImportAudioCommand, UpdateRegionCommand, type RegionUpdateProperties } from '../core/commands'; import { KGAudioTrack } from '../core/track/KGAudioTrack'; import { KGAudioRegion } from '../core/region/KGAudioRegion'; import { KGAudioFileStorage } from '../core/io/KGAudioFileStorage'; @@ -183,6 +183,7 @@ interface ProjectState { removeTrack: (id: number) => Promise; updateTrack: (track: KGTrack) => Promise; updateTrackProperties: (trackId: number, properties: TrackUpdateProperties) => Promise; + updateRegionProperties: (regionId: string, properties: RegionUpdateProperties) => Promise; setTrackInstrument: (trackId: number, instrument: InstrumentType) => Promise; reorderTracks: (sourceIndex: number, destinationIndex: number) => void; setStatus: (status: string) => void; @@ -856,6 +857,24 @@ export const useProjectStore = create((set, get) => { } }, + updateRegionProperties: async (regionId: string, properties: RegionUpdateProperties) => { + try { + const command = new UpdateRegionCommand(regionId, properties); + KGCore.instance().executeCommand(command); + + const project = KGCore.instance().getCurrentProject(); + set({ + tracks: [...project.getTracks()] as KGTrack[], + globalTracks: [...getProjectGlobalTracks(project)], + }); + + console.log(`Updated region ${regionId} properties`); + } catch (error) { + console.error('Error updating region properties:', error); + get().setStatus('Failed to update region properties'); + } + }, + setTrackInstrument: async (trackId: number, instrument: InstrumentType) => { try { // Use the new updateTrackProperties method with command pattern diff --git a/src/util/regionColor.test.ts b/src/util/regionColor.test.ts new file mode 100644 index 0000000..1cff1d2 --- /dev/null +++ b/src/util/regionColor.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest'; +import { + DEFAULT_AUDIO_REGION_COLOR, + DEFAULT_MIDI_REGION_COLOR, +} from '../constants/regionColorPalette'; +import { buildRegionSurfaceColors, resolveRegionColor } from './regionColor'; + +describe('regionColor helpers', () => { + it('prefers the region color over the track color', () => { + expect(resolveRegionColor('#123456', '#654321', false)).toBe('#123456'); + }); + + it('falls back to the track color when the region color is missing', () => { + expect(resolveRegionColor(undefined, '#654321', false)).toBe('#654321'); + }); + + it('falls back to the default type color when no overrides are present', () => { + expect(resolveRegionColor(undefined, undefined, false)).toBe(DEFAULT_MIDI_REGION_COLOR); + expect(resolveRegionColor(undefined, undefined, true)).toBe(DEFAULT_AUDIO_REGION_COLOR); + }); + + it('builds distinct surface colors for region chrome', () => { + const colors = buildRegionSurfaceColors('#4CBEC2'); + + expect(colors.borderColor).not.toBe('#4CBEC2'); + expect(colors.headerColor).not.toBe('#4CBEC2'); + expect(colors.contentColor).not.toBe('#4CBEC2'); + }); +}); diff --git a/src/util/regionColor.ts b/src/util/regionColor.ts new file mode 100644 index 0000000..f5bdf25 --- /dev/null +++ b/src/util/regionColor.ts @@ -0,0 +1,83 @@ +import { + DEFAULT_AUDIO_REGION_COLOR, + DEFAULT_MIDI_REGION_COLOR, +} from '../constants/regionColorPalette'; + +function clampChannel(value: number): number { + return Math.max(0, Math.min(255, Math.round(value))); +} + +function normalizeHex(color: string): string { + const trimmed = color.trim(); + const hex = trimmed.startsWith('#') ? trimmed.slice(1) : trimmed; + if (hex.length === 3) { + return `#${hex.split('').map(channel => channel + channel).join('').toUpperCase()}`; + } + return `#${hex.toUpperCase()}`; +} + +function parseHexColor(color: string): [number, number, number] | null { + const normalized = normalizeHex(color); + if (!/^#[0-9A-F]{6}$/.test(normalized)) { + return null; + } + + return [ + parseInt(normalized.slice(1, 3), 16), + parseInt(normalized.slice(3, 5), 16), + parseInt(normalized.slice(5, 7), 16), + ]; +} + +function rgbToHex(red: number, green: number, blue: number): string { + return `#${[red, green, blue].map(channel => clampChannel(channel).toString(16).padStart(2, '0')).join('').toUpperCase()}`; +} + +function mixColor(color: string, target: [number, number, number], amount: number): string { + const rgb = parseHexColor(color); + if (!rgb) { + return color; + } + + return rgbToHex( + rgb[0] + (target[0] - rgb[0]) * amount, + rgb[1] + (target[1] - rgb[1]) * amount, + rgb[2] + (target[2] - rgb[2]) * amount, + ); +} + +export function normalizeRegionColor(color: string | undefined): string | undefined { + if (!color) { + return undefined; + } + + const normalized = normalizeHex(color); + return /^#[0-9A-F]{6}$/.test(normalized) ? normalized : undefined; +} + +export function resolveRegionColor( + regionColor: string | undefined, + trackColor: string | undefined, + isAudioRegion: boolean, +): string { + return normalizeRegionColor(regionColor) + ?? normalizeRegionColor(trackColor) + ?? (isAudioRegion ? DEFAULT_AUDIO_REGION_COLOR : DEFAULT_MIDI_REGION_COLOR); +} + +export function darkenRegionColor(color: string, amount: number): string { + return mixColor(color, [0, 0, 0], amount); +} + +export function lightenRegionColor(color: string, amount: number): string { + return mixColor(color, [255, 255, 255], amount); +} + +export function buildRegionSurfaceColors(color: string) { + return { + borderColor: darkenRegionColor(color, 0.35), + headerColor: darkenRegionColor(color, 0.2), + headerHoverColor: darkenRegionColor(color, 0.1), + contentColor: lightenRegionColor(color, 0.06), + }; +} From 86ec455dfebef3d4f47c963801e1c15b411aadbc Mon Sep 17 00:00:00 2001 From: Xiaohan-Tian <157918347+Xiaohan-Tian@users.noreply.github.com> Date: Thu, 2 Jul 2026 14:11:25 -0700 Subject: [PATCH 2/3] feat: cache soundfont MP3s in OPFS and add cache controls --- .../sections/GeneralSettings.test.tsx | 62 +++++ .../settings/sections/GeneralSettings.tsx | 136 +++++++++- .../audio-interface/KGToneBuffersPool.test.ts | 188 ++++++++++++++ src/core/audio-interface/KGToneBuffersPool.ts | 244 ++++++++++++++---- src/i18n/messages/en_us.ts | 10 + src/i18n/messages/fr_fr.ts | 10 + src/i18n/messages/zh_cn.ts | 10 + src/i18n/messages/zh_hk.ts | 10 + src/util/soundfontInstrumentCache.test.ts | 199 ++++++++++++++ src/util/soundfontInstrumentCache.ts | 226 ++++++++++++++++ 10 files changed, 1040 insertions(+), 55 deletions(-) create mode 100644 src/core/audio-interface/KGToneBuffersPool.test.ts create mode 100644 src/util/soundfontInstrumentCache.test.ts create mode 100644 src/util/soundfontInstrumentCache.ts diff --git a/src/components/settings/sections/GeneralSettings.test.tsx b/src/components/settings/sections/GeneralSettings.test.tsx index 6e2f8d0..b2cf163 100644 --- a/src/components/settings/sections/GeneralSettings.test.tsx +++ b/src/components/settings/sections/GeneralSettings.test.tsx @@ -12,6 +12,14 @@ const { localSeparatorModelCacheMock } = vi.hoisted(() => ({ }, })); +const { soundfontInstrumentCacheMock } = vi.hoisted(() => ({ + soundfontInstrumentCacheMock: { + deleteInstrument: vi.fn().mockResolvedValue(undefined), + deleteAll: vi.fn().mockResolvedValue(undefined), + getCacheSummary: vi.fn().mockResolvedValue({ instrumentCount: 2, instruments: ['acoustic_grand_piano', 'violin'] }), + }, +})); + const configState = new Map([ ['general.language', 'auto'], ['general.agent_mode', 'regular'], @@ -90,6 +98,10 @@ vi.mock('../../../util/local-separator/modelCache', () => ({ LocalSeparatorModelCache: localSeparatorModelCacheMock, })); +vi.mock('../../../util/soundfontInstrumentCache', () => ({ + SoundfontInstrumentCache: soundfontInstrumentCacheMock, +})); + describe('GeneralSettings', () => { const renderSettings = (locale: 'en_us' | 'zh_cn' = 'en_us') => render( { localSeparatorModelCacheMock.delete.mockClear(); localSeparatorModelCacheMock.exists.mockClear(); localSeparatorModelCacheMock.exists.mockResolvedValue(true); + soundfontInstrumentCacheMock.deleteAll.mockClear(); + soundfontInstrumentCacheMock.deleteInstrument.mockClear(); + soundfontInstrumentCacheMock.getCacheSummary.mockClear(); + soundfontInstrumentCacheMock.getCacheSummary.mockResolvedValue({ instrumentCount: 2, instruments: ['acoustic_grand_piano', 'violin'] }); }); it('renders the local context length selector and VRAM hint', async () => { @@ -338,4 +354,50 @@ describe('GeneralSettings', () => { expect(configManagerMock.set).toHaveBeenCalledWith('general.language', 'fr_fr'); }); }); + + it('renders the soundfont cache status and deletes all cached soundfonts', async () => { + soundfontInstrumentCacheMock.getCacheSummary + .mockResolvedValueOnce({ instrumentCount: 2, instruments: ['acoustic_grand_piano', 'violin'] }) + .mockResolvedValueOnce({ instrumentCount: 0, instruments: [] }); + + renderSettings(); + + expect(await screen.findByText('Soundfont Settings')).toBeTruthy(); + expect(screen.getByText('2 instruments cached in browser storage.')).toBeTruthy(); + + const soundfontDeleteButton = screen.getByRole('button', { name: 'Delete Soundfont Cache' }); + expect(soundfontDeleteButton).not.toBeDisabled(); + fireEvent.click(soundfontDeleteButton); + + await waitFor(() => { + expect(soundfontInstrumentCacheMock.deleteAll).toHaveBeenCalledOnce(); + }); + + await waitFor(() => { + expect(screen.getByText('No cached instruments yet.')).toBeTruthy(); + }); + }); + + it('deletes the selected cached soundfont instrument', async () => { + soundfontInstrumentCacheMock.getCacheSummary + .mockResolvedValueOnce({ instrumentCount: 2, instruments: ['acoustic_grand_piano', 'violin'] }) + .mockResolvedValueOnce({ instrumentCount: 1, instruments: ['acoustic_grand_piano'] }); + + renderSettings(); + + const select = await screen.findByLabelText('Cached Instrument'); + fireEvent.change(select, { target: { value: 'violin' } }); + + const deleteSelectedButton = screen.getByRole('button', { name: 'Delete Selected Instrument Cache' }); + expect(deleteSelectedButton).not.toBeDisabled(); + fireEvent.click(deleteSelectedButton); + + await waitFor(() => { + expect(soundfontInstrumentCacheMock.deleteInstrument).toHaveBeenCalledWith('violin'); + }); + + await waitFor(() => { + expect(screen.getByText('1 instruments cached in browser storage.')).toBeTruthy(); + }); + }); }); diff --git a/src/components/settings/sections/GeneralSettings.tsx b/src/components/settings/sections/GeneralSettings.tsx index 33354c5..7d4f8c9 100644 --- a/src/components/settings/sections/GeneralSettings.tsx +++ b/src/components/settings/sections/GeneralSettings.tsx @@ -2,6 +2,7 @@ import React, { useState, useEffect, useCallback, useMemo } from 'react'; import { ConfigManager } from '../../../core/config/ConfigManager'; import { LocalLLMModelManager, type LocalLLMModelState } from '../../../util/localLLMModelManager'; import { LocalSeparatorModelCache } from '../../../util/local-separator/modelCache'; +import { SoundfontInstrumentCache, type SoundfontCacheSummary } from '../../../util/soundfontInstrumentCache'; import { useI18n } from '../../../i18n/useI18n'; import type { LanguageSetting } from '../../../i18n/types'; import { @@ -58,6 +59,11 @@ const GeneralSettings: React.FC = () => { const [kgoneBaseUrl, setKgoneBaseUrl] = useState(''); const [kgoneServerManaged, setKgoneServerManaged] = useState(false); const [soundfontServerManaged, setSoundfontServerManaged] = useState(false); + const [soundfontCacheSummary, setSoundfontCacheSummary] = useState({ instrumentCount: 0, instruments: [] }); + const [isCheckingSoundfontCache, setIsCheckingSoundfontCache] = useState(false); + const [isDeletingSoundfontCache, setIsDeletingSoundfontCache] = useState(false); + const [selectedCachedSoundfontInstrument, setSelectedCachedSoundfontInstrument] = useState(''); + const [isDeletingCachedSoundfontInstrument, setIsDeletingCachedSoundfontInstrument] = useState(false); const [localContextLength, setLocalContextLength] = useState(LOCAL_LLM_DEFAULT_CONTEXT_LENGTH); const [localModelState, setLocalModelState] = useState(LocalLLMModelManager.getState()); const [localModelUrl, setLocalModelUrl] = useState(''); @@ -103,6 +109,35 @@ const GeneralSettings: React.FC = () => { } }, []); + const refreshSoundfontCacheState = useCallback(async () => { + setIsCheckingSoundfontCache(true); + try { + const currentBaseUrl = ((configManager.get('general.soundfont.base_url') as string) || '').trim(); + if (!currentBaseUrl) { + setSoundfontCacheSummary({ instrumentCount: 0, instruments: [] }); + return; + } + + const summary = await SoundfontInstrumentCache.getCacheSummary(currentBaseUrl); + setSoundfontCacheSummary(summary); + setSelectedCachedSoundfontInstrument((currentSelection) => { + if (summary.instruments.length === 0) { + return ''; + } + if (currentSelection && summary.instruments.includes(currentSelection)) { + return currentSelection; + } + return summary.instruments[0]; + }); + } catch (error) { + console.error('Failed to check soundfont cache state:', error); + setSoundfontCacheSummary({ instrumentCount: 0, instruments: [] }); + setSelectedCachedSoundfontInstrument(''); + } finally { + setIsCheckingSoundfontCache(false); + } + }, [configManager]); + // Load configuration values on component mount useEffect(() => { const loadConfig = async () => { @@ -150,8 +185,9 @@ const GeneralSettings: React.FC = () => { loadConfig(); const unsubscribe = LocalLLMModelManager.subscribe(setLocalModelState); void refreshUvr5ModelCacheState(); + void refreshSoundfontCacheState(); return unsubscribe; - }, [configManager, refreshUvr5ModelCacheState]); + }, [configManager, refreshSoundfontCacheState, refreshUvr5ModelCacheState]); // Debounced save function for text inputs const debouncedSave = useCallback((key: string, value: string) => { @@ -359,6 +395,35 @@ const GeneralSettings: React.FC = () => { } }; + const handleDeleteSoundfontCache = async () => { + setIsDeletingSoundfontCache(true); + try { + await SoundfontInstrumentCache.deleteAll(); + setSoundfontCacheSummary({ instrumentCount: 0, instruments: [] }); + } catch (error) { + console.error('Failed to delete soundfont cache:', error); + } finally { + setIsDeletingSoundfontCache(false); + await refreshSoundfontCacheState(); + } + }; + + const handleDeleteCachedSoundfontInstrument = async () => { + if (!selectedCachedSoundfontInstrument) { + return; + } + + setIsDeletingCachedSoundfontInstrument(true); + try { + await SoundfontInstrumentCache.deleteInstrument(selectedCachedSoundfontInstrument); + } catch (error) { + console.error(`Failed to delete soundfont cache for ${selectedCachedSoundfontInstrument}:`, error); + } finally { + setIsDeletingCachedSoundfontInstrument(false); + await refreshSoundfontCacheState(); + } + }; + const handleLocalContextLengthChange = async (value: string) => { const parsed = Number(value); const normalized = normalizeLocalLLMContextLength(parsed); @@ -998,6 +1063,75 @@ const GeneralSettings: React.FC = () => {
+ +
+ +
+ {isCheckingSoundfontCache + ? t('settings.general.soundfont.cacheChecking') + : soundfontCacheSummary.instrumentCount > 0 + ? t('settings.general.soundfont.cacheReady', { count: soundfontCacheSummary.instrumentCount }) + : t('settings.general.soundfont.cacheEmpty')} +
+
+ {t('settings.general.soundfont.cacheHelp')} +
+
+ +
+ + +
+ {t('settings.general.soundfont.cachedInstrumentHelp')} +
+
+ +
+
+ + + +
+
diff --git a/src/core/audio-interface/KGToneBuffersPool.test.ts b/src/core/audio-interface/KGToneBuffersPool.test.ts new file mode 100644 index 0000000..1f2dafa --- /dev/null +++ b/src/core/audio-interface/KGToneBuffersPool.test.ts @@ -0,0 +1,188 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { + toneAudioBuffersCtorMock, + existsMock, + getInstrumentObjectUrlsMock, + storeInstrumentMock, + deleteInstrumentMock, + configGetMock, +} = vi.hoisted(() => ({ + toneAudioBuffersCtorMock: vi.fn(), + existsMock: vi.fn(), + getInstrumentObjectUrlsMock: vi.fn(), + storeInstrumentMock: vi.fn(), + deleteInstrumentMock: vi.fn(), + configGetMock: vi.fn(), +})); + +vi.mock('../../constants/generalMidiConstants', () => ({ + FLUIDR3_INSTRUMENT_MAP: { + test_instrument: { + displayName: 'Test Instrument', + midiInstrument: 1, + image: 'test.png', + group: 'TEST', + pitchRange: [60, 61], + }, + }, +})); + +vi.mock('../config/ConfigManager', () => ({ + ConfigManager: { + instance: () => ({ + getIsInitialized: () => true, + initialize: vi.fn().mockResolvedValue(undefined), + get: configGetMock, + }), + }, +})); + +vi.mock('../../util/soundfontInstrumentCache', () => ({ + SoundfontInstrumentCache: { + exists: existsMock, + getInstrumentObjectUrls: getInstrumentObjectUrlsMock, + storeInstrument: storeInstrumentMock, + deleteInstrument: deleteInstrumentMock, + }, +})); + +vi.mock('tone', () => ({ + ToneAudioBuffers: toneAudioBuffersCtorMock, +})); + +import { KGToneBuffersPool } from './KGToneBuffersPool'; + +describe('KGToneBuffersPool soundfont cache behavior', () => { + beforeEach(() => { + vi.clearAllMocks(); + configGetMock.mockReturnValue('https://cdn.example.com/FluidR3_GM/'); + existsMock.mockResolvedValue(false); + getInstrumentObjectUrlsMock.mockResolvedValue({ + C4: 'blob:cached-c4', + Db4: 'blob:cached-db4', + }); + storeInstrumentMock.mockResolvedValue(undefined); + deleteInstrumentMock.mockResolvedValue(undefined); + + toneAudioBuffersCtorMock.mockImplementation((options: { + urls: Record; + onload: () => void; + onerror?: (error: Error) => void; + }) => { + const buffers = { + loaded: true, + has: (key: string) => key in options.urls, + get: (key: string) => ({ key, duration: 1, loaded: true }), + dispose: vi.fn(), + }; + queueMicrotask(() => options.onload()); + return buffers; + }); + + vi.stubGlobal('fetch', vi.fn(async (url: string) => { + if (url.includes('Db4') && url.includes('fail-db4')) { + return new Response(null, { status: 500 }); + } + return new Response(new Uint8Array([1, 2, 3]), { status: 200 }); + })); + vi.stubGlobal('URL', { + createObjectURL: vi.fn((blob: Blob) => `blob:${blob.size}:${Math.random()}`), + revokeObjectURL: vi.fn(), + }); + + KGToneBuffersPool.instance().dispose(); + (KGToneBuffersPool as unknown as { _instance: KGToneBuffersPool | null })._instance = null; + }); + + it('loads from OPFS cache without refetching remote URLs', async () => { + existsMock.mockResolvedValue(true); + + const pool = KGToneBuffersPool.instance(); + const buffers = await pool.getToneAudioBuffers('test_instrument'); + + expect(buffers.loaded).toBe(true); + expect(getInstrumentObjectUrlsMock).toHaveBeenCalledOnce(); + expect(fetch).not.toHaveBeenCalled(); + }); + + it('stores a complete remote instrument and reuses the in-memory cache', async () => { + const pool = KGToneBuffersPool.instance(); + + await pool.getToneAudioBuffers('test_instrument'); + await pool.getToneAudioBuffers('test_instrument'); + + expect(fetch).toHaveBeenCalledTimes(2); + expect(storeInstrumentMock).toHaveBeenCalledOnce(); + expect(toneAudioBuffersCtorMock).toHaveBeenCalledOnce(); + }); + + it('does not persist or memoize a partial remote load', async () => { + configGetMock.mockReturnValue('https://fail-db4.example.com/FluidR3_GM/'); + vi.stubGlobal('fetch', vi.fn(async (url: string) => { + if (url.includes('Db4')) { + return new Response(null, { status: 500 }); + } + return new Response(new Uint8Array([1, 2, 3]), { status: 200 }); + })); + + const pool = KGToneBuffersPool.instance(); + const first = await pool.getToneAudioBuffers('test_instrument'); + const second = await pool.getToneAudioBuffers('test_instrument'); + + expect(first.loaded).toBe(true); + expect(second.loaded).toBe(true); + expect(fetch).toHaveBeenCalledTimes(4); + expect(storeInstrumentMock).not.toHaveBeenCalled(); + expect(deleteInstrumentMock).toHaveBeenCalled(); + }); + + it('retries remote loading after a previous partial success', async () => { + let requestCount = 0; + vi.stubGlobal('fetch', vi.fn(async (url: string) => { + requestCount += 1; + if (requestCount <= 2 && url.includes('Db4')) { + return new Response(null, { status: 500 }); + } + return new Response(new Uint8Array([1, 2, 3]), { status: 200 }); + })); + + const pool = KGToneBuffersPool.instance(); + await pool.getToneAudioBuffers('test_instrument'); + await pool.getToneAudioBuffers('test_instrument'); + + expect(fetch).toHaveBeenCalledTimes(4); + expect(storeInstrumentMock).toHaveBeenCalledOnce(); + }); + + it('deduplicates concurrent loads for the same instrument', async () => { + let onloadCount = 0; + toneAudioBuffersCtorMock.mockImplementation((options: { + urls: Record; + onload: () => void; + }) => { + const buffers = { + loaded: true, + has: (key: string) => key in options.urls, + get: (key: string) => ({ key, duration: 1, loaded: true }), + dispose: vi.fn(), + }; + setTimeout(() => { + onloadCount += 1; + options.onload(); + }, 0); + return buffers; + }); + + const pool = KGToneBuffersPool.instance(); + const [first, second] = await Promise.all([ + pool.getToneAudioBuffers('test_instrument'), + pool.getToneAudioBuffers('test_instrument'), + ]); + + expect(first).toBe(second); + expect(fetch).toHaveBeenCalledTimes(2); + expect(toneAudioBuffersCtorMock).toHaveBeenCalledOnce(); + expect(onloadCount).toBe(1); + }); +}); diff --git a/src/core/audio-interface/KGToneBuffersPool.ts b/src/core/audio-interface/KGToneBuffersPool.ts index 4223fbd..46e87ae 100644 --- a/src/core/audio-interface/KGToneBuffersPool.ts +++ b/src/core/audio-interface/KGToneBuffersPool.ts @@ -1,8 +1,14 @@ import { SAMPLER_CONSTANTS } from '../../constants/coreConstants'; import { FLUIDR3_INSTRUMENT_MAP } from '../../constants/generalMidiConstants'; import { ConfigManager } from '../config/ConfigManager'; +import { SoundfontInstrumentCache } from '../../util/soundfontInstrumentCache'; import * as Tone from 'tone'; +interface ToneBufferLoadResult { + buffers: Tone.ToneAudioBuffers; + cacheInMemory: boolean; +} + /** * KGToneBuffersPool - Singleton class for managing ToneAudioBuffers * Handles loading and caching of soundfont audio buffers for instruments @@ -20,6 +26,8 @@ export class KGToneBuffersPool { // Simple event listeners for load start/end without coupling to UI layer private loadingListeners: Array<(_evt: { type: 'start' | 'end'; instrument: string }) => void> = []; + private activeBaseUrl: string | null = null; + // Private constructor to prevent direct instantiation private constructor() { console.log("KGToneBuffersPool initialized"); @@ -67,6 +75,9 @@ export class KGToneBuffersPool { * Handles race conditions by ensuring only one loading operation per instrument */ public async getToneAudioBuffers(name: string): Promise { + const baseUrl = await this.getSoundfontBaseUrl(); + this.ensureMemoryCacheMatchesBaseUrl(baseUrl); + // Check if already fully loaded and cached const cachedBuffers = this.bufferMap.get(name); if (cachedBuffers && cachedBuffers.loaded) { @@ -82,18 +93,22 @@ export class KGToneBuffersPool { // Start new loading operation console.log(`KGToneBuffersPool: Starting new loading operation for ${name}`); - const loadingPromise = this.createToneAudioBuffers(name); - this.loadingPromises.set(name, loadingPromise); + const loadingPromise = this.createToneAudioBuffers(name, baseUrl); + this.loadingPromises.set(name, loadingPromise.then(result => result.buffers)); // Emit start AFTER registering the promise to avoid duplicate start events in races this.emitLoadingEvent({ type: 'start', instrument: name }); console.log(`[KGToneBuffersPool] start: Active load count: ${this.getActiveLoadCount()}`); try { - const buffers = await loadingPromise; + const result = await loadingPromise; + const buffers = result.buffers; - // Cache the fully loaded buffers - this.bufferMap.set(name, buffers); - console.log(`KGToneBuffersPool: Cached loaded buffers for ${name}`); + if (result.cacheInMemory) { + this.bufferMap.set(name, buffers); + console.log(`KGToneBuffersPool: Cached loaded buffers for ${name}`); + } else { + console.log(`KGToneBuffersPool: Skipping in-memory cache for ${name} due to partial soundfont load`); + } // Remove from loading promises since it's complete this.loadingPromises.delete(name); @@ -115,43 +130,63 @@ export class KGToneBuffersPool { /** * Create ToneAudioBuffers for an instrument */ - private async createToneAudioBuffers(name: string): Promise { - const configManager = ConfigManager.instance(); - if (!configManager.getIsInitialized()) { - await configManager.initialize(); + private async createToneAudioBuffers(name: string, baseUrl: string): Promise { + const instrumentName = name; + + if (!instrumentName) { + throw new Error(`Unknown instrument: ${name}`); } - return new Promise((resolve, reject) => { - try { - const instrumentName = name; + const keyNames = this.getInstrumentKeyNames(instrumentName); - if (!instrumentName) { - throw new Error(`Unknown instrument: ${name}`); + try { + if (await SoundfontInstrumentCache.exists(instrumentName, keyNames, baseUrl)) { + console.log(`Loading ToneAudioBuffers for ${name} from OPFS cache...`); + const cachedUrls = await SoundfontInstrumentCache.getInstrumentObjectUrls(instrumentName, keyNames, baseUrl); + try { + const buffers = await this.loadToneAudioBuffers(cachedUrls, name); + return { buffers, cacheInMemory: true }; + } catch (error) { + console.warn(`Cached soundfont load failed for ${name}, deleting cache and retrying remote download.`, error); + this.revokeObjectUrls(cachedUrls); + await SoundfontInstrumentCache.deleteInstrument(instrumentName); } - - const baseUrl = (ConfigManager.instance().get('general.soundfont.base_url') as string) - || SAMPLER_CONSTANTS.TONE_SAMPLERS.FLUID.url; - - const urls = this.generateKeyUrls(baseUrl, instrumentName); - - console.log(`Loading ToneAudioBuffers for ${name} (${instrumentName})...`); - - // Create ToneAudioBuffers with onload callback - const buffers = new Tone.ToneAudioBuffers( - urls, - () => { - console.log(`ToneAudioBuffers loaded successfully for ${name}`); - resolve(buffers); - } - ); - - // Don't cache until loading is complete - this will be handled in getToneAudioBuffers - - } catch (error) { - console.error(`Error creating ToneAudioBuffers for ${name}:`, error); - reject(error); } - }); + + console.log(`Loading ToneAudioBuffers for ${name} (${instrumentName}) from remote source...`); + const remoteUrls = this.generateKeyUrls(baseUrl, instrumentName); + const fetchResults = await this.fetchRemoteInstrumentBlobs(remoteUrls); + const successfulKeys = Object.keys(fetchResults.successfulBlobs); + + if (successfulKeys.length === 0) { + throw new Error(`Failed to load any soundfont samples for ${instrumentName}`); + } + + const loadUrls = Object.fromEntries( + successfulKeys.map(key => [key, URL.createObjectURL(fetchResults.successfulBlobs[key])]), + ) as Record; + + const buffers = await this.loadToneAudioBuffers(loadUrls, name); + + if (fetchResults.failures.length === 0) { + try { + await SoundfontInstrumentCache.storeInstrument(instrumentName, keyNames, fetchResults.successfulBlobs, baseUrl); + } catch (error) { + console.warn(`Failed to persist soundfont cache for ${instrumentName}:`, error); + } + } else { + console.warn(`Skipping cache finalize for ${instrumentName} because ${fetchResults.failures.length} pitch samples failed to load.`); + await SoundfontInstrumentCache.deleteInstrument(instrumentName); + } + + return { + buffers, + cacheInMemory: fetchResults.failures.length === 0, + }; + } catch (error) { + console.error(`Error creating ToneAudioBuffers for ${name}:`, error); + throw error; + } } /** @@ -161,21 +196,7 @@ export class KGToneBuffersPool { private generateKeyUrls(baseUrl: string, instrumentName: string): { [key: string]: string } { const urls: { [key: string]: string } = {}; - // get the range of the instrument. - // TODO: make the sound library name configurable. - const range = FLUIDR3_INSTRUMENT_MAP[instrumentName]?.pitchRange || [21, 108]; - - // Note names in order (using flats instead of sharps where applicable) - const noteNames = ['C', 'Db', 'D', 'Eb', 'E', 'F', 'Gb', 'G', 'Ab', 'A', 'Bb', 'B']; - - // Generate keys from A0 to C8 (MIDI notes 21 to 108) - for (let midiNote = range[0]; midiNote <= range[1]; midiNote++) { - const octave = Math.floor((midiNote - 12) / 12); - const noteIndex = (midiNote - 12) % 12; - const noteName = noteNames[noteIndex]; - const keyName = `${noteName}${octave}`; - - // Generate URL for this key + for (const keyName of this.getInstrumentKeyNames(instrumentName)) { urls[keyName] = `${baseUrl}${instrumentName}-mp3/${keyName}.mp3`; } @@ -184,6 +205,121 @@ export class KGToneBuffersPool { return urls; } + private getInstrumentKeyNames(instrumentName: string): string[] { + const range = FLUIDR3_INSTRUMENT_MAP[instrumentName]?.pitchRange || [21, 108]; + const noteNames = ['C', 'Db', 'D', 'Eb', 'E', 'F', 'Gb', 'G', 'Ab', 'A', 'Bb', 'B']; + const keys: string[] = []; + + for (let midiNote = range[0]; midiNote <= range[1]; midiNote++) { + const octave = Math.floor((midiNote - 12) / 12); + const noteIndex = (midiNote - 12) % 12; + const noteName = noteNames[noteIndex]; + keys.push(`${noteName}${octave}`); + } + + return keys; + } + + private async fetchRemoteInstrumentBlobs(urls: Record): Promise<{ + successfulBlobs: Record; + failures: string[]; + }> { + const entries = Object.entries(urls); + const successfulBlobs: Record = {}; + const failures: string[] = []; + + await Promise.all(entries.map(async ([key, url]) => { + try { + const response = await this.fetchWithTimeout(url, 10000); + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + + successfulBlobs[key] = await response.blob(); + } catch (error) { + console.warn(`Failed to fetch soundfont sample ${key}:`, error); + failures.push(key); + } + })); + + return { successfulBlobs, failures }; + } + + private async fetchWithTimeout(url: string, timeoutMs: number): Promise { + const controller = new AbortController(); + const timeout = window.setTimeout(() => controller.abort(), timeoutMs); + + try { + return await fetch(url, { signal: controller.signal }); + } finally { + clearTimeout(timeout); + } + } + + private loadToneAudioBuffers(urls: Record, name: string): Promise { + return new Promise((resolve, reject) => { + if (Object.keys(urls).length === 0) { + reject(new Error(`No audio sources were available for ${name}`)); + return; + } + + let settled = false; + const cleanup = () => this.revokeObjectUrls(urls); + + const buffers = new Tone.ToneAudioBuffers({ + urls, + onload: () => { + if (settled) return; + settled = true; + cleanup(); + console.log(`ToneAudioBuffers loaded successfully for ${name}`); + resolve(buffers); + }, + onerror: (error) => { + if (settled) return; + settled = true; + cleanup(); + reject(error); + }, + }); + }); + } + + private revokeObjectUrls(urls: Record): void { + Object.values(urls).forEach(url => { + if (url.startsWith('blob:')) { + URL.revokeObjectURL(url); + } + }); + } + + private async getSoundfontBaseUrl(): Promise { + const configManager = ConfigManager.instance(); + if (!configManager.getIsInitialized()) { + await configManager.initialize(); + } + + return (configManager.get('general.soundfont.base_url') as string) + || SAMPLER_CONSTANTS.TONE_SAMPLERS.FLUID.url; + } + + private ensureMemoryCacheMatchesBaseUrl(baseUrl: string): void { + if (this.activeBaseUrl === baseUrl) { + return; + } + + this.activeBaseUrl = baseUrl; + this.bufferMap.forEach((buffers, name) => { + try { + buffers.dispose(); + console.log(`Disposed ToneAudioBuffers for ${name} due to soundfont base URL change`); + } catch (error) { + console.error(`Error disposing ToneAudioBuffers for ${name} during soundfont base URL change:`, error); + } + }); + this.bufferMap.clear(); + } + /** * Clear all cached buffers and dispose of resources */ @@ -222,4 +358,4 @@ export class KGToneBuffersPool { await Promise.allSettled(loadPromises); console.log(`Preloading completed for ${instrumentNames.length} instruments`); } -} \ No newline at end of file +} diff --git a/src/i18n/messages/en_us.ts b/src/i18n/messages/en_us.ts index a44e376..ba27541 100644 --- a/src/i18n/messages/en_us.ts +++ b/src/i18n/messages/en_us.ts @@ -97,6 +97,16 @@ export const enUsMessages: TranslationMessages = { 'settings.general.soundfont.managed': 'Soundfont configuration is managed by the server (kgone-server.json). Settings are read-only.', 'settings.general.soundfont.baseUrl': 'Base URL', 'settings.general.soundfont.baseUrlHelp': 'Changing this URL to an incompatible soundfont source may cause some instruments to sound wrong or not play.', + 'settings.general.soundfont.cachedStatus': 'Cached Soundfont Status', + 'settings.general.soundfont.cacheChecking': 'Checking soundfont cache...', + 'settings.general.soundfont.cacheReady': '{count} instruments cached in browser storage.', + 'settings.general.soundfont.cacheEmpty': 'No cached instruments yet.', + 'settings.general.soundfont.cacheHelp': 'Cached instruments are stored in browser OPFS and reused for future instrument loads until you change the base URL or delete the cache.', + 'settings.general.soundfont.cachedInstrument': 'Cached Instrument', + 'settings.general.soundfont.cachedInstrumentHelp': 'Choose a cached instrument to delete only that instrument’s local soundfont files.', + 'settings.general.soundfont.noCachedInstrumentOption': 'No cached instruments', + 'settings.general.soundfont.deleteSelectedCache': 'Delete Selected Instrument Cache', + 'settings.general.soundfont.deleteCache': 'Delete Soundfont Cache', 'settings.general.kgone.section': 'K.G.One Settings', 'settings.general.kgone.managed': 'K.G.One configuration is managed by the server (kgone-server.json). Settings are read-only.', 'settings.general.kgone.enabled': 'Enable K.G.One Integration', diff --git a/src/i18n/messages/fr_fr.ts b/src/i18n/messages/fr_fr.ts index 60a4806..4bd5ec3 100644 --- a/src/i18n/messages/fr_fr.ts +++ b/src/i18n/messages/fr_fr.ts @@ -94,6 +94,16 @@ export const frFrMessages: TranslationMessages = { 'settings.general.soundfont.managed': 'La configuration des soundfonts est gérée par le serveur (kgone-server.json). Ces réglages sont en lecture seule.', 'settings.general.soundfont.baseUrl': 'URL de base', 'settings.general.soundfont.baseUrlHelp': 'Une source de soundfont incompatible peut provoquer des timbres incorrects ou empêcher certains instruments de jouer.', + 'settings.general.soundfont.cachedStatus': 'État du cache des soundfonts', + 'settings.general.soundfont.cacheChecking': 'Vérification du cache des soundfonts...', + 'settings.general.soundfont.cacheReady': '{count} instruments en cache dans le navigateur.', + 'settings.general.soundfont.cacheEmpty': 'Aucun instrument en cache pour le moment.', + 'settings.general.soundfont.cacheHelp': 'Les instruments en cache sont stockés dans l’OPFS du navigateur et réutilisés lors des prochains chargements jusqu’à un changement d’URL de base ou une suppression du cache.', + 'settings.general.soundfont.cachedInstrument': 'Instrument en cache', + 'settings.general.soundfont.cachedInstrumentHelp': 'Choisissez un instrument en cache pour supprimer uniquement ses fichiers soundfont locaux.', + 'settings.general.soundfont.noCachedInstrumentOption': 'Aucun instrument en cache', + 'settings.general.soundfont.deleteSelectedCache': 'Supprimer le cache de l’instrument sélectionné', + 'settings.general.soundfont.deleteCache': 'Supprimer le cache des soundfonts', 'settings.general.kgone.section': 'Réglages K.G.One', 'settings.general.kgone.managed': 'La configuration K.G.One est gérée par le serveur (kgone-server.json). Ces réglages sont en lecture seule.', 'settings.general.kgone.enabled': 'Activer l\'intégration K.G.One', diff --git a/src/i18n/messages/zh_cn.ts b/src/i18n/messages/zh_cn.ts index 78e4aaa..e02dbb6 100644 --- a/src/i18n/messages/zh_cn.ts +++ b/src/i18n/messages/zh_cn.ts @@ -95,6 +95,16 @@ export const zhCnMessages: TranslationMessages = { 'settings.general.soundfont.managed': 'Soundfont 配置由服务器(kgone-server.json)管理,当前设置为只读。', 'settings.general.soundfont.baseUrl': '基础 URL', 'settings.general.soundfont.baseUrlHelp': '如果改成不兼容的 soundfont 源,某些乐器可能发声错误或无法播放。', + 'settings.general.soundfont.cachedStatus': 'Soundfont 缓存状态', + 'settings.general.soundfont.cacheChecking': '正在检查 soundfont 缓存...', + 'settings.general.soundfont.cacheReady': '浏览器存储中已缓存 {count} 个乐器。', + 'settings.general.soundfont.cacheEmpty': '当前还没有已缓存的乐器。', + 'settings.general.soundfont.cacheHelp': '已缓存的乐器会保存在浏览器 OPFS 中,后续加载时会复用;更改基础 URL 或删除缓存后将重新下载。', + 'settings.general.soundfont.cachedInstrument': '已缓存乐器', + 'settings.general.soundfont.cachedInstrumentHelp': '选择一个已缓存乐器,只删除该乐器的本地 soundfont 文件。', + 'settings.general.soundfont.noCachedInstrumentOption': '没有已缓存的乐器', + 'settings.general.soundfont.deleteSelectedCache': '删除所选乐器缓存', + 'settings.general.soundfont.deleteCache': '删除 Soundfont 缓存', 'settings.general.kgone.section': 'K.G.One 设置', 'settings.general.kgone.managed': 'K.G.One 配置由服务器(kgone-server.json)管理,当前设置为只读。', 'settings.general.kgone.enabled': '启用 K.G.One 集成', diff --git a/src/i18n/messages/zh_hk.ts b/src/i18n/messages/zh_hk.ts index b58922d..b61c6d1 100644 --- a/src/i18n/messages/zh_hk.ts +++ b/src/i18n/messages/zh_hk.ts @@ -95,6 +95,16 @@ export const zhHkMessages: TranslationMessages = { 'settings.general.soundfont.managed': 'Soundfont 設定由伺服器(kgone-server.json)管理,目前設定為唯讀。', 'settings.general.soundfont.baseUrl': '基礎 URL', 'settings.general.soundfont.baseUrlHelp': '如果改成不兼容的 soundfont 源,某些樂器可能發聲錯誤或無法播放。', + 'settings.general.soundfont.cachedStatus': 'Soundfont 快取狀態', + 'settings.general.soundfont.cacheChecking': '正在檢查 soundfont 快取...', + 'settings.general.soundfont.cacheReady': '瀏覽器儲存空間中已快取 {count} 個樂器。', + 'settings.general.soundfont.cacheEmpty': '目前還沒有已快取的樂器。', + 'settings.general.soundfont.cacheHelp': '已快取的樂器會保存在瀏覽器 OPFS 中,後續載入時會重用;更改基礎 URL 或刪除快取後將重新下載。', + 'settings.general.soundfont.cachedInstrument': '已快取樂器', + 'settings.general.soundfont.cachedInstrumentHelp': '選擇一個已快取樂器,只刪除該樂器的本地 soundfont 檔案。', + 'settings.general.soundfont.noCachedInstrumentOption': '沒有已快取的樂器', + 'settings.general.soundfont.deleteSelectedCache': '刪除所選樂器快取', + 'settings.general.soundfont.deleteCache': '刪除 Soundfont 快取', 'settings.general.kgone.section': 'K.G.One 設定', 'settings.general.kgone.managed': 'K.G.One 設定由伺服器(kgone-server.json)管理,目前設定為唯讀。', 'settings.general.kgone.enabled': '啟用 K.G.One 集成', diff --git a/src/util/soundfontInstrumentCache.test.ts b/src/util/soundfontInstrumentCache.test.ts new file mode 100644 index 0000000..71fd0cf --- /dev/null +++ b/src/util/soundfontInstrumentCache.test.ts @@ -0,0 +1,199 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { SoundfontInstrumentCache } from './soundfontInstrumentCache'; + +class MockWritableFileStream { + private readonly handle: MockFileSystemFileHandle; + private chunks: Uint8Array[] = []; + + constructor(handle: MockFileSystemFileHandle) { + this.handle = handle; + } + + async write(content: Blob | ArrayBuffer | ArrayBufferView | string): Promise { + let bytes: Uint8Array; + if (typeof content === 'string') { + bytes = new TextEncoder().encode(content); + } else if (typeof (content as Blob).arrayBuffer === 'function') { + bytes = new Uint8Array(await (content as Blob).arrayBuffer()); + } else if (content instanceof ArrayBuffer) { + bytes = new Uint8Array(content); + } else { + const view = content as ArrayBufferView; + bytes = new Uint8Array(view.buffer, view.byteOffset, view.byteLength); + } + this.chunks.push(new Uint8Array(bytes)); + } + + async close(): Promise { + const total = this.chunks.reduce((sum, chunk) => sum + chunk.byteLength, 0); + const merged = new Uint8Array(total); + let offset = 0; + for (const chunk of this.chunks) { + merged.set(chunk, offset); + offset += chunk.byteLength; + } + this.handle.setContent(merged); + } + + async abort(): Promise { + this.chunks = []; + } +} + +class MockFileSystemFileHandle { + kind = 'file' as const; + private content = new Uint8Array(); + + constructor(public readonly name: string) {} + + setContent(content: Uint8Array): void { + this.content = content; + } + + async getFile(): Promise { + return { + size: this.content.byteLength, + text: async () => new TextDecoder().decode(this.content), + } as unknown as File; + } + + async createWritable(): Promise { + return new MockWritableFileStream(this); + } +} + +class MockFileSystemDirectoryHandle { + kind = 'directory' as const; + private children = new Map(); + + constructor(public readonly name: string) {} + + async getDirectoryHandle(name: string, options?: { create?: boolean }): Promise { + let child = this.children.get(name); + if (!child || child.kind !== 'directory') { + if (!options?.create) { + throw new DOMException(`Directory "${name}" not found`, 'NotFoundError'); + } + child = new MockFileSystemDirectoryHandle(name); + this.children.set(name, child); + } + return child as MockFileSystemDirectoryHandle; + } + + async getFileHandle(name: string, options?: { create?: boolean }): Promise { + let child = this.children.get(name); + if (!child || child.kind !== 'file') { + if (!options?.create) { + throw new DOMException(`File "${name}" not found`, 'NotFoundError'); + } + child = new MockFileSystemFileHandle(name); + this.children.set(name, child); + } + return child as MockFileSystemFileHandle; + } + + async removeEntry(name: string, options?: { recursive?: boolean }): Promise { + const child = this.children.get(name); + if (!child) { + throw new DOMException(`Entry "${name}" not found`, 'NotFoundError'); + } + if (child.kind === 'directory' && child.size() > 0 && !options?.recursive) { + throw new DOMException(`Directory "${name}" is not empty`, 'InvalidModificationError'); + } + this.children.delete(name); + } + + async *entries(): AsyncIterableIterator<[string, MockFileSystemDirectoryHandle | MockFileSystemFileHandle]> { + for (const entry of this.children.entries()) { + yield entry; + } + } + + clear(): void { + this.children.clear(); + } + + size(): number { + return this.children.size; + } +} + +const mockRoot = new MockFileSystemDirectoryHandle('root'); + +vi.stubGlobal('navigator', { + ...navigator, + storage: { + getDirectory: vi.fn(async () => mockRoot), + }, +}); + +describe('SoundfontInstrumentCache', () => { + const baseUrl = 'https://cdn.example.com/FluidR3_GM/'; + const instrumentName = 'test_instrument'; + const expectedKeys = ['C4', 'Db4']; + const blobsByKey = { + C4: new Blob([new Uint8Array([1, 2, 3])], { type: 'audio/mpeg' }), + Db4: new Blob([new Uint8Array([4, 5, 6])], { type: 'audio/mpeg' }), + }; + + beforeEach(async () => { + mockRoot.clear(); + vi.restoreAllMocks(); + }); + + it('stores and validates a finalized instrument cache', async () => { + await SoundfontInstrumentCache.storeInstrument(instrumentName, expectedKeys, blobsByKey, baseUrl); + + const summary = await SoundfontInstrumentCache.getCacheSummary(baseUrl); + expect(summary.instrumentCount).toBe(1); + expect(summary.instruments).toEqual([instrumentName]); + + const urls = await SoundfontInstrumentCache.getInstrumentObjectUrls(instrumentName, expectedKeys, baseUrl); + expect(Object.keys(urls)).toEqual(expectedKeys); + Object.values(urls).forEach(url => URL.revokeObjectURL(url)); + }); + + it('rejects incomplete finalize attempts', async () => { + await expect(SoundfontInstrumentCache.storeInstrument( + instrumentName, + expectedKeys, + { C4: blobsByKey.C4 }, + baseUrl, + )).rejects.toThrow(/incomplete key set/i); + + expect(await SoundfontInstrumentCache.exists(instrumentName, expectedKeys, baseUrl)).toBe(false); + }); + + it('invalidates cached instruments when the base URL changes', async () => { + await SoundfontInstrumentCache.storeInstrument(instrumentName, expectedKeys, blobsByKey, baseUrl); + expect((await SoundfontInstrumentCache.getCacheSummary(baseUrl)).instrumentCount).toBe(1); + + expect((await SoundfontInstrumentCache.getCacheSummary('https://other.example.com/FluidR3_GM/')).instrumentCount).toBe(0); + const summary = await SoundfontInstrumentCache.getCacheSummary('https://other.example.com/FluidR3_GM/'); + expect(summary.instrumentCount).toBe(0); + }); + + it('treats broken cached instruments as invalid and removes them', async () => { + await SoundfontInstrumentCache.storeInstrument(instrumentName, expectedKeys, blobsByKey, baseUrl); + + const root = await navigator.storage.getDirectory(); + const soundfontDir = await root.getDirectoryHandle('soundfont'); + const libraryDir = await soundfontDir.getDirectoryHandle('FluidR3_GM'); + const instrumentDir = await libraryDir.getDirectoryHandle(instrumentName); + await instrumentDir.removeEntry('Db4.mp3'); + + expect(await SoundfontInstrumentCache.exists(instrumentName, expectedKeys, baseUrl)).toBe(false); + const summary = await SoundfontInstrumentCache.getCacheSummary(baseUrl); + expect(summary.instrumentCount).toBe(0); + }); + + it('deletes all cached instruments', async () => { + await SoundfontInstrumentCache.storeInstrument(instrumentName, expectedKeys, blobsByKey, baseUrl); + await SoundfontInstrumentCache.storeInstrument('other_instrument', expectedKeys, blobsByKey, baseUrl); + + await SoundfontInstrumentCache.deleteAll(); + + const summary = await SoundfontInstrumentCache.getCacheSummary(baseUrl); + expect(summary.instrumentCount).toBe(0); + }); +}); diff --git a/src/util/soundfontInstrumentCache.ts b/src/util/soundfontInstrumentCache.ts new file mode 100644 index 0000000..20e190e --- /dev/null +++ b/src/util/soundfontInstrumentCache.ts @@ -0,0 +1,226 @@ +interface SoundfontCacheRootMetadata { + baseUrl: string; + updatedAt: string; +} + +interface SoundfontInstrumentMetadata { + complete: boolean; + keys: string[]; + updatedAt: string; +} + +export interface SoundfontCacheSummary { + instrumentCount: number; + instruments: string[]; +} + +const SOUND_FONT_ROOT_DIR = 'soundfont'; +const FLUIDR3_DIR = 'FluidR3_GM'; +const ROOT_METADATA_FILE = 'cache-metadata.json'; +const INSTRUMENT_METADATA_FILE = 'instrument-metadata.json'; + +export class SoundfontInstrumentCache { + public static async exists(instrumentName: string, expectedKeys: string[], baseUrl: string): Promise { + try { + const rootDir = await this.ensureLibraryDir(baseUrl); + const instrumentDir = await rootDir.getDirectoryHandle(instrumentName); + const metadata = await this.readJson(instrumentDir, INSTRUMENT_METADATA_FILE); + if (!metadata?.complete || !this.sameKeys(metadata.keys, expectedKeys)) { + await this.deleteInstrument(instrumentName); + return false; + } + + for (const key of expectedKeys) { + const handle = await instrumentDir.getFileHandle(`${key}.mp3`); + const file = await handle.getFile(); + if (file.size <= 0) { + await this.deleteInstrument(instrumentName); + return false; + } + } + + return true; + } catch { + return false; + } + } + + public static async getInstrumentObjectUrls( + instrumentName: string, + expectedKeys: string[], + baseUrl: string, + ): Promise> { + const rootDir = await this.ensureLibraryDir(baseUrl); + const instrumentDir = await rootDir.getDirectoryHandle(instrumentName); + const urls: Record = {}; + + for (const key of expectedKeys) { + const fileHandle = await instrumentDir.getFileHandle(`${key}.mp3`); + const file = await fileHandle.getFile(); + urls[key] = URL.createObjectURL(file); + } + + return urls; + } + + public static async storeInstrument( + instrumentName: string, + expectedKeys: string[], + blobsByKey: Record, + baseUrl: string, + ): Promise { + if (!this.sameKeys(Object.keys(blobsByKey), expectedKeys)) { + throw new Error(`Cannot finalize soundfont cache for ${instrumentName}: incomplete key set.`); + } + + const rootDir = await this.ensureLibraryDir(baseUrl); + await this.removeIfExists(rootDir, instrumentName, true); + const instrumentDir = await rootDir.getDirectoryHandle(instrumentName, { create: true }); + + try { + for (const key of expectedKeys) { + const fileHandle = await instrumentDir.getFileHandle(`${key}.mp3`, { create: true }); + const writable = await fileHandle.createWritable(); + try { + await writable.write(blobsByKey[key]); + await writable.close(); + } catch (error) { + await writable.abort(); + throw error; + } + } + + const metadata: SoundfontInstrumentMetadata = { + complete: true, + keys: [...expectedKeys], + updatedAt: new Date().toISOString(), + }; + await this.writeJson(instrumentDir, INSTRUMENT_METADATA_FILE, metadata); + } catch (error) { + await this.removeIfExists(rootDir, instrumentName, true); + throw error; + } + } + + public static async deleteInstrument(instrumentName: string): Promise { + try { + const rootDir = await this.getLibraryDir(false); + if (!rootDir) return; + await this.removeIfExists(rootDir, instrumentName, true); + } catch { + // Ignore missing cache roots during cleanup. + } + } + + public static async deleteAll(): Promise { + try { + const rootDir = await this.getLibraryDir(false); + if (!rootDir) return; + + for await (const [name] of rootDir.entries()) { + await this.removeIfExists(rootDir, name, true); + } + } catch { + // Ignore cleanup failures for missing cache roots. + } + } + + public static async getCacheSummary(baseUrl: string): Promise { + const rootDir = await this.ensureLibraryDir(baseUrl); + const instruments: string[] = []; + + for await (const [name, entry] of rootDir.entries()) { + if (entry.kind !== 'directory') continue; + + try { + const metadata = await this.readJson(entry, INSTRUMENT_METADATA_FILE); + if (metadata?.complete) { + instruments.push(name); + } + } catch { + // Ignore broken entries in summary output. + } + } + + instruments.sort(); + return { + instrumentCount: instruments.length, + instruments, + }; + } + + private static async ensureLibraryDir(baseUrl: string): Promise { + const root = await navigator.storage.getDirectory(); + const soundfontDir = await root.getDirectoryHandle(SOUND_FONT_ROOT_DIR, { create: true }); + const libraryDir = await soundfontDir.getDirectoryHandle(FLUIDR3_DIR, { create: true }); + const metadata = await this.readRootMetadata(libraryDir); + + if (!metadata || metadata.baseUrl !== baseUrl) { + for await (const [name] of libraryDir.entries()) { + await this.removeIfExists(libraryDir, name, true); + } + + const nextMetadata: SoundfontCacheRootMetadata = { + baseUrl, + updatedAt: new Date().toISOString(), + }; + await this.writeJson(libraryDir, ROOT_METADATA_FILE, nextMetadata); + } + + return libraryDir; + } + + private static async getLibraryDir(create: boolean): Promise { + try { + const root = await navigator.storage.getDirectory(); + const soundfontDir = await root.getDirectoryHandle(SOUND_FONT_ROOT_DIR, { create }); + return await soundfontDir.getDirectoryHandle(FLUIDR3_DIR, { create }); + } catch { + return null; + } + } + + private static async readRootMetadata(dir: FileSystemDirectoryHandle): Promise { + try { + return await this.readJson(dir, ROOT_METADATA_FILE); + } catch { + return null; + } + } + + private static async readJson(dir: FileSystemDirectoryHandle, filename: string): Promise { + const handle = await dir.getFileHandle(filename); + const file = await handle.getFile(); + return JSON.parse(await file.text()) as T; + } + + private static async writeJson(dir: FileSystemDirectoryHandle, filename: string, value: unknown): Promise { + const handle = await dir.getFileHandle(filename, { create: true }); + const writable = await handle.createWritable(); + try { + await writable.write(JSON.stringify(value, null, 2)); + await writable.close(); + } catch (error) { + await writable.abort(); + throw error; + } + } + + private static sameKeys(actual: string[], expected: string[]): boolean { + if (actual.length !== expected.length) return false; + const expectedSet = new Set(expected); + return actual.every(key => expectedSet.has(key)); + } + + private static async removeIfExists( + dir: FileSystemDirectoryHandle, + name: string, + recursive: boolean = false, + ): Promise { + try { + await dir.removeEntry(name, recursive ? ({ recursive: true } as FileSystemRemoveOptions) : undefined); + } catch { + // Ignore missing entry cleanup. + } + } +} From 09dd717262a6c706ed2482c2405dd5535b99092e Mon Sep 17 00:00:00 2001 From: Xiaohan-Tian <157918347+Xiaohan-Tian@users.noreply.github.com> Date: Thu, 2 Jul 2026 14:18:33 -0700 Subject: [PATCH 3/3] fix: close Settings after successful saved project load --- src/components/Toolbar.test.tsx | 87 ++++++++++++++++++++++++++++++++- src/components/Toolbar.tsx | 17 +++++-- 2 files changed, 100 insertions(+), 4 deletions(-) diff --git a/src/components/Toolbar.test.tsx b/src/components/Toolbar.test.tsx index 930d22d..d56a72b 100644 --- a/src/components/Toolbar.test.tsx +++ b/src/components/Toolbar.test.tsx @@ -4,6 +4,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import Toolbar from './Toolbar'; import { createDefaultGlobalTracks } from '../core/global-track'; import { KGKeySignatureRegion } from '../core/region/KGKeySignatureRegion'; +import { KGProject } from '../core/KGProject'; +import { KGProjectStorage } from '../core/io/KGProjectStorage'; +import { showAlert, showConfirm } from '../util/dialogUtil'; const executeCommandMock = vi.fn(); const storeState = { @@ -41,6 +44,7 @@ const storeState = { toggleKGOnePanel: vi.fn(), toggleEventListPanel: vi.fn(), activateSidePanel: vi.fn(), + setShowSettings: vi.fn(), showKGOnePanel: true, showEventListPanel: false, showChatBox: true, @@ -61,6 +65,7 @@ const storeState = { refreshProjectState: vi.fn(), requestMainContentScroll: vi.fn(), requestPianoRollScroll: vi.fn(), + loadProject: vi.fn(), tracks: [] as unknown[], globalTracks: createDefaultGlobalTracks(), }; @@ -141,7 +146,24 @@ vi.mock('../util/midiUtil', () => ({ vi.mock('../core/audio-interface/KGOfflineRenderer', () => ({ KGOfflineRenderer: { instance: vi.fn(() => ({})) } })); vi.mock('./common/FileImportModal', () => ({ default: () => null })); vi.mock('./common/LoadingOverlay', () => ({ default: () => null })); -vi.mock('./common/OpenProjectModal', () => ({ default: () => null })); +vi.mock('./common/OpenProjectModal', () => ({ + default: ({ + onConfirmOpenProject, + onOpenProject, + }: { + onConfirmOpenProject: (projectName: string) => Promise; + onOpenProject: (projectName: string) => Promise; + }) => ( +
+ + +
+ ), +})); vi.mock('../util/chatUtil', () => ({ clearChatHistoryAndUI: vi.fn() })); vi.mock('./common/icons/PianoIcon', () => ({ default: () => piano })); vi.mock('./common/icons/MetronomeIcon', () => ({ default: () => metro })); @@ -205,7 +227,11 @@ describe('Toolbar settings side-panel behavior', () => { storeState.toggleKGOnePanel.mockClear(); storeState.toggleEventListPanel.mockClear(); storeState.activateSidePanel.mockClear(); + storeState.setShowSettings.mockClear(); storeState.setStatus.mockClear(); + storeState.cleanupProjectState.mockClear(); + storeState.loadProject.mockReset(); + storeState.loadProject.mockResolvedValue(undefined); storeState.showSettings = true; storeState.showChatBox = true; storeState.showKGOnePanel = true; @@ -216,6 +242,9 @@ describe('Toolbar settings side-panel behavior', () => { storeState.setKeySignature.mockClear(); storeState.refreshProjectState.mockClear(); executeCommandMock.mockClear(); + vi.mocked(KGProjectStorage.getInstance).mockReset(); + vi.mocked(showConfirm).mockReset(); + vi.mocked(showAlert).mockReset(); }); it('suppresses active styling for side-panel buttons while Settings is visible', () => { @@ -301,4 +330,60 @@ describe('Toolbar settings side-panel behavior', () => { expect(storeState.refreshProjectState).toHaveBeenCalled(); expect(storeState.setStatus).toHaveBeenCalledWith('Key signature changed to E minor'); }); + + it('closes Settings after a saved project finishes loading successfully', async () => { + const storageMock = { + load: vi.fn().mockResolvedValue(new KGProject()), + cleanupOrphanMedia: vi.fn().mockResolvedValue(undefined), + }; + vi.mocked(KGProjectStorage.getInstance).mockReturnValue(storageMock as unknown as ReturnType); + + render(); + + fireEvent.click(screen.getByTitle('Load')); + fireEvent.click(screen.getByRole('button', { name: 'open-loaded-project' })); + + await waitFor(() => { + expect(storageMock.load).toHaveBeenCalledWith('Loaded Project'); + }); + await waitFor(() => { + expect(storeState.loadProject).toHaveBeenCalled(); + }); + await waitFor(() => { + expect(storeState.setShowSettings).toHaveBeenCalledWith(false); + }); + }); + + it('keeps Settings open when the user cancels project loading', async () => { + vi.mocked(showConfirm).mockResolvedValue(false); + + render(); + + fireEvent.click(screen.getByTitle('Load')); + fireEvent.click(screen.getByRole('button', { name: 'confirm-open-project' })); + + await waitFor(() => { + expect(showConfirm).toHaveBeenCalled(); + }); + expect(storeState.setShowSettings).not.toHaveBeenCalled(); + }); + + it('keeps Settings open when project loading fails', async () => { + const storageMock = { + load: vi.fn().mockResolvedValue(new KGProject()), + cleanupOrphanMedia: vi.fn().mockResolvedValue(undefined), + }; + vi.mocked(KGProjectStorage.getInstance).mockReturnValue(storageMock as unknown as ReturnType); + storeState.loadProject.mockRejectedValueOnce(new Error('load failed')); + + render(); + + fireEvent.click(screen.getByTitle('Load')); + fireEvent.click(screen.getByRole('button', { name: 'open-loaded-project' })); + + await waitFor(() => { + expect(showAlert).toHaveBeenCalled(); + }); + expect(storeState.setShowSettings).not.toHaveBeenCalled(); + }); }); diff --git a/src/components/Toolbar.tsx b/src/components/Toolbar.tsx index d4d3710..69a057f 100644 --- a/src/components/Toolbar.tsx +++ b/src/components/Toolbar.tsx @@ -55,7 +55,7 @@ const Toolbar: React.FC = () => { isLooping, toggleLoop, globalTracks, canUndo, canRedo, undoDescription, redoDescription, undo, redo, - toggleChatBox, toggleSettings, toggleKGOnePanel, toggleEventListPanel, activateSidePanel, showKGOnePanel, showEventListPanel, showChatBox, showSettings, cleanupProjectState, toggleMetronome, isMetronomeEnabled, + toggleChatBox, toggleSettings, toggleKGOnePanel, toggleEventListPanel, activateSidePanel, showKGOnePanel, showEventListPanel, showChatBox, showSettings, setShowSettings, cleanupProjectState, toggleMetronome, isMetronomeEnabled, isRecording, startRecording, stopRecording, // Piano roll state/actions showPianoRoll, setShowPianoRoll, activeRegionId, setActiveRegionId, @@ -220,7 +220,7 @@ const Toolbar: React.FC = () => { }; // Common project loading logic extracted for reuse - const loadProjectFromData = async (project: KGProject, sourceDescription: string, savedName?: string) => { + const loadProjectFromData = async (project: KGProject, sourceDescription: string, savedName?: string): Promise => { try { // Clean up UI state first cleanupProjectState(); @@ -245,10 +245,13 @@ const Toolbar: React.FC = () => { console.log(`project loaded successfully from ${sourceDescription}`); } + return true; + } catch (error) { console.error(`Error loading project from ${sourceDescription}:`, error); setStatus(t('toolbar.status.loadFailed', { error: String(error) })); await showAlert(t('toolbar.load.error', { error: String(error) })); + return false; } }; @@ -299,7 +302,15 @@ const Toolbar: React.FC = () => { return; } - await loadProjectFromData(loadedProject, `Project "${projectNameToLoad}"`, projectNameToLoad); + const didLoadProject = await loadProjectFromData( + loadedProject, + `Project "${projectNameToLoad}"`, + projectNameToLoad + ); + + if (didLoadProject && showSettings) { + setShowSettings(false); + } } catch (error) { console.error("Error loading project:", error); await showAlert(t('toolbar.load.error', { error: String(error) }));