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 { KGMidiControllerEvent } from '../core/midi/KGMidiControllerEvent'; import { KGRegion } from '../core/region/KGRegion'; import { AddTrackCommand, AddAudioTrackCommand, RemoveTrackCommand, ReorderTracksCommand, UpdateTrackCommand, type TrackUpdateProperties, PasteRegionsCommand, PasteNotesCommand, ChangeProjectPropertyCommand, ImportAudioCommand } from '../core/commands'; import { KGAudioTrack } from '../core/track/KGAudioTrack'; import { KGAudioRegion } from '../core/region/KGAudioRegion'; import { KGAudioFileStorage } from '../core/io/KGAudioFileStorage'; import { ConfigManager } from '../core/config/ConfigManager'; import { upgradeProjectToLatest } from '../core/project-upgrader/KGProjectUpgrader'; import { toggleLoop } from '../util/loopUtil'; import { TOOLBAR_CONSTANTS } from '../constants/uiConstants'; import * as Tone from 'tone'; import { KGMidiInput } from '../core/midi-input/KGMidiInput'; import { KGMidiRegion } from '../core/region/KGMidiRegion'; import { KGMidiPitchBend } from '../core/midi/KGMidiPitchBend'; import { CreateMidiEventsCommand, type NoteCreationData, type PitchBendCreationData, type ControllerEventCreationData } from '../core/commands/note/CreateMidiEventsCommand'; import { MIDI_PITCH_BEND_CENTER } from '../util/midiUtil'; import { KGTrackAutomationPoint, type TrackAutomationType } from '../core/track/KGTrackAutomationPoint'; import type { AudioRecordingPeak } from '../core/audio-interface/KGAudioRecorder'; /** * 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()); } /** * Update CSS custom property for track grid bar width based on multiplier */ function updateBarWidthMultiplierCSS(multiplier: number): void { document.documentElement.style.setProperty( '--track-grid-bar-width', `${TOOLBAR_CONSTANTS.BASE_BAR_WIDTH * multiplier}px` ); } // Define the store state interface interface ProjectState { // State projectName: string; savedProjectName: string; // OPFS folder name where the project is currently saved tracks: KGTrack[]; currentStatus: string; maxBars: number; barWidthMultiplier: number; timeSignature: TimeSignature; bpm: number; keySignature: KeySignature; selectedMode: string; isLooping: boolean; isMetronomeEnabled: boolean; loopingRange: [number, number]; // [startBar, endBar] - bar indices (0-based) playheadPosition: number; // in beats isPlaying: boolean; isPreparingPlayback: boolean; autoScrollEnabled: boolean; currentTime: string; // formatted time string // Selection state for UI reactivity selectedNoteIds: string[]; selectedPitchBendIds: string[]; selectedControllerEventIds: string[]; selectedTrackAutomationPointIds: string[]; selectedRegionIds: string[]; selectedTrackId: string | null; // Piano roll state showPianoRoll: boolean; activeRegionId: string | null; pianoRollMode: 'midi-edit' | 'spectrogram' | 'hybrid'; hybridAudioRegionId: string | null; automationRedrawVersion: number; activeTrackAutomationTrackId: string | null; activeTrackAutomationType: TrackAutomationType | null; trackAutomationRedrawVersion: number; // ChatBox state showChatBox: boolean; // K.G.One panel state showKGOnePanel: boolean; // List event panel state showListEventPanel: boolean; // Instrument selection panel state showInstrumentSelection: boolean; // instrumentSelectionTrackId removed; panel now follows selectedTrackId // Audio import modal state showAudioImportModal: boolean; audioImportTargetTrackId: string | null; // Settings state showSettings: boolean; // Recording state isRecording: boolean; recordingMode: 'midi' | 'audio' | null; recordingTargetRegionId: string | null; recordingTargetTrackId: string | null; recordingTargetTrackIndex: number | null; recordingNotes: Array<{ pitch: number; startBeat: number; endBeat: number; velocity: number }>; recordingPitchBends: Array<{ beat: number; value: number }>; recordingControllerEventsByType: Array>; recordingOriginalPlayhead: number; recordingStartBeatAbsolute: number; recordingCommitStartBeatAbsolute: number; recordingAudioPreviewPeaks: AudioRecordingPeak[]; recordingAudioPreviewCurrentBeat: number; recordingAudioPreviewFileName: string | null; // Undo/redo state canUndo: boolean; canRedo: boolean; undoDescription: string | null; redoDescription: string | null; // Cross-component scroll request state requestMainContentScroll: (beatPosition: number) => void; requestPianoRollScroll: (beatPosition: number) => void; mainContentScrollRequest: number | null; pianoRollScrollRequest: number | null; // Actions setProjectName: (name: string) => void; setSavedProjectName: (name: string) => void; addTrack: () => Promise; addAudioTrack: () => Promise; importAudioToTrack: (trackId: string, file: File) => Promise; openAudioImportModal: (trackId: string) => void; closeAudioImportModal: () => void; 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, savedName?: string) => Promise; setPlayheadPosition: (position: number) => void; setAutoScrollEnabled: (enabled: boolean) => void; startPlaying: () => Promise; stopPlaying: () => Promise; stopTransport: () => Promise; toggleLoop: () => void; toggleMetronome: () => void; setBpm: (bpm: number) => void; setMaxBars: (maxBars: number) => void; setBarWidthMultiplier: (multiplier: number) => void; setTimeSignature: (timeSignature: TimeSignature) => void; setKeySignature: (keySignature: KeySignature) => void; setSelectedMode: (selectedMode: string) => void; // Selection actions syncSelectionFromCore: () => void; clearAllSelections: () => void; setSelectedTrack: (trackId: string | null) => void; setTrackAutomationView: (trackId: string | null, automationType: TrackAutomationType | null) => void; // Piano roll actions setShowPianoRoll: (show: boolean) => void; setActiveRegionId: (regionId: string | null) => void; openMidiPianoRoll: (regionId: string) => void; openSpectrogramViewer: (regionId: string) => void; openHybridMode: (midiRegionId: string, audioRegionId: string) => void; bumpAutomationRedrawVersion: () => void; bumpTrackAutomationRedrawVersion: () => void; // Project state cleanup cleanupProjectState: () => void; // ChatBox actions setShowChatBox: (show: boolean) => void; toggleChatBox: () => void; // K.G.One panel actions toggleKGOnePanel: () => void; // List event panel actions toggleListEventPanel: () => 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; // Recording actions startRecording: () => Promise; stopRecording: () => Promise; // Initialization actions initializeFromConfig: () => Promise; } // Module-level recording state (not reactive — only used for timing during active recording) let _recordingActiveNotes: Map = new Map(); // pitch → note-on data let _recordingRegionStartBeat: number = 0; let _lastRecordedPitchBendValue: number | null = null; let _lastRecordedControllerValues: Map = new Map(); let _audioRecordingStartTimeoutId: number | null = null; let _audioRecordingForcedStopBeatAbsolute: number | null = null; let _audioRecordingHasStarted: boolean = false; function createEmptyRecordedControllerBuckets(): Array> { return Array.from({ length: 128 }, () => []); } function getRecordingLoopEndBeatRelative(): number | null { const project = KGCore.instance().getCurrentProject(); if (!project.getIsLooping()) { return null; } const [startBar, endBarOriginal] = project.getLoopingRange(); const beatsPerBar = project.getTimeSignature().numerator; const endBar = (startBar === 0 && endBarOriginal === 0) ? project.getMaxBars() : endBarOriginal; const loopEndBeatAbsolute = (endBar + 1) * beatsPerBar; return loopEndBeatAbsolute - _recordingRegionStartBeat; } function finalizeRecordedNote(startBeat: number, candidateEndBeat: number): number { const loopEndBeatRelative = getRecordingLoopEndBeatRelative(); if (loopEndBeatRelative !== null && candidateEndBeat < startBeat) { return loopEndBeatRelative; } return candidateEndBeat; } function clearPendingAudioRecordingStart(): void { if (_audioRecordingStartTimeoutId !== null) { window.clearTimeout(_audioRecordingStartTimeoutId); _audioRecordingStartTimeoutId = null; } } function getAudioRecordingExtension(mimeType: string): string { if (mimeType.includes('ogg')) return 'ogg'; if (mimeType.includes('mp4')) return 'm4a'; if (mimeType.includes('wav')) return 'wav'; return 'webm'; } // 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()); // Initialize CSS variable for bar width multiplier on store creation updateBarWidthMultiplierCSS(currentProject.getBarWidthMultiplier()); // 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(state => ({ playheadPosition: position, currentTime: beatsToTimeString(position, bpm, timeSignature), recordingAudioPreviewCurrentBeat: state.recordingMode === 'audio' ? Math.max(state.recordingCommitStartBeatAbsolute, position) : state.recordingAudioPreviewCurrentBeat, })); }); // 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 pitchBendIds = selectedItems .filter(item => item instanceof KGMidiPitchBend) .map(item => item.getId()); const controllerEventIds = selectedItems .filter(item => item instanceof KGMidiControllerEvent) .map(item => item.getId()); const trackAutomationPointIds = selectedItems .filter(item => item instanceof KGTrackAutomationPoint) .map(item => item.getId()); const regionIds = selectedItems .filter(item => item instanceof KGRegion) .map(item => item.getId()); set({ selectedNoteIds: noteIds, selectedPitchBendIds: pitchBendIds, selectedControllerEventIds: controllerEventIds, selectedTrackAutomationPointIds: trackAutomationPointIds, 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(), savedProjectName: currentProject.getName(), tracks: currentProject.getTracks() as KGTrack[], currentStatus: KGCore.instance().getStatus() || 'Unknown', maxBars: currentProject.getMaxBars(), barWidthMultiplier: currentProject.getBarWidthMultiplier(), timeSignature: currentProject.getTimeSignature(), bpm: currentProject.getBpm(), keySignature: currentProject.getKeySignature(), selectedMode: currentProject.getSelectedMode(), isLooping: currentProject.getIsLooping(), isMetronomeEnabled: false, loopingRange: currentProject.getLoopingRange(), playheadPosition: KGCore.instance().getPlayheadPosition(), isPlaying: KGCore.instance().getIsPlaying(), isPreparingPlayback: false, autoScrollEnabled: true, currentTime: beatsToTimeString(KGCore.instance().getPlayheadPosition(), currentProject.getBpm(), currentProject.getTimeSignature()), // Initial selection state selectedNoteIds: [], selectedPitchBendIds: [], selectedControllerEventIds: [], selectedTrackAutomationPointIds: [], selectedRegionIds: [], selectedTrackId: initialSelectedTrackId, // Initial piano roll state showPianoRoll: false, activeRegionId: null, pianoRollMode: 'midi-edit' as const, hybridAudioRegionId: null, automationRedrawVersion: 0, activeTrackAutomationTrackId: null, activeTrackAutomationType: null, trackAutomationRedrawVersion: 0, // Initial ChatBox state showChatBox: initialChatBoxState, // Initial K.G.One panel state showKGOnePanel: false, // Initial List Event panel state showListEventPanel: false, // Initial Instrument Selection panel state showInstrumentSelection: initialShowInstrumentSelection, // Initial audio import modal state showAudioImportModal: false, audioImportTargetTrackId: null, // Initial Settings state showSettings: false, // Initial undo/redo state canUndo: false, canRedo: false, undoDescription: null, redoDescription: null, // Initial recording state isRecording: false, recordingMode: null, recordingTargetRegionId: null, recordingTargetTrackId: null, recordingTargetTrackIndex: null, recordingNotes: [], recordingPitchBends: [], recordingControllerEventsByType: createEmptyRecordedControllerBuckets(), recordingOriginalPlayhead: 0, recordingStartBeatAbsolute: 0, recordingCommitStartBeatAbsolute: 0, recordingAudioPreviewPeaks: [], recordingAudioPreviewCurrentBeat: 0, recordingAudioPreviewFileName: null, // Initial cross-component scroll request state mainContentScrollRequest: null, pianoRollScrollRequest: 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'); } }, setSavedProjectName: (name: string) => { set({ savedProjectName: 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'); } }, addAudioTrack: async () => { try { const command = new AddAudioTrackCommand(); KGCore.instance().executeCommand(command); const project = KGCore.instance().getCurrentProject(); set({ tracks: [...project.getTracks()] as KGTrack[] }); const newTrackId = command.getTrackId().toString(); set({ selectedTrackId: newTrackId, showAudioImportModal: true, audioImportTargetTrackId: newTrackId, }); console.log(`Added audio track ${command.getTrackId()}`); } catch (error) { console.error('Error adding audio track:', error); get().setStatus('Failed to add audio track'); } }, importAudioToTrack: async (trackId: string, file: File) => { try { get().setStatus(`Importing "${file.name}"...`); // Decode the audio file to get duration const arrayBuffer = await file.arrayBuffer(); const toneBuffer = new Tone.ToneAudioBuffer(); await new Promise((resolve, reject) => { toneBuffer.onload = () => resolve(); // Set buffer from array buffer const audioContext = Tone.getContext().rawContext as AudioContext; audioContext.decodeAudioData( arrayBuffer.slice(0), // slice to avoid detached buffer (decoded) => { toneBuffer.set(decoded); resolve(); }, (err) => reject(err) ); }); const audioDurationSeconds = toneBuffer.duration; const { bpm, timeSignature, playheadPosition, maxBars } = get(); // Calculate duration in beats const durationInBeats = audioDurationSeconds * (bpm / 60); // Calculate if we need to expand maxBars const beatsPerBar = timeSignature.numerator; const endBeat = playheadPosition + durationInBeats; const requiredBars = Math.ceil(endBeat / beatsPerBar); const newMaxBars = Math.max(maxBars, requiredBars); // Store audio file in OPFS const projectName = get().projectName; const audioFileId = KGAudioFileStorage.generateAudioFileId(file.name); await KGAudioFileStorage.storeAudioFile(projectName, audioFileId, file); // Load buffer into the audio player bus const audioInterface = KGAudioInterface.instance(); audioInterface.loadAudioBufferForTrack(trackId, audioFileId, toneBuffer); // Find the track to get trackIndex const project = KGCore.instance().getCurrentProject(); const track = project.getTracks().find(t => t.getId().toString() === trackId); if (!track) { throw new Error(`Track ${trackId} not found`); } // Execute the import command const command = new ImportAudioCommand( track.getId(), track.getTrackIndex(), audioFileId, file.name, audioDurationSeconds, playheadPosition, durationInBeats, maxBars, newMaxBars ); KGCore.instance().executeCommand(command); // Update store state const updatedState: Partial = { tracks: [...project.getTracks()] as KGTrack[], }; if (newMaxBars > maxBars) { updatedState.maxBars = newMaxBars; updateMaxBarsCSS(newMaxBars); } set(updatedState); get().setStatus(`Imported "${file.name}" successfully`); console.log(`Imported audio "${file.name}" to track ${trackId}`); } catch (error) { console.error('Error importing audio:', error); get().setStatus(`Failed to import audio: ${error}`); } }, openAudioImportModal: (trackId: string) => { set({ showAudioImportModal: true, audioImportTargetTrackId: trackId }); }, closeAudioImportModal: () => { set({ showAudioImportModal: false, audioImportTargetTrackId: null }); }, 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, savedName?: string) => { 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/buses first tracks.forEach(track => { const trackId = track.getId().toString(); audioInterface.removeTrackSynth(trackId); audioInterface.removeTrackAudioPlayerBus(trackId); }); // Create synths/buses for all tracks (with their stored volumes) const projectName = projectToLoad.getName(); for (const track of tracks) { const trackId = track.getId().toString(); if (track.getCurrentType() === 'KGAudioTrack') { // Audio track: create player bus and load audio buffers await audioInterface.createTrackAudioPlayerBus(trackId, track.getVolume()); // Load audio buffers for all regions in this audio track const audioTrack = track as KGAudioTrack; for (const region of audioTrack.getRegions()) { if (region.getCurrentType() === 'KGAudioRegion') { const audioRegion = region as KGAudioRegion; const audioFileId = audioRegion.getAudioFileId(); if (audioFileId) { try { const arrayBuffer = await KGAudioFileStorage.loadAudioFile(projectName, audioFileId); const audioContext = Tone.getContext().rawContext as AudioContext; const decoded = await audioContext.decodeAudioData(arrayBuffer); const toneBuffer = new Tone.ToneAudioBuffer(); toneBuffer.set(decoded); audioInterface.loadAudioBufferForTrack(trackId, audioFileId, toneBuffer); } catch (err) { console.error(`Failed to load audio file ${audioFileId}:`, err); } } } } } else { // MIDI track: create sampler-based audio bus let instrument: InstrumentType = 'acoustic_grand_piano'; if (track.getCurrentType() === 'KGMidiTrack' && 'getInstrument' in track) { instrument = (track as KGMidiTrack).getInstrument(); } audioInterface.createTrackSynth(trackId, instrument); audioInterface.setTrackVolume(trackId, track.getVolume()); } } // Update CSS variables updateTimeSignatureCSS(timeSignature); updateMaxBarsCSS(maxBars); updateBarWidthMultiplierCSS(projectToLoad.getBarWidthMultiplier()); // 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(), savedProjectName: savedName ?? projectToLoad.getName(), tracks: [...tracks], maxBars, barWidthMultiplier: projectToLoad.getBarWidthMultiplier(), timeSignature, bpm, keySignature, selectedMode: projectToLoad.getSelectedMode(), isLooping: projectToLoad.getIsLooping(), loopingRange: projectToLoad.getLoopingRange(), activeTrackAutomationTrackId: null, activeTrackAutomationType: null, trackAutomationRedrawVersion: 0, isRecording: false, recordingMode: null, recordingTargetRegionId: null, recordingTargetTrackId: null, recordingTargetTrackIndex: null, recordingNotes: [], recordingPitchBends: [], recordingControllerEventsByType: createEmptyRecordedControllerBuckets(), recordingOriginalPlayhead: 0, recordingStartBeatAbsolute: 0, recordingCommitStartBeatAbsolute: 0, recordingAudioPreviewPeaks: [], recordingAudioPreviewCurrentBeat: 0, recordingAudioPreviewFileName: null, 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) }); }, setAutoScrollEnabled: (enabled: boolean) => { set({ autoScrollEnabled: enabled }); }, requestMainContentScroll: (beatPosition: number) => { set({ mainContentScrollRequest: beatPosition }); }, requestPianoRollScroll: (beatPosition: number) => { set({ pianoRollScrollRequest: beatPosition }); }, startPlaying: async () => { if (get().isPreparingPlayback) { return; } set({ isPreparingPlayback: true }); try { await KGCore.instance().startPlaying(); set({ isPlaying: true, autoScrollEnabled: true }); } finally { set({ isPreparingPlayback: false }); } }, stopPlaying: async () => { try { await KGCore.instance().stopPlaying(); set({ isPlaying: false }); } finally { set({ isPreparingPlayback: false }); } }, stopTransport: async () => { if (get().isRecording) { await get().stopRecording(); return; } try { await get().stopPlaying(); } finally { set({ isPreparingPlayback: false }); } }, startRecording: async () => { const { activeRegionId, timeSignature, playheadPosition, setPlayheadPosition, selectedTrackId, tracks, } = get(); const selectedTrack = tracks.find(track => track.getId().toString() === selectedTrackId) ?? null; if (selectedTrack instanceof KGAudioTrack) { const project = KGCore.instance().getCurrentProject(); const beatsPerBar = timeSignature.numerator; const projectLooping = project.getIsLooping(); const [loopStartBar] = project.getLoopingRange(); const loopStartBeat = loopStartBar * beatsPerBar; const recordingCommitStartBeatAbsolute = projectLooping ? loopStartBeat : playheadPosition; const recordingStartBeatAbsolute = recordingCommitStartBeatAbsolute - beatsPerBar; const previewFileName = `Recording_${new Date().toISOString().replace(/[:.]/g, '-')}`; clearPendingAudioRecordingStart(); _audioRecordingForcedStopBeatAbsolute = null; _audioRecordingHasStarted = false; set({ isRecording: true, recordingMode: 'audio', recordingTargetRegionId: null, recordingTargetTrackId: selectedTrack.getId().toString(), recordingTargetTrackIndex: selectedTrack.getTrackIndex(), recordingNotes: [], recordingPitchBends: [], recordingControllerEventsByType: createEmptyRecordedControllerBuckets(), recordingOriginalPlayhead: playheadPosition, recordingStartBeatAbsolute, recordingCommitStartBeatAbsolute, recordingAudioPreviewPeaks: [], recordingAudioPreviewCurrentBeat: recordingCommitStartBeatAbsolute, recordingAudioPreviewFileName: previewFileName, }); KGCore.instance().setLoopBoundaryReachedCallback(projectLooping ? (loopEndBeat: number) => { _audioRecordingForcedStopBeatAbsolute = loopEndBeat; void get().stopRecording(); } : null); setPlayheadPosition(recordingStartBeatAbsolute); set({ isPreparingPlayback: true }); try { await KGCore.instance().startPlaying({ preserveLoopPreroll: projectLooping, }); set({ isPlaying: true, autoScrollEnabled: true }); const prerollMs = Math.max(0, ((recordingCommitStartBeatAbsolute - recordingStartBeatAbsolute) * (60 / project.getBpm())) * 1000); _audioRecordingStartTimeoutId = window.setTimeout(() => { _audioRecordingStartTimeoutId = null; const inputDeviceId = (ConfigManager.instance().get('audio.input_device_id') as string | undefined) ?? 'default'; void KGAudioInterface.instance().startAudioRecording(inputDeviceId, (peaks) => { set({ recordingAudioPreviewPeaks: peaks }); }).then((startResult) => { _audioRecordingHasStarted = true; if (startResult.fellBackToDefault) { void ConfigManager.instance().set('audio.input_device_id', 'default'); get().setStatus('Previously selected audio input device is unavailable; using System Default.'); } }).catch(async (error) => { console.error('Failed to start audio recording:', error); await KGAudioInterface.instance().cancelAudioRecording(); KGCore.instance().setLoopBoundaryReachedCallback(null); await get().stopPlaying(); setPlayheadPosition(playheadPosition); set({ isRecording: false, recordingMode: null, recordingTargetTrackId: null, recordingTargetTrackIndex: null, recordingStartBeatAbsolute: 0, recordingCommitStartBeatAbsolute: 0, recordingAudioPreviewPeaks: [], recordingAudioPreviewCurrentBeat: 0, recordingAudioPreviewFileName: null, }); get().setStatus(error instanceof Error ? error.message : 'Unable to start audio recording.'); }); }, prerollMs); } finally { set({ isPreparingPlayback: false }); } return; } const project = KGCore.instance().getCurrentProject(); let targetRegion: KGMidiRegion | null = null; for (const track of project.getTracks()) { const found = track.getRegions().find(r => r.getId() === activeRegionId); if (found instanceof KGMidiRegion) { targetRegion = found; break; } } if (!targetRegion) return; _recordingRegionStartBeat = targetRegion.getStartFromBeat(); _recordingActiveNotes = new Map(); _lastRecordedPitchBendValue = null; _lastRecordedControllerValues = new Map(); const projectLooping = project.getIsLooping(); const [loopStartBar] = project.getLoopingRange(); const loopStartBeat = loopStartBar * timeSignature.numerator; const recordingStartBeat = projectLooping ? loopStartBeat - timeSignature.numerator : playheadPosition - timeSignature.numerator; set({ isRecording: true, recordingMode: 'midi', recordingNotes: [], recordingPitchBends: [], recordingControllerEventsByType: createEmptyRecordedControllerBuckets(), recordingTargetRegionId: activeRegionId, recordingTargetTrackId: null, recordingTargetTrackIndex: null, recordingOriginalPlayhead: playheadPosition, recordingStartBeatAbsolute: recordingStartBeat, recordingCommitStartBeatAbsolute: targetRegion.getStartFromBeat(), recordingAudioPreviewPeaks: [], recordingAudioPreviewCurrentBeat: 0, recordingAudioPreviewFileName: null, }); const buildCorrectedBeat = (): number => { const bpm = get().bpm; const playbackDelaySec = (ConfigManager.instance().get('audio.playback_delay') as number) ?? 0.2; const recordingOffsetSec = (ConfigManager.instance().get('audio.recording_offset') as number) ?? 0; const correctionBeats = (playbackDelaySec + recordingOffsetSec) * (bpm / 60); return KGAudioInterface.instance().getTransportPosition() - correctionBeats - _recordingRegionStartBeat; }; KGMidiInput.instance().setRecordingCallbacks( (pitch: number, velocity: number) => { const beat = buildCorrectedBeat(); _recordingActiveNotes.set(pitch, { startBeat: beat, velocity }); }, (pitch: number) => { const endBeat = buildCorrectedBeat(); const activeNote = _recordingActiveNotes.get(pitch); if (activeNote !== undefined) { _recordingActiveNotes.delete(pitch); const finalizedEndBeat = finalizeRecordedNote(activeNote.startBeat, endBeat); set(state => ({ recordingNotes: [...state.recordingNotes, { pitch, startBeat: activeNote.startBeat, endBeat: finalizedEndBeat, velocity: activeNote.velocity, }], })); } }, (value: number) => { if (_lastRecordedPitchBendValue === value) { return; } _lastRecordedPitchBendValue = value; const beat = buildCorrectedBeat(); set(state => ({ recordingPitchBends: [...state.recordingPitchBends, { beat, value }], })); }, (controller: number, value: number) => { if (_lastRecordedControllerValues.get(controller) === value) { return; } _lastRecordedControllerValues.set(controller, value); const beat = buildCorrectedBeat(); set(state => { const nextRecordingControllerEventsByType = state.recordingControllerEventsByType.map(events => [...events]); nextRecordingControllerEventsByType[controller].push({ beat, value }); return { recordingControllerEventsByType: nextRecordingControllerEventsByType }; }); } ); setPlayheadPosition(recordingStartBeat); set({ isPreparingPlayback: true }); try { await KGCore.instance().startPlaying({ preserveLoopPreroll: projectLooping, }); set({ isPlaying: true, autoScrollEnabled: true }); } finally { set({ isPreparingPlayback: false }); } }, stopRecording: async () => { const { recordingMode, recordingNotes, recordingPitchBends, recordingControllerEventsByType, recordingTargetRegionId, recordingTargetTrackId, recordingTargetTrackIndex, recordingOriginalPlayhead, recordingCommitStartBeatAbsolute, recordingAudioPreviewFileName, stopPlaying, setPlayheadPosition, refreshProjectState, projectName, maxBars, } = get(); if (recordingMode === 'audio') { clearPendingAudioRecordingStart(); KGCore.instance().setLoopBoundaryReachedCallback(null); const stopBeatAbsolute = _audioRecordingForcedStopBeatAbsolute ?? Math.max(recordingCommitStartBeatAbsolute, KGAudioInterface.instance().getTransportPosition()); _audioRecordingForcedStopBeatAbsolute = null; const recordingResult = _audioRecordingHasStarted ? await KGAudioInterface.instance().stopAudioRecording() : (await KGAudioInterface.instance().cancelAudioRecording(), null); _audioRecordingHasStarted = false; if ( recordingResult && recordingTargetTrackId && recordingTargetTrackIndex !== null && stopBeatAbsolute > recordingCommitStartBeatAbsolute ) { try { const extension = getAudioRecordingExtension(recordingResult.mimeType); const fileName = `${recordingAudioPreviewFileName ?? 'Recording'}.${extension}`; const audioFile = new File([recordingResult.blob], fileName, { type: recordingResult.mimeType }); const fileId = KGAudioFileStorage.generateAudioFileId(fileName); const arrayBuffer = await audioFile.arrayBuffer(); const toneBuffer = new Tone.ToneAudioBuffer(); await new Promise((resolve, reject) => { const audioContext = Tone.getContext().rawContext as AudioContext; audioContext.decodeAudioData( arrayBuffer.slice(0), (decoded) => { toneBuffer.set(decoded); resolve(); }, (err) => reject(err) ); }); const track = KGCore.instance().getCurrentProject().getTracks().find(candidate => candidate.getId().toString() === recordingTargetTrackId); if (track) { await KGAudioFileStorage.storeAudioFile(projectName, fileId, audioFile); KGAudioInterface.instance().loadAudioBufferForTrack(recordingTargetTrackId, fileId, toneBuffer); const prevMaxBars = maxBars; const durationInBeats = stopBeatAbsolute - recordingCommitStartBeatAbsolute; const beatsPerBar = KGCore.instance().getCurrentProject().getTimeSignature().numerator; const endBarNumber = Math.ceil((recordingCommitStartBeatAbsolute + durationInBeats) / beatsPerBar); const newMaxBars = Math.max(prevMaxBars, endBarNumber); const command = new ImportAudioCommand( track.getId(), recordingTargetTrackIndex, fileId, fileName, toneBuffer.duration, recordingCommitStartBeatAbsolute, durationInBeats, prevMaxBars, newMaxBars ); KGCore.instance().executeCommand(command); refreshProjectState(); } } catch (error) { console.error('Failed to finalize audio recording:', error); } } await stopPlaying(); setPlayheadPosition(recordingOriginalPlayhead); set({ isRecording: false, recordingMode: null, isPreparingPlayback: false, recordingTargetRegionId: null, recordingTargetTrackId: null, recordingTargetTrackIndex: null, recordingNotes: [], recordingPitchBends: [], recordingControllerEventsByType: createEmptyRecordedControllerBuckets(), recordingStartBeatAbsolute: 0, recordingCommitStartBeatAbsolute: 0, recordingAudioPreviewPeaks: [], recordingAudioPreviewCurrentBeat: 0, recordingAudioPreviewFileName: null, }); return; } // Finalize any held keys const finalNotes = [...recordingNotes]; const finalPitchBends = [...recordingPitchBends]; const finalControllerEventsByType = recordingControllerEventsByType.map(events => [...events]); const bpm = get().bpm; const playbackDelaySec = (ConfigManager.instance().get('audio.playback_delay') as number) ?? 0.2; const recordingOffsetSec = (ConfigManager.instance().get('audio.recording_offset') as number) ?? 0; const correctionBeats = (playbackDelaySec + recordingOffsetSec) * (bpm / 60); const endBeatForHeld = KGAudioInterface.instance().getTransportPosition() - correctionBeats - _recordingRegionStartBeat; _recordingActiveNotes.forEach((activeNote, pitch) => { finalNotes.push({ pitch, startBeat: activeNote.startBeat, endBeat: finalizeRecordedNote(activeNote.startBeat, endBeatForHeld), velocity: activeNote.velocity, }); }); _recordingActiveNotes.clear(); if (_lastRecordedPitchBendValue !== null && _lastRecordedPitchBendValue !== MIDI_PITCH_BEND_CENTER) { finalPitchBends.push({ beat: endBeatForHeld, value: MIDI_PITCH_BEND_CENTER, }); _lastRecordedPitchBendValue = MIDI_PITCH_BEND_CENTER; } if (_lastRecordedControllerValues.get(64) === 127) { finalControllerEventsByType[64].push({ beat: endBeatForHeld, value: 0, }); _lastRecordedControllerValues.set(64, 0); } KGMidiInput.instance().setRecordingCallbacks(null, null, null, null); const hasControllerEvents = finalControllerEventsByType.some(events => events.length > 0); if ((finalNotes.length > 0 || finalPitchBends.length > 0 || hasControllerEvents) && recordingTargetRegionId) { const noteData: NoteCreationData[] = finalNotes.map(n => ({ regionId: recordingTargetRegionId, startBeat: n.startBeat, endBeat: n.endBeat, pitch: n.pitch, velocity: n.velocity, })); const pitchBendData: PitchBendCreationData[] = finalPitchBends.map(event => ({ regionId: recordingTargetRegionId, beat: event.beat, value: event.value, })); const controllerEventData: ControllerEventCreationData[] = finalControllerEventsByType.flatMap((events, controller) => ( events.map(event => ({ regionId: recordingTargetRegionId, controller, beat: event.beat, value: event.value, })) )); const command = new CreateMidiEventsCommand(noteData, pitchBendData, controllerEventData); KGCore.instance().executeCommand(command); refreshProjectState(); } await stopPlaying(); setPlayheadPosition(recordingOriginalPlayhead); set({ isRecording: false, recordingMode: null, isPreparingPlayback: false, recordingNotes: [], recordingPitchBends: [], recordingControllerEventsByType: createEmptyRecordedControllerBuckets(), recordingTargetRegionId: null, recordingTargetTrackId: null, recordingTargetTrackIndex: null, recordingStartBeatAbsolute: 0, recordingCommitStartBeatAbsolute: 0, recordingAudioPreviewPeaks: [], recordingAudioPreviewCurrentBeat: 0, recordingAudioPreviewFileName: null, }); _lastRecordedPitchBendValue = null; _lastRecordedControllerValues = new Map(); }, toggleLoop: () => { const { isLooping, loopingRange, maxBars, isPlaying, stopPlaying } = get(); // Stop playback if currently playing if (isPlaying) { stopPlaying(); } toggleLoop(isLooping, loopingRange, maxBars); }, toggleMetronome: () => { const { isMetronomeEnabled, isPlaying, timeSignature } = get(); const newValue = !isMetronomeEnabled; set({ isMetronomeEnabled: newValue }); const audio = KGAudioInterface.instance(); audio.setMetronomeEnabled(newValue); if (isPlaying) { if (newValue) { const currentBeat = KGCore.instance().getPlayheadPosition(); audio.startMetronomeDuringPlayback(currentBeat, timeSignature.numerator); } else { audio.stopMetronomeDuringPlayback(); } } }, 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'); } }, setBarWidthMultiplier: (multiplier: number) => { const project = KGCore.instance().getCurrentProject(); project.setBarWidthMultiplier(multiplier); set({ barWidthMultiplier: multiplier }); updateBarWidthMultiplierCSS(multiplier); }, 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'); } }, setSelectedMode: (selectedMode: string) => { try { // Create and execute the change project property command const command = new ChangeProjectPropertyCommand({ selectedMode }); KGCore.instance().executeCommand(command); // Update the store state set({ selectedMode }); console.log(`Set selected mode to ${selectedMode}`); } catch (error) { console.error('Error setting selected mode:', error); get().setStatus('Failed to set selected mode'); } }, // 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 }); }, setTrackAutomationView: (trackId: string | null, automationType: TrackAutomationType | null) => { const core = KGCore.instance(); core.getSelectedItems() .filter(item => item instanceof KGTrackAutomationPoint) .forEach(item => core.removeSelectedItem(item)); if (trackId && automationType === null) { set({ activeTrackAutomationTrackId: null, activeTrackAutomationType: null, selectedTrackAutomationPointIds: [], selectedTrackId: trackId, }); return; } set({ activeTrackAutomationTrackId: trackId, activeTrackAutomationType: trackId ? automationType : null, selectedTrackAutomationPointIds: [], selectedTrackId: trackId, }); }, // Piano roll actions setShowPianoRoll: (show: boolean) => { set({ showPianoRoll: show }); }, setActiveRegionId: (regionId: string | null) => { set({ activeRegionId: regionId }); }, openMidiPianoRoll: (regionId: string) => { set({ showPianoRoll: true, activeRegionId: regionId, pianoRollMode: 'midi-edit', hybridAudioRegionId: null }); }, openSpectrogramViewer: (regionId: string) => { set({ showPianoRoll: true, activeRegionId: regionId, pianoRollMode: 'spectrogram', hybridAudioRegionId: null }); }, openHybridMode: (midiRegionId: string, audioRegionId: string) => { set({ showPianoRoll: true, activeRegionId: midiRegionId, hybridAudioRegionId: audioRegionId, pianoRollMode: 'hybrid' }); }, bumpAutomationRedrawVersion: () => { set(state => ({ automationRedrawVersion: state.automationRedrawVersion + 1 })); }, bumpTrackAutomationRedrawVersion: () => { set(state => ({ trackAutomationRedrawVersion: state.trackAutomationRedrawVersion + 1 })); }, // Project state cleanup - used when starting new/loading projects cleanupProjectState: () => { // Close piano roll if it's visible set({ showPianoRoll: false }); // Clear active region and hybrid state set({ activeRegionId: null, hybridAudioRegionId: null, pianoRollMode: 'midi-edit', activeTrackAutomationTrackId: null, activeTrackAutomationType: null, trackAutomationRedrawVersion: 0, recordingAudioPreviewPeaks: [], recordingAudioPreviewCurrentBeat: 0, }); // 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, showKGOnePanel: show ? false : get().showKGOnePanel, showListEventPanel: show ? false : get().showListEventPanel }); }, toggleChatBox: () => { const { showChatBox } = get(); set({ showChatBox: !showChatBox, showKGOnePanel: false, showListEventPanel: false }); }, toggleKGOnePanel: () => { const { showKGOnePanel } = get(); set({ showKGOnePanel: !showKGOnePanel, showChatBox: false, showListEventPanel: false }); }, toggleListEventPanel: () => { const { showListEventPanel } = get(); set({ showListEventPanel: !showListEventPanel, showChatBox: false, showKGOnePanel: false }); }, // 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(); get().bumpTrackAutomationRedrawVersion(); console.log('Undo completed'); } }, redo: () => { const core = KGCore.instance(); if (core.redo()) { // Use centralized refresh method get().refreshProjectState(); get().bumpTrackAutomationRedrawVersion(); 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(), barWidthMultiplier: project.getBarWidthMultiplier(), timeSignature: project.getTimeSignature(), bpm: project.getBpm(), keySignature: project.getKeySignature(), selectedMode: project.getSelectedMode() }); // Sync CSS variables that affect layout updateTimeSignatureCSS(project.getTimeSignature()); updateMaxBarsCSS(project.getMaxBars()); updateBarWidthMultiplierCSS(project.getBarWidthMultiplier()); // 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 }); } } }; });