import { create } from 'zustand'; import { KGCore } from '../core/KGCore'; import { KGTrack } from '../core/track/KGTrack'; import { KGProject, type KeySignature } from '../core/KGProject'; import type { TimeSignature } from '../types/projectTypes'; import { KGMidiTrack, type InstrumentType } from '../core/track/KGMidiTrack'; import { beatsToTimeString } from '../util/timeUtil'; import { KGAudioInterface } from '../core/audio-interface/KGAudioInterface'; import { KGPianoRollState } from '../core/state/KGPianoRollState'; import { KGMidiNote } from '../core/midi/KGMidiNote'; import { KGRegion } from '../core/region/KGRegion'; import { AddTrackCommand, RemoveTrackCommand, ReorderTracksCommand, UpdateTrackCommand, type TrackUpdateProperties, PasteRegionsCommand, PasteNotesCommand, ChangeProjectPropertyCommand } from '../core/commands'; import { ConfigManager } from '../core/config/ConfigManager'; import { upgradeProjectToLatest } from '../core/project-upgrader/KGProjectUpgrader'; /** * Update CSS custom property for time signature numerator * This ensures UI calculations are synchronized with project settings */ function updateTimeSignatureCSS(timeSignature: TimeSignature): void { document.documentElement.style.setProperty('--time-signature-numerator', timeSignature.numerator.toString()); } /** * Update CSS custom property for max number of bars * Keeps layout widths in sync with project max bars */ function updateMaxBarsCSS(maxBars: number): void { document.documentElement.style.setProperty('--max-number-of-bars', maxBars.toString()); } // Define the store state interface interface ProjectState { // State projectName: string; tracks: KGTrack[]; currentStatus: string; maxBars: number; timeSignature: TimeSignature; bpm: number; keySignature: KeySignature; playheadPosition: number; // in beats isPlaying: boolean; currentTime: string; // formatted time string // Selection state for UI reactivity selectedNoteIds: string[]; selectedRegionIds: string[]; selectedTrackId: string | null; // Piano roll state showPianoRoll: boolean; activeRegionId: string | null; // ChatBox state showChatBox: boolean; // Instrument selection panel state showInstrumentSelection: boolean; // instrumentSelectionTrackId removed; panel now follows selectedTrackId // Settings state showSettings: boolean; // Undo/redo state canUndo: boolean; canRedo: boolean; undoDescription: string | null; redoDescription: string | null; // Actions setProjectName: (name: string) => void; addTrack: () => Promise; removeTrack: (id: number) => Promise; updateTrack: (track: KGTrack) => Promise; updateTrackProperties: (trackId: number, properties: TrackUpdateProperties) => Promise; setTrackInstrument: (trackId: number, instrument: InstrumentType) => Promise; reorderTracks: (sourceIndex: number, destinationIndex: number) => void; setStatus: (status: string) => void; removeStatus: () => void; refreshStatus: () => void; loadProject: (project: KGProject | null) => Promise; setPlayheadPosition: (position: number) => void; startPlaying: () => Promise; stopPlaying: () => Promise; setBpm: (bpm: number) => void; setMaxBars: (maxBars: number) => void; setTimeSignature: (timeSignature: TimeSignature) => void; setKeySignature: (keySignature: KeySignature) => void; // Selection actions syncSelectionFromCore: () => void; clearAllSelections: () => void; setSelectedTrack: (trackId: string | null) => void; // Piano roll actions setShowPianoRoll: (show: boolean) => void; setActiveRegionId: (regionId: string | null) => void; // Project state cleanup cleanupProjectState: () => void; // ChatBox actions setShowChatBox: (show: boolean) => void; toggleChatBox: () => void; // Instrument selection panel actions openInstrumentSelectionForTrack: () => void; toggleInstrumentSelectionForTrack: () => void; closeInstrumentSelection: () => void; // Settings actions setShowSettings: (show: boolean) => void; toggleSettings: () => void; // Copy/paste actions pasteRegionsAtTrack: (trackId: string, position: number) => void; pasteNotesToActiveRegion: (regionId: string, position: number) => void; // Undo/redo actions undo: () => void; redo: () => void; syncUndoRedoState: () => void; // Project state refresh actions refreshProjectState: () => void; // Initialization actions initializeFromConfig: () => Promise; } // Create the store export const useProjectStore = create((set, get) => { const currentProject = KGCore.instance().getCurrentProject(); // Initialize CSS variable for time signature on store creation updateTimeSignatureCSS(currentProject.getTimeSignature()); // Initialize CSS variable for max bars on store creation updateMaxBarsCSS(currentProject.getMaxBars()); // Get initial ChatBox state from config const configManager = ConfigManager.instance(); const initialChatBoxState = configManager.getIsInitialized() ? (configManager.get('chatbox.default_open') as boolean) ?? false : false; // Set up playhead update callback to keep store in sync during playback KGCore.instance().setPlayheadUpdateCallback((position: number) => { const { bpm, timeSignature } = get(); set({ playheadPosition: position, currentTime: beatsToTimeString(position, bpm, timeSignature) }); }); // Keep store isPlaying in sync when core auto-stops (e.g., at maxBars) KGCore.instance().setPlaybackStateChangeCallback((isPlaying: boolean) => { set({ isPlaying }); }); // Set up selection sync callback const syncSelectionFromCore = () => { const core = KGCore.instance(); const selectedItems = core.getSelectedItems(); const noteIds = selectedItems .filter(item => item instanceof KGMidiNote) .map(item => item.getId()); const regionIds = selectedItems .filter(item => item instanceof KGRegion) .map(item => item.getId()); set({ selectedNoteIds: noteIds, selectedRegionIds: regionIds }); }; // Register the sync callback with KGCore KGCore.instance().onSelectionChanged(syncSelectionFromCore); // Initial selection sync syncSelectionFromCore(); // Set up command history sync callback const syncUndoRedoState = () => { const core = KGCore.instance(); set({ canUndo: core.canUndo(), canRedo: core.canRedo(), undoDescription: core.getUndoDescription(), redoDescription: core.getRedoDescription() }); }; // Register the undo/redo sync callback with KGCore KGCore.instance().setOnCommandHistoryChanged(syncUndoRedoState); // Initial undo/redo state sync syncUndoRedoState(); // Ensure a default track exists on initial app start // Also auto-select it and open instrument selection panel let initialSelectedTrackId: string | null = null; let initialShowInstrumentSelection = false; try { const project = KGCore.instance().getCurrentProject(); if (project.getTracks().length === 0) { const addDefaultTrackCommand = new AddTrackCommand(undefined, 'Melody'); KGCore.instance().executeCommand(addDefaultTrackCommand); const createdId = String(addDefaultTrackCommand.getTrackId()); initialSelectedTrackId = createdId; initialShowInstrumentSelection = true; } } catch (error) { console.error('Error creating default track on startup:', error); } return { // Initial state projectName: currentProject.getName(), tracks: currentProject.getTracks() as KGTrack[], currentStatus: KGCore.instance().getStatus() || 'Unknown', maxBars: currentProject.getMaxBars(), timeSignature: currentProject.getTimeSignature(), bpm: currentProject.getBpm(), keySignature: currentProject.getKeySignature(), playheadPosition: KGCore.instance().getPlayheadPosition(), isPlaying: KGCore.instance().getIsPlaying(), currentTime: beatsToTimeString(KGCore.instance().getPlayheadPosition(), currentProject.getBpm(), currentProject.getTimeSignature()), // Initial selection state selectedNoteIds: [], selectedRegionIds: [], selectedTrackId: initialSelectedTrackId, // Initial piano roll state showPianoRoll: false, activeRegionId: null, // Initial ChatBox state showChatBox: initialChatBoxState, // Initial Instrument Selection panel state showInstrumentSelection: initialShowInstrumentSelection, // Initial Settings state showSettings: false, // Initial undo/redo state canUndo: false, canRedo: false, undoDescription: null, redoDescription: null, // Actions setProjectName: (name: string) => { try { // Create and execute the change project property command const command = new ChangeProjectPropertyCommand({ name }); KGCore.instance().executeCommand(command); // Update the store state set({ projectName: name }); console.log(`Set project name to "${name}"`); } catch (error) { console.error('Error setting project name:', error); get().setStatus('Failed to set project name'); } }, addTrack: async () => { try { // Create and execute the add track command const command = new AddTrackCommand(); KGCore.instance().executeCommand(command); // Update the store state with a new array reference to trigger re-render const project = KGCore.instance().getCurrentProject(); set({ tracks: [...project.getTracks()] as KGTrack[] }); // Auto-select the newly created track and open instrument selection panel const newTrackId = command.getTrackId().toString(); set({ selectedTrackId: newTrackId, showInstrumentSelection: true, }); console.log(`Added track ${command.getTrackId()}`); } catch (error) { console.error('Error adding track:', error); get().setStatus('Failed to add track'); } }, removeTrack: async (id: number) => { try { // Get the current tracks and find the index of the track being deleted const currentTracks = KGCore.instance().getCurrentProject().getTracks(); const deletedTrackIndex = currentTracks.findIndex(track => track.getId() === id); const { selectedTrackId } = get(); const isCurrentTrackSelected = selectedTrackId === id.toString(); // Create and execute the remove track command const command = new RemoveTrackCommand(id); KGCore.instance().executeCommand(command); // Update the store state with a new array reference to trigger re-render const project = KGCore.instance().getCurrentProject(); const remainingTracks = [...project.getTracks()] as KGTrack[]; set({ tracks: remainingTracks }); // Auto-select another track if any remain if (remainingTracks.length > 0) { // Prefer previous track, fallback to next track const newSelectedIndex = deletedTrackIndex > 0 ? deletedTrackIndex - 1 // Select previous track : 0; // Select first remaining track (was next) const newSelectedTrack = remainingTracks[newSelectedIndex]; const newSelectedTrackId = newSelectedTrack.getId().toString(); setTimeout(() => { set({ selectedTrackId: isCurrentTrackSelected ? newSelectedTrackId : selectedTrackId, }); }, 0); } else { // No tracks left, clear selection and close instrument panel set({ selectedTrackId: null, showInstrumentSelection: false, }); } console.log(`Removed track ${id}`); } catch (error) { console.error('Error removing track:', error); get().setStatus('Failed to remove track'); } }, updateTrack: async (updatedTrack: KGTrack) => { const { tracks } = get(); try { // Find the old track to compare instruments const oldTrack = tracks.find(track => track.getId() === updatedTrack.getId()); // Type guard to check if track is KGMidiTrack const isMidiTrack = (track: KGTrack): track is KGMidiTrack => { return track.getCurrentType() === 'KGMidiTrack' && 'getInstrument' in track; }; // Check if instrument changed (only for MIDI tracks) const shouldUpdateInstrument = isMidiTrack(updatedTrack) && oldTrack && isMidiTrack(oldTrack) && updatedTrack.getInstrument() !== oldTrack.getInstrument(); // Update instrument in audio interface if changed if (shouldUpdateInstrument && isMidiTrack(updatedTrack)) { const audioInterface = KGAudioInterface.instance(); const newInstrument = updatedTrack.getInstrument(); audioInterface.setTrackInstrument(updatedTrack.getId().toString(), newInstrument); console.log(`Updated track ${updatedTrack.getId()} instrument to ${newInstrument}`); } // Find and update the track const updatedTracks = tracks.map(track => track.getId() === updatedTrack.getId() ? updatedTrack : track ); // Update the core model KGCore.instance().getCurrentProject().setTracks(updatedTracks); // Update the store set({ tracks: updatedTracks }); console.log(`Updated track ${updatedTrack.getId()}`); } catch (error) { console.error('Error updating track:', error); get().setStatus('Failed to update track'); } }, updateTrackProperties: async (trackId: number, properties: TrackUpdateProperties) => { try { // Create and execute the update track command const command = new UpdateTrackCommand(trackId, properties); KGCore.instance().executeCommand(command); // Update the store state with the current project state const project = KGCore.instance().getCurrentProject(); set({ tracks: [...project.getTracks()] as KGTrack[] }); console.log(`Updated track ${trackId} properties`); } catch (error) { console.error('Error updating track properties:', error); get().setStatus('Failed to update track properties'); } }, setTrackInstrument: async (trackId: number, instrument: InstrumentType) => { try { // Use the new updateTrackProperties method with command pattern await get().updateTrackProperties(trackId, { instrument }); console.log(`Set track ${trackId} instrument to ${instrument}`); } catch (error) { console.error('Error setting track instrument:', error); get().setStatus('Failed to change instrument'); } }, reorderTracks: (sourceIndex: number, destinationIndex: number) => { try { // Create and execute the reorder tracks command const command = new ReorderTracksCommand(sourceIndex, destinationIndex); KGCore.instance().executeCommand(command); // Update the store state with the current project state const project = KGCore.instance().getCurrentProject(); set({ tracks: [...project.getTracks()] as KGTrack[] }); console.log(`Reordered track from index ${sourceIndex} to ${destinationIndex}`); } catch (error) { console.error('Error reordering tracks:', error); get().setStatus('Failed to reorder tracks'); } }, setStatus: (status: string) => { KGCore.instance().setStatus(status); set({ currentStatus: status }); }, removeStatus: () => { KGCore.instance().setStatus(''); set({ currentStatus: '' }); }, refreshStatus: () => { set({ currentStatus: KGCore.instance().getStatus() || 'Unknown' }); }, loadProject: async (project: KGProject | null = null) => { try { const { setPlayheadPosition } = get(); // Upgrade incoming project data to latest structure version (only when provided explicitly) if (project) { project = upgradeProjectToLatest(project); } // Get project from KGCore if null const projectToLoad = project || KGCore.instance().getCurrentProject(); // Set the project in KGCore if one was provided if (project) { KGCore.instance().setCurrentProject(projectToLoad); } // Ensure a default "Melody" track exists for empty projects if (projectToLoad.getTracks().length === 0) { const addDefaultTrackCommand = new AddTrackCommand(undefined, 'Melody'); KGCore.instance().executeCommand(addDefaultTrackCommand); } // Reset playhead to 0 when loading a project setPlayheadPosition(0); // Get project properties const maxBars = projectToLoad.getMaxBars(); const timeSignature = projectToLoad.getTimeSignature(); const bpm = projectToLoad.getBpm(); const keySignature = projectToLoad.getKeySignature(); const tracks = projectToLoad.getTracks(); // Setup audio synths for all tracks const audioInterface = KGAudioInterface.instance(); // Clear any existing synths first tracks.forEach(track => { audioInterface.removeTrackSynth(track.getId().toString()); }); // Create synths for all tracks (with their stored volumes) tracks.forEach(track => { const trackId = track.getId().toString(); // Get instrument from track model if it's a MIDI track let instrument: InstrumentType = 'acoustic_grand_piano'; // Default fallback if (track.getCurrentType() === 'KGMidiTrack' && 'getInstrument' in track) { instrument = (track as KGMidiTrack).getInstrument(); } audioInterface.createTrackSynth(trackId, instrument); // Volume is applied during bus creation; ensure sync if bus already existed audioInterface.setTrackVolume(trackId, track.getVolume()); }); // Update CSS variable for time signature numerator updateTimeSignatureCSS(timeSignature); // Update CSS variable for max bars updateMaxBarsCSS(maxBars); // Log project loading info console.log(`Project max bars: ${maxBars}`); console.log(`Setup audio synths for ${tracks.length} tracks`); // Update the store state to reflect the loaded project // Force a new array reference for tracks to trigger React/Zustand re-render set({ projectName: projectToLoad.getName(), tracks: [...tracks], maxBars, timeSignature, bpm, keySignature, playheadPosition: 0, // Ensure store state is also updated currentTime: beatsToTimeString(0, bpm, timeSignature) // Reset time display }); // After loading a project, auto-select the first track and open Instrument Selection const firstTrack = tracks[0]; if (firstTrack) { const firstTrackIdStr = firstTrack.getId().toString(); set({ selectedTrackId: firstTrackIdStr, showInstrumentSelection: true, }); } // Reset piano roll state for new/loaded project KGPianoRollState.instance().setLastEditedNoteLength(1); // Add a status message KGCore.instance().setStatus(`Project "${projectToLoad.getName()}" loaded with audio setup`); set({ currentStatus: KGCore.instance().getStatus() || 'Unknown' }); } catch (error) { console.error('Error loading project:', error); get().setStatus('Failed to load project'); } }, setPlayheadPosition: (position: number) => { const { bpm, timeSignature } = get(); KGCore.instance().setPlayheadPosition(position); set({ playheadPosition: position, currentTime: beatsToTimeString(position, bpm, timeSignature) }); }, startPlaying: async () => { await KGCore.instance().startPlaying(); set({ isPlaying: true }); }, stopPlaying: async () => { await KGCore.instance().stopPlaying(); set({ isPlaying: false }); }, setBpm: (bpm: number) => { try { // Create and execute the change project property command const command = new ChangeProjectPropertyCommand({ bpm }); KGCore.instance().executeCommand(command); // Update the store state set({ bpm }); console.log(`Set BPM to ${bpm}`); } catch (error) { console.error('Error setting BPM:', error); get().setStatus('Failed to set BPM'); } }, setMaxBars: (maxBars: number) => { try { // Create and execute the change project property command const command = new ChangeProjectPropertyCommand({ maxBars }); KGCore.instance().executeCommand(command); // Update the store state set({ maxBars }); // Sync CSS var so layout adjusts immediately updateMaxBarsCSS(maxBars); console.log(`Set max bars to ${maxBars}`); } catch (error) { console.error('Error setting max bars:', error); get().setStatus('Failed to set max bars'); } }, setTimeSignature: (timeSignature: TimeSignature) => { try { // Create and execute the change project property command const command = new ChangeProjectPropertyCommand({ timeSignature }); KGCore.instance().executeCommand(command); // Update the store state set({ timeSignature }); updateTimeSignatureCSS(timeSignature); console.log(`Set time signature to ${timeSignature.numerator}/${timeSignature.denominator}`); } catch (error) { console.error('Error setting time signature:', error); get().setStatus('Failed to set time signature'); } }, setKeySignature: (keySignature: KeySignature) => { try { // Create and execute the change project property command const command = new ChangeProjectPropertyCommand({ keySignature }); KGCore.instance().executeCommand(command); // Update the store state set({ keySignature }); console.log(`Set key signature to ${keySignature}`); } catch (error) { console.error('Error setting key signature:', error); get().setStatus('Failed to set key signature'); } }, // Selection actions syncSelectionFromCore, clearAllSelections: () => { KGCore.instance().clearSelectedItems(); // Note: syncSelectionFromCore will be called automatically via callback }, setSelectedTrack: (trackId: string | null) => { // Update selected track id set({ selectedTrackId: trackId }); }, // Piano roll actions setShowPianoRoll: (show: boolean) => { set({ showPianoRoll: show }); }, setActiveRegionId: (regionId: string | null) => { set({ activeRegionId: regionId }); }, // Project state cleanup - used when starting new/loading projects cleanupProjectState: () => { // Close piano roll if it's visible set({ showPianoRoll: false }); // Clear active region set({ activeRegionId: null }); // Clear any selected items KGCore.instance().clearSelectedItems(); // Note: syncSelectionFromCore will be called automatically via callback console.log("Cleaned up project state: closed piano roll, cleared active region, cleared selections"); }, // ChatBox action implementations setShowChatBox: (show: boolean) => { set({ showChatBox: show }); }, toggleChatBox: () => { const { showChatBox } = get(); set({ showChatBox: !showChatBox }); }, // Instrument selection panel actions openInstrumentSelectionForTrack: () => { set({ showInstrumentSelection: true }); }, toggleInstrumentSelectionForTrack: () => { set({ showInstrumentSelection: true }); }, closeInstrumentSelection: () => { set({ showInstrumentSelection: false }); }, // Settings action implementations setShowSettings: (show: boolean) => { set({ showSettings: show }); }, toggleSettings: () => { const { showSettings } = get(); set({ showSettings: !showSettings }); }, // Copy/paste actions pasteRegionsAtTrack: (trackId: string, position: number) => { // Use command pattern for region pasting with undo support const command = PasteRegionsCommand.fromClipboard(trackId, position); if (!command) { console.log('No regions to paste'); return; } try { KGCore.instance().executeCommand(command); // Update the store to trigger re-render const { tracks } = get(); const updatedTracks = [...tracks]; set({ tracks: updatedTracks }); console.log(`Executed PasteRegionsCommand: pasted regions to track ${trackId} using command pattern`); } catch (error) { console.error('Error pasting regions:', error); } }, pasteNotesToActiveRegion: (regionId: string, position: number) => { // Use command pattern for note pasting with undo support const command = PasteNotesCommand.fromClipboard(regionId, position); if (!command) { console.log('No notes to paste'); return; } try { KGCore.instance().executeCommand(command); // Update the store to trigger re-render const { tracks } = get(); const updatedTracks = [...tracks]; set({ tracks: updatedTracks }); console.log(`Executed PasteNotesCommand: pasted notes to region ${regionId} using command pattern`); } catch (error) { console.error('Error pasting notes:', error); } }, // Undo/redo actions undo: () => { const core = KGCore.instance(); if (core.undo()) { // Use centralized refresh method get().refreshProjectState(); console.log('Undo completed'); } }, redo: () => { const core = KGCore.instance(); if (core.redo()) { // Use centralized refresh method get().refreshProjectState(); console.log('Redo completed'); } }, syncUndoRedoState: () => { const core = KGCore.instance(); set({ canUndo: core.canUndo(), canRedo: core.canRedo(), undoDescription: core.getUndoDescription(), redoDescription: core.getRedoDescription() }); }, // Centralized project state refresh method // Used by undo/redo and external operations (like XML tools) to sync UI with core model refreshProjectState: () => { const core = KGCore.instance(); const project = core.getCurrentProject(); // Force new array reference to trigger React re-renders set({ projectName: project.getName(), tracks: [...project.getTracks()] as KGTrack[], // Force new array reference - key for re-rendering! maxBars: project.getMaxBars(), timeSignature: project.getTimeSignature(), bpm: project.getBpm(), keySignature: project.getKeySignature() }); // Sync CSS variables that affect layout updateTimeSignatureCSS(project.getTimeSignature()); updateMaxBarsCSS(project.getMaxBars()); // Sync all related state const actions = get(); actions.syncUndoRedoState(); actions.syncSelectionFromCore(); }, // Initialize store with configuration values initializeFromConfig: async () => { const configManager = ConfigManager.instance(); if (configManager.getIsInitialized()) { const defaultChatBoxOpen = (configManager.get('chatbox.default_open') as boolean) ?? false; set({ showChatBox: defaultChatBoxOpen }); } } } });