Merge pull request #54 from KGAudioLab/feat/2026-06-30-misc
Feat/2026 06 30 misc
This commit is contained in:
@@ -4,6 +4,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|||||||
import Toolbar from './Toolbar';
|
import Toolbar from './Toolbar';
|
||||||
import { createDefaultGlobalTracks } from '../core/global-track';
|
import { createDefaultGlobalTracks } from '../core/global-track';
|
||||||
import { KGKeySignatureRegion } from '../core/region/KGKeySignatureRegion';
|
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 executeCommandMock = vi.fn();
|
||||||
const storeState = {
|
const storeState = {
|
||||||
@@ -41,6 +44,7 @@ const storeState = {
|
|||||||
toggleKGOnePanel: vi.fn(),
|
toggleKGOnePanel: vi.fn(),
|
||||||
toggleEventListPanel: vi.fn(),
|
toggleEventListPanel: vi.fn(),
|
||||||
activateSidePanel: vi.fn(),
|
activateSidePanel: vi.fn(),
|
||||||
|
setShowSettings: vi.fn(),
|
||||||
showKGOnePanel: true,
|
showKGOnePanel: true,
|
||||||
showEventListPanel: false,
|
showEventListPanel: false,
|
||||||
showChatBox: true,
|
showChatBox: true,
|
||||||
@@ -61,6 +65,7 @@ const storeState = {
|
|||||||
refreshProjectState: vi.fn(),
|
refreshProjectState: vi.fn(),
|
||||||
requestMainContentScroll: vi.fn(),
|
requestMainContentScroll: vi.fn(),
|
||||||
requestPianoRollScroll: vi.fn(),
|
requestPianoRollScroll: vi.fn(),
|
||||||
|
loadProject: vi.fn(),
|
||||||
tracks: [] as unknown[],
|
tracks: [] as unknown[],
|
||||||
globalTracks: createDefaultGlobalTracks(),
|
globalTracks: createDefaultGlobalTracks(),
|
||||||
};
|
};
|
||||||
@@ -141,7 +146,24 @@ vi.mock('../util/midiUtil', () => ({
|
|||||||
vi.mock('../core/audio-interface/KGOfflineRenderer', () => ({ KGOfflineRenderer: { instance: vi.fn(() => ({})) } }));
|
vi.mock('../core/audio-interface/KGOfflineRenderer', () => ({ KGOfflineRenderer: { instance: vi.fn(() => ({})) } }));
|
||||||
vi.mock('./common/FileImportModal', () => ({ default: () => null }));
|
vi.mock('./common/FileImportModal', () => ({ default: () => null }));
|
||||||
vi.mock('./common/LoadingOverlay', () => ({ 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<boolean>;
|
||||||
|
onOpenProject: (projectName: string) => Promise<void>;
|
||||||
|
}) => (
|
||||||
|
<div>
|
||||||
|
<button type="button" onClick={() => void onConfirmOpenProject('Loaded Project')}>
|
||||||
|
confirm-open-project
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => void onOpenProject('Loaded Project')}>
|
||||||
|
open-loaded-project
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}));
|
||||||
vi.mock('../util/chatUtil', () => ({ clearChatHistoryAndUI: vi.fn() }));
|
vi.mock('../util/chatUtil', () => ({ clearChatHistoryAndUI: vi.fn() }));
|
||||||
vi.mock('./common/icons/PianoIcon', () => ({ default: () => <span>piano</span> }));
|
vi.mock('./common/icons/PianoIcon', () => ({ default: () => <span>piano</span> }));
|
||||||
vi.mock('./common/icons/MetronomeIcon', () => ({ default: () => <span>metro</span> }));
|
vi.mock('./common/icons/MetronomeIcon', () => ({ default: () => <span>metro</span> }));
|
||||||
@@ -205,7 +227,11 @@ describe('Toolbar settings side-panel behavior', () => {
|
|||||||
storeState.toggleKGOnePanel.mockClear();
|
storeState.toggleKGOnePanel.mockClear();
|
||||||
storeState.toggleEventListPanel.mockClear();
|
storeState.toggleEventListPanel.mockClear();
|
||||||
storeState.activateSidePanel.mockClear();
|
storeState.activateSidePanel.mockClear();
|
||||||
|
storeState.setShowSettings.mockClear();
|
||||||
storeState.setStatus.mockClear();
|
storeState.setStatus.mockClear();
|
||||||
|
storeState.cleanupProjectState.mockClear();
|
||||||
|
storeState.loadProject.mockReset();
|
||||||
|
storeState.loadProject.mockResolvedValue(undefined);
|
||||||
storeState.showSettings = true;
|
storeState.showSettings = true;
|
||||||
storeState.showChatBox = true;
|
storeState.showChatBox = true;
|
||||||
storeState.showKGOnePanel = true;
|
storeState.showKGOnePanel = true;
|
||||||
@@ -216,6 +242,9 @@ describe('Toolbar settings side-panel behavior', () => {
|
|||||||
storeState.setKeySignature.mockClear();
|
storeState.setKeySignature.mockClear();
|
||||||
storeState.refreshProjectState.mockClear();
|
storeState.refreshProjectState.mockClear();
|
||||||
executeCommandMock.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', () => {
|
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.refreshProjectState).toHaveBeenCalled();
|
||||||
expect(storeState.setStatus).toHaveBeenCalledWith('Key signature changed to E minor');
|
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<typeof KGProjectStorage.getInstance>);
|
||||||
|
|
||||||
|
render(<Toolbar />);
|
||||||
|
|
||||||
|
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(<Toolbar />);
|
||||||
|
|
||||||
|
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<typeof KGProjectStorage.getInstance>);
|
||||||
|
storeState.loadProject.mockRejectedValueOnce(new Error('load failed'));
|
||||||
|
|
||||||
|
render(<Toolbar />);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTitle('Load'));
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'open-loaded-project' }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(showAlert).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
expect(storeState.setShowSettings).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ const Toolbar: React.FC = () => {
|
|||||||
isLooping, toggleLoop,
|
isLooping, toggleLoop,
|
||||||
globalTracks,
|
globalTracks,
|
||||||
canUndo, canRedo, undoDescription, redoDescription, undo, redo,
|
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,
|
isRecording, startRecording, stopRecording,
|
||||||
// Piano roll state/actions
|
// Piano roll state/actions
|
||||||
showPianoRoll, setShowPianoRoll, activeRegionId, setActiveRegionId,
|
showPianoRoll, setShowPianoRoll, activeRegionId, setActiveRegionId,
|
||||||
@@ -220,7 +220,7 @@ const Toolbar: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Common project loading logic extracted for reuse
|
// 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<boolean> => {
|
||||||
try {
|
try {
|
||||||
// Clean up UI state first
|
// Clean up UI state first
|
||||||
cleanupProjectState();
|
cleanupProjectState();
|
||||||
@@ -245,10 +245,13 @@ const Toolbar: React.FC = () => {
|
|||||||
console.log(`project loaded successfully from ${sourceDescription}`);
|
console.log(`project loaded successfully from ${sourceDescription}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Error loading project from ${sourceDescription}:`, error);
|
console.error(`Error loading project from ${sourceDescription}:`, error);
|
||||||
setStatus(t('toolbar.status.loadFailed', { error: String(error) }));
|
setStatus(t('toolbar.status.loadFailed', { error: String(error) }));
|
||||||
await showAlert(t('toolbar.load.error', { error: String(error) }));
|
await showAlert(t('toolbar.load.error', { error: String(error) }));
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -299,7 +302,15 @@ const Toolbar: React.FC = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await loadProjectFromData(loadedProject, `Project "${projectNameToLoad}"`, projectNameToLoad);
|
const didLoadProject = await loadProjectFromData(
|
||||||
|
loadedProject,
|
||||||
|
`Project "${projectNameToLoad}"`,
|
||||||
|
projectNameToLoad
|
||||||
|
);
|
||||||
|
|
||||||
|
if (didLoadProject && showSettings) {
|
||||||
|
setShowSettings(false);
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error loading project:", error);
|
console.error("Error loading project:", error);
|
||||||
await showAlert(t('toolbar.load.error', { error: String(error) }));
|
await showAlert(t('toolbar.load.error', { error: String(error) }));
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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<ColorPalettePopupProps> = ({
|
||||||
|
selectedColor,
|
||||||
|
onSelect,
|
||||||
|
className = '',
|
||||||
|
}) => (
|
||||||
|
<div className={`color-palette-popup ${className}`.trim()} role="menu" aria-label="Color palette">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`color-palette-none${selectedColor === undefined ? ' active' : ''}`}
|
||||||
|
onClick={() => onSelect(null)}
|
||||||
|
aria-label="Reset color"
|
||||||
|
title="Reset color"
|
||||||
|
>
|
||||||
|
<FaUndoAlt aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
<div className="color-palette-grid">
|
||||||
|
{LOGIC_REGION_COLOR_SWATCHES.flat().map((color) => (
|
||||||
|
<button
|
||||||
|
key={color}
|
||||||
|
type="button"
|
||||||
|
className={`color-palette-swatch${selectedColor === color ? ' active' : ''}`}
|
||||||
|
style={{ backgroundColor: color }}
|
||||||
|
onClick={() => onSelect(color)}
|
||||||
|
title={color}
|
||||||
|
aria-label={`Select color ${color}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default ColorPalettePopup;
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
export { default as KGDropdown } from './KGDropdown';
|
export { default as KGDropdown } from './KGDropdown';
|
||||||
|
export { default as ColorPalettePopup } from './ColorPalettePopup';
|
||||||
export { default as Playhead } from './Playhead';
|
export { default as Playhead } from './Playhead';
|
||||||
export { default as FileImportModal } from './FileImportModal';
|
export { default as FileImportModal } from './FileImportModal';
|
||||||
export { default as LoadingOverlay } from './LoadingOverlay';
|
export { default as LoadingOverlay } from './LoadingOverlay';
|
||||||
|
|||||||
@@ -16,6 +16,10 @@ export interface RegionUI {
|
|||||||
barNumber: number;
|
barNumber: number;
|
||||||
length: number;
|
length: number;
|
||||||
name: string;
|
name: string;
|
||||||
|
color?: string;
|
||||||
|
trackColor?: string;
|
||||||
|
effectiveColor?: string;
|
||||||
|
isAudioRegion?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RegionPreviewContentStyle {
|
export interface RegionPreviewContentStyle {
|
||||||
|
|||||||
@@ -247,6 +247,22 @@
|
|||||||
cursor: default;
|
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 {
|
.piano-roll-automation-toolbar-group {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -101,7 +101,7 @@ const PianoRoll: React.FC<PianoRollProps> = ({
|
|||||||
const isAudioWaveform = currentMode === 'audio-waveform';
|
const isAudioWaveform = currentMode === 'audio-waveform';
|
||||||
const isAudioOnly = isAudioWaveform || isSpectrogram;
|
const isAudioOnly = isAudioWaveform || isSpectrogram;
|
||||||
const isHybrid = currentMode === 'hybrid';
|
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();
|
const { t } = useI18n();
|
||||||
|
|
||||||
// Tool state for piano roll
|
// Tool state for piano roll
|
||||||
@@ -167,6 +167,14 @@ const PianoRoll: React.FC<PianoRollProps> = ({
|
|||||||
const activeInstrument = useMemo<InstrumentType>(() => (
|
const activeInstrument = useMemo<InstrumentType>(() => (
|
||||||
parentMidiTrack instanceof KGMidiTrack ? parentMidiTrack.getInstrument() : 'acoustic_grand_piano'
|
parentMidiTrack instanceof KGMidiTrack ? parentMidiTrack.getInstrument() : 'acoustic_grand_piano'
|
||||||
), [parentMidiTrack]);
|
), [parentMidiTrack]);
|
||||||
|
const activeEditableRegionId = audioRegion?.getId() ?? activeRegion?.getId() ?? null;
|
||||||
|
const selectedRegionColor = useMemo(() => {
|
||||||
|
if (audioRegion) {
|
||||||
|
return audioRegion.getColor();
|
||||||
|
}
|
||||||
|
|
||||||
|
return activeRegion?.getColor();
|
||||||
|
}, [activeRegion, audioRegion]);
|
||||||
const parsedSheetQuantization = useMemo(
|
const parsedSheetQuantization = useMemo(
|
||||||
() => parseSheetQuantization(sheetQuantization),
|
() => parseSheetQuantization(sheetQuantization),
|
||||||
[sheetQuantization]
|
[sheetQuantization]
|
||||||
@@ -492,6 +500,20 @@ const PianoRoll: React.FC<PianoRollProps> = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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 () => {
|
const handleDetectChords = useCallback(async () => {
|
||||||
if (!audioRegion && !activeRegion) {
|
if (!audioRegion && !activeRegion) {
|
||||||
await showAlert('Open a MIDI or audio region before detecting chords.');
|
await showAlert('Open a MIDI or audio region before detecting chords.');
|
||||||
@@ -1597,6 +1619,8 @@ const PianoRoll: React.FC<PianoRollProps> = ({
|
|||||||
detectingChords={isDetectingChords}
|
detectingChords={isDetectingChords}
|
||||||
onDetectTempo={audioRegion ? handleDetectTempo : undefined}
|
onDetectTempo={audioRegion ? handleDetectTempo : undefined}
|
||||||
detectingTempo={isDetectingTempo}
|
detectingTempo={isDetectingTempo}
|
||||||
|
selectedRegionColor={selectedRegionColor}
|
||||||
|
onRegionColorSelect={activeEditableRegionId ? (color) => { void handleRegionColorSelect(color); } : undefined}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<NoteAttributeBar selectedNotes={selectedNotes} isSpectrogram={isAudioOnly} activeRegion={activeRegion} />
|
<NoteAttributeBar selectedNotes={selectedNotes} isSpectrogram={isAudioOnly} activeRegion={activeRegion} />
|
||||||
|
|||||||
@@ -30,6 +30,16 @@ vi.mock('../common', () => ({
|
|||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
ColorPalettePopup: ({ onSelect }: { onSelect: (value: string | null) => void }) => (
|
||||||
|
<div>
|
||||||
|
<button type="button" aria-label="Reset color" onClick={() => onSelect(null)}>
|
||||||
|
Reset
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => onSelect('#3C8AC4')}>
|
||||||
|
Color Swatch
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../../core/KGCore', () => ({
|
vi.mock('../../core/KGCore', () => ({
|
||||||
@@ -431,4 +441,23 @@ describe('PianoRollToolbar', () => {
|
|||||||
|
|
||||||
expect(screen.getByRole('button', { name: '和声小调' })).toBeInTheDocument();
|
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(
|
||||||
|
<PianoRollToolbar
|
||||||
|
{...baseProps}
|
||||||
|
mode="midi-edit"
|
||||||
|
onRegionColorSelect={onRegionColorSelect}
|
||||||
|
selectedRegionColor="#3C8AC4"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { FaMousePointer, FaPencilAlt } from 'react-icons/fa';
|
import { FaMousePointer, FaPencilAlt } from 'react-icons/fa';
|
||||||
import { TbArrowBarToUp } from 'react-icons/tb';
|
import { TbArrowBarToUp } from 'react-icons/tb';
|
||||||
import { KGDropdown } from '../common';
|
import { ColorPalettePopup, KGDropdown } from '../common';
|
||||||
import { KGPianoRollState } from '../../core/state/KGPianoRollState';
|
import { KGPianoRollState } from '../../core/state/KGPianoRollState';
|
||||||
import { KGCore } from '../../core/KGCore';
|
import { KGCore } from '../../core/KGCore';
|
||||||
import {
|
import {
|
||||||
@@ -50,6 +50,8 @@ interface PianoRollToolbarProps {
|
|||||||
detectingChords?: boolean;
|
detectingChords?: boolean;
|
||||||
onDetectTempo?: () => void | Promise<void>;
|
onDetectTempo?: () => void | Promise<void>;
|
||||||
detectingTempo?: boolean;
|
detectingTempo?: boolean;
|
||||||
|
selectedRegionColor?: string;
|
||||||
|
onRegionColorSelect?: (color: string | null) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const PianoRollToolbar: React.FC<PianoRollToolbarProps> = ({
|
const PianoRollToolbar: React.FC<PianoRollToolbarProps> = ({
|
||||||
@@ -92,6 +94,8 @@ const PianoRollToolbar: React.FC<PianoRollToolbarProps> = ({
|
|||||||
detectingChords = false,
|
detectingChords = false,
|
||||||
onDetectTempo,
|
onDetectTempo,
|
||||||
detectingTempo = false,
|
detectingTempo = false,
|
||||||
|
selectedRegionColor,
|
||||||
|
onRegionColorSelect,
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const showMidiControls = mode !== 'spectrogram' && mode !== 'audio-waveform' && !sheetMusicViewEnabled;
|
const showMidiControls = mode !== 'spectrogram' && mode !== 'audio-waveform' && !sheetMusicViewEnabled;
|
||||||
@@ -151,6 +155,7 @@ const PianoRollToolbar: React.FC<PianoRollToolbarProps> = ({
|
|||||||
}, [showZoomSlider]);
|
}, [showZoomSlider]);
|
||||||
|
|
||||||
const [showMoreMenu, setShowMoreMenu] = React.useState(false);
|
const [showMoreMenu, setShowMoreMenu] = React.useState(false);
|
||||||
|
const [showRegionColorPalette, setShowRegionColorPalette] = React.useState(false);
|
||||||
const specMenuRef = React.useRef<HTMLDivElement>(null);
|
const specMenuRef = React.useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
@@ -158,6 +163,7 @@ const PianoRollToolbar: React.FC<PianoRollToolbarProps> = ({
|
|||||||
const handleClickOutside = (e: MouseEvent) => {
|
const handleClickOutside = (e: MouseEvent) => {
|
||||||
if (specMenuRef.current && !specMenuRef.current.contains(e.target as Node)) {
|
if (specMenuRef.current && !specMenuRef.current.contains(e.target as Node)) {
|
||||||
setShowMoreMenu(false);
|
setShowMoreMenu(false);
|
||||||
|
setShowRegionColorPalette(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
document.addEventListener('mousedown', handleClickOutside);
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
@@ -386,7 +392,29 @@ const PianoRollToolbar: React.FC<PianoRollToolbarProps> = ({
|
|||||||
...
|
...
|
||||||
</button>
|
</button>
|
||||||
{showMoreMenu && (
|
{showMoreMenu && (
|
||||||
<div className="quant-dropdown" style={{ right: 0, left: 'auto', width: 'auto', whiteSpace: 'nowrap' }}>
|
<div className="quant-dropdown piano-roll-more-menu" style={{ right: 0, left: 'auto', width: 'auto', whiteSpace: 'nowrap' }}>
|
||||||
|
{onRegionColorSelect && (
|
||||||
|
<div className="piano-roll-menu-item-wrapper">
|
||||||
|
<div
|
||||||
|
className="quant-option"
|
||||||
|
onClick={() => setShowRegionColorPalette(open => !open)}
|
||||||
|
>
|
||||||
|
{t('pianoRoll.regionColor')}
|
||||||
|
</div>
|
||||||
|
{showRegionColorPalette && (
|
||||||
|
<div className="piano-roll-region-color-popup">
|
||||||
|
<ColorPalettePopup
|
||||||
|
selectedColor={selectedRegionColor}
|
||||||
|
onSelect={(color) => {
|
||||||
|
onRegionColorSelect(color);
|
||||||
|
setShowRegionColorPalette(false);
|
||||||
|
setShowMoreMenu(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{onDetectChords && (
|
{onDetectChords && (
|
||||||
<div
|
<div
|
||||||
className={`quant-option${detectingChords ? ' disabled' : ''}`}
|
className={`quant-option${detectingChords ? ' disabled' : ''}`}
|
||||||
@@ -394,6 +422,7 @@ const PianoRollToolbar: React.FC<PianoRollToolbarProps> = ({
|
|||||||
if (detectingChords) {
|
if (detectingChords) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
setShowRegionColorPalette(false);
|
||||||
setShowMoreMenu(false);
|
setShowMoreMenu(false);
|
||||||
void onDetectChords();
|
void onDetectChords();
|
||||||
}}
|
}}
|
||||||
@@ -409,6 +438,7 @@ const PianoRollToolbar: React.FC<PianoRollToolbarProps> = ({
|
|||||||
if (detectingTempo) {
|
if (detectingTempo) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
setShowRegionColorPalette(false);
|
||||||
setShowMoreMenu(false);
|
setShowMoreMenu(false);
|
||||||
void onDetectTempo();
|
void onDetectTempo();
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -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<string, unknown>([
|
const configState = new Map<string, unknown>([
|
||||||
['general.language', 'auto'],
|
['general.language', 'auto'],
|
||||||
['general.agent_mode', 'regular'],
|
['general.agent_mode', 'regular'],
|
||||||
@@ -90,6 +98,10 @@ vi.mock('../../../util/local-separator/modelCache', () => ({
|
|||||||
LocalSeparatorModelCache: localSeparatorModelCacheMock,
|
LocalSeparatorModelCache: localSeparatorModelCacheMock,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../util/soundfontInstrumentCache', () => ({
|
||||||
|
SoundfontInstrumentCache: soundfontInstrumentCacheMock,
|
||||||
|
}));
|
||||||
|
|
||||||
describe('GeneralSettings', () => {
|
describe('GeneralSettings', () => {
|
||||||
const renderSettings = (locale: 'en_us' | 'zh_cn' = 'en_us') => render(
|
const renderSettings = (locale: 'en_us' | 'zh_cn' = 'en_us') => render(
|
||||||
<I18nContext.Provider
|
<I18nContext.Provider
|
||||||
@@ -132,6 +144,10 @@ describe('GeneralSettings', () => {
|
|||||||
localSeparatorModelCacheMock.delete.mockClear();
|
localSeparatorModelCacheMock.delete.mockClear();
|
||||||
localSeparatorModelCacheMock.exists.mockClear();
|
localSeparatorModelCacheMock.exists.mockClear();
|
||||||
localSeparatorModelCacheMock.exists.mockResolvedValue(true);
|
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 () => {
|
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');
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
|||||||
import { ConfigManager } from '../../../core/config/ConfigManager';
|
import { ConfigManager } from '../../../core/config/ConfigManager';
|
||||||
import { LocalLLMModelManager, type LocalLLMModelState } from '../../../util/localLLMModelManager';
|
import { LocalLLMModelManager, type LocalLLMModelState } from '../../../util/localLLMModelManager';
|
||||||
import { LocalSeparatorModelCache } from '../../../util/local-separator/modelCache';
|
import { LocalSeparatorModelCache } from '../../../util/local-separator/modelCache';
|
||||||
|
import { SoundfontInstrumentCache, type SoundfontCacheSummary } from '../../../util/soundfontInstrumentCache';
|
||||||
import { useI18n } from '../../../i18n/useI18n';
|
import { useI18n } from '../../../i18n/useI18n';
|
||||||
import type { LanguageSetting } from '../../../i18n/types';
|
import type { LanguageSetting } from '../../../i18n/types';
|
||||||
import {
|
import {
|
||||||
@@ -58,6 +59,11 @@ const GeneralSettings: React.FC = () => {
|
|||||||
const [kgoneBaseUrl, setKgoneBaseUrl] = useState<string>('');
|
const [kgoneBaseUrl, setKgoneBaseUrl] = useState<string>('');
|
||||||
const [kgoneServerManaged, setKgoneServerManaged] = useState<boolean>(false);
|
const [kgoneServerManaged, setKgoneServerManaged] = useState<boolean>(false);
|
||||||
const [soundfontServerManaged, setSoundfontServerManaged] = useState<boolean>(false);
|
const [soundfontServerManaged, setSoundfontServerManaged] = useState<boolean>(false);
|
||||||
|
const [soundfontCacheSummary, setSoundfontCacheSummary] = useState<SoundfontCacheSummary>({ instrumentCount: 0, instruments: [] });
|
||||||
|
const [isCheckingSoundfontCache, setIsCheckingSoundfontCache] = useState<boolean>(false);
|
||||||
|
const [isDeletingSoundfontCache, setIsDeletingSoundfontCache] = useState<boolean>(false);
|
||||||
|
const [selectedCachedSoundfontInstrument, setSelectedCachedSoundfontInstrument] = useState<string>('');
|
||||||
|
const [isDeletingCachedSoundfontInstrument, setIsDeletingCachedSoundfontInstrument] = useState<boolean>(false);
|
||||||
const [localContextLength, setLocalContextLength] = useState<LocalLLMContextLength>(LOCAL_LLM_DEFAULT_CONTEXT_LENGTH);
|
const [localContextLength, setLocalContextLength] = useState<LocalLLMContextLength>(LOCAL_LLM_DEFAULT_CONTEXT_LENGTH);
|
||||||
const [localModelState, setLocalModelState] = useState<LocalLLMModelState>(LocalLLMModelManager.getState());
|
const [localModelState, setLocalModelState] = useState<LocalLLMModelState>(LocalLLMModelManager.getState());
|
||||||
const [localModelUrl, setLocalModelUrl] = useState<string>('');
|
const [localModelUrl, setLocalModelUrl] = useState<string>('');
|
||||||
@@ -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
|
// Load configuration values on component mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadConfig = async () => {
|
const loadConfig = async () => {
|
||||||
@@ -150,8 +185,9 @@ const GeneralSettings: React.FC = () => {
|
|||||||
loadConfig();
|
loadConfig();
|
||||||
const unsubscribe = LocalLLMModelManager.subscribe(setLocalModelState);
|
const unsubscribe = LocalLLMModelManager.subscribe(setLocalModelState);
|
||||||
void refreshUvr5ModelCacheState();
|
void refreshUvr5ModelCacheState();
|
||||||
|
void refreshSoundfontCacheState();
|
||||||
return unsubscribe;
|
return unsubscribe;
|
||||||
}, [configManager, refreshUvr5ModelCacheState]);
|
}, [configManager, refreshSoundfontCacheState, refreshUvr5ModelCacheState]);
|
||||||
|
|
||||||
// Debounced save function for text inputs
|
// Debounced save function for text inputs
|
||||||
const debouncedSave = useCallback((key: string, value: string) => {
|
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 handleLocalContextLengthChange = async (value: string) => {
|
||||||
const parsed = Number(value);
|
const parsed = Number(value);
|
||||||
const normalized = normalizeLocalLLMContextLength(parsed);
|
const normalized = normalizeLocalLLMContextLength(parsed);
|
||||||
@@ -998,6 +1063,75 @@ const GeneralSettings: React.FC = () => {
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="settings-item">
|
||||||
|
<label className="settings-label">
|
||||||
|
{t('settings.general.soundfont.cachedStatus')}
|
||||||
|
</label>
|
||||||
|
<div className="settings-help" style={{ fontSize: '12px', color: '#888', marginTop: '4px' }}>
|
||||||
|
{isCheckingSoundfontCache
|
||||||
|
? t('settings.general.soundfont.cacheChecking')
|
||||||
|
: soundfontCacheSummary.instrumentCount > 0
|
||||||
|
? t('settings.general.soundfont.cacheReady', { count: soundfontCacheSummary.instrumentCount })
|
||||||
|
: t('settings.general.soundfont.cacheEmpty')}
|
||||||
|
</div>
|
||||||
|
<div className="settings-help" style={{ fontSize: '12px', color: '#888', marginTop: '4px' }}>
|
||||||
|
{t('settings.general.soundfont.cacheHelp')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="settings-item">
|
||||||
|
<label className="settings-label" htmlFor="soundfont-cached-instrument-select">
|
||||||
|
{t('settings.general.soundfont.cachedInstrument')}
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="soundfont-cached-instrument-select"
|
||||||
|
className="settings-select"
|
||||||
|
value={selectedCachedSoundfontInstrument}
|
||||||
|
onChange={(e) => setSelectedCachedSoundfontInstrument(e.target.value)}
|
||||||
|
disabled={isCheckingSoundfontCache || soundfontCacheSummary.instrumentCount === 0 || isDeletingCachedSoundfontInstrument}
|
||||||
|
>
|
||||||
|
{soundfontCacheSummary.instrumentCount === 0 ? (
|
||||||
|
<option value="">{t('settings.general.soundfont.noCachedInstrumentOption')}</option>
|
||||||
|
) : (
|
||||||
|
soundfontCacheSummary.instruments.map((instrumentName) => (
|
||||||
|
<option key={instrumentName} value={instrumentName}>
|
||||||
|
{instrumentName}
|
||||||
|
</option>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</select>
|
||||||
|
<div className="settings-help" style={{ fontSize: '12px', color: '#888', marginTop: '4px' }}>
|
||||||
|
{t('settings.general.soundfont.cachedInstrumentHelp')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="settings-item" style={{ marginTop: '12px' }}>
|
||||||
|
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="settings-btn settings-btn-danger"
|
||||||
|
onClick={() => void handleDeleteCachedSoundfontInstrument()}
|
||||||
|
disabled={
|
||||||
|
isDeletingCachedSoundfontInstrument
|
||||||
|
|| isCheckingSoundfontCache
|
||||||
|
|| !selectedCachedSoundfontInstrument
|
||||||
|
|| soundfontCacheSummary.instrumentCount === 0
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{isDeletingCachedSoundfontInstrument ? t('settings.deleting') : t('settings.general.soundfont.deleteSelectedCache')}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="settings-btn settings-btn-danger"
|
||||||
|
onClick={() => void handleDeleteSoundfontCache()}
|
||||||
|
disabled={isDeletingSoundfontCache || isCheckingSoundfontCache || soundfontCacheSummary.instrumentCount === 0}
|
||||||
|
>
|
||||||
|
{isDeletingSoundfontCache ? t('settings.deleting') : t('settings.general.soundfont.deleteCache')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="settings-group">
|
<div className="settings-group">
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
/* Track Region Styles */
|
/* Track Region Styles */
|
||||||
.track-region {
|
.track-region {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
background-color: #4a6b8a;
|
background-color: var(--region-header-bg, #4a6b8a);
|
||||||
border: 2px solid #5a7b9a;
|
border: 2px solid var(--region-border-color, #5a7b9a);
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
height: calc(100%);
|
height: calc(100%);
|
||||||
margin: 0px;
|
margin: 0px;
|
||||||
@@ -48,7 +48,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.region-header {
|
.region-header {
|
||||||
background-color: #5a7b9a;
|
background-color: var(--region-header-bg, #5a7b9a);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
padding: 2px 6px;
|
padding: 2px 6px;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
@@ -61,19 +61,19 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.track-region:hover .region-header {
|
.track-region:hover .region-header {
|
||||||
background-color: #6a8baa;
|
background-color: var(--region-header-hover-bg, #6a8baa);
|
||||||
}
|
}
|
||||||
|
|
||||||
.region-content {
|
.region-content {
|
||||||
height: calc(100% - 18px);
|
height: calc(100% - 18px);
|
||||||
background-color: #87CEFA; /* Light blue */
|
background-color: var(--region-content-bg, #4B9A41); /* Default MIDI region color */
|
||||||
width: 100%;
|
width: 100%;
|
||||||
position: relative; /* Allow overlayed controls */
|
position: relative; /* Allow overlayed controls */
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.region-content.audio-region-content {
|
.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 {
|
.region-preview-content {
|
||||||
@@ -91,8 +91,8 @@
|
|||||||
|
|
||||||
/* Audio region overrides */
|
/* Audio region overrides */
|
||||||
.track-region.audio-region {
|
.track-region.audio-region {
|
||||||
background-color: #3a6b4a;
|
background-color: var(--region-header-bg, #3a6b4a);
|
||||||
border-color: #4a8b5a;
|
border-color: var(--region-border-color, #4a8b5a);
|
||||||
}
|
}
|
||||||
|
|
||||||
.track-region.audio-region.selected {
|
.track-region.audio-region.selected {
|
||||||
@@ -109,11 +109,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.track-region.audio-region .region-header {
|
.track-region.audio-region .region-header {
|
||||||
background-color: #4a8b5a;
|
background-color: var(--region-header-bg, #4a8b5a);
|
||||||
}
|
}
|
||||||
|
|
||||||
.track-region.audio-region:hover .region-header {
|
.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 */
|
/* Left-side button cluster inside region-content */
|
||||||
|
|||||||
@@ -259,6 +259,49 @@
|
|||||||
color: #101010;
|
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 {
|
.track-grid.automation-active {
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { useProjectStore } from '../../stores/projectStore';
|
|||||||
import { isModifierKeyPressed } from '../../util/osUtil';
|
import { isModifierKeyPressed } from '../../util/osUtil';
|
||||||
import { CHORD_REGION_IMPORT_MIME_TYPE } from '../../util/chordRegionImportUtil';
|
import { CHORD_REGION_IMPORT_MIME_TYPE } from '../../util/chordRegionImportUtil';
|
||||||
import { TrackType } from '../../core/track/KGTrack';
|
import { TrackType } from '../../core/track/KGTrack';
|
||||||
|
import { buildRegionSurfaceColors, resolveRegionColor } from '../../util/regionColor';
|
||||||
|
|
||||||
interface RegionResizePreviewBaseline {
|
interface RegionResizePreviewBaseline {
|
||||||
regionId: string;
|
regionId: string;
|
||||||
@@ -795,12 +796,22 @@ const TrackGridItem: React.FC<TrackGridItemProps> = ({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const effectiveColor = region.effectiveColor
|
||||||
|
?? resolveRegionColor(coreRegion?.getColor(), track.getColor(), !!audioRegion || !!region.isAudioRegion);
|
||||||
|
const surfaceColors = buildRegionSurfaceColors(effectiveColor);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<RegionItem
|
<RegionItem
|
||||||
key={region.id}
|
key={region.id}
|
||||||
id={region.id}
|
id={region.id}
|
||||||
name={region.name}
|
name={region.name}
|
||||||
style={getRegionStyle(region)}
|
style={{
|
||||||
|
...getRegionStyle(region),
|
||||||
|
['--region-border-color' as string]: surfaceColors.borderColor,
|
||||||
|
['--region-header-bg' as string]: surfaceColors.headerColor,
|
||||||
|
['--region-header-hover-bg' as string]: surfaceColors.headerHoverColor,
|
||||||
|
['--region-content-bg' as string]: surfaceColors.contentColor,
|
||||||
|
}}
|
||||||
barNumber={region.barNumber}
|
barNumber={region.barNumber}
|
||||||
length={region.length}
|
length={region.length}
|
||||||
trackIndex={index}
|
trackIndex={index}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ const storeState = {
|
|||||||
removeTrack: vi.fn(),
|
removeTrack: vi.fn(),
|
||||||
toggleInstrumentSelectionForTrack: vi.fn(),
|
toggleInstrumentSelectionForTrack: vi.fn(),
|
||||||
importAudioToTrack: vi.fn(),
|
importAudioToTrack: vi.fn(),
|
||||||
|
updateTrackProperties: vi.fn(),
|
||||||
tracks: [] as KGAudioTrack[],
|
tracks: [] as KGAudioTrack[],
|
||||||
activeTrackAutomationTrackId: null as string | null,
|
activeTrackAutomationTrackId: null as string | null,
|
||||||
activeTrackAutomationType: null as string | null,
|
activeTrackAutomationType: null as string | null,
|
||||||
@@ -21,8 +22,13 @@ const storeState = {
|
|||||||
let fileImportModalProps: Record<string, unknown> | null = null;
|
let fileImportModalProps: Record<string, unknown> | null = null;
|
||||||
|
|
||||||
vi.mock('../../stores/projectStore', () => ({
|
vi.mock('../../stores/projectStore', () => ({
|
||||||
useProjectStore: (selector?: (state: typeof storeState) => unknown) => (
|
useProjectStore: Object.assign(
|
||||||
selector ? selector(storeState) : storeState
|
(selector?: (state: typeof storeState) => unknown) => (
|
||||||
|
selector ? selector(storeState) : storeState
|
||||||
|
),
|
||||||
|
{
|
||||||
|
getState: () => storeState,
|
||||||
|
},
|
||||||
),
|
),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -73,6 +79,7 @@ describe('TrackInfoItem audio import', () => {
|
|||||||
storeState.removeTrack.mockReset();
|
storeState.removeTrack.mockReset();
|
||||||
storeState.toggleInstrumentSelectionForTrack.mockReset();
|
storeState.toggleInstrumentSelectionForTrack.mockReset();
|
||||||
storeState.importAudioToTrack.mockReset();
|
storeState.importAudioToTrack.mockReset();
|
||||||
|
storeState.updateTrackProperties.mockReset();
|
||||||
storeState.setTrackAutomationView.mockReset();
|
storeState.setTrackAutomationView.mockReset();
|
||||||
vi.mocked(showConfirm).mockReset();
|
vi.mocked(showConfirm).mockReset();
|
||||||
});
|
});
|
||||||
@@ -127,4 +134,32 @@ describe('TrackInfoItem audio import', () => {
|
|||||||
translate('track.controls.settings.deleteTrackConfirm', { name: '钢琴' }, 'zh_cn')
|
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(
|
||||||
|
<TrackInfoItem
|
||||||
|
track={audioTrack}
|
||||||
|
index={0}
|
||||||
|
isDragging={false}
|
||||||
|
isDragOver={false}
|
||||||
|
onTrackNameEdit={vi.fn()}
|
||||||
|
onDragStart={vi.fn()}
|
||||||
|
onDragOver={vi.fn()}
|
||||||
|
onDrop={vi.fn()}
|
||||||
|
onDragEnd={vi.fn()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
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 });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { TbPiano } from 'react-icons/tb';
|
|||||||
import { TbDots } from 'react-icons/tb';
|
import { TbDots } from 'react-icons/tb';
|
||||||
import { FaFileAudio } from 'react-icons/fa';
|
import { FaFileAudio } from 'react-icons/fa';
|
||||||
import KGDropdown from '../common/KGDropdown';
|
import KGDropdown from '../common/KGDropdown';
|
||||||
|
import ColorPalettePopup from '../common/ColorPalettePopup';
|
||||||
import FileImportModal from '../common/FileImportModal';
|
import FileImportModal from '../common/FileImportModal';
|
||||||
import { FLUIDR3_INSTRUMENT_MAP } from '../../constants/generalMidiConstants';
|
import { FLUIDR3_INSTRUMENT_MAP } from '../../constants/generalMidiConstants';
|
||||||
import { DEBUG_MODE } from '../../constants/uiConstants';
|
import { DEBUG_MODE } from '../../constants/uiConstants';
|
||||||
@@ -91,6 +92,7 @@ const TrackInfoItem: React.FC<TrackInfoItemProps> = ({
|
|||||||
const [showSettingsDropdown, setShowSettingsDropdown] = useState(false);
|
const [showSettingsDropdown, setShowSettingsDropdown] = useState(false);
|
||||||
const [showAudioImportModal, setShowAudioImportModal] = useState(false);
|
const [showAudioImportModal, setShowAudioImportModal] = useState(false);
|
||||||
const [showAutomationDropdown, setShowAutomationDropdown] = useState(false);
|
const [showAutomationDropdown, setShowAutomationDropdown] = useState(false);
|
||||||
|
const [showTrackColorPalette, setShowTrackColorPalette] = useState(false);
|
||||||
const settingsDropdownRef = useRef<HTMLDivElement>(null);
|
const settingsDropdownRef = useRef<HTMLDivElement>(null);
|
||||||
const automationDropdownRef = useRef<HTMLDivElement>(null);
|
const automationDropdownRef = useRef<HTMLDivElement>(null);
|
||||||
const suppressDragRef = useRef(false);
|
const suppressDragRef = useRef(false);
|
||||||
@@ -115,6 +117,7 @@ const TrackInfoItem: React.FC<TrackInfoItemProps> = ({
|
|||||||
!settingsDropdownRef.current.contains(event.target as Node)
|
!settingsDropdownRef.current.contains(event.target as Node)
|
||||||
) {
|
) {
|
||||||
setShowSettingsDropdown(false);
|
setShowSettingsDropdown(false);
|
||||||
|
setShowTrackColorPalette(false);
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
showAutomationDropdown &&
|
showAutomationDropdown &&
|
||||||
@@ -336,6 +339,9 @@ const TrackInfoItem: React.FC<TrackInfoItemProps> = ({
|
|||||||
const handleSettingsButtonClick = (e: React.MouseEvent) => {
|
const handleSettingsButtonClick = (e: React.MouseEvent) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setShowSettingsDropdown(!showSettingsDropdown);
|
setShowSettingsDropdown(!showSettingsDropdown);
|
||||||
|
if (showSettingsDropdown) {
|
||||||
|
setShowTrackColorPalette(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAutomationButtonClick = (e: React.MouseEvent<HTMLButtonElement>) => {
|
const handleAutomationButtonClick = (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||||
@@ -387,6 +393,17 @@ const TrackInfoItem: React.FC<TrackInfoItemProps> = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`track-info ${isDragOver ? 'drag-over' : ''} ${isDragging ? 'dragging' : ''} ${isSelected ? 'selected' : ''}`}
|
className={`track-info ${isDragOver ? 'drag-over' : ''} ${isDragging ? 'dragging' : ''} ${isSelected ? 'selected' : ''}`}
|
||||||
@@ -545,18 +562,38 @@ const TrackInfoItem: React.FC<TrackInfoItemProps> = ({
|
|||||||
>
|
>
|
||||||
<TbDots />
|
<TbDots />
|
||||||
</button>
|
</button>
|
||||||
<div style={{ position: 'absolute', top: '100%', left: 0, zIndex: 10000 }}>
|
{showSettingsDropdown && (
|
||||||
<KGDropdown
|
<div className="track-settings-menu">
|
||||||
options={[{ label: t('track.controls.settings.deleteTrack'), value: 'Delete Track' }]}
|
<button
|
||||||
value={''}
|
type="button"
|
||||||
onChange={handleSettingsAction}
|
className="track-settings-menu-item"
|
||||||
label={t('track.controls.moreActions')}
|
onClick={(e) => {
|
||||||
hideButton={true}
|
e.stopPropagation();
|
||||||
isOpen={showSettingsDropdown}
|
setShowTrackColorPalette((open) => !open);
|
||||||
onToggle={setShowSettingsDropdown}
|
}}
|
||||||
className="settings-dropdown"
|
>
|
||||||
/>
|
{t('track.controls.settings.trackColor')}
|
||||||
</div>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="track-settings-menu-item"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
void handleSettingsAction('Delete Track');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('track.controls.settings.deleteTrack')}
|
||||||
|
</button>
|
||||||
|
{showTrackColorPalette && (
|
||||||
|
<div className="track-settings-color-popup">
|
||||||
|
<ColorPalettePopup
|
||||||
|
selectedColor={track.getColor()}
|
||||||
|
onSelect={handleTrackColorSelect}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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();
|
||||||
@@ -5,6 +5,7 @@ import { ConfigManager } from './config/ConfigManager';
|
|||||||
import { KGProjectStorage } from './io/KGProjectStorage';
|
import { KGProjectStorage } from './io/KGProjectStorage';
|
||||||
import { KGConfigUpgrader } from './config-upgrader/KGConfigUpgrader';
|
import { KGConfigUpgrader } from './config-upgrader/KGConfigUpgrader';
|
||||||
import { KGMidiRegion } from './region/KGMidiRegion';
|
import { KGMidiRegion } from './region/KGMidiRegion';
|
||||||
|
import { KGAudioRegion } from './region/KGAudioRegion';
|
||||||
import { KGMidiNote } from './midi/KGMidiNote';
|
import { KGMidiNote } from './midi/KGMidiNote';
|
||||||
import { KGMidiControllerEvent } from './midi/KGMidiControllerEvent';
|
import { KGMidiControllerEvent } from './midi/KGMidiControllerEvent';
|
||||||
import { KGMidiPitchBend } from './midi/KGMidiPitchBend';
|
import { KGMidiPitchBend } from './midi/KGMidiPitchBend';
|
||||||
@@ -535,6 +536,7 @@ export class KGCore {
|
|||||||
region.getStartFromBeat(),
|
region.getStartFromBeat(),
|
||||||
region.getLength()
|
region.getLength()
|
||||||
);
|
);
|
||||||
|
clonedRegion.setColor(region.getColor());
|
||||||
|
|
||||||
// Copy all notes within the region
|
// Copy all notes within the region
|
||||||
const originalNotes = region.getNotes();
|
const originalNotes = region.getNotes();
|
||||||
@@ -569,6 +571,25 @@ export class KGCore {
|
|||||||
break;
|
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': {
|
case 'KGRegion': {
|
||||||
const region = item as KGRegion;
|
const region = item as KGRegion;
|
||||||
const clonedRegion = new KGRegion(
|
const clonedRegion = new KGRegion(
|
||||||
@@ -579,6 +600,7 @@ export class KGCore {
|
|||||||
region.getStartFromBeat(),
|
region.getStartFromBeat(),
|
||||||
region.getLength()
|
region.getLength()
|
||||||
);
|
);
|
||||||
|
clonedRegion.setColor(region.getColor());
|
||||||
clonedItems.push(clonedRegion);
|
clonedItems.push(clonedRegion);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<string, string>;
|
||||||
|
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<string, string>;
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,8 +1,14 @@
|
|||||||
import { SAMPLER_CONSTANTS } from '../../constants/coreConstants';
|
import { SAMPLER_CONSTANTS } from '../../constants/coreConstants';
|
||||||
import { FLUIDR3_INSTRUMENT_MAP } from '../../constants/generalMidiConstants';
|
import { FLUIDR3_INSTRUMENT_MAP } from '../../constants/generalMidiConstants';
|
||||||
import { ConfigManager } from '../config/ConfigManager';
|
import { ConfigManager } from '../config/ConfigManager';
|
||||||
|
import { SoundfontInstrumentCache } from '../../util/soundfontInstrumentCache';
|
||||||
import * as Tone from 'tone';
|
import * as Tone from 'tone';
|
||||||
|
|
||||||
|
interface ToneBufferLoadResult {
|
||||||
|
buffers: Tone.ToneAudioBuffers;
|
||||||
|
cacheInMemory: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* KGToneBuffersPool - Singleton class for managing ToneAudioBuffers
|
* KGToneBuffersPool - Singleton class for managing ToneAudioBuffers
|
||||||
* Handles loading and caching of soundfont audio buffers for instruments
|
* 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
|
// Simple event listeners for load start/end without coupling to UI layer
|
||||||
private loadingListeners: Array<(_evt: { type: 'start' | 'end'; instrument: string }) => void> = [];
|
private loadingListeners: Array<(_evt: { type: 'start' | 'end'; instrument: string }) => void> = [];
|
||||||
|
|
||||||
|
private activeBaseUrl: string | null = null;
|
||||||
|
|
||||||
// Private constructor to prevent direct instantiation
|
// Private constructor to prevent direct instantiation
|
||||||
private constructor() {
|
private constructor() {
|
||||||
console.log("KGToneBuffersPool initialized");
|
console.log("KGToneBuffersPool initialized");
|
||||||
@@ -67,6 +75,9 @@ export class KGToneBuffersPool {
|
|||||||
* Handles race conditions by ensuring only one loading operation per instrument
|
* Handles race conditions by ensuring only one loading operation per instrument
|
||||||
*/
|
*/
|
||||||
public async getToneAudioBuffers(name: string): Promise<Tone.ToneAudioBuffers> {
|
public async getToneAudioBuffers(name: string): Promise<Tone.ToneAudioBuffers> {
|
||||||
|
const baseUrl = await this.getSoundfontBaseUrl();
|
||||||
|
this.ensureMemoryCacheMatchesBaseUrl(baseUrl);
|
||||||
|
|
||||||
// Check if already fully loaded and cached
|
// Check if already fully loaded and cached
|
||||||
const cachedBuffers = this.bufferMap.get(name);
|
const cachedBuffers = this.bufferMap.get(name);
|
||||||
if (cachedBuffers && cachedBuffers.loaded) {
|
if (cachedBuffers && cachedBuffers.loaded) {
|
||||||
@@ -82,18 +93,22 @@ export class KGToneBuffersPool {
|
|||||||
|
|
||||||
// Start new loading operation
|
// Start new loading operation
|
||||||
console.log(`KGToneBuffersPool: Starting new loading operation for ${name}`);
|
console.log(`KGToneBuffersPool: Starting new loading operation for ${name}`);
|
||||||
const loadingPromise = this.createToneAudioBuffers(name);
|
const loadingPromise = this.createToneAudioBuffers(name, baseUrl);
|
||||||
this.loadingPromises.set(name, loadingPromise);
|
this.loadingPromises.set(name, loadingPromise.then(result => result.buffers));
|
||||||
// Emit start AFTER registering the promise to avoid duplicate start events in races
|
// Emit start AFTER registering the promise to avoid duplicate start events in races
|
||||||
this.emitLoadingEvent({ type: 'start', instrument: name });
|
this.emitLoadingEvent({ type: 'start', instrument: name });
|
||||||
console.log(`[KGToneBuffersPool] start: Active load count: ${this.getActiveLoadCount()}`);
|
console.log(`[KGToneBuffersPool] start: Active load count: ${this.getActiveLoadCount()}`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const buffers = await loadingPromise;
|
const result = await loadingPromise;
|
||||||
|
const buffers = result.buffers;
|
||||||
|
|
||||||
// Cache the fully loaded buffers
|
if (result.cacheInMemory) {
|
||||||
this.bufferMap.set(name, buffers);
|
this.bufferMap.set(name, buffers);
|
||||||
console.log(`KGToneBuffersPool: Cached loaded buffers for ${name}`);
|
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
|
// Remove from loading promises since it's complete
|
||||||
this.loadingPromises.delete(name);
|
this.loadingPromises.delete(name);
|
||||||
@@ -115,43 +130,63 @@ export class KGToneBuffersPool {
|
|||||||
/**
|
/**
|
||||||
* Create ToneAudioBuffers for an instrument
|
* Create ToneAudioBuffers for an instrument
|
||||||
*/
|
*/
|
||||||
private async createToneAudioBuffers(name: string): Promise<Tone.ToneAudioBuffers> {
|
private async createToneAudioBuffers(name: string, baseUrl: string): Promise<ToneBufferLoadResult> {
|
||||||
const configManager = ConfigManager.instance();
|
const instrumentName = name;
|
||||||
if (!configManager.getIsInitialized()) {
|
|
||||||
await configManager.initialize();
|
if (!instrumentName) {
|
||||||
|
throw new Error(`Unknown instrument: ${name}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
const keyNames = this.getInstrumentKeyNames(instrumentName);
|
||||||
try {
|
|
||||||
const instrumentName = name;
|
|
||||||
|
|
||||||
if (!instrumentName) {
|
try {
|
||||||
throw new Error(`Unknown instrument: ${name}`);
|
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<string, string>;
|
||||||
|
|
||||||
|
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 } {
|
private generateKeyUrls(baseUrl: string, instrumentName: string): { [key: string]: string } {
|
||||||
const urls: { [key: string]: string } = {};
|
const urls: { [key: string]: string } = {};
|
||||||
|
|
||||||
// get the range of the instrument.
|
for (const keyName of this.getInstrumentKeyNames(instrumentName)) {
|
||||||
// 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
|
|
||||||
urls[keyName] = `${baseUrl}${instrumentName}-mp3/${keyName}.mp3`;
|
urls[keyName] = `${baseUrl}${instrumentName}-mp3/${keyName}.mp3`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -184,6 +205,121 @@ export class KGToneBuffersPool {
|
|||||||
return urls;
|
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<string, string>): Promise<{
|
||||||
|
successfulBlobs: Record<string, Blob>;
|
||||||
|
failures: string[];
|
||||||
|
}> {
|
||||||
|
const entries = Object.entries(urls);
|
||||||
|
const successfulBlobs: Record<string, Blob> = {};
|
||||||
|
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<Response> {
|
||||||
|
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<string, string>, name: string): Promise<Tone.ToneAudioBuffers> {
|
||||||
|
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<string, string>): void {
|
||||||
|
Object.values(urls).forEach(url => {
|
||||||
|
if (url.startsWith('blob:')) {
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getSoundfontBaseUrl(): Promise<string> {
|
||||||
|
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
|
* Clear all cached buffers and dispose of resources
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { KGMidiPitchBend } from '../../midi/KGMidiPitchBend';
|
|||||||
import { KGTrack } from '../../track/KGTrack';
|
import { KGTrack } from '../../track/KGTrack';
|
||||||
import { generateUniqueId } from '../../../util/miscUtil';
|
import { generateUniqueId } from '../../../util/miscUtil';
|
||||||
import { useProjectStore } from '../../../stores/projectStore';
|
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
|
* 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,
|
newPosition,
|
||||||
originalRegion.getLength()
|
originalRegion.getLength()
|
||||||
);
|
);
|
||||||
|
newRegion.setColor(originalRegion.getColor());
|
||||||
|
|
||||||
// Copy all notes from the original region
|
// Copy all notes from the original region
|
||||||
const originalNotes = originalRegion.getNotes();
|
const originalNotes = originalRegion.getNotes();
|
||||||
@@ -102,6 +104,22 @@ export class PasteRegionsCommand extends KGCommand {
|
|||||||
});
|
});
|
||||||
|
|
||||||
console.log(`Created MIDI region "${newRegion.getName()}" with ${originalNotes.length} notes`);
|
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 {
|
} else {
|
||||||
// Fallback for other region types
|
// Fallback for other region types
|
||||||
newRegion = new KGRegion(
|
newRegion = new KGRegion(
|
||||||
@@ -112,6 +130,7 @@ export class PasteRegionsCommand extends KGCommand {
|
|||||||
newPosition,
|
newPosition,
|
||||||
originalRegion.getLength()
|
originalRegion.getLength()
|
||||||
);
|
);
|
||||||
|
newRegion.setColor(originalRegion.getColor());
|
||||||
|
|
||||||
console.log(`Created region "${newRegion.getName()}"`);
|
console.log(`Created region "${newRegion.getName()}"`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -93,6 +93,8 @@ export class SplitRegionCommand extends KGCommand {
|
|||||||
this.splitAtBeat,
|
this.splitAtBeat,
|
||||||
regionLength - splitOffsetBeats
|
regionLength - splitOffsetBeats
|
||||||
);
|
);
|
||||||
|
region1.setColor(originalRegion.getColor());
|
||||||
|
region2.setColor(originalRegion.getColor());
|
||||||
|
|
||||||
for (const note of originalRegion.getNotes()) {
|
for (const note of originalRegion.getNotes()) {
|
||||||
if (note.getStartBeat() < splitOffsetBeats) {
|
if (note.getStartBeat() < splitOffsetBeats) {
|
||||||
@@ -167,6 +169,7 @@ export class SplitRegionCommand extends KGCommand {
|
|||||||
originalRegion.getAudioDurationSeconds(),
|
originalRegion.getAudioDurationSeconds(),
|
||||||
originalRegion.getClipStartOffsetSeconds()
|
originalRegion.getClipStartOffsetSeconds()
|
||||||
);
|
);
|
||||||
|
this.region1.setColor(originalRegion.getColor());
|
||||||
|
|
||||||
this.region2 = new KGAudioRegion(
|
this.region2 = new KGAudioRegion(
|
||||||
generateUniqueId('KGAudioRegion'),
|
generateUniqueId('KGAudioRegion'),
|
||||||
@@ -180,6 +183,7 @@ export class SplitRegionCommand extends KGCommand {
|
|||||||
originalRegion.getAudioDurationSeconds(),
|
originalRegion.getAudioDurationSeconds(),
|
||||||
originalRegion.getClipStartOffsetSeconds() + splitOffsetSeconds
|
originalRegion.getClipStartOffsetSeconds() + splitOffsetSeconds
|
||||||
);
|
);
|
||||||
|
this.region2.setColor(originalRegion.getColor());
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
throw new Error(`Unsupported region type for splitting: ${originalRegion.getCurrentType()}`);
|
throw new Error(`Unsupported region type for splitting: ${originalRegion.getCurrentType()}`);
|
||||||
|
|||||||
@@ -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']));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -8,7 +8,7 @@ import { KGTrack } from '../../track/KGTrack';
|
|||||||
*/
|
*/
|
||||||
export interface RegionUpdateProperties {
|
export interface RegionUpdateProperties {
|
||||||
name?: string;
|
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
|
// Store original properties for undo
|
||||||
this.originalProperties = {
|
this.originalProperties = {
|
||||||
name: this.targetRegion.getName(),
|
name: this.targetRegion.getName(),
|
||||||
|
color: this.targetRegion.getColor(),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Apply updates and track what actually changes
|
// 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}"`);
|
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) {
|
if (updatedProperties.length > 0) {
|
||||||
console.log(`Updated region ${this.regionId}: ${updatedProperties.join(', ')}`);
|
console.log(`Updated region ${this.regionId}: ${updatedProperties.join(', ')}`);
|
||||||
} else {
|
} else {
|
||||||
@@ -91,6 +98,11 @@ export class UpdateRegionCommand extends KGCommand {
|
|||||||
restoredProperties.push(`name: "${this.originalProperties.name}"`);
|
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(', ')}`);
|
console.log(`Restored region ${this.regionId}: ${restoredProperties.join(', ')}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,6 +113,9 @@ export class UpdateRegionCommand extends KGCommand {
|
|||||||
if (this.newProperties.name !== undefined) {
|
if (this.newProperties.name !== undefined) {
|
||||||
updatedProps.push('name');
|
updatedProps.push('name');
|
||||||
}
|
}
|
||||||
|
if ('color' in this.newProperties) {
|
||||||
|
updatedProps.push('color');
|
||||||
|
}
|
||||||
|
|
||||||
if (updatedProps.length === 1) {
|
if (updatedProps.length === 1) {
|
||||||
return `Update region "${regionName}" ${updatedProps[0]}`;
|
return `Update region "${regionName}" ${updatedProps[0]}`;
|
||||||
|
|||||||
@@ -84,4 +84,27 @@ describe('UpdateTrackCommand', () => {
|
|||||||
expect(mockAudioInterface.setTrackSolo).not.toHaveBeenCalled();
|
expect(mockAudioInterface.setTrackSolo).not.toHaveBeenCalled();
|
||||||
expect(command.getChangedProperties()).toEqual(new Set());
|
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']));
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ export interface TrackUpdateProperties {
|
|||||||
volume?: number;
|
volume?: number;
|
||||||
muted?: boolean;
|
muted?: boolean;
|
||||||
solo?: boolean;
|
solo?: boolean;
|
||||||
|
color?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -51,6 +52,7 @@ export class UpdateTrackCommand extends KGCommand {
|
|||||||
volume: this.targetTrack.getVolume(),
|
volume: this.targetTrack.getVolume(),
|
||||||
muted: this.targetTrack.getMuted(),
|
muted: this.targetTrack.getMuted(),
|
||||||
solo: this.targetTrack.getSolo(),
|
solo: this.targetTrack.getSolo(),
|
||||||
|
color: this.targetTrack.getColor(),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Store original instrument if it's a MIDI track
|
// Store original instrument if it's a MIDI track
|
||||||
@@ -133,6 +135,16 @@ export class UpdateTrackCommand extends KGCommand {
|
|||||||
updatedProperties.push(`solo: ${originalSolo} → ${newSolo}`);
|
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) {
|
if (updatedProperties.length > 0) {
|
||||||
console.log(`Updated track ${this.trackId}: ${updatedProperties.join(', ')}`);
|
console.log(`Updated track ${this.trackId}: ${updatedProperties.join(', ')}`);
|
||||||
} else {
|
} else {
|
||||||
@@ -204,6 +216,11 @@ export class UpdateTrackCommand extends KGCommand {
|
|||||||
restoredProperties.push(`solo: ${this.originalProperties.solo}`);
|
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(', ')}`);
|
console.log(`Restored track ${this.trackId}: ${restoredProperties.join(', ')}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -229,6 +246,9 @@ export class UpdateTrackCommand extends KGCommand {
|
|||||||
if (this.newProperties.solo !== undefined) {
|
if (this.newProperties.solo !== undefined) {
|
||||||
updatedProps.push('solo');
|
updatedProps.push('solo');
|
||||||
}
|
}
|
||||||
|
if ('color' in this.newProperties) {
|
||||||
|
updatedProps.push('color');
|
||||||
|
}
|
||||||
|
|
||||||
if (updatedProps.length === 1) {
|
if (updatedProps.length === 1) {
|
||||||
return `Update track "${trackName}" ${updatedProps[0]}`;
|
return `Update track "${trackName}" ${updatedProps[0]}`;
|
||||||
|
|||||||
@@ -27,6 +27,9 @@ export class KGRegion implements Selectable {
|
|||||||
@Expose()
|
@Expose()
|
||||||
protected length: number = 0;
|
protected length: number = 0;
|
||||||
|
|
||||||
|
@Expose()
|
||||||
|
protected color?: string;
|
||||||
|
|
||||||
@Expose()
|
@Expose()
|
||||||
protected selected: boolean = false;
|
protected selected: boolean = false;
|
||||||
|
|
||||||
@@ -66,6 +69,10 @@ export class KGRegion implements Selectable {
|
|||||||
return this.length;
|
return this.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public getColor(): string | undefined {
|
||||||
|
return this.color;
|
||||||
|
}
|
||||||
|
|
||||||
// Setters
|
// Setters
|
||||||
public setId(id: string): void {
|
public setId(id: string): void {
|
||||||
this.id = id;
|
this.id = id;
|
||||||
@@ -91,6 +98,10 @@ export class KGRegion implements Selectable {
|
|||||||
this.length = length;
|
this.length = length;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public setColor(color: string | undefined): void {
|
||||||
|
this.color = color;
|
||||||
|
}
|
||||||
|
|
||||||
// interface methods
|
// interface methods
|
||||||
public select(): void {
|
public select(): void {
|
||||||
this.selected = true;
|
this.selected = true;
|
||||||
|
|||||||
@@ -34,6 +34,9 @@ export class KGTrack {
|
|||||||
@Expose()
|
@Expose()
|
||||||
protected type: TrackType;
|
protected type: TrackType;
|
||||||
|
|
||||||
|
@Expose()
|
||||||
|
protected color?: string;
|
||||||
|
|
||||||
@Expose()
|
@Expose()
|
||||||
@WithDefault(AUDIO_INTERFACE_CONSTANTS.DEFAULT_TRACK_VOLUME)
|
@WithDefault(AUDIO_INTERFACE_CONSTANTS.DEFAULT_TRACK_VOLUME)
|
||||||
protected volume: number = 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;
|
return this.volume;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public getColor(): string | undefined {
|
||||||
|
return this.color;
|
||||||
|
}
|
||||||
|
|
||||||
public getMuted(): boolean {
|
public getMuted(): boolean {
|
||||||
return this.muted;
|
return this.muted;
|
||||||
}
|
}
|
||||||
@@ -136,6 +143,10 @@ export class KGTrack {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public setColor(color: string | undefined): void {
|
||||||
|
this.color = color;
|
||||||
|
}
|
||||||
|
|
||||||
public setMuted(muted: boolean): void {
|
public setMuted(muted: boolean): void {
|
||||||
this.muted = muted;
|
this.muted = muted;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { DEBUG_MODE } from '../constants';
|
|||||||
import { useProjectStore } from '../stores/projectStore';
|
import { useProjectStore } from '../stores/projectStore';
|
||||||
import { useRegionOperations } from './useRegionOperations';
|
import { useRegionOperations } from './useRegionOperations';
|
||||||
import { getAudioRegionDisplayLengthBeats } from '../util/globalTrackUtil';
|
import { getAudioRegionDisplayLengthBeats } from '../util/globalTrackUtil';
|
||||||
|
import { resolveRegionColor } from '../util/regionColor';
|
||||||
|
|
||||||
const DEFAULT_REGION_CLICK_OPTIONS: RegionClickOptions = {
|
const DEFAULT_REGION_CLICK_OPTIONS: RegionClickOptions = {
|
||||||
shiftKey: false,
|
shiftKey: false,
|
||||||
@@ -179,6 +180,10 @@ export function useMainContentRegions({
|
|||||||
barNumber,
|
barNumber,
|
||||||
length,
|
length,
|
||||||
name: region.getName(),
|
name: region.getName(),
|
||||||
|
color: region.getColor(),
|
||||||
|
trackColor: track.getColor(),
|
||||||
|
effectiveColor: resolveRegionColor(region.getColor(), track.getColor(), region instanceof KGAudioRegion),
|
||||||
|
isAudioRegion: region instanceof KGAudioRegion,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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.managed': 'Soundfont configuration is managed by the server (kgone-server.json). Settings are read-only.',
|
||||||
'settings.general.soundfont.baseUrl': 'Base URL',
|
'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.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.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.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',
|
'settings.general.kgone.enabled': 'Enable K.G.One Integration',
|
||||||
@@ -327,6 +337,7 @@ export const enUsMessages: TranslationMessages = {
|
|||||||
'pianoRoll.curve': 'Curve',
|
'pianoRoll.curve': 'Curve',
|
||||||
'pianoRoll.zoom': 'Zoom',
|
'pianoRoll.zoom': 'Zoom',
|
||||||
'pianoRoll.moreOptions': 'More options',
|
'pianoRoll.moreOptions': 'More options',
|
||||||
|
'pianoRoll.regionColor': 'Region Color...',
|
||||||
'pianoRoll.quantize.1/1': '1/1',
|
'pianoRoll.quantize.1/1': '1/1',
|
||||||
'pianoRoll.quantize.1/2': '1/2',
|
'pianoRoll.quantize.1/2': '1/2',
|
||||||
'pianoRoll.quantize.1/3': '1/3',
|
'pianoRoll.quantize.1/3': '1/3',
|
||||||
@@ -538,6 +549,7 @@ export const enUsMessages: TranslationMessages = {
|
|||||||
'track.controls.automation.volume': 'Volume',
|
'track.controls.automation.volume': 'Volume',
|
||||||
'track.controls.automation.pan': 'Pan',
|
'track.controls.automation.pan': 'Pan',
|
||||||
'track.controls.moreActions': 'More actions',
|
'track.controls.moreActions': 'More actions',
|
||||||
|
'track.controls.settings.trackColor': 'Track Color...',
|
||||||
'track.controls.settings.deleteTrack': 'Delete Track',
|
'track.controls.settings.deleteTrack': 'Delete Track',
|
||||||
'track.controls.settings.deleteTrackConfirm': 'Are you sure you want to delete track "{name}"?',
|
'track.controls.settings.deleteTrackConfirm': 'Are you sure you want to delete track "{name}"?',
|
||||||
'track.controls.settings.deleteTrackError': 'Failed to delete track. Please try again.',
|
'track.controls.settings.deleteTrackError': 'Failed to delete track. Please try again.',
|
||||||
|
|||||||
@@ -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.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.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.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.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.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',
|
'settings.general.kgone.enabled': 'Activer l\'intégration K.G.One',
|
||||||
@@ -315,6 +325,7 @@ export const frFrMessages: TranslationMessages = {
|
|||||||
'pianoRoll.curve': 'Courbe',
|
'pianoRoll.curve': 'Courbe',
|
||||||
'pianoRoll.zoom': 'Zoom',
|
'pianoRoll.zoom': 'Zoom',
|
||||||
'pianoRoll.moreOptions': 'Plus d\'options',
|
'pianoRoll.moreOptions': 'Plus d\'options',
|
||||||
|
'pianoRoll.regionColor': 'Couleur de la région...',
|
||||||
'pianoRoll.showActiveRegionOnly': 'Afficher seulement la région active',
|
'pianoRoll.showActiveRegionOnly': 'Afficher seulement la région active',
|
||||||
'pianoRoll.showEntireTrack': 'Afficher toute la piste',
|
'pianoRoll.showEntireTrack': 'Afficher toute la piste',
|
||||||
'pianoRoll.detectChords': 'Détecter les accords...',
|
'pianoRoll.detectChords': 'Détecter les accords...',
|
||||||
@@ -422,6 +433,7 @@ export const frFrMessages: TranslationMessages = {
|
|||||||
'track.controls.automation.volume': 'Volume',
|
'track.controls.automation.volume': 'Volume',
|
||||||
'track.controls.automation.pan': 'Panoramique',
|
'track.controls.automation.pan': 'Panoramique',
|
||||||
'track.controls.moreActions': 'Autres actions',
|
'track.controls.moreActions': 'Autres actions',
|
||||||
|
'track.controls.settings.trackColor': 'Couleur de la piste...',
|
||||||
'track.controls.settings.deleteTrack': 'Supprimer la piste',
|
'track.controls.settings.deleteTrack': 'Supprimer la piste',
|
||||||
'track.controls.settings.deleteTrackConfirm': 'Voulez-vous vraiment supprimer la piste « {name} » ?',
|
'track.controls.settings.deleteTrackConfirm': 'Voulez-vous vraiment supprimer la piste « {name} » ?',
|
||||||
'track.controls.settings.deleteTrackError': 'Impossible de supprimer la piste. Veuillez réessayer.',
|
'track.controls.settings.deleteTrackError': 'Impossible de supprimer la piste. Veuillez réessayer.',
|
||||||
|
|||||||
@@ -95,6 +95,16 @@ export const zhCnMessages: TranslationMessages = {
|
|||||||
'settings.general.soundfont.managed': 'Soundfont 配置由服务器(kgone-server.json)管理,当前设置为只读。',
|
'settings.general.soundfont.managed': 'Soundfont 配置由服务器(kgone-server.json)管理,当前设置为只读。',
|
||||||
'settings.general.soundfont.baseUrl': '基础 URL',
|
'settings.general.soundfont.baseUrl': '基础 URL',
|
||||||
'settings.general.soundfont.baseUrlHelp': '如果改成不兼容的 soundfont 源,某些乐器可能发声错误或无法播放。',
|
'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.section': 'K.G.One 设置',
|
||||||
'settings.general.kgone.managed': 'K.G.One 配置由服务器(kgone-server.json)管理,当前设置为只读。',
|
'settings.general.kgone.managed': 'K.G.One 配置由服务器(kgone-server.json)管理,当前设置为只读。',
|
||||||
'settings.general.kgone.enabled': '启用 K.G.One 集成',
|
'settings.general.kgone.enabled': '启用 K.G.One 集成',
|
||||||
@@ -325,6 +335,7 @@ export const zhCnMessages: TranslationMessages = {
|
|||||||
'pianoRoll.curve': '曲线',
|
'pianoRoll.curve': '曲线',
|
||||||
'pianoRoll.zoom': '缩放',
|
'pianoRoll.zoom': '缩放',
|
||||||
'pianoRoll.moreOptions': '更多选项',
|
'pianoRoll.moreOptions': '更多选项',
|
||||||
|
'pianoRoll.regionColor': '区域颜色...',
|
||||||
'pianoRoll.quantize.1/1': '1/1',
|
'pianoRoll.quantize.1/1': '1/1',
|
||||||
'pianoRoll.quantize.1/2': '1/2',
|
'pianoRoll.quantize.1/2': '1/2',
|
||||||
'pianoRoll.quantize.1/3': '1/3',
|
'pianoRoll.quantize.1/3': '1/3',
|
||||||
@@ -536,6 +547,7 @@ export const zhCnMessages: TranslationMessages = {
|
|||||||
'track.controls.automation.volume': '音量',
|
'track.controls.automation.volume': '音量',
|
||||||
'track.controls.automation.pan': '声像',
|
'track.controls.automation.pan': '声像',
|
||||||
'track.controls.moreActions': '更多操作',
|
'track.controls.moreActions': '更多操作',
|
||||||
|
'track.controls.settings.trackColor': '轨道颜色...',
|
||||||
'track.controls.settings.deleteTrack': '删除轨道',
|
'track.controls.settings.deleteTrack': '删除轨道',
|
||||||
'track.controls.settings.deleteTrackConfirm': '确定要删除轨道“{name}”吗?',
|
'track.controls.settings.deleteTrackConfirm': '确定要删除轨道“{name}”吗?',
|
||||||
'track.controls.settings.deleteTrackError': '删除轨道失败。请重试。',
|
'track.controls.settings.deleteTrackError': '删除轨道失败。请重试。',
|
||||||
|
|||||||
@@ -95,6 +95,16 @@ export const zhHkMessages: TranslationMessages = {
|
|||||||
'settings.general.soundfont.managed': 'Soundfont 設定由伺服器(kgone-server.json)管理,目前設定為唯讀。',
|
'settings.general.soundfont.managed': 'Soundfont 設定由伺服器(kgone-server.json)管理,目前設定為唯讀。',
|
||||||
'settings.general.soundfont.baseUrl': '基礎 URL',
|
'settings.general.soundfont.baseUrl': '基礎 URL',
|
||||||
'settings.general.soundfont.baseUrlHelp': '如果改成不兼容的 soundfont 源,某些樂器可能發聲錯誤或無法播放。',
|
'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.section': 'K.G.One 設定',
|
||||||
'settings.general.kgone.managed': 'K.G.One 設定由伺服器(kgone-server.json)管理,目前設定為唯讀。',
|
'settings.general.kgone.managed': 'K.G.One 設定由伺服器(kgone-server.json)管理,目前設定為唯讀。',
|
||||||
'settings.general.kgone.enabled': '啟用 K.G.One 集成',
|
'settings.general.kgone.enabled': '啟用 K.G.One 集成',
|
||||||
@@ -325,6 +335,7 @@ export const zhHkMessages: TranslationMessages = {
|
|||||||
'pianoRoll.curve': '曲線',
|
'pianoRoll.curve': '曲線',
|
||||||
'pianoRoll.zoom': '縮放',
|
'pianoRoll.zoom': '縮放',
|
||||||
'pianoRoll.moreOptions': '更多選項',
|
'pianoRoll.moreOptions': '更多選項',
|
||||||
|
'pianoRoll.regionColor': '區域顏色...',
|
||||||
'pianoRoll.quantize.1/1': '1/1',
|
'pianoRoll.quantize.1/1': '1/1',
|
||||||
'pianoRoll.quantize.1/2': '1/2',
|
'pianoRoll.quantize.1/2': '1/2',
|
||||||
'pianoRoll.quantize.1/3': '1/3',
|
'pianoRoll.quantize.1/3': '1/3',
|
||||||
@@ -536,6 +547,7 @@ export const zhHkMessages: TranslationMessages = {
|
|||||||
'track.controls.automation.volume': '音量',
|
'track.controls.automation.volume': '音量',
|
||||||
'track.controls.automation.pan': '聲像',
|
'track.controls.automation.pan': '聲像',
|
||||||
'track.controls.moreActions': '更多操作',
|
'track.controls.moreActions': '更多操作',
|
||||||
|
'track.controls.settings.trackColor': '音軌顏色...',
|
||||||
'track.controls.settings.deleteTrack': '刪除音軌',
|
'track.controls.settings.deleteTrack': '刪除音軌',
|
||||||
'track.controls.settings.deleteTrackConfirm': '確定要刪除音軌「{name}」嗎?',
|
'track.controls.settings.deleteTrackConfirm': '確定要刪除音軌「{name}」嗎?',
|
||||||
'track.controls.settings.deleteTrackError': '刪除音軌失敗。請再試一次。',
|
'track.controls.settings.deleteTrackError': '刪除音軌失敗。請再試一次。',
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { KGPianoRollState } from '../core/state/KGPianoRollState';
|
|||||||
import { KGMidiNote } from '../core/midi/KGMidiNote';
|
import { KGMidiNote } from '../core/midi/KGMidiNote';
|
||||||
import { KGMidiControllerEvent } from '../core/midi/KGMidiControllerEvent';
|
import { KGMidiControllerEvent } from '../core/midi/KGMidiControllerEvent';
|
||||||
import { KGRegion } from '../core/region/KGRegion';
|
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 { KGAudioTrack } from '../core/track/KGAudioTrack';
|
||||||
import { KGAudioRegion } from '../core/region/KGAudioRegion';
|
import { KGAudioRegion } from '../core/region/KGAudioRegion';
|
||||||
import { KGAudioFileStorage } from '../core/io/KGAudioFileStorage';
|
import { KGAudioFileStorage } from '../core/io/KGAudioFileStorage';
|
||||||
@@ -183,6 +183,7 @@ interface ProjectState {
|
|||||||
removeTrack: (id: number) => Promise<void>;
|
removeTrack: (id: number) => Promise<void>;
|
||||||
updateTrack: (track: KGTrack) => Promise<void>;
|
updateTrack: (track: KGTrack) => Promise<void>;
|
||||||
updateTrackProperties: (trackId: number, properties: TrackUpdateProperties) => Promise<void>;
|
updateTrackProperties: (trackId: number, properties: TrackUpdateProperties) => Promise<void>;
|
||||||
|
updateRegionProperties: (regionId: string, properties: RegionUpdateProperties) => Promise<void>;
|
||||||
setTrackInstrument: (trackId: number, instrument: InstrumentType) => Promise<void>;
|
setTrackInstrument: (trackId: number, instrument: InstrumentType) => Promise<void>;
|
||||||
reorderTracks: (sourceIndex: number, destinationIndex: number) => void;
|
reorderTracks: (sourceIndex: number, destinationIndex: number) => void;
|
||||||
setStatus: (status: string) => void;
|
setStatus: (status: string) => void;
|
||||||
@@ -856,6 +857,24 @@ export const useProjectStore = create<ProjectState>((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) => {
|
setTrackInstrument: async (trackId: number, instrument: InstrumentType) => {
|
||||||
try {
|
try {
|
||||||
// Use the new updateTrackProperties method with command pattern
|
// Use the new updateTrackProperties method with command pattern
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<File> {
|
||||||
|
return {
|
||||||
|
size: this.content.byteLength,
|
||||||
|
text: async () => new TextDecoder().decode(this.content),
|
||||||
|
} as unknown as File;
|
||||||
|
}
|
||||||
|
|
||||||
|
async createWritable(): Promise<MockWritableFileStream> {
|
||||||
|
return new MockWritableFileStream(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class MockFileSystemDirectoryHandle {
|
||||||
|
kind = 'directory' as const;
|
||||||
|
private children = new Map<string, MockFileSystemDirectoryHandle | MockFileSystemFileHandle>();
|
||||||
|
|
||||||
|
constructor(public readonly name: string) {}
|
||||||
|
|
||||||
|
async getDirectoryHandle(name: string, options?: { create?: boolean }): Promise<MockFileSystemDirectoryHandle> {
|
||||||
|
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<MockFileSystemFileHandle> {
|
||||||
|
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<void> {
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<boolean> {
|
||||||
|
try {
|
||||||
|
const rootDir = await this.ensureLibraryDir(baseUrl);
|
||||||
|
const instrumentDir = await rootDir.getDirectoryHandle(instrumentName);
|
||||||
|
const metadata = await this.readJson<SoundfontInstrumentMetadata>(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<Record<string, string>> {
|
||||||
|
const rootDir = await this.ensureLibraryDir(baseUrl);
|
||||||
|
const instrumentDir = await rootDir.getDirectoryHandle(instrumentName);
|
||||||
|
const urls: Record<string, string> = {};
|
||||||
|
|
||||||
|
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<string, Blob>,
|
||||||
|
baseUrl: string,
|
||||||
|
): Promise<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<SoundfontCacheSummary> {
|
||||||
|
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<SoundfontInstrumentMetadata>(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<FileSystemDirectoryHandle> {
|
||||||
|
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<FileSystemDirectoryHandle | null> {
|
||||||
|
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<SoundfontCacheRootMetadata | null> {
|
||||||
|
try {
|
||||||
|
return await this.readJson<SoundfontCacheRootMetadata>(dir, ROOT_METADATA_FILE);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async readJson<T>(dir: FileSystemDirectoryHandle, filename: string): Promise<T> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
try {
|
||||||
|
await dir.removeEntry(name, recursive ? ({ recursive: true } as FileSystemRemoveOptions) : undefined);
|
||||||
|
} catch {
|
||||||
|
// Ignore missing entry cleanup.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user