Files
KGStudio/src/stores/projectStore.ts
T

1228 lines
44 KiB
TypeScript

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, 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 { CreateNotesCommand } from '../core/commands/note/CreateNotesCommand';
import type { NoteCreationData } from '../core/commands/note/CreateNotesCommand';
/**
* 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;
autoScrollEnabled: 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;
pianoRollMode: 'midi-edit' | 'spectrogram' | 'hybrid';
hybridAudioRegionId: string | null;
// ChatBox state
showChatBox: boolean;
// K.G.One panel state
showKGOnePanel: 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;
recordingTargetRegionId: string | null;
recordingNotes: Array<{ pitch: number; startBeat: number; endBeat: number }>;
recordingOriginalPlayhead: number;
// Undo/redo state
canUndo: boolean;
canRedo: boolean;
undoDescription: string | null;
redoDescription: string | null;
// Actions
setProjectName: (name: string) => void;
setSavedProjectName: (name: string) => void;
addTrack: () => Promise<void>;
addAudioTrack: () => Promise<void>;
importAudioToTrack: (trackId: string, file: File) => Promise<void>;
openAudioImportModal: (trackId: string) => void;
closeAudioImportModal: () => void;
removeTrack: (id: number) => Promise<void>;
updateTrack: (track: KGTrack) => Promise<void>;
updateTrackProperties: (trackId: number, properties: TrackUpdateProperties) => Promise<void>;
setTrackInstrument: (trackId: number, instrument: InstrumentType) => Promise<void>;
reorderTracks: (sourceIndex: number, destinationIndex: number) => void;
setStatus: (status: string) => void;
removeStatus: () => void;
refreshStatus: () => void;
loadProject: (project: KGProject | null, savedName?: string) => Promise<void>;
setPlayheadPosition: (position: number) => void;
setAutoScrollEnabled: (enabled: boolean) => void;
startPlaying: () => Promise<void>;
stopPlaying: () => Promise<void>;
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;
// 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;
// Project state cleanup
cleanupProjectState: () => void;
// ChatBox actions
setShowChatBox: (show: boolean) => void;
toggleChatBox: () => void;
// K.G.One panel actions
toggleKGOnePanel: () => 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<void>;
stopRecording: () => Promise<void>;
// Initialization actions
initializeFromConfig: () => Promise<void>;
}
// Module-level recording state (not reactive — only used for timing during active recording)
let _recordingActiveNotes: Map<number, number> = new Map(); // pitch → region-relative startBeat
let _recordingRegionStartBeat: number = 0;
// Create the store
export const useProjectStore = create<ProjectState>((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({
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(),
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(),
autoScrollEnabled: true,
currentTime: beatsToTimeString(KGCore.instance().getPlayheadPosition(), currentProject.getBpm(), currentProject.getTimeSignature()),
// Initial selection state
selectedNoteIds: [],
selectedRegionIds: [],
selectedTrackId: initialSelectedTrackId,
// Initial piano roll state
showPianoRoll: false,
activeRegionId: null,
pianoRollMode: 'midi-edit' as const,
hybridAudioRegionId: null,
// Initial ChatBox state
showChatBox: initialChatBoxState,
// Initial K.G.One panel state
showKGOnePanel: 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,
recordingTargetRegionId: null,
recordingNotes: [],
recordingOriginalPlayhead: 0,
// 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<void>((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<ProjectState> = {
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(),
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 });
},
startPlaying: async () => {
await KGCore.instance().startPlaying();
set({ isPlaying: true, autoScrollEnabled: true });
},
stopPlaying: async () => {
await KGCore.instance().stopPlaying();
set({ isPlaying: false });
},
startRecording: async () => {
const { activeRegionId, timeSignature, playheadPosition, setPlayheadPosition } = get();
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();
set({
isRecording: true,
recordingNotes: [],
recordingTargetRegionId: activeRegionId,
recordingOriginalPlayhead: playheadPosition,
});
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) => {
const beat = buildCorrectedBeat();
_recordingActiveNotes.set(pitch, beat);
},
(pitch: number) => {
const endBeat = buildCorrectedBeat();
const startBeat = _recordingActiveNotes.get(pitch);
if (startBeat !== undefined) {
_recordingActiveNotes.delete(pitch);
set(state => ({
recordingNotes: [...state.recordingNotes, { pitch, startBeat, endBeat }],
}));
}
}
);
const projectLooping = project.getIsLooping();
const [loopStartBar] = project.getLoopingRange();
const loopStartBeat = loopStartBar * timeSignature.numerator;
const recordingStartBeat = projectLooping
? loopStartBeat - timeSignature.numerator
: playheadPosition - timeSignature.numerator;
setPlayheadPosition(recordingStartBeat);
await KGCore.instance().startPlaying({
preserveLoopPreroll: projectLooping,
});
set({ isPlaying: true, autoScrollEnabled: true });
},
stopRecording: async () => {
const { recordingNotes, recordingTargetRegionId, recordingOriginalPlayhead, stopPlaying, setPlayheadPosition, refreshProjectState } = get();
// Finalize any held keys
const finalNotes = [...recordingNotes];
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((startBeat, pitch) => {
finalNotes.push({ pitch, startBeat, endBeat: endBeatForHeld });
});
_recordingActiveNotes.clear();
KGMidiInput.instance().setRecordingCallbacks(null, null);
if (finalNotes.length > 0 && recordingTargetRegionId) {
const noteData: NoteCreationData[] = finalNotes.map(n => ({
regionId: recordingTargetRegionId,
startBeat: n.startBeat,
endBeat: n.endBeat,
pitch: n.pitch,
velocity: 127,
}));
const command = new CreateNotesCommand(noteData);
KGCore.instance().executeCommand(command);
refreshProjectState();
}
await stopPlaying();
setPlayheadPosition(recordingOriginalPlayhead);
set({ isRecording: false, recordingNotes: [], recordingTargetRegionId: null });
},
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 });
},
// 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' });
},
// 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' });
// 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, showKGOnePanel: false });
},
toggleKGOnePanel: () => {
const { showKGOnePanel } = get();
set({ showKGOnePanel: !showKGOnePanel, showChatBox: 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();
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(),
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 });
}
}
};
});