initial public release.
This commit is contained in:
@@ -0,0 +1,549 @@
|
||||
import type { Selectable } from '../components/interfaces';
|
||||
import { KGProject } from './KGProject';
|
||||
import { PLAYING_CONSTANTS } from '../constants/uiConstants';
|
||||
import { KGAudioInterface } from './audio-interface/KGAudioInterface';
|
||||
import { ConfigManager } from './config/ConfigManager';
|
||||
import { KGMidiRegion } from './region/KGMidiRegion';
|
||||
import { KGMidiNote } from './midi/KGMidiNote';
|
||||
import { KGRegion } from './region/KGRegion';
|
||||
import { generateUniqueId } from '../util/miscUtil';
|
||||
import { KGCommand, KGCommandHistory } from './commands';
|
||||
|
||||
/**
|
||||
* KGCore - Main application class for the DAW
|
||||
* Implements the singleton pattern for global access
|
||||
*/
|
||||
export class KGCore {
|
||||
// Private static instance for singleton pattern
|
||||
private static _instance: KGCore | null = null;
|
||||
|
||||
private currentProject: KGProject = new KGProject();
|
||||
|
||||
private status: string = "Ready";
|
||||
|
||||
private playheadPosition: number = 0; // in beats
|
||||
|
||||
private selectedItems: Selectable[] = [];
|
||||
private copiedItems: Selectable[] = [];
|
||||
|
||||
private isPlaying: boolean = false;
|
||||
|
||||
// Timer management for playback
|
||||
private playbackIntervalId: number | null = null;
|
||||
private playbackStartTime: number = 0;
|
||||
private playbackStartPosition: number = 0;
|
||||
|
||||
// Callback for external state updates (e.g., store)
|
||||
private playheadUpdateCallback: ((position: number) => void) | null = null;
|
||||
private playbackStateChangeCallback: ((isPlaying: boolean) => void) | null = null;
|
||||
|
||||
// Selection change callbacks for store synchronization
|
||||
private selectionChangeCallbacks: (() => void)[] = [];
|
||||
|
||||
// Command history for undo/redo functionality
|
||||
private commandHistory: KGCommandHistory = KGCommandHistory.instance();
|
||||
|
||||
// Private constructor to prevent direct instantiation
|
||||
private constructor() {
|
||||
console.log("KGCore initialized");
|
||||
// Initialize core components here
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the singleton instance of KGCore
|
||||
* Creates the instance if it doesn't exist yet
|
||||
*/
|
||||
public static instance(): KGCore {
|
||||
if (!KGCore._instance) {
|
||||
KGCore._instance = new KGCore();
|
||||
}
|
||||
return KGCore._instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the audio engine and core components
|
||||
*/
|
||||
public async initialize(): Promise<void> {
|
||||
try {
|
||||
// Initialize configuration manager
|
||||
const configManager = ConfigManager.instance();
|
||||
await configManager.initialize();
|
||||
|
||||
// Initialize audio interface
|
||||
const audioInterface = KGAudioInterface.instance();
|
||||
await audioInterface.initialize();
|
||||
|
||||
console.log("KGCore components initialized successfully");
|
||||
} catch (error) {
|
||||
console.error("Failed to initialize KGCore:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up resources when application is closed
|
||||
*/
|
||||
public async dispose(): Promise<void> {
|
||||
try {
|
||||
// Stop playback if playing
|
||||
if (this.isPlaying) {
|
||||
await this.stopPlaying();
|
||||
}
|
||||
|
||||
// Dispose audio interface
|
||||
const audioInterface = KGAudioInterface.instance();
|
||||
await audioInterface.dispose();
|
||||
|
||||
// Dispose config manager
|
||||
const configManager = ConfigManager.instance();
|
||||
await configManager.dispose();
|
||||
|
||||
// Clear playback timer
|
||||
this.stopPlaybackUpdates();
|
||||
|
||||
console.log("KGCore resources disposed successfully");
|
||||
} catch (error) {
|
||||
console.error("Error disposing KGCore resources:", error);
|
||||
}
|
||||
}
|
||||
|
||||
// Add more core functionality methods here
|
||||
public getCurrentProject(): KGProject {
|
||||
return this.currentProject;
|
||||
}
|
||||
|
||||
public setCurrentProject(project: KGProject): void {
|
||||
this.currentProject = project;
|
||||
|
||||
// Sync project settings with audio interface
|
||||
try {
|
||||
const audioInterface = KGAudioInterface.instance();
|
||||
if (audioInterface.getIsInitialized()) {
|
||||
audioInterface.setBpm(project.getBpm());
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error syncing project with audio interface:', error);
|
||||
}
|
||||
}
|
||||
|
||||
public setStatus(status: string): void {
|
||||
this.status = status;
|
||||
}
|
||||
|
||||
public getStatus(): string {
|
||||
return this.status;
|
||||
}
|
||||
|
||||
public getPlayheadPosition(): number {
|
||||
return this.playheadPosition;
|
||||
}
|
||||
|
||||
public setPlayheadPosition(position: number): void {
|
||||
this.playheadPosition = position;
|
||||
|
||||
// Sync with audio interface transport if not playing
|
||||
// (During playback, audio interface controls transport position)
|
||||
if (!this.isPlaying) {
|
||||
try {
|
||||
const audioInterface = KGAudioInterface.instance();
|
||||
if (audioInterface.getIsInitialized()) {
|
||||
audioInterface.setTransportPosition(position);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error syncing playhead position with audio interface:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Notify external listeners if callback is set
|
||||
if (this.playheadUpdateCallback) {
|
||||
this.playheadUpdateCallback(position);
|
||||
}
|
||||
}
|
||||
|
||||
// Method to set external update callback
|
||||
public setPlayheadUpdateCallback(callback: (position: number) => void): void {
|
||||
this.playheadUpdateCallback = callback;
|
||||
}
|
||||
|
||||
// Method to set external playback state change callback
|
||||
public setPlaybackStateChangeCallback(callback: (isPlaying: boolean) => void): void {
|
||||
this.playbackStateChangeCallback = callback;
|
||||
}
|
||||
|
||||
// Selection change callback management
|
||||
public onSelectionChanged(callback: () => void): void {
|
||||
this.selectionChangeCallbacks.push(callback);
|
||||
}
|
||||
|
||||
public removeSelectionChangeCallback(callback: () => void): void {
|
||||
const index = this.selectionChangeCallbacks.indexOf(callback);
|
||||
if (index > -1) {
|
||||
this.selectionChangeCallbacks.splice(index, 1);
|
||||
}
|
||||
}
|
||||
|
||||
private notifySelectionChanged(): void {
|
||||
this.selectionChangeCallbacks.forEach(callback => callback());
|
||||
}
|
||||
|
||||
// play
|
||||
public getIsPlaying(): boolean {
|
||||
return this.isPlaying;
|
||||
}
|
||||
|
||||
public async preparePlay(): Promise<void> {
|
||||
try {
|
||||
const audioInterface = KGAudioInterface.instance();
|
||||
|
||||
// Ensure audio context is started (required for Web Audio)
|
||||
await audioInterface.startAudioContext();
|
||||
|
||||
// Prepare playback with current project and playhead position
|
||||
audioInterface.preparePlayback(this.currentProject, this.playheadPosition);
|
||||
|
||||
// Sync BPM and transport settings
|
||||
audioInterface.setBpm(this.currentProject.getBpm());
|
||||
audioInterface.setTransportPosition(this.playheadPosition);
|
||||
|
||||
console.log("Playback prepared successfully");
|
||||
} catch (error) {
|
||||
console.error("Failed to prepare playback:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
public async play(): Promise<void> {
|
||||
try {
|
||||
const audioInterface = KGAudioInterface.instance();
|
||||
|
||||
// Start audio playback
|
||||
audioInterface.startPlayback();
|
||||
|
||||
// Update local state
|
||||
this.isPlaying = true;
|
||||
if (this.playbackStateChangeCallback) {
|
||||
this.playbackStateChangeCallback(true);
|
||||
}
|
||||
|
||||
console.log("Audio playback started successfully");
|
||||
} catch (error) {
|
||||
console.error("Failed to start playback:", error);
|
||||
this.isPlaying = false;
|
||||
if (this.playbackStateChangeCallback) {
|
||||
this.playbackStateChangeCallback(false);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
public async stop(): Promise<void> {
|
||||
try {
|
||||
const audioInterface = KGAudioInterface.instance();
|
||||
|
||||
// Stop audio playback
|
||||
audioInterface.stopPlayback();
|
||||
|
||||
// Update local state
|
||||
this.isPlaying = false;
|
||||
if (this.playbackStateChangeCallback) {
|
||||
this.playbackStateChangeCallback(false);
|
||||
}
|
||||
|
||||
console.log("Audio playback stopped successfully");
|
||||
} catch (error) {
|
||||
console.error("Failed to stop playback:", error);
|
||||
// Still update state even if stop fails
|
||||
this.isPlaying = false;
|
||||
if (this.playbackStateChangeCallback) {
|
||||
this.playbackStateChangeCallback(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// High-level playback control methods
|
||||
public async startPlaying(): Promise<void> {
|
||||
// Prepare playback first
|
||||
await this.preparePlay();
|
||||
|
||||
// Start playing (non-blocking)
|
||||
this.play(); // Don't await this
|
||||
|
||||
// Set up the regular playback update timer
|
||||
this.playbackStartTime = performance.now();
|
||||
this.playbackStartPosition = this.playheadPosition;
|
||||
|
||||
this.startPlaybackUpdates();
|
||||
|
||||
}
|
||||
|
||||
public async stopPlaying(): Promise<void> {
|
||||
// Stop the timer first
|
||||
this.stopPlaybackUpdates();
|
||||
|
||||
// Stop playback (wait for completion)
|
||||
await this.stop();
|
||||
}
|
||||
|
||||
// Timer management for regular playback updates
|
||||
private startPlaybackUpdates(): void {
|
||||
if (this.playbackIntervalId !== null) {
|
||||
this.stopPlaybackUpdates(); // Clear any existing timer
|
||||
}
|
||||
|
||||
this.playbackIntervalId = window.setInterval(() => {
|
||||
this.onPlaybackUpdate();
|
||||
}, PLAYING_CONSTANTS.UPDATE_INTERVAL_MS);
|
||||
}
|
||||
|
||||
private stopPlaybackUpdates(): void {
|
||||
if (this.playbackIntervalId !== null) {
|
||||
clearInterval(this.playbackIntervalId);
|
||||
this.playbackIntervalId = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Regular playback update callback - more generic name as requested
|
||||
private onPlaybackUpdate(): void {
|
||||
if (!this.isPlaying) {
|
||||
this.stopPlaybackUpdates();
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate current playhead position based on elapsed time
|
||||
const elapsedMs = performance.now() - this.playbackStartTime;
|
||||
const bpm = this.currentProject.getBpm();
|
||||
const beatsPerMs = bpm / (60 * 1000);
|
||||
const newPosition = this.playbackStartPosition + (elapsedMs * beatsPerMs);
|
||||
|
||||
// Stop playback at the end of project (maxBars)
|
||||
const maxBars = this.currentProject.getMaxBars();
|
||||
const beatsPerBar = this.currentProject.getTimeSignature().numerator;
|
||||
const maxBeats = maxBars * beatsPerBar;
|
||||
if (newPosition >= maxBeats) {
|
||||
// Clamp to max and stop
|
||||
this.setPlayheadPosition(maxBeats);
|
||||
// Stop playback (non-blocking)
|
||||
this.stopPlaying();
|
||||
return;
|
||||
}
|
||||
|
||||
// Update playhead position
|
||||
this.setPlayheadPosition(newPosition);
|
||||
|
||||
// TODO: Future enhancements
|
||||
// - Sync with Tone.Transport position for more accurate timing
|
||||
// - Handle tempo changes mid-playback
|
||||
// - Account for latency compensation
|
||||
// - Support for loop regions
|
||||
}
|
||||
|
||||
// selected items
|
||||
public getSelectedItems(): Selectable[] {
|
||||
return this.selectedItems;
|
||||
}
|
||||
|
||||
public addSelectedItem(item: Selectable): void {
|
||||
this.selectedItems.push(item);
|
||||
this.notifySelectionChanged();
|
||||
}
|
||||
|
||||
public addSelectedItems(items: Selectable[]): void {
|
||||
this.selectedItems.push(...items);
|
||||
this.notifySelectionChanged();
|
||||
}
|
||||
|
||||
public removeSelectedItem(item: Selectable): void {
|
||||
this.selectedItems = this.selectedItems.filter(i => i.getId() !== item.getId());
|
||||
this.notifySelectionChanged();
|
||||
}
|
||||
|
||||
public removeSelectedItems(items: Selectable[]): void {
|
||||
this.selectedItems = this.selectedItems.filter(i => !items.includes(i));
|
||||
this.notifySelectionChanged();
|
||||
}
|
||||
|
||||
public clearSelectedItems(): void {
|
||||
this.selectedItems = [];
|
||||
this.notifySelectionChanged();
|
||||
}
|
||||
|
||||
// copied items
|
||||
public getCopiedItems(): Selectable[] {
|
||||
return this.copiedItems;
|
||||
}
|
||||
|
||||
public addCopiedItem(item: Selectable): void {
|
||||
this.copiedItems.push(item);
|
||||
}
|
||||
|
||||
public addCopiedItems(items: Selectable[]): void {
|
||||
this.copiedItems.push(...items);
|
||||
}
|
||||
|
||||
public removeCopiedItem(item: Selectable): void {
|
||||
this.copiedItems = this.copiedItems.filter(i => i.getId() !== item.getId());
|
||||
}
|
||||
|
||||
public clearCopiedItems(): void {
|
||||
this.copiedItems = [];
|
||||
}
|
||||
|
||||
public copySelectedItems(): void {
|
||||
// Clear existing copied items
|
||||
this.clearCopiedItems();
|
||||
|
||||
const selectedItems = this.getSelectedItems();
|
||||
|
||||
if (selectedItems.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Direct copy using getCurrentType() for type identification
|
||||
const clonedItems: Selectable[] = [];
|
||||
|
||||
selectedItems.forEach(item => {
|
||||
const currentType = item.getCurrentType();
|
||||
|
||||
switch (currentType) {
|
||||
case 'KGMidiNote': {
|
||||
const note = item as KGMidiNote;
|
||||
const clonedNote = new KGMidiNote(
|
||||
generateUniqueId('KGMidiNote'),
|
||||
note.getStartBeat(),
|
||||
note.getEndBeat(),
|
||||
note.getPitch(),
|
||||
note.getVelocity()
|
||||
);
|
||||
clonedItems.push(clonedNote);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'KGMidiRegion': {
|
||||
const region = item as KGMidiRegion;
|
||||
const clonedRegion = new KGMidiRegion(
|
||||
generateUniqueId('KGMidiRegion'),
|
||||
region.getTrackId(),
|
||||
region.getTrackIndex(),
|
||||
region.getName(),
|
||||
region.getStartFromBeat(),
|
||||
region.getLength()
|
||||
);
|
||||
|
||||
// Copy all notes within the region
|
||||
const originalNotes = region.getNotes();
|
||||
originalNotes.forEach(note => {
|
||||
const clonedNote = new KGMidiNote(
|
||||
generateUniqueId('KGMidiNote'),
|
||||
note.getStartBeat(),
|
||||
note.getEndBeat(),
|
||||
note.getPitch(),
|
||||
note.getVelocity()
|
||||
);
|
||||
clonedRegion.addNote(clonedNote);
|
||||
});
|
||||
|
||||
clonedItems.push(clonedRegion);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'KGRegion': {
|
||||
const region = item as KGRegion;
|
||||
const clonedRegion = new KGRegion(
|
||||
generateUniqueId('KGRegion'),
|
||||
region.getTrackId(),
|
||||
region.getTrackIndex(),
|
||||
region.getName(),
|
||||
region.getStartFromBeat(),
|
||||
region.getLength()
|
||||
);
|
||||
clonedItems.push(clonedRegion);
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
console.warn(`Unknown item type for copying: ${currentType}`);
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
// Add the cloned items to the clipboard
|
||||
this.addCopiedItems(clonedItems);
|
||||
|
||||
console.log(`Copied ${clonedItems.length} items to clipboard (${selectedItems.map(item => item.getCurrentType()).join(', ')})`);
|
||||
}
|
||||
|
||||
// Command system methods for undo/redo functionality
|
||||
|
||||
/**
|
||||
* Execute a command through the command history system
|
||||
* @param command The command to execute
|
||||
*/
|
||||
public executeCommand(command: KGCommand): void {
|
||||
this.commandHistory.executeCommand(command);
|
||||
}
|
||||
|
||||
/**
|
||||
* Undo the last executed command
|
||||
* @returns true if undo was successful, false otherwise
|
||||
*/
|
||||
public undo(): boolean {
|
||||
return this.commandHistory.undo();
|
||||
}
|
||||
|
||||
/**
|
||||
* Redo the last undone command
|
||||
* @returns true if redo was successful, false otherwise
|
||||
*/
|
||||
public redo(): boolean {
|
||||
return this.commandHistory.redo();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if undo operation is available
|
||||
*/
|
||||
public canUndo(): boolean {
|
||||
return this.commandHistory.canUndo();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if redo operation is available
|
||||
*/
|
||||
public canRedo(): boolean {
|
||||
return this.commandHistory.canRedo();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get description of the next command that would be undone
|
||||
*/
|
||||
public getUndoDescription(): string | null {
|
||||
return this.commandHistory.getUndoDescription();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get description of the next command that would be redone
|
||||
*/
|
||||
public getRedoDescription(): string | null {
|
||||
return this.commandHistory.getRedoDescription();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all command history
|
||||
*/
|
||||
public clearCommandHistory(): void {
|
||||
this.commandHistory.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Set callback for when command history changes (for UI updates)
|
||||
*/
|
||||
public setOnCommandHistoryChanged(callback: () => void): void {
|
||||
this.commandHistory.setOnHistoryChanged(callback);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current command history statistics for debugging
|
||||
*/
|
||||
public getCommandHistoryStats(): { undoCount: number; redoCount: number; maxSize: number } {
|
||||
return this.commandHistory.getHistoryStats();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,415 @@
|
||||
/**
|
||||
* KGDebugger - Global debugging utility for KGSP
|
||||
* Provides console access to internal methods for testing and debugging
|
||||
*/
|
||||
|
||||
import { KGCore } from './KGCore';
|
||||
import { KGMidiRegion } from './region/KGMidiRegion';
|
||||
import { convertRegionToABCNotation } from '../util/abcNotationUtil';
|
||||
import { extractXMLFromString } from '../util/xmlUtil';
|
||||
import { XMLToolExecutor } from '../agent/core/XMLToolExecutor';
|
||||
import { AgentCore } from '../agent/core/AgentCore';
|
||||
import { AttemptCompletionTool } from '../agent/tools/AttemptCompletionTool';
|
||||
import type { TimeSignature } from '../types/projectTypes';
|
||||
import { useProjectStore } from '../stores/projectStore';
|
||||
|
||||
/**
|
||||
* Global debugger singleton for console-based testing
|
||||
*/
|
||||
export class KGDebugger {
|
||||
// Private static instance for singleton pattern
|
||||
private static _instance: KGDebugger | null = null;
|
||||
|
||||
// Private constructor to prevent direct instantiation
|
||||
private constructor() {
|
||||
console.log("🔧 KGDebugger initialized - Available methods:", [
|
||||
'convertSelectedRegionToABCNotation(startFromBeat?)',
|
||||
'testQuantizeDuration(durationBeats, timeSignature?)',
|
||||
'debugSelectedItems()',
|
||||
'createTestRegion()',
|
||||
'testExtractXMLFromString(input)',
|
||||
'testXMLToolExecution(input)',
|
||||
'testAttemptCompletion(comment)'
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the singleton instance of KGDebugger
|
||||
*/
|
||||
public static instance(): KGDebugger {
|
||||
if (!KGDebugger._instance) {
|
||||
KGDebugger._instance = new KGDebugger();
|
||||
}
|
||||
return KGDebugger._instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert the currently selected region to ABC notation
|
||||
* @param startFromBeat - Optional absolute beat position to start from (defaults to region start)
|
||||
*/
|
||||
public convertSelectedRegionToABCNotation(startFromBeat?: number): void {
|
||||
const core = KGCore.instance();
|
||||
const selectedItems = core.getSelectedItems();
|
||||
|
||||
let midiRegion: KGMidiRegion | null = null;
|
||||
|
||||
// First try to find MIDI region in selected items
|
||||
if (selectedItems && selectedItems.length > 0) {
|
||||
console.log(`📝 Converting selected region to ABC notation...`);
|
||||
console.log(`📊 Selected items count: ${selectedItems.length}`);
|
||||
|
||||
midiRegion = selectedItems.find(item =>
|
||||
item.getCurrentType() === 'KGMidiRegion'
|
||||
) as KGMidiRegion;
|
||||
}
|
||||
|
||||
// If no MIDI region selected, try to use active region from piano roll
|
||||
if (!midiRegion) {
|
||||
console.log("📝 No MIDI region selected, checking for active region...");
|
||||
|
||||
const storeState = useProjectStore.getState();
|
||||
const activeRegionId = storeState.activeRegionId;
|
||||
const tracks = storeState.tracks;
|
||||
|
||||
if (activeRegionId) {
|
||||
// Find the active region in tracks
|
||||
for (const track of tracks) {
|
||||
const regions = track.getRegions();
|
||||
const region = regions.find(r => r.getId() === activeRegionId);
|
||||
|
||||
if (region && region instanceof KGMidiRegion) {
|
||||
midiRegion = region;
|
||||
console.log(`✅ Found active region: "${region.getName()}"`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!midiRegion) {
|
||||
console.error("❌ No MIDI region found.");
|
||||
console.log("💡 Try one of these:");
|
||||
console.log(" • Select a region in the track grid");
|
||||
console.log(" • Open a region in the piano roll editor");
|
||||
return;
|
||||
}
|
||||
|
||||
// Use provided startFromBeat or default to region start
|
||||
const effectiveStartBeat = startFromBeat ?? midiRegion.getStartFromBeat();
|
||||
|
||||
console.log(`🎵 Converting region: "${midiRegion.getName()}"`);
|
||||
console.log(`📍 Region starts at beat: ${midiRegion.getStartFromBeat()}`);
|
||||
console.log(`📍 Conversion starts at beat: ${effectiveStartBeat}`);
|
||||
console.log(`🎼 Notes in region: ${midiRegion.getNotes().length}`);
|
||||
|
||||
try {
|
||||
const abcNotation = convertRegionToABCNotation(midiRegion, effectiveStartBeat);
|
||||
|
||||
console.log("✅ ABC Notation conversion successful!");
|
||||
console.log("📄 Result:");
|
||||
console.log("─".repeat(50));
|
||||
console.log(abcNotation);
|
||||
console.log("─".repeat(50));
|
||||
|
||||
// Also copy to clipboard if possible
|
||||
if (navigator.clipboard) {
|
||||
navigator.clipboard.writeText(abcNotation).then(() => {
|
||||
console.log("📋 ABC notation copied to clipboard!");
|
||||
}).catch(() => {
|
||||
console.log("📋 Could not copy to clipboard (requires HTTPS)");
|
||||
});
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error("❌ Error converting to ABC notation:", error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test the quantization duration method with a specific duration
|
||||
* @param durationBeats - Duration in beats to test
|
||||
* @param timeSignature - Optional time signature (defaults to project time signature)
|
||||
*/
|
||||
public testQuantizeDuration(durationBeats: number, timeSignature?: TimeSignature): void {
|
||||
const core = KGCore.instance();
|
||||
const project = core.getCurrentProject();
|
||||
const effectiveTimeSignature = timeSignature ?? project.getTimeSignature();
|
||||
|
||||
console.log(`🧮 Testing quantization for ${durationBeats} beats...`);
|
||||
console.log(`⏱️ Time signature: ${effectiveTimeSignature.numerator}/${effectiveTimeSignature.denominator}`);
|
||||
|
||||
// Import quantization testing (we'll need to expose some internal methods)
|
||||
// For now, let's create a simple test
|
||||
const ticksPerBeat = 480 * (4 / effectiveTimeSignature.denominator);
|
||||
const durationTicks = Math.round(durationBeats * ticksPerBeat);
|
||||
|
||||
console.log(`🎵 Input: ${durationBeats} beats = ${durationTicks} ticks`);
|
||||
|
||||
// Test different quantization values manually for demonstration
|
||||
const testValues = [
|
||||
{ name: '1/1', ticks: 1920 },
|
||||
{ name: '1/2', ticks: 960 },
|
||||
{ name: '1/3', ticks: 640 },
|
||||
{ name: '1/4', ticks: 480 },
|
||||
{ name: '1/6', ticks: 320 },
|
||||
{ name: '1/8', ticks: 240 },
|
||||
{ name: '1/12', ticks: 160 },
|
||||
{ name: '1/16', ticks: 120 }
|
||||
];
|
||||
|
||||
console.log("📊 Quantization analysis:");
|
||||
let bestMatch = { name: '1/4', error: Infinity, ticks: 480 };
|
||||
|
||||
testValues.forEach(val => {
|
||||
const remainder = durationTicks % val.ticks;
|
||||
const error = Math.min(remainder, val.ticks - remainder);
|
||||
const errorPercent = ((error / val.ticks) * 100).toFixed(1);
|
||||
|
||||
if (error < bestMatch.error) {
|
||||
bestMatch = { name: val.name, error, ticks: val.ticks };
|
||||
}
|
||||
|
||||
console.log(` ${val.name}: ${error} ticks error (${errorPercent}%)`);
|
||||
});
|
||||
|
||||
const quantizedTicks = Math.round(durationTicks / bestMatch.ticks) * bestMatch.ticks;
|
||||
const quantizedBeats = quantizedTicks / ticksPerBeat;
|
||||
|
||||
console.log(`✅ Best match: ${bestMatch.name} grid`);
|
||||
console.log(`🎯 Quantized: ${quantizedBeats} beats = ${quantizedTicks} ticks`);
|
||||
console.log(`📏 Difference: ${Math.abs(durationBeats - quantizedBeats).toFixed(4)} beats`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Debug currently selected items
|
||||
*/
|
||||
public debugSelectedItems(): void {
|
||||
const core = KGCore.instance();
|
||||
const selectedItems = core.getSelectedItems();
|
||||
|
||||
console.log(`🔍 Currently selected items: ${selectedItems.length}`);
|
||||
|
||||
if (selectedItems.length === 0) {
|
||||
console.log("📝 No items selected. Try selecting regions or notes first.");
|
||||
return;
|
||||
}
|
||||
|
||||
selectedItems.forEach((item, index) => {
|
||||
const type = item.getCurrentType();
|
||||
const id = item.getId();
|
||||
|
||||
console.log(` ${index + 1}. ${type} (ID: ${id})`);
|
||||
|
||||
if (type === 'KGMidiRegion') {
|
||||
const region = item as KGMidiRegion;
|
||||
console.log(` 📍 Position: ${region.getStartFromBeat()} beats`);
|
||||
console.log(` 📏 Length: ${region.getLength()} beats`);
|
||||
console.log(` 🎵 Notes: ${region.getNotes().length}`);
|
||||
console.log(` 📛 Name: "${region.getName()}"`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a test region with sample notes for testing (future implementation)
|
||||
*/
|
||||
public createTestRegion(): void {
|
||||
console.log("🚧 createTestRegion() - Not implemented yet");
|
||||
console.log("💡 This method would create a region with sample notes for testing");
|
||||
console.log("💡 For now, please create regions manually in the DAW interface");
|
||||
}
|
||||
|
||||
/**
|
||||
* Test the extractXMLFromString utility function with a given input
|
||||
* @param input - String that may contain XML blocks to extract
|
||||
*/
|
||||
public testExtractXMLFromString(input: string): void {
|
||||
console.log("🔍 Testing extractXMLFromString utility...");
|
||||
console.log("📝 Input string:");
|
||||
console.log("─".repeat(50));
|
||||
console.log(input);
|
||||
console.log("─".repeat(50));
|
||||
|
||||
try {
|
||||
const xmlBlocks = extractXMLFromString(input);
|
||||
|
||||
console.log(`✅ Extraction successful! Found ${xmlBlocks.length} XML block(s):`);
|
||||
|
||||
if (xmlBlocks.length === 0) {
|
||||
console.log("📭 No XML blocks found in the input string.");
|
||||
console.log("💡 Try input with XML tags like: <add_notes>...</add_notes>");
|
||||
} else {
|
||||
xmlBlocks.forEach((block, index) => {
|
||||
console.log("\n📄 XML Block " + (index + 1) + ":");
|
||||
console.log("─".repeat(30));
|
||||
console.log(block);
|
||||
console.log("─".repeat(30));
|
||||
});
|
||||
|
||||
// Copy all blocks to clipboard if possible
|
||||
if (navigator.clipboard && xmlBlocks.length > 0) {
|
||||
const allBlocks = xmlBlocks.join('\n\n');
|
||||
navigator.clipboard.writeText(allBlocks).then(() => {
|
||||
console.log("📋 XML blocks copied to clipboard!");
|
||||
}).catch(() => {
|
||||
console.log("📋 Could not copy to clipboard (requires HTTPS)");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error("❌ Error extracting XML:", error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test the complete XML tool execution pipeline
|
||||
* @param input - String containing XML tool invocations to execute
|
||||
*/
|
||||
public async testXMLToolExecution(input: string): Promise<void> {
|
||||
console.log('------------ ASSISTANT ------------');
|
||||
console.log(input);
|
||||
console.log('-----------------------------------');
|
||||
|
||||
try {
|
||||
// Extract XML blocks first to get tool names (same logic as ChatBox)
|
||||
const xmlBlocks = extractXMLFromString(input);
|
||||
|
||||
if (xmlBlocks.length === 0) {
|
||||
console.log('------------ USER ------------');
|
||||
console.log('No XML tool invocations found in the input string.');
|
||||
console.log('------------------------------');
|
||||
return;
|
||||
}
|
||||
|
||||
const executor = XMLToolExecutor.instance();
|
||||
let accumulatedResults = '';
|
||||
|
||||
// Execute tools sequentially and format like ChatBox
|
||||
for (let i = 0; i < xmlBlocks.length; i++) {
|
||||
// Determine tool name from XML block (same as ChatBox lines 148-149)
|
||||
const toolNameMatch = xmlBlocks[i].match(/<([a-zA-Z_][a-zA-Z0-9_-]*)/);
|
||||
const toolName = toolNameMatch ? toolNameMatch[1] : 'unknown_tool';
|
||||
|
||||
try {
|
||||
// Execute single XML block
|
||||
const results = await executor.executeXMLTools(xmlBlocks[i]);
|
||||
const result = results[0]; // Single block should give single result
|
||||
|
||||
if (result) {
|
||||
// Format exactly like ChatBox lines 161-162
|
||||
const formattedResult = `tool: ${toolName}\nsuccess: ${result.success}\nresult:\n${result.result}\n------------\n`;
|
||||
accumulatedResults += formattedResult;
|
||||
}
|
||||
} catch (error) {
|
||||
// Handle individual tool error (same format)
|
||||
const formattedResult = `tool: ${toolName}\nsuccess: false\nresult:\nTool execution failed: ${error}\n------------\n`;
|
||||
accumulatedResults += formattedResult;
|
||||
}
|
||||
}
|
||||
|
||||
// Log accumulated results as USER (what gets sent back to LLM)
|
||||
console.log('------------ USER ------------');
|
||||
console.log(accumulatedResults);
|
||||
console.log('------------------------------');
|
||||
|
||||
// Copy results to clipboard if possible
|
||||
if (navigator.clipboard) {
|
||||
navigator.clipboard.writeText(accumulatedResults).then(() => {
|
||||
console.log("Tool execution results copied to clipboard!");
|
||||
}).catch(() => {
|
||||
console.log("Could not copy to clipboard (requires HTTPS)");
|
||||
});
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.log('------------ USER ------------');
|
||||
console.log(`Error testing XML tool execution: ${error}`);
|
||||
console.log('------------------------------');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test the AttemptCompletionTool with agent state integration
|
||||
* @param comment - Completion comment to test with
|
||||
*/
|
||||
public async testAttemptCompletion(comment: string): Promise<void> {
|
||||
console.log("🎯 Testing AttemptCompletionTool...");
|
||||
console.log(`📝 Comment: "${comment}"`);
|
||||
|
||||
try {
|
||||
// Get current agent state before test
|
||||
const agentCore = AgentCore.instance();
|
||||
const agentState = agentCore.getAgentState();
|
||||
const initialTaskState = agentState.getIsWorkingOnTask();
|
||||
|
||||
console.log(`📊 Initial agent state:`);
|
||||
console.log(` • isWorkingOnTask: ${initialTaskState}`);
|
||||
|
||||
// Set to working state to test the completion properly
|
||||
if (!initialTaskState) {
|
||||
console.log("🔄 Setting isWorkingOnTask to true for testing...");
|
||||
agentState.setIsWorkingOnTask(true);
|
||||
}
|
||||
|
||||
// Create and execute the tool
|
||||
const completionTool = new AttemptCompletionTool();
|
||||
const result = await completionTool.execute({ comment });
|
||||
|
||||
console.log(`✅ Tool execution result:`);
|
||||
console.log(` • Success: ${result.success}`);
|
||||
console.log(` • Result: ${result.result}`);
|
||||
|
||||
// Check final agent state
|
||||
const finalTaskState = agentState.getIsWorkingOnTask();
|
||||
console.log(`📊 Final agent state:`);
|
||||
console.log(` • isWorkingOnTask: ${finalTaskState}`);
|
||||
|
||||
// Verify state change
|
||||
if (result.success && finalTaskState === false) {
|
||||
console.log("🎉 Success! Agent state correctly updated to not working on task.");
|
||||
} else if (!result.success) {
|
||||
console.log("⚠️ Tool execution failed - state may not have changed.");
|
||||
} else {
|
||||
console.log("⚠️ Warning: State did not change as expected.");
|
||||
}
|
||||
|
||||
// Copy result to clipboard if possible
|
||||
if (navigator.clipboard) {
|
||||
const clipboardContent = JSON.stringify(result, null, 2);
|
||||
navigator.clipboard.writeText(clipboardContent).then(() => {
|
||||
console.log("📋 Test results copied to clipboard!");
|
||||
}).catch(() => {
|
||||
console.log("📋 Could not copy to clipboard (requires HTTPS)");
|
||||
});
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error("❌ Error testing AttemptCompletionTool:", error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show help information
|
||||
*/
|
||||
public help(): void {
|
||||
console.log("🔧 KGDebugger Help");
|
||||
console.log("Available methods:");
|
||||
console.log(" convertSelectedRegionToABCNotation(startFromBeat?) - Convert selected region to ABC");
|
||||
console.log(" testQuantizeDuration(beats, timeSignature?) - Test quantization logic");
|
||||
console.log(" debugSelectedItems() - Show info about selected items");
|
||||
console.log(" createTestRegion() - Create test region (not implemented)");
|
||||
console.log(" testExtractXMLFromString(input) - Test XML extraction from string");
|
||||
console.log(" testXMLToolExecution(input) - Test complete XML tool execution pipeline");
|
||||
console.log(" testAttemptCompletion(comment) - Test AttemptCompletionTool with agent state");
|
||||
console.log(" help() - Show this help");
|
||||
console.log("");
|
||||
console.log("💡 Usage tips:");
|
||||
console.log(" - Select regions in the DAW first, then run debug methods");
|
||||
console.log(" - Results are logged to console and copied to clipboard when possible");
|
||||
console.log(" - Use browser developer tools for best experience");
|
||||
console.log(" - For XML testing, try: testExtractXMLFromString('I will <add_notes><note>...</note></add_notes> create notes');");
|
||||
console.log(" - For full tool execution, try: await testXMLToolExecution('Create notes: <add_notes><note><pitch>C4</pitch><start_beat>0</start_beat><length>1</length></note></add_notes>');");
|
||||
console.log(" - For completion testing, try: await testAttemptCompletion('Successfully created a C major chord');");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import { Expose, Type } from 'class-transformer';
|
||||
import { KGTrack } from './track/KGTrack';
|
||||
import { KGMidiTrack } from './track/KGMidiTrack';
|
||||
import { type TimeSignature, WithDefault } from '../types/projectTypes';
|
||||
import { TIME_CONSTANTS, KEY_SIGNATURE_MAP } from '../constants/coreConstants';
|
||||
|
||||
// Type for valid key signatures
|
||||
export type KeySignature = keyof typeof KEY_SIGNATURE_MAP;
|
||||
|
||||
/**
|
||||
* KGProject - Class representing a project in the DAW
|
||||
* Contains project settings and track data
|
||||
*/
|
||||
export class KGProject {
|
||||
@Expose()
|
||||
private name: string = "Untitled Project";
|
||||
|
||||
@Expose()
|
||||
private maxBars: number = 32;
|
||||
|
||||
@Expose()
|
||||
private currentBars: number = 0;
|
||||
|
||||
@Expose()
|
||||
private timeSignature: TimeSignature = TIME_CONSTANTS.DEFAULT_TIME_SIGNATURE;
|
||||
|
||||
@Expose()
|
||||
private bpm: number = TIME_CONSTANTS.DEFAULT_BPM;
|
||||
|
||||
@Expose()
|
||||
@WithDefault("C major")
|
||||
private keySignature: KeySignature = "C major";
|
||||
|
||||
@Expose()
|
||||
@WithDefault(0)
|
||||
private projectStructureVersion: number = 0;
|
||||
|
||||
public static readonly CURRENT_PROJECT_STRUCTURE_VERSION: number = 1;
|
||||
|
||||
@Expose()
|
||||
@Type(() => KGTrack, {
|
||||
discriminator: {
|
||||
property: '__type',
|
||||
subTypes: [
|
||||
{ value: KGTrack, name: 'KGTrack' },
|
||||
{ value: KGMidiTrack, name: 'KGMidiTrack' },
|
||||
],
|
||||
},
|
||||
})
|
||||
private tracks: KGTrack[] = [];
|
||||
|
||||
// Constructor
|
||||
constructor(name: string = "Untitled Project", maxBars: number = 32, currentBars: number = 0, bpm: number = 125, timeSignature: TimeSignature = { numerator: 4, denominator: 4 }, keySignature: KeySignature = "C major", tracks: KGTrack[] = [], projectStructureVersion: number = KGProject.CURRENT_PROJECT_STRUCTURE_VERSION) {
|
||||
this.name = name;
|
||||
this.maxBars = maxBars;
|
||||
this.currentBars = currentBars;
|
||||
this.bpm = bpm;
|
||||
this.timeSignature = timeSignature;
|
||||
this.keySignature = keySignature;
|
||||
this.tracks = tracks;
|
||||
this.projectStructureVersion = projectStructureVersion;
|
||||
}
|
||||
|
||||
// Getters
|
||||
public getName(): string {
|
||||
return this.name;
|
||||
}
|
||||
|
||||
public getMaxBars(): number {
|
||||
return this.maxBars;
|
||||
}
|
||||
|
||||
public getCurrentBars(): number {
|
||||
return this.currentBars;
|
||||
}
|
||||
|
||||
public getTimeSignature(): TimeSignature {
|
||||
return this.timeSignature;
|
||||
}
|
||||
|
||||
public setTimeSignature(timeSignature: TimeSignature): void {
|
||||
this.timeSignature = timeSignature;
|
||||
}
|
||||
|
||||
public getBpm(): number {
|
||||
return this.bpm;
|
||||
}
|
||||
|
||||
public getKeySignature(): KeySignature {
|
||||
return this.keySignature;
|
||||
}
|
||||
|
||||
public getTracks(): KGTrack[] {
|
||||
return this.tracks;
|
||||
}
|
||||
|
||||
// Setters
|
||||
public setName(name: string): void {
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
public setMaxBars(maxBars: number): void {
|
||||
this.maxBars = maxBars;
|
||||
}
|
||||
|
||||
public setCurrentBars(currentBars: number): void {
|
||||
this.currentBars = currentBars;
|
||||
}
|
||||
|
||||
public setBpm(bpm: number): void {
|
||||
this.bpm = bpm;
|
||||
}
|
||||
|
||||
public setKeySignature(keySignature: KeySignature): void {
|
||||
this.keySignature = keySignature;
|
||||
}
|
||||
|
||||
public setTracks(tracks: KGTrack[]): void {
|
||||
this.tracks = tracks;
|
||||
}
|
||||
|
||||
public setProjectStructureVersion(projectStructureVersion: number): void {
|
||||
this.projectStructureVersion = projectStructureVersion;
|
||||
}
|
||||
|
||||
public getProjectStructureVersion(): number {
|
||||
return this.projectStructureVersion;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,362 @@
|
||||
import * as Tone from 'tone';
|
||||
import { AUDIO_INTERFACE_CONSTANTS } from '../../constants/coreConstants';
|
||||
import type { InstrumentType } from '../track/KGMidiTrack';
|
||||
import { KGToneSamplerFactory } from './KGToneSamplerFactory';
|
||||
|
||||
// InstrumentType is defined in KGMidiTrack and re-used here
|
||||
|
||||
/**
|
||||
* KGAudioBus - Represents a complete audio bus for a track
|
||||
* Replaces the separate trackSynths, trackInstruments, trackVolumes, trackMuted, trackSolo maps
|
||||
* Each instance manages a single track's audio processing chain
|
||||
*/
|
||||
export class KGAudioBus {
|
||||
// Core audio components
|
||||
private sampler: Tone.Sampler;
|
||||
private instrument: InstrumentType;
|
||||
|
||||
// Audio properties
|
||||
private volume: number;
|
||||
private muted: boolean;
|
||||
private solo: boolean;
|
||||
|
||||
// Audio processing chain (for future expansion)
|
||||
// private gain: Tone.Gain;
|
||||
// private filter: Tone.Filter;
|
||||
|
||||
/**
|
||||
* Private constructor - use KGAudioBus.create() instead
|
||||
*/
|
||||
private constructor(
|
||||
sampler: Tone.Sampler,
|
||||
instrument: InstrumentType,
|
||||
volume: number,
|
||||
muted: boolean,
|
||||
solo: boolean
|
||||
) {
|
||||
this.sampler = sampler;
|
||||
this.instrument = instrument;
|
||||
this.volume = volume;
|
||||
this.muted = muted;
|
||||
this.solo = solo;
|
||||
|
||||
// Set initial volume on the sampler
|
||||
this.updateSamplerVolume();
|
||||
|
||||
console.log(`KGAudioBus created for ${instrument} - volume: ${volume}, muted: ${muted}, solo: ${solo}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new KGAudioBus instance (async factory method)
|
||||
* This is the main way to create audio buses since we need to wait for sampler creation
|
||||
*/
|
||||
public static async create(
|
||||
instrument: InstrumentType = 'acoustic_grand_piano',
|
||||
volume: number = AUDIO_INTERFACE_CONSTANTS.DEFAULT_TRACK_VOLUME,
|
||||
muted: boolean = false,
|
||||
solo: boolean = false
|
||||
): Promise<KGAudioBus> {
|
||||
try {
|
||||
console.log(`Creating KGAudioBus for ${instrument}...`);
|
||||
|
||||
// Create the sampler using the factory
|
||||
const samplerFactory = KGToneSamplerFactory.instance();
|
||||
const sampler = await samplerFactory.createSampler(String(instrument));
|
||||
|
||||
// Create the audio bus instance
|
||||
const audioBus = new KGAudioBus(sampler, instrument, volume, muted, solo);
|
||||
|
||||
console.log(`KGAudioBus created successfully for ${instrument}`);
|
||||
return audioBus;
|
||||
|
||||
} catch (error) {
|
||||
console.error(`Failed to create KGAudioBus for ${instrument}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// ===== AUDIO PLAYBACK =====
|
||||
|
||||
/**
|
||||
* Trigger a note on this audio bus
|
||||
*/
|
||||
public triggerAttackRelease(
|
||||
note: string,
|
||||
duration: Tone.Unit.Time,
|
||||
time?: number,
|
||||
velocity?: number
|
||||
): void {
|
||||
if (!this.shouldPlay()) {
|
||||
return; // Don't play if muted or should be silent due to solo logic
|
||||
}
|
||||
|
||||
try {
|
||||
this.sampler.triggerAttackRelease(note, duration, time, velocity);
|
||||
} catch (error) {
|
||||
console.error(`Error triggering note ${note} on ${this.instrument}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger note attack (start playing) without automatic release
|
||||
* Used for sustained notes like piano key presses
|
||||
*/
|
||||
public triggerAttack(
|
||||
note: string,
|
||||
time?: number,
|
||||
velocity?: number
|
||||
): void {
|
||||
if (!this.shouldPlay()) {
|
||||
return; // Don't play if muted or should be silent due to solo logic
|
||||
}
|
||||
|
||||
try {
|
||||
this.sampler.triggerAttack(note, time, velocity);
|
||||
} catch (error) {
|
||||
console.error(`Error triggering attack for note ${note} on ${this.instrument}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Release a specific note
|
||||
* Used for ending sustained notes like piano key releases
|
||||
*/
|
||||
public triggerRelease(
|
||||
note: string,
|
||||
time?: number
|
||||
): void {
|
||||
try {
|
||||
this.sampler.triggerRelease(note, time);
|
||||
} catch (error) {
|
||||
console.error(`Error releasing note ${note} on ${this.instrument}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Release all currently playing notes
|
||||
*/
|
||||
public releaseAll(): void {
|
||||
try {
|
||||
this.sampler.releaseAll();
|
||||
} catch (error) {
|
||||
console.error(`Error releasing all notes on ${this.instrument}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
// ===== AUDIO PROPERTIES =====
|
||||
|
||||
/**
|
||||
* Set the volume for this audio bus
|
||||
*/
|
||||
public setVolume(volume: number): void {
|
||||
this.volume = volume;
|
||||
this.updateSamplerVolume();
|
||||
console.log(`Set ${this.instrument} volume to ${volume}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current volume
|
||||
*/
|
||||
public getVolume(): number {
|
||||
return this.volume;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the mute state for this audio bus
|
||||
*/
|
||||
public setMuted(muted: boolean): void {
|
||||
this.muted = muted;
|
||||
this.updateSamplerVolume();
|
||||
console.log(`Set ${this.instrument} muted to ${muted}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current mute state
|
||||
*/
|
||||
public getMuted(): boolean {
|
||||
return this.muted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the solo state for this audio bus
|
||||
*/
|
||||
public setSolo(solo: boolean): void {
|
||||
this.solo = solo;
|
||||
console.log(`Set ${this.instrument} solo to ${solo}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current solo state
|
||||
*/
|
||||
public getSolo(): boolean {
|
||||
return this.solo;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current instrument type
|
||||
*/
|
||||
public getInstrument(): InstrumentType {
|
||||
return this.instrument;
|
||||
}
|
||||
|
||||
/**
|
||||
* Change the instrument for this audio bus
|
||||
*/
|
||||
public async setInstrument(newInstrument: InstrumentType): Promise<void> {
|
||||
try {
|
||||
console.log(`Changing instrument from ${this.instrument} to ${newInstrument}...`);
|
||||
|
||||
// Dispose of the current sampler
|
||||
this.sampler.dispose();
|
||||
|
||||
// Create new sampler with new instrument
|
||||
const samplerFactory = KGToneSamplerFactory.instance();
|
||||
this.sampler = await samplerFactory.createSampler(String(newInstrument));
|
||||
this.instrument = newInstrument;
|
||||
|
||||
// Restore volume settings
|
||||
// this.updateSamplerVolume();
|
||||
|
||||
console.log(`Instrument changed successfully to ${newInstrument}`);
|
||||
} catch (error) {
|
||||
console.error(`Failed to change instrument to ${newInstrument}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// ===== AUDIO ROUTING =====
|
||||
|
||||
/**
|
||||
* Connect this audio bus to a destination (gain node, master output, etc.)
|
||||
*/
|
||||
public connect(destination: Tone.InputNode): void {
|
||||
try {
|
||||
this.sampler.connect(destination);
|
||||
console.log(`Connected ${this.instrument} to audio destination`);
|
||||
} catch (error) {
|
||||
console.error(`Error connecting ${this.instrument} to destination:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect this audio bus from all destinations
|
||||
*/
|
||||
public disconnect(): void {
|
||||
try {
|
||||
this.sampler.disconnect();
|
||||
console.log(`Disconnected ${this.instrument} from all destinations`);
|
||||
} catch (error) {
|
||||
console.error(`Error disconnecting ${this.instrument}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to the main output
|
||||
*/
|
||||
public toDestination(): void {
|
||||
try {
|
||||
this.sampler.toDestination();
|
||||
console.log(`Connected ${this.instrument} to main output`);
|
||||
} catch (error) {
|
||||
console.error(`Error connecting ${this.instrument} to main output:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
// ===== RESOURCE MANAGEMENT =====
|
||||
|
||||
/**
|
||||
* Dispose of this audio bus and clean up resources
|
||||
*/
|
||||
public dispose(): void {
|
||||
try {
|
||||
this.sampler.dispose();
|
||||
console.log(`Disposed KGAudioBus for ${this.instrument}`);
|
||||
} catch (error) {
|
||||
console.error(`Error disposing KGAudioBus for ${this.instrument}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
// ===== PRIVATE UTILITY METHODS =====
|
||||
|
||||
/**
|
||||
* Update the sampler volume based on current volume and mute state
|
||||
*/
|
||||
private updateSamplerVolume(): void {
|
||||
try {
|
||||
const effectiveVolume = this.muted ? 0 : this.volume;
|
||||
const volumeDb = effectiveVolume > 0 ? 20 * Math.log10(effectiveVolume) : -Infinity;
|
||||
this.sampler.volume.value = volumeDb;
|
||||
} catch (error) {
|
||||
console.error(`Error updating volume for ${this.instrument}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply effective volume considering both mute and solo context
|
||||
* When any track is soloed, only soloed tracks should be audible
|
||||
*/
|
||||
public applyEffectiveVolume(hasSoloedTracks: boolean): void {
|
||||
try {
|
||||
let effectiveVolume = this.volume;
|
||||
if (this.muted) {
|
||||
effectiveVolume = 0;
|
||||
} else if (hasSoloedTracks && !this.solo) {
|
||||
effectiveVolume = 0;
|
||||
}
|
||||
const volumeDb = effectiveVolume > 0 ? 20 * Math.log10(effectiveVolume) : -Infinity;
|
||||
this.sampler.volume.value = volumeDb;
|
||||
} catch (error) {
|
||||
console.error(`Error applying effective volume for ${this.instrument}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this audio bus should play (handles mute state)
|
||||
* Note: Solo logic should be handled at the audio interface level
|
||||
*/
|
||||
private shouldPlay(): boolean {
|
||||
return !this.muted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this audio bus should play considering solo logic
|
||||
* Called by audio interface with knowledge of other tracks' solo states
|
||||
*/
|
||||
public shouldPlayWithSolo(hasSoloedTracks: boolean): boolean {
|
||||
if (this.muted) {
|
||||
return false; // Muted tracks never play
|
||||
}
|
||||
|
||||
if (hasSoloedTracks) {
|
||||
return this.solo; // Only soloed tracks play when any track is soloed
|
||||
}
|
||||
|
||||
return true; // All non-muted tracks play when no tracks are soloed
|
||||
}
|
||||
|
||||
// ===== GETTERS FOR DEBUGGING =====
|
||||
|
||||
/**
|
||||
* Get the underlying Tone.Sampler (for debugging/advanced use)
|
||||
*/
|
||||
public getSampler(): Tone.Sampler {
|
||||
return this.sampler;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a summary of this audio bus state
|
||||
*/
|
||||
public getState(): {
|
||||
instrument: InstrumentType;
|
||||
volume: number;
|
||||
muted: boolean;
|
||||
solo: boolean;
|
||||
} {
|
||||
return {
|
||||
instrument: this.instrument,
|
||||
volume: this.volume,
|
||||
muted: this.muted,
|
||||
solo: this.solo
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,675 @@
|
||||
import type { KGProject } from '../KGProject';
|
||||
import type { KGMidiNote } from '../midi/KGMidiNote';
|
||||
import { TIME_CONSTANTS, AUDIO_INTERFACE_CONSTANTS } from '../../constants/coreConstants';
|
||||
import { FLUIDR3_INSTRUMENT_MAP } from '../../constants/generalMidiConstants';
|
||||
import { pitchToNoteNameString } from '../../util/midiUtil';
|
||||
import * as Tone from 'tone';
|
||||
import { KGAudioBus } from './KGAudioBus';
|
||||
import type { InstrumentType } from '../track/KGMidiTrack';
|
||||
import { KGCore } from '../KGCore';
|
||||
|
||||
/**
|
||||
* KGAudioInterface - Audio engine interface for the DAW
|
||||
* Implements the singleton pattern for global audio management
|
||||
* Abstracts audio engine implementation (Tone.js) for potential future replacement
|
||||
*/
|
||||
export class KGAudioInterface {
|
||||
// Private static instance for singleton pattern
|
||||
private static _instance: KGAudioInterface | null = null;
|
||||
|
||||
// Audio engine state
|
||||
private isInitialized: boolean = false;
|
||||
private isAudioContextStarted: boolean = false;
|
||||
|
||||
// Track management - now using KGAudioBus
|
||||
private trackAudioBuses: Map<string, KGAudioBus> = new Map();
|
||||
|
||||
// Playback state
|
||||
private isPlaying: boolean = false;
|
||||
private masterVolume: number = AUDIO_INTERFACE_CONSTANTS.DEFAULT_MASTER_VOLUME;
|
||||
private scheduledEvents: Set<number> = new Set(); // Tone event IDs
|
||||
|
||||
// Master volume control
|
||||
private masterGain: Tone.Gain | null = null;
|
||||
|
||||
// Private constructor to prevent direct instantiation
|
||||
private constructor() {
|
||||
console.log("KGAudioInterface initialized");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the singleton instance of KGAudioInterface
|
||||
* Creates the instance if it doesn't exist yet
|
||||
*/
|
||||
public static instance(): KGAudioInterface {
|
||||
if (!KGAudioInterface._instance) {
|
||||
KGAudioInterface._instance = new KGAudioInterface();
|
||||
}
|
||||
return KGAudioInterface._instance;
|
||||
}
|
||||
|
||||
// ===== INITIALIZATION =====
|
||||
|
||||
/**
|
||||
* Initialize the audio engine (Tone.js)
|
||||
*/
|
||||
public async initialize(): Promise<void> {
|
||||
if (this.isInitialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Set up master gain for volume control
|
||||
this.masterGain = new Tone.Gain(this.masterVolume).toDestination();
|
||||
|
||||
// Configure transport settings
|
||||
Tone.Transport.bpm.value = TIME_CONSTANTS.DEFAULT_BPM; // Default BPM
|
||||
Tone.Transport.timeSignature = [TIME_CONSTANTS.DEFAULT_TIME_SIGNATURE.numerator, TIME_CONSTANTS.DEFAULT_TIME_SIGNATURE.denominator]; // Default time signature
|
||||
|
||||
this.isInitialized = true;
|
||||
console.log("Audio engine initialized successfully");
|
||||
} catch (error) {
|
||||
console.error("Failed to initialize audio engine:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the audio context (required for Web Audio)
|
||||
*/
|
||||
public async startAudioContext(): Promise<void> {
|
||||
if (this.isAudioContextStarted) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await Tone.start();
|
||||
this.isAudioContextStarted = true;
|
||||
console.log("Audio context started successfully");
|
||||
} catch (error) {
|
||||
console.error("Failed to start audio context:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up audio resources
|
||||
*/
|
||||
public async dispose(): Promise<void> {
|
||||
try {
|
||||
// Stop playback
|
||||
this.stopPlayback();
|
||||
|
||||
// Clear all scheduled events
|
||||
this.clearScheduledEvents();
|
||||
|
||||
// Dispose of all audio buses
|
||||
this.trackAudioBuses.forEach(audioBus => {
|
||||
audioBus.dispose();
|
||||
});
|
||||
this.trackAudioBuses.clear();
|
||||
|
||||
// Dispose master gain
|
||||
if (this.masterGain) {
|
||||
this.masterGain.dispose();
|
||||
this.masterGain = null;
|
||||
}
|
||||
|
||||
this.isInitialized = false;
|
||||
this.isAudioContextStarted = false;
|
||||
|
||||
console.log("Audio resources disposed successfully");
|
||||
} catch (error) {
|
||||
console.error("Error disposing audio resources:", error);
|
||||
}
|
||||
}
|
||||
|
||||
// ===== TRACK MANAGEMENT =====
|
||||
|
||||
/**
|
||||
* Create a synth/sampler for a track (backward compatibility wrapper)
|
||||
*/
|
||||
public async createTrackSynth(trackId: string, instrumentType: InstrumentType): Promise<void> {
|
||||
await this.createTrackAudioBus(trackId, instrumentType);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a track's synth (backward compatibility wrapper)
|
||||
*/
|
||||
public async removeTrackSynth(trackId: string): Promise<void> {
|
||||
await this.removeTrackAudioBus(trackId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an audio bus for a track (replaces createTrackSynth)
|
||||
*/
|
||||
public async createTrackAudioBus(trackId: string, instrumentType: InstrumentType): Promise<void> {
|
||||
// Remove existing audio bus if it exists
|
||||
await this.removeTrackAudioBus(trackId);
|
||||
|
||||
try {
|
||||
console.log(`Creating audio bus for track ${trackId} with instrument ${instrumentType}`);
|
||||
|
||||
// Create new audio bus
|
||||
// Initialize with track's stored volume if available
|
||||
const project = KGCore.instance().getCurrentProject();
|
||||
const track = project.getTracks().find(t => t.getId().toString() === trackId);
|
||||
const initialVolume = track ? track.getVolume() : AUDIO_INTERFACE_CONSTANTS.DEFAULT_TRACK_VOLUME;
|
||||
const audioBus = await KGAudioBus.create(instrumentType, initialVolume);
|
||||
|
||||
// Connect to master gain if available, otherwise to destination
|
||||
if (this.masterGain) {
|
||||
audioBus.connect(this.masterGain);
|
||||
} else {
|
||||
audioBus.toDestination();
|
||||
}
|
||||
|
||||
// Store the audio bus
|
||||
this.trackAudioBuses.set(trackId, audioBus);
|
||||
|
||||
console.log(`Created audio bus for track ${trackId} with ${instrumentType}`);
|
||||
} catch (error) {
|
||||
console.error(`Failed to create audio bus for track ${trackId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a track's audio bus (replaces removeTrackSynth)
|
||||
*/
|
||||
public async removeTrackAudioBus(trackId: string): Promise<void> {
|
||||
try {
|
||||
const audioBus = this.trackAudioBuses.get(trackId);
|
||||
if (audioBus) {
|
||||
// Dispose of the audio bus
|
||||
audioBus.dispose();
|
||||
|
||||
// Remove from map
|
||||
this.trackAudioBuses.delete(trackId);
|
||||
|
||||
console.log(`Removed audio bus for track ${trackId}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error removing audio bus for track ${trackId}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Change instrument type for a track (replaces setTrackInstrument)
|
||||
*/
|
||||
public async setTrackInstrument(trackId: string, instrumentType: InstrumentType): Promise<void> {
|
||||
try {
|
||||
const audioBus = this.trackAudioBuses.get(trackId);
|
||||
if (audioBus) {
|
||||
await audioBus.setInstrument(instrumentType);
|
||||
|
||||
// reconnect to master gain
|
||||
if (this.masterGain) {
|
||||
audioBus.connect(this.masterGain);
|
||||
} else {
|
||||
audioBus.toDestination();
|
||||
}
|
||||
|
||||
console.log(`Changed track ${trackId} instrument to ${instrumentType}`);
|
||||
} else {
|
||||
// Create new audio bus if it doesn't exist
|
||||
await this.createTrackAudioBus(trackId, instrumentType);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to change instrument for track ${trackId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// ===== PLAYBACK CONTROL =====
|
||||
|
||||
/**
|
||||
* Prepare playback by scheduling all MIDI events
|
||||
*/
|
||||
public preparePlayback(project: KGProject, startPosition: number): void {
|
||||
// Clear any existing scheduled events
|
||||
this.clearScheduledEvents();
|
||||
|
||||
try {
|
||||
// Set project BPM and time signature FIRST (this affects timing calculations)
|
||||
Tone.Transport.bpm.value = project.getBpm();
|
||||
const timeSignature = project.getTimeSignature();
|
||||
Tone.Transport.timeSignature = [timeSignature.numerator, timeSignature.denominator];
|
||||
|
||||
console.log(`Setting Tone.js BPM to ${project.getBpm()}, actual value: ${Tone.Transport.bpm.value}`);
|
||||
|
||||
// Set transport position (convert beats to Tone.js format)
|
||||
this.setTransportPosition(startPosition);
|
||||
|
||||
// Schedule all MIDI events
|
||||
project.getTracks().forEach(track => {
|
||||
const trackId = track.getId().toString();
|
||||
const audioBus = this.trackAudioBuses.get(trackId);
|
||||
|
||||
if (audioBus && track.getType() === 'MIDI') {
|
||||
track.getRegions().forEach(region => {
|
||||
if (region.constructor.name === 'KGMidiRegion') {
|
||||
const midiRegion = region as unknown as { getNotes: () => KGMidiNote[] };
|
||||
|
||||
// Get notes from region (assuming it has a getNotes method)
|
||||
if (midiRegion.getNotes) {
|
||||
midiRegion.getNotes().forEach((note: KGMidiNote) => {
|
||||
// Calculate absolute note timing in beats (note position + region start position)
|
||||
const regionStartBeat = region.getStartFromBeat();
|
||||
const noteStartBeat = note.getStartBeat() + regionStartBeat;
|
||||
const noteDurationBeats = note.getEndBeat() - note.getStartBeat();
|
||||
|
||||
// Only schedule notes that start at or after the playback start position
|
||||
if (noteStartBeat < startPosition) {
|
||||
return; // Skip notes that would have already finished before playback starts
|
||||
}
|
||||
|
||||
// Convert beats to Tone.js time format for scheduling
|
||||
const noteStartTime = this.beatsToToneTime(noteStartBeat);
|
||||
const noteDuration = this.beatsToToneTime(noteDurationBeats);
|
||||
|
||||
// Convert MIDI note number to note name
|
||||
const noteName = pitchToNoteNameString(note.getPitch());
|
||||
const velocity = note.getVelocity() / 127; // Normalize to 0-1
|
||||
|
||||
console.log(
|
||||
`Scheduling note ${noteName} at beat ${Number(noteStartBeat.toFixed ? noteStartBeat.toFixed(3) : noteStartBeat.toLocaleString(undefined, {maximumFractionDigits: 3}))}, Tone time: ${Number(Number(noteStartTime).toFixed(3))}, duration: ${Number(Number(noteDuration).toFixed(3))}`
|
||||
);
|
||||
|
||||
// Schedule the note
|
||||
const eventId = Tone.Transport.schedule((time) => {
|
||||
// Check if track should play considering solo logic
|
||||
const hasSoloedTracks = this.hasSoloedTracks();
|
||||
if (audioBus.shouldPlayWithSolo(hasSoloedTracks)) {
|
||||
audioBus.triggerAttackRelease(noteName, noteDuration, time, velocity);
|
||||
}
|
||||
}, noteStartTime);
|
||||
|
||||
this.scheduledEvents.add(eventId);
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
console.log(`Prepared playback from position ${startPosition} with ${this.scheduledEvents.size} events`);
|
||||
} catch (error) {
|
||||
console.error('Error preparing playback:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start playback
|
||||
*/
|
||||
public startPlayback(): void {
|
||||
try {
|
||||
if (!this.isInitialized) {
|
||||
throw new Error('Audio interface not initialized');
|
||||
}
|
||||
|
||||
if (!this.isAudioContextStarted) {
|
||||
throw new Error('Audio context not started');
|
||||
}
|
||||
|
||||
Tone.Transport.start();
|
||||
this.isPlaying = true;
|
||||
|
||||
console.log('Audio playback started');
|
||||
} catch (error) {
|
||||
console.error('Error starting playback:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop playback
|
||||
*/
|
||||
public stopPlayback(): void {
|
||||
try {
|
||||
Tone.Transport.stop();
|
||||
|
||||
// Release all currently playing notes
|
||||
this.trackAudioBuses.forEach(audioBus => {
|
||||
audioBus.releaseAll();
|
||||
});
|
||||
|
||||
this.isPlaying = false;
|
||||
|
||||
console.log('Audio playback stopped');
|
||||
} catch (error) {
|
||||
console.error('Error stopping playback:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger a single MIDI note
|
||||
*/
|
||||
public triggerNote(trackId: string, note: KGMidiNote, time?: number): void {
|
||||
try {
|
||||
const audioBus = this.trackAudioBuses.get(trackId);
|
||||
if (!audioBus) {
|
||||
console.warn(`No audio bus found for track ${trackId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const noteName = pitchToNoteNameString(note.getPitch());
|
||||
const velocity = note.getVelocity() / 127; // Normalize to 0-1
|
||||
|
||||
// Convert note duration from beats to Tone.js time format
|
||||
const durationInBeats = note.getEndBeat() - note.getStartBeat();
|
||||
const duration = this.beatsToToneTime(durationInBeats);
|
||||
const triggerTime = time ?? Tone.now();
|
||||
|
||||
// Check if track should play considering solo logic
|
||||
const hasSoloedTracks = this.hasSoloedTracks();
|
||||
if (audioBus.shouldPlayWithSolo(hasSoloedTracks)) {
|
||||
audioBus.triggerAttackRelease(noteName, duration, triggerTime, velocity);
|
||||
console.log(`Triggered note ${noteName} for track ${trackId}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error triggering note for track ${trackId}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger note attack (start playing) without automatic release
|
||||
* Used for piano key press
|
||||
*/
|
||||
public triggerNoteAttack(trackId: string, pitch: number, velocity: number = 127, time?: number): void {
|
||||
try {
|
||||
const audioBus = this.trackAudioBuses.get(trackId);
|
||||
if (!audioBus) {
|
||||
console.warn(`No audio bus found for track ${trackId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const noteName = pitchToNoteNameString(pitch);
|
||||
const normalizedVelocity = velocity / 127; // Normalize to 0-1
|
||||
const triggerTime = time ?? Tone.now();
|
||||
|
||||
// Check if track should play considering solo logic
|
||||
const hasSoloedTracks = this.hasSoloedTracks();
|
||||
if (audioBus.shouldPlayWithSolo(hasSoloedTracks)) {
|
||||
audioBus.triggerAttack(noteName, triggerTime, normalizedVelocity);
|
||||
console.log(`Triggered attack for note ${noteName} (pitch ${pitch}) on track ${trackId}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error triggering note attack for track ${trackId}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Release a specific note
|
||||
* Used for piano key release
|
||||
*/
|
||||
public releaseNote(trackId: string, pitch: number, time?: number): void {
|
||||
try {
|
||||
const audioBus = this.trackAudioBuses.get(trackId);
|
||||
if (!audioBus) {
|
||||
console.warn(`No audio bus found for track ${trackId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const noteName = pitchToNoteNameString(pitch);
|
||||
const releaseTime = time ?? Tone.now();
|
||||
|
||||
audioBus.triggerRelease(noteName, releaseTime);
|
||||
console.log(`Released note ${noteName} (pitch ${pitch}) on track ${trackId}`);
|
||||
} catch (error) {
|
||||
console.error(`Error releasing note for track ${trackId}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all scheduled events
|
||||
*/
|
||||
public clearScheduledEvents(): void {
|
||||
try {
|
||||
// Cancel all scheduled events
|
||||
this.scheduledEvents.forEach(eventId => {
|
||||
Tone.Transport.clear(eventId);
|
||||
});
|
||||
|
||||
// Clear the set
|
||||
this.scheduledEvents.clear();
|
||||
|
||||
console.log('Cleared all scheduled events');
|
||||
} catch (error) {
|
||||
console.error('Error clearing scheduled events:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// ===== TRANSPORT CONTROL =====
|
||||
|
||||
/**
|
||||
* Set transport position
|
||||
*/
|
||||
public setTransportPosition(position: number): void {
|
||||
try {
|
||||
// Convert beats to Tone.js time format
|
||||
const toneTime = this.beatsToToneTime(position);
|
||||
Tone.Transport.position = toneTime;
|
||||
console.log(`Set transport position to ${position} beats (${toneTime})`);
|
||||
} catch (error) {
|
||||
console.error('Error setting transport position:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current transport position
|
||||
*/
|
||||
public getTransportPosition(): number {
|
||||
try {
|
||||
const position = Tone.Transport.position;
|
||||
return this.toneTimeToBeats(position);
|
||||
} catch (error) {
|
||||
console.error('Error getting transport position:', error);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set transport BPM
|
||||
*/
|
||||
public setBpm(bpm: number): void {
|
||||
try {
|
||||
Tone.Transport.bpm.value = bpm;
|
||||
console.log(`Set BPM to ${bpm}`);
|
||||
} catch (error) {
|
||||
console.error('Error setting BPM:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// ===== TRACK PROPERTIES =====
|
||||
|
||||
/**
|
||||
* Set track volume
|
||||
*/
|
||||
public setTrackVolume(trackId: string, volume: number): void {
|
||||
try {
|
||||
const audioBus = this.trackAudioBuses.get(trackId);
|
||||
if (audioBus) {
|
||||
audioBus.setVolume(volume);
|
||||
console.log(`Set track ${trackId} volume to ${volume}`);
|
||||
} else {
|
||||
console.warn(`No audio bus found for track ${trackId}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error setting track ${trackId} volume:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set track mute state
|
||||
*/
|
||||
public setTrackMute(trackId: string, muted: boolean): void {
|
||||
try {
|
||||
const audioBus = this.trackAudioBuses.get(trackId);
|
||||
if (audioBus) {
|
||||
audioBus.setMuted(muted);
|
||||
console.log(`Set track ${trackId} mute to ${muted}`);
|
||||
// Recompute effective volumes across all buses (solo logic)
|
||||
this.updateAllEffectiveVolumes();
|
||||
} else {
|
||||
console.warn(`No audio bus found for track ${trackId}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error setting track ${trackId} mute:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set track solo state
|
||||
*/
|
||||
public setTrackSolo(trackId: string, solo: boolean): void {
|
||||
try {
|
||||
const audioBus = this.trackAudioBuses.get(trackId);
|
||||
if (audioBus) {
|
||||
audioBus.setSolo(solo);
|
||||
console.log(`Set track ${trackId} solo to ${solo}`);
|
||||
// Recompute effective volumes across all buses (solo logic)
|
||||
this.updateAllEffectiveVolumes();
|
||||
} else {
|
||||
console.warn(`No audio bus found for track ${trackId}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error setting track ${trackId} solo:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set master volume
|
||||
*/
|
||||
public setMasterVolume(volume: number): void {
|
||||
try {
|
||||
if (this.masterGain) {
|
||||
this.masterGain.gain.value = volume;
|
||||
}
|
||||
|
||||
this.masterVolume = volume;
|
||||
console.log(`Set master volume to ${volume}`);
|
||||
} catch (error) {
|
||||
console.error('Error setting master volume:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// ===== GETTERS =====
|
||||
|
||||
public getIsInitialized(): boolean {
|
||||
return this.isInitialized;
|
||||
}
|
||||
|
||||
public getIsAudioContextStarted(): boolean {
|
||||
return this.isAudioContextStarted;
|
||||
}
|
||||
|
||||
public getIsPlaying(): boolean {
|
||||
return this.isPlaying;
|
||||
}
|
||||
|
||||
public getTrackInstrument(trackId: string): InstrumentType | undefined {
|
||||
const audioBus = this.trackAudioBuses.get(trackId);
|
||||
return audioBus?.getInstrument();
|
||||
}
|
||||
|
||||
public getTrackVolume(trackId: string): number {
|
||||
const audioBus = this.trackAudioBuses.get(trackId);
|
||||
return audioBus?.getVolume() ?? AUDIO_INTERFACE_CONSTANTS.DEFAULT_TRACK_VOLUME;
|
||||
}
|
||||
|
||||
public getTrackMuted(trackId: string): boolean {
|
||||
const audioBus = this.trackAudioBuses.get(trackId);
|
||||
return audioBus?.getMuted() ?? false;
|
||||
}
|
||||
|
||||
public getTrackSolo(trackId: string): boolean {
|
||||
const audioBus = this.trackAudioBuses.get(trackId);
|
||||
return audioBus?.getSolo() ?? false;
|
||||
}
|
||||
|
||||
public getMasterVolume(): number {
|
||||
return this.masterVolume;
|
||||
}
|
||||
|
||||
public getAvailableInstruments(): InstrumentType[] {
|
||||
return Object.keys(FLUIDR3_INSTRUMENT_MAP) as InstrumentType[];
|
||||
}
|
||||
|
||||
// ===== PRIVATE UTILITY METHODS =====
|
||||
|
||||
/**
|
||||
* Check if any tracks are currently soloed
|
||||
*/
|
||||
private hasSoloedTracks(): boolean {
|
||||
return Array.from(this.trackAudioBuses.values()).some(audioBus => audioBus.getSolo());
|
||||
}
|
||||
|
||||
/**
|
||||
* Update effective volume for all tracks according to mute/solo state
|
||||
*/
|
||||
private updateAllEffectiveVolumes(): void {
|
||||
try {
|
||||
const hasSoloedTracks = this.hasSoloedTracks();
|
||||
this.trackAudioBuses.forEach(bus => bus.applyEffectiveVolume(hasSoloedTracks));
|
||||
} catch (error) {
|
||||
console.error('Error updating effective volumes:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// ===== TIME CONVERSION UTILITIES =====
|
||||
|
||||
/**
|
||||
* Convert beats to Tone.js time format using raw seconds
|
||||
* This approach handles triplets and all subdivisions correctly
|
||||
*/
|
||||
private beatsToToneTime(beats: number): Tone.Unit.Time {
|
||||
const project = KGCore.instance().getCurrentProject();
|
||||
const bpm = project.getBpm();
|
||||
|
||||
// Calculate seconds per beat - BPM is always quarter note beats per minute
|
||||
// Time signature denominator doesn't affect BPM, only subdivision
|
||||
const secondsPerBeat = 60 / bpm;
|
||||
|
||||
// Convert beats directly to seconds
|
||||
const totalSeconds = beats * secondsPerBeat;
|
||||
|
||||
return totalSeconds as Tone.Unit.Time;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert Tone.js time format to beats
|
||||
*/
|
||||
private toneTimeToBeats(toneTime: Tone.Unit.Time): number {
|
||||
const project = KGCore.instance().getCurrentProject();
|
||||
const bpm = project.getBpm();
|
||||
|
||||
// Calculate seconds per beat - BPM is always quarter note beats per minute
|
||||
// Time signature denominator doesn't affect BPM, only subdivision
|
||||
const secondsPerBeat = 60 / bpm;
|
||||
|
||||
// Tone.Time() can handle both numbers and strings
|
||||
const seconds = Tone.Time(toneTime).toSeconds();
|
||||
const beats = seconds / secondsPerBeat;
|
||||
|
||||
return beats;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current BPM from Tone.js transport
|
||||
*/
|
||||
public getCurrentBpm(): number {
|
||||
return Tone.Transport.bpm.value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Debug method to check BPM setting
|
||||
*/
|
||||
public debugBpm(): void {
|
||||
console.log('=== BPM Debug Info ===');
|
||||
console.log('Tone.Transport.bpm.value:', Tone.Transport.bpm.value);
|
||||
console.log('Tone.Transport.state:', Tone.Transport.state);
|
||||
console.log('Audio context sample rate:', Tone.getContext().sampleRate);
|
||||
console.log('Audio context state:', Tone.getContext().state);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
import { SAMPLER_CONSTANTS } from '../../constants/coreConstants';
|
||||
import { FLUIDR3_INSTRUMENT_MAP } from '../../constants/generalMidiConstants';
|
||||
import * as Tone from 'tone';
|
||||
|
||||
/**
|
||||
* KGToneBuffersPool - Singleton class for managing ToneAudioBuffers
|
||||
* Handles loading and caching of soundfont audio buffers for instruments
|
||||
*/
|
||||
export class KGToneBuffersPool {
|
||||
// Private static instance for singleton pattern
|
||||
private static _instance: KGToneBuffersPool | null = null;
|
||||
|
||||
// Map to store ToneAudioBuffers by instrument name
|
||||
private bufferMap: Map<string, Tone.ToneAudioBuffers> = new Map();
|
||||
|
||||
// Map to store loading promises to prevent duplicate loading and handle race conditions
|
||||
private loadingPromises: Map<string, Promise<Tone.ToneAudioBuffers>> = new Map();
|
||||
|
||||
// Simple event listeners for load start/end without coupling to UI layer
|
||||
private loadingListeners: Array<(_evt: { type: 'start' | 'end'; instrument: string }) => void> = [];
|
||||
|
||||
// Private constructor to prevent direct instantiation
|
||||
private constructor() {
|
||||
console.log("KGToneBuffersPool initialized");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the singleton instance of KGToneBuffersPool
|
||||
* Creates the instance if it doesn't exist yet
|
||||
*/
|
||||
public static instance(): KGToneBuffersPool {
|
||||
if (!KGToneBuffersPool._instance) {
|
||||
KGToneBuffersPool._instance = new KGToneBuffersPool();
|
||||
}
|
||||
return KGToneBuffersPool._instance;
|
||||
}
|
||||
|
||||
/** Get the number of instruments currently loading */
|
||||
public getActiveLoadCount(): number {
|
||||
return this.loadingPromises.size;
|
||||
}
|
||||
|
||||
/** Register a listener for buffer loading events */
|
||||
public addLoadingListener(listener: (_evt: { type: 'start' | 'end'; instrument: string }) => void): void {
|
||||
this.loadingListeners.push(listener);
|
||||
}
|
||||
|
||||
/** Unregister a previously added listener */
|
||||
public removeLoadingListener(listener: (_evt: { type: 'start' | 'end'; instrument: string }) => void): void {
|
||||
this.loadingListeners = this.loadingListeners.filter(l => l !== listener);
|
||||
}
|
||||
|
||||
private emitLoadingEvent(_evt: { type: 'start' | 'end'; instrument: string }): void {
|
||||
try {
|
||||
this.loadingListeners.forEach(l => {
|
||||
try { l(_evt); } catch { /* swallow listener errors */ }
|
||||
});
|
||||
} catch {
|
||||
// no-op
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get ToneAudioBuffers for a specific instrument name
|
||||
* If not cached, creates and loads the buffers
|
||||
* Handles race conditions by ensuring only one loading operation per instrument
|
||||
*/
|
||||
public async getToneAudioBuffers(name: string): Promise<Tone.ToneAudioBuffers> {
|
||||
// Check if already fully loaded and cached
|
||||
const cachedBuffers = this.bufferMap.get(name);
|
||||
if (cachedBuffers && cachedBuffers.loaded) {
|
||||
console.log(`KGToneBuffersPool: Returning cached buffers for ${name}`);
|
||||
return cachedBuffers;
|
||||
}
|
||||
|
||||
// Check if currently loading - if so, wait for that promise
|
||||
if (this.loadingPromises.has(name)) {
|
||||
console.log(`KGToneBuffersPool: Waiting for existing loading operation for ${name}`);
|
||||
return await this.loadingPromises.get(name)!;
|
||||
}
|
||||
|
||||
// Start new loading operation
|
||||
console.log(`KGToneBuffersPool: Starting new loading operation for ${name}`);
|
||||
const loadingPromise = this.createToneAudioBuffers(name);
|
||||
this.loadingPromises.set(name, loadingPromise);
|
||||
// Emit start AFTER registering the promise to avoid duplicate start events in races
|
||||
this.emitLoadingEvent({ type: 'start', instrument: name });
|
||||
console.log(`[KGToneBuffersPool] start: Active load count: ${this.getActiveLoadCount()}`);
|
||||
|
||||
try {
|
||||
const buffers = await loadingPromise;
|
||||
|
||||
// Cache the fully loaded buffers
|
||||
this.bufferMap.set(name, buffers);
|
||||
console.log(`KGToneBuffersPool: Cached loaded buffers for ${name}`);
|
||||
|
||||
// Remove from loading promises since it's complete
|
||||
this.loadingPromises.delete(name);
|
||||
this.emitLoadingEvent({ type: 'end', instrument: name });
|
||||
console.log(`[KGToneBuffersPool] end: Active load count: ${this.getActiveLoadCount()}`);
|
||||
|
||||
return buffers;
|
||||
} catch (error) {
|
||||
// Remove failed loading promise so it can be retried
|
||||
this.loadingPromises.delete(name);
|
||||
console.error(`KGToneBuffersPool: Failed to load buffers for ${name}:`, error);
|
||||
// Emit end to allow UI to close spinner even on failure
|
||||
this.emitLoadingEvent({ type: 'end', instrument: name });
|
||||
console.log(`[KGToneBuffersPool] end: Active load count: ${this.getActiveLoadCount()}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create ToneAudioBuffers for an instrument
|
||||
*/
|
||||
private async createToneAudioBuffers(name: string): Promise<Tone.ToneAudioBuffers> {
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
// Get instrument configuration from constants
|
||||
const fluidConfig = SAMPLER_CONSTANTS.TONE_SAMPLERS.FLUID;
|
||||
const instrumentName = name;
|
||||
|
||||
if (!instrumentName) {
|
||||
throw new Error(`Unknown instrument: ${name}`);
|
||||
}
|
||||
|
||||
// Generate URL mapping for all keys from A0 to Bb7
|
||||
const urls = this.generateKeyUrls(fluidConfig.url, instrumentName);
|
||||
|
||||
console.log(`Loading ToneAudioBuffers for ${name} (${instrumentName})...`);
|
||||
|
||||
// Create ToneAudioBuffers with onload callback
|
||||
const buffers = new Tone.ToneAudioBuffers(
|
||||
urls,
|
||||
() => {
|
||||
console.log(`ToneAudioBuffers loaded successfully for ${name}`);
|
||||
resolve(buffers);
|
||||
}
|
||||
);
|
||||
|
||||
// Don't cache until loading is complete - this will be handled in getToneAudioBuffers
|
||||
|
||||
} catch (error) {
|
||||
console.error(`Error creating ToneAudioBuffers for ${name}:`, error);
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate URL mapping for all keys from A0 to Bb7
|
||||
* Uses Db notation instead of C# as specified
|
||||
*/
|
||||
private generateKeyUrls(baseUrl: string, instrumentName: string): { [key: string]: string } {
|
||||
const urls: { [key: string]: string } = {};
|
||||
|
||||
// get the range of the instrument.
|
||||
// TODO: make the sound library name configurable.
|
||||
const range = FLUIDR3_INSTRUMENT_MAP[instrumentName]?.pitchRange || [21, 108];
|
||||
|
||||
// Note names in order (using flats instead of sharps where applicable)
|
||||
const noteNames = ['C', 'Db', 'D', 'Eb', 'E', 'F', 'Gb', 'G', 'Ab', 'A', 'Bb', 'B'];
|
||||
|
||||
// Generate keys from A0 to C8 (MIDI notes 21 to 108)
|
||||
for (let midiNote = range[0]; midiNote <= range[1]; midiNote++) {
|
||||
const octave = Math.floor((midiNote - 12) / 12);
|
||||
const noteIndex = (midiNote - 12) % 12;
|
||||
const noteName = noteNames[noteIndex];
|
||||
const keyName = `${noteName}${octave}`;
|
||||
|
||||
// Generate URL for this key
|
||||
urls[keyName] = `${baseUrl}${instrumentName}-mp3/${keyName}.mp3`;
|
||||
}
|
||||
|
||||
console.log(`Generated ${Object.keys(urls).length} key URLs for ${instrumentName} from A0 to Bb7`);
|
||||
|
||||
return urls;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all cached buffers and dispose of resources
|
||||
*/
|
||||
public dispose(): void {
|
||||
try {
|
||||
// Dispose of all ToneAudioBuffers
|
||||
this.bufferMap.forEach((buffers, name) => {
|
||||
try {
|
||||
buffers.dispose();
|
||||
console.log(`Disposed ToneAudioBuffers for ${name}`);
|
||||
} catch (error) {
|
||||
console.error(`Error disposing ToneAudioBuffers for ${name}:`, error);
|
||||
}
|
||||
});
|
||||
|
||||
// Clear both maps
|
||||
this.bufferMap.clear();
|
||||
this.loadingPromises.clear();
|
||||
|
||||
console.log("KGToneBuffersPool disposed successfully");
|
||||
} catch (error) {
|
||||
console.error("Error disposing KGToneBuffersPool:", error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Preload buffers for specific instruments (optional performance optimization)
|
||||
*/
|
||||
public async preloadInstruments(instrumentNames: string[]): Promise<void> {
|
||||
const loadPromises = instrumentNames.map(name =>
|
||||
this.getToneAudioBuffers(name).catch(error => {
|
||||
console.warn(`Failed to preload ${name}:`, error);
|
||||
})
|
||||
);
|
||||
|
||||
await Promise.allSettled(loadPromises);
|
||||
console.log(`Preloading completed for ${instrumentNames.length} instruments`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import * as Tone from 'tone';
|
||||
import { KGToneBuffersPool } from './KGToneBuffersPool';
|
||||
import { FLUIDR3_INSTRUMENT_MAP } from '../../constants/generalMidiConstants';
|
||||
|
||||
/**
|
||||
* KGToneSamplerFactory - Singleton class for creating Tone.Sampler instances
|
||||
* Uses ToneAudioBuffers from KGToneBuffersPool to create samplers with real instrument sounds
|
||||
*/
|
||||
export class KGToneSamplerFactory {
|
||||
// Private static instance for singleton pattern
|
||||
private static _instance: KGToneSamplerFactory | null = null;
|
||||
|
||||
// Private constructor to prevent direct instantiation
|
||||
private constructor() {
|
||||
console.log("KGToneSamplerFactory initialized");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the singleton instance of KGToneSamplerFactory
|
||||
* Creates the instance if it doesn't exist yet
|
||||
*/
|
||||
public static instance(): KGToneSamplerFactory {
|
||||
if (!KGToneSamplerFactory._instance) {
|
||||
KGToneSamplerFactory._instance = new KGToneSamplerFactory();
|
||||
}
|
||||
return KGToneSamplerFactory._instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a Tone.Sampler for the specified instrument
|
||||
* Uses ToneAudioBuffers from the pool for realistic instrument sounds
|
||||
*/
|
||||
public async createSampler(instrumentName: string): Promise<Tone.Sampler> {
|
||||
try {
|
||||
console.log(`Creating sampler for instrument: ${instrumentName}`);
|
||||
|
||||
// Get ToneAudioBuffers from the pool
|
||||
const buffersPool = KGToneBuffersPool.instance();
|
||||
const audioBuffers = await buffersPool.getToneAudioBuffers(instrumentName);
|
||||
|
||||
// Create sampler and wait for it to load
|
||||
return new Promise<Tone.Sampler>((resolve, reject) => {
|
||||
// Set a timeout to prevent hanging indefinitely
|
||||
const timeout = setTimeout(() => {
|
||||
console.error(`Timeout: Sampler failed to load for ${instrumentName} after 30 seconds`);
|
||||
reject(new Error(`Sampler loading timeout for ${instrumentName}`));
|
||||
}, 30000); // 30 second timeout
|
||||
|
||||
try {
|
||||
const sampler = new Tone.Sampler({
|
||||
urls: this.convertBuffersToUrls(audioBuffers, FLUIDR3_INSTRUMENT_MAP[instrumentName]?.pitchRange || [21, 108]),
|
||||
onload: () => {
|
||||
clearTimeout(timeout);
|
||||
console.log(`Sampler loaded successfully for ${instrumentName}`);
|
||||
resolve(sampler);
|
||||
},
|
||||
onerror: (error) => {
|
||||
clearTimeout(timeout);
|
||||
console.error(`Sampler failed to load for ${instrumentName}:`, error);
|
||||
reject(new Error(`Sampler loading error for ${instrumentName}: ${error}`));
|
||||
}
|
||||
});
|
||||
|
||||
console.log(`Sampler created for ${instrumentName}, waiting for load...`);
|
||||
} catch (error) {
|
||||
clearTimeout(timeout);
|
||||
console.error(`Failed to create sampler for ${instrumentName}:`, error);
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`Failed to create sampler for ${instrumentName}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert ToneAudioBuffers to the URL format expected by Tone.Sampler
|
||||
* This creates a mapping from note names to the actual audio buffers
|
||||
*/
|
||||
private convertBuffersToUrls(audioBuffers: Tone.ToneAudioBuffers, range: number[] = [21, 118]): { [key: string]: Tone.ToneAudioBuffer } {
|
||||
const urls: { [key: string]: Tone.ToneAudioBuffer } = {};
|
||||
|
||||
// Note names in order (using flats instead of sharps where applicable)
|
||||
const noteNames = ['C', 'Db', 'D', 'Eb', 'E', 'F', 'Gb', 'G', 'Ab', 'A', 'Bb', 'B'];
|
||||
|
||||
// Generate keys from A0 to Bb7 (MIDI notes 21 to 118)
|
||||
for (let midiNote = range[0]; midiNote <= range[1]; midiNote++) {
|
||||
const octave = Math.floor((midiNote - 12) / 12);
|
||||
const noteIndex = (midiNote - 12) % 12;
|
||||
const noteName = noteNames[noteIndex];
|
||||
const keyName = `${noteName}${octave}`;
|
||||
|
||||
// Get the buffer for this key if it exists
|
||||
if (audioBuffers.has(keyName)) {
|
||||
urls[keyName] = audioBuffers.get(keyName)!;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Converted ${Object.keys(urls).length} audio buffers to sampler format`);
|
||||
return urls;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create multiple samplers for different instruments
|
||||
* Useful for preloading multiple instruments at once
|
||||
*/
|
||||
public async createMultipleSamplers(instrumentNames: string[]): Promise<Map<string, Tone.Sampler>> {
|
||||
const samplers = new Map<string, Tone.Sampler>();
|
||||
|
||||
try {
|
||||
const createPromises = instrumentNames.map(async (instrumentName) => {
|
||||
try {
|
||||
const sampler = await this.createSampler(instrumentName);
|
||||
samplers.set(instrumentName, sampler);
|
||||
} catch (error) {
|
||||
console.error(`Failed to create sampler for ${instrumentName}:`, error);
|
||||
}
|
||||
});
|
||||
|
||||
await Promise.allSettled(createPromises);
|
||||
console.log(`Created ${samplers.size} samplers out of ${instrumentNames.length} requested`);
|
||||
|
||||
return samplers;
|
||||
} catch (error) {
|
||||
console.error('Error creating multiple samplers:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a sampler can be created for the given instrument
|
||||
* (i.e., if the buffers are available in the pool)
|
||||
*/
|
||||
public async canCreateSampler(instrumentName: string): Promise<boolean> {
|
||||
try {
|
||||
const buffersPool = KGToneBuffersPool.instance();
|
||||
const audioBuffers = await buffersPool.getToneAudioBuffers(instrumentName);
|
||||
return audioBuffers.loaded;
|
||||
} catch (error) {
|
||||
console.warn(`Cannot create sampler for ${instrumentName}:`, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Base command interface for the command pattern implementation
|
||||
* All undoable operations in KGSP should extend this class
|
||||
*/
|
||||
export abstract class KGCommand {
|
||||
/**
|
||||
* Execute the command (perform the operation)
|
||||
*/
|
||||
abstract execute(): void;
|
||||
|
||||
/**
|
||||
* Undo the command (reverse the operation)
|
||||
*/
|
||||
abstract undo(): void;
|
||||
|
||||
/**
|
||||
* Get a human-readable description of what the command does
|
||||
* Used for UI feedback and debugging
|
||||
*/
|
||||
abstract getDescription(): string;
|
||||
|
||||
/**
|
||||
* Check if this command can be merged with another command
|
||||
* Used for continuous operations like dragging or resizing
|
||||
* @param other The other command to potentially merge with
|
||||
*/
|
||||
canMergeWith(other: KGCommand): boolean {
|
||||
return false; // Default: commands cannot be merged
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge this command with another command
|
||||
* Only called if canMergeWith returns true
|
||||
* @param other The other command to merge with
|
||||
* @returns A new merged command, or null if merge fails
|
||||
*/
|
||||
mergeWith(other: KGCommand): KGCommand | null {
|
||||
return null; // Default: no merging implemented
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the timestamp when this command was created
|
||||
* Used for command history management
|
||||
*/
|
||||
public readonly timestamp: number = Date.now();
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
import { KGCommand } from './KGCommand';
|
||||
import { DEBUG_MODE } from '../../constants';
|
||||
|
||||
/**
|
||||
* Manages the command history for undo/redo functionality
|
||||
* Implements a singleton pattern to ensure single source of truth
|
||||
*/
|
||||
export class KGCommandHistory {
|
||||
private static _instance: KGCommandHistory | null = null;
|
||||
|
||||
private undoStack: KGCommand[] = [];
|
||||
private redoStack: KGCommand[] = [];
|
||||
private maxHistorySize: number = 30;
|
||||
|
||||
// Callbacks for UI updates
|
||||
private onHistoryChanged?: () => void;
|
||||
|
||||
private constructor() {
|
||||
// Private constructor for singleton pattern
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the singleton instance
|
||||
*/
|
||||
public static instance(): KGCommandHistory {
|
||||
if (!this._instance) {
|
||||
this._instance = new KGCommandHistory();
|
||||
}
|
||||
return this._instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a command and add it to the history
|
||||
* @param command The command to execute
|
||||
*/
|
||||
public executeCommand(command: KGCommand): void {
|
||||
try {
|
||||
// Execute the command
|
||||
command.execute();
|
||||
|
||||
// Check if we can merge with the last command
|
||||
if (this.undoStack.length > 0) {
|
||||
const lastCommand = this.undoStack[this.undoStack.length - 1];
|
||||
if (lastCommand.canMergeWith(command)) {
|
||||
const mergedCommand = lastCommand.mergeWith(command);
|
||||
if (mergedCommand) {
|
||||
// Replace the last command with the merged one
|
||||
this.undoStack[this.undoStack.length - 1] = mergedCommand;
|
||||
|
||||
if (DEBUG_MODE.CORE) {
|
||||
console.log(`Merged command: ${mergedCommand.getDescription()}`);
|
||||
}
|
||||
|
||||
this.notifyHistoryChanged();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add to undo stack
|
||||
this.undoStack.push(command);
|
||||
|
||||
// Clear redo stack since we're executing a new command
|
||||
this.redoStack = [];
|
||||
|
||||
// Maintain history size limit
|
||||
if (this.undoStack.length > this.maxHistorySize) {
|
||||
this.undoStack.shift();
|
||||
}
|
||||
|
||||
if (DEBUG_MODE.CORE) {
|
||||
console.log(`Executed command: ${command.getDescription()}`);
|
||||
console.log(`Undo stack size: ${this.undoStack.length}`);
|
||||
}
|
||||
|
||||
this.notifyHistoryChanged();
|
||||
} catch (error) {
|
||||
console.error('Failed to execute command:', error);
|
||||
// Don't add failed commands to history
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Undo the last command
|
||||
* @returns true if undo was successful, false otherwise
|
||||
*/
|
||||
public undo(): boolean {
|
||||
if (this.undoStack.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const command = this.undoStack.pop()!;
|
||||
|
||||
try {
|
||||
command.undo();
|
||||
this.redoStack.push(command);
|
||||
|
||||
if (DEBUG_MODE.CORE) {
|
||||
console.log(`Undid command: ${command.getDescription()}`);
|
||||
console.log(`Undo stack size: ${this.undoStack.length}, Redo stack size: ${this.redoStack.length}`);
|
||||
}
|
||||
|
||||
this.notifyHistoryChanged();
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Failed to undo command:', error);
|
||||
// Put the command back on the stack if undo fails
|
||||
this.undoStack.push(command);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Redo the last undone command
|
||||
* @returns true if redo was successful, false otherwise
|
||||
*/
|
||||
public redo(): boolean {
|
||||
if (this.redoStack.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const command = this.redoStack.pop()!;
|
||||
|
||||
try {
|
||||
command.execute();
|
||||
this.undoStack.push(command);
|
||||
|
||||
if (DEBUG_MODE.CORE) {
|
||||
console.log(`Redid command: ${command.getDescription()}`);
|
||||
console.log(`Undo stack size: ${this.undoStack.length}, Redo stack size: ${this.redoStack.length}`);
|
||||
}
|
||||
|
||||
this.notifyHistoryChanged();
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Failed to redo command:', error);
|
||||
// Put the command back on the redo stack if redo fails
|
||||
this.redoStack.push(command);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if undo is available
|
||||
*/
|
||||
public canUndo(): boolean {
|
||||
return this.undoStack.length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if redo is available
|
||||
*/
|
||||
public canRedo(): boolean {
|
||||
return this.redoStack.length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get description of the next command that would be undone
|
||||
*/
|
||||
public getUndoDescription(): string | null {
|
||||
if (this.undoStack.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return this.undoStack[this.undoStack.length - 1].getDescription();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get description of the next command that would be redone
|
||||
*/
|
||||
public getRedoDescription(): string | null {
|
||||
if (this.redoStack.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return this.redoStack[this.redoStack.length - 1].getDescription();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all command history
|
||||
*/
|
||||
public clear(): void {
|
||||
this.undoStack = [];
|
||||
this.redoStack = [];
|
||||
|
||||
if (DEBUG_MODE.CORE) {
|
||||
console.log('Cleared command history');
|
||||
}
|
||||
|
||||
this.notifyHistoryChanged();
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the maximum number of commands to keep in history
|
||||
* @param size Maximum history size (default: 30)
|
||||
*/
|
||||
public setMaxHistorySize(size: number): void {
|
||||
this.maxHistorySize = Math.max(1, size);
|
||||
|
||||
// Trim history if needed
|
||||
while (this.undoStack.length > this.maxHistorySize) {
|
||||
this.undoStack.shift();
|
||||
}
|
||||
|
||||
this.notifyHistoryChanged();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current history size limit
|
||||
*/
|
||||
public getMaxHistorySize(): number {
|
||||
return this.maxHistorySize;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set callback for when history changes (for UI updates)
|
||||
*/
|
||||
public setOnHistoryChanged(callback: () => void): void {
|
||||
this.onHistoryChanged = callback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current history statistics for debugging
|
||||
*/
|
||||
public getHistoryStats(): { undoCount: number; redoCount: number; maxSize: number } {
|
||||
return {
|
||||
undoCount: this.undoStack.length,
|
||||
redoCount: this.redoStack.length,
|
||||
maxSize: this.maxHistorySize
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify listeners that history has changed
|
||||
*/
|
||||
private notifyHistoryChanged(): void {
|
||||
if (this.onHistoryChanged) {
|
||||
this.onHistoryChanged();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Command system exports
|
||||
* Provides centralized access to command pattern classes
|
||||
*/
|
||||
|
||||
export { KGCommand } from './KGCommand';
|
||||
export { KGCommandHistory } from './KGCommandHistory';
|
||||
|
||||
// Track commands
|
||||
export { AddTrackCommand } from './track/AddTrackCommand';
|
||||
export { RemoveTrackCommand } from './track/RemoveTrackCommand';
|
||||
export { ReorderTracksCommand } from './track/ReorderTracksCommand';
|
||||
export { UpdateTrackCommand, type TrackUpdateProperties } from './track/UpdateTrackCommand';
|
||||
|
||||
// Region commands
|
||||
export { CreateRegionCommand } from './region/CreateRegionCommand';
|
||||
export { DeleteRegionCommand, DeleteMultipleRegionsCommand } from './region/DeleteRegionCommand';
|
||||
export { ResizeRegionCommand } from './region/ResizeRegionCommand';
|
||||
export { MoveRegionCommand } from './region/MoveRegionCommand';
|
||||
export { PasteRegionsCommand } from './region/PasteRegionsCommand';
|
||||
export { UpdateRegionCommand, type RegionUpdateProperties } from './region/UpdateRegionCommand';
|
||||
|
||||
// Note commands
|
||||
export { CreateNoteCommand } from './note/CreateNoteCommand';
|
||||
export { DeleteNotesCommand, DeleteNoteCommand } from './note/DeleteNotesCommand';
|
||||
export { ResizeNotesCommand } from './note/ResizeNotesCommand';
|
||||
export { MoveNotesCommand } from './note/MoveNotesCommand';
|
||||
export { PasteNotesCommand } from './note/PasteNotesCommand';
|
||||
|
||||
// Project commands
|
||||
export { ChangeProjectPropertyCommand, type ProjectUpdateProperties } from './project/ChangeProjectPropertyCommand';
|
||||
@@ -0,0 +1,173 @@
|
||||
import { KGCommand } from '../KGCommand';
|
||||
import { KGCore } from '../../KGCore';
|
||||
import { KGMidiNote } from '../../midi/KGMidiNote';
|
||||
import { KGMidiRegion } from '../../region/KGMidiRegion';
|
||||
import { generateUniqueId } from '../../../util/miscUtil';
|
||||
|
||||
/**
|
||||
* Command to create a new MIDI note in a region
|
||||
* Handles both the core model update and provides undo functionality
|
||||
*/
|
||||
export class CreateNoteCommand extends KGCommand {
|
||||
private regionId: string;
|
||||
private noteId: string;
|
||||
private startBeat: number;
|
||||
private endBeat: number;
|
||||
private pitch: number;
|
||||
private velocity: number;
|
||||
private createdNote: KGMidiNote | null = null;
|
||||
|
||||
constructor(
|
||||
regionId: string,
|
||||
startBeat: number,
|
||||
endBeat: number,
|
||||
pitch: number,
|
||||
velocity: number = 127,
|
||||
noteId?: string
|
||||
) {
|
||||
super();
|
||||
this.regionId = regionId;
|
||||
this.startBeat = startBeat;
|
||||
this.endBeat = endBeat;
|
||||
this.pitch = pitch;
|
||||
this.velocity = velocity;
|
||||
this.noteId = noteId || generateUniqueId('KGMidiNote');
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
const core = KGCore.instance();
|
||||
const currentProject = core.getCurrentProject();
|
||||
const tracks = currentProject.getTracks();
|
||||
|
||||
// Find the region that contains this note
|
||||
let targetRegion: KGMidiRegion | null = null;
|
||||
|
||||
for (const track of tracks) {
|
||||
const regions = track.getRegions();
|
||||
const region = regions.find(r => r.getId() === this.regionId);
|
||||
if (region && region instanceof KGMidiRegion) {
|
||||
targetRegion = region;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!targetRegion) {
|
||||
throw new Error(`MIDI region with ID ${this.regionId} not found`);
|
||||
}
|
||||
|
||||
// Create the new MIDI note
|
||||
this.createdNote = new KGMidiNote(
|
||||
this.noteId,
|
||||
this.startBeat,
|
||||
this.endBeat,
|
||||
this.pitch,
|
||||
this.velocity
|
||||
);
|
||||
|
||||
// Add the note to the region
|
||||
targetRegion.addNote(this.createdNote);
|
||||
|
||||
console.log(`Created note in region ${this.regionId}: pitch=${this.pitch}, start=${this.startBeat}, end=${this.endBeat}`);
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
if (!this.createdNote) {
|
||||
throw new Error('Cannot undo: no note was created');
|
||||
}
|
||||
|
||||
const core = KGCore.instance();
|
||||
const currentProject = core.getCurrentProject();
|
||||
const tracks = currentProject.getTracks();
|
||||
|
||||
// Find the region that contains this note
|
||||
let targetRegion: KGMidiRegion | null = null;
|
||||
|
||||
for (const track of tracks) {
|
||||
const regions = track.getRegions();
|
||||
const region = regions.find(r => r.getId() === this.regionId);
|
||||
if (region && region instanceof KGMidiRegion) {
|
||||
targetRegion = region;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!targetRegion) {
|
||||
throw new Error(`MIDI region with ID ${this.regionId} not found during undo`);
|
||||
}
|
||||
|
||||
// Remove the note from the region
|
||||
targetRegion.removeNote(this.noteId);
|
||||
|
||||
console.log(`Removed note from region ${this.regionId}: pitch=${this.pitch}`);
|
||||
}
|
||||
|
||||
getDescription(): string {
|
||||
// Convert MIDI pitch to note name for user-friendly description
|
||||
const noteNames = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'];
|
||||
const octave = Math.floor(this.pitch / 12) - 1;
|
||||
const noteName = noteNames[this.pitch % 12];
|
||||
return `Create note ${noteName}${octave}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the ID of the note that was/will be created
|
||||
*/
|
||||
public getNoteId(): string {
|
||||
return this.noteId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the created note instance (only available after execute)
|
||||
*/
|
||||
public getCreatedNote(): KGMidiNote | null {
|
||||
return this.createdNote;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the region ID where the note was/will be created
|
||||
*/
|
||||
public getRegionId(): string {
|
||||
return this.regionId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory method to create a note command from UI coordinates
|
||||
*/
|
||||
public static fromUICoordinates(
|
||||
regionId: string,
|
||||
mouseX: number,
|
||||
mouseY: number,
|
||||
pianoGridElement: HTMLElement,
|
||||
beatWidth: number,
|
||||
noteHeight: number,
|
||||
regionStartBeat: number,
|
||||
noteLength: number,
|
||||
velocity: number = 127
|
||||
): CreateNoteCommand {
|
||||
// Calculate relative position within the grid
|
||||
const rect = pianoGridElement.getBoundingClientRect();
|
||||
const x = mouseX - rect.left;
|
||||
const y = mouseY - rect.top;
|
||||
|
||||
// Calculate the beat number (relative to region start)
|
||||
const rawBeatNumber = x / beatWidth;
|
||||
const beatNumber = Math.floor(rawBeatNumber); // Snap to beat grid
|
||||
|
||||
// Calculate the pitch (MIDI note number)
|
||||
// The piano roll is drawn from bottom to top, with higher notes at the top
|
||||
const pitchIndex = Math.floor(y / noteHeight);
|
||||
const pitch = 107 - pitchIndex; // Convert index to pitch (B7 is 107)
|
||||
|
||||
// Calculate note start and end beats
|
||||
const noteStartBeat = beatNumber;
|
||||
const noteEndBeat = noteStartBeat + noteLength;
|
||||
|
||||
return new CreateNoteCommand(
|
||||
regionId,
|
||||
noteStartBeat,
|
||||
noteEndBeat,
|
||||
pitch,
|
||||
velocity
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
import { KGCommand } from '../KGCommand';
|
||||
import { KGCore } from '../../KGCore';
|
||||
import { KGMidiNote } from '../../midi/KGMidiNote';
|
||||
import { KGMidiRegion } from '../../region/KGMidiRegion';
|
||||
import { generateUniqueId } from '../../../util/miscUtil';
|
||||
|
||||
/**
|
||||
* Data structure for a note to be created
|
||||
*/
|
||||
export interface NoteCreationData {
|
||||
regionId: string;
|
||||
startBeat: number;
|
||||
endBeat: number;
|
||||
pitch: number;
|
||||
velocity: number;
|
||||
noteId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Command to create multiple MIDI notes in regions
|
||||
* Handles bulk creation as a single undoable operation
|
||||
*/
|
||||
export class CreateNotesCommand extends KGCommand {
|
||||
private noteCreationData: NoteCreationData[];
|
||||
private createdNotes: Array<{
|
||||
note: KGMidiNote;
|
||||
regionId: string;
|
||||
}> = [];
|
||||
|
||||
constructor(noteCreationData: NoteCreationData[]) {
|
||||
super();
|
||||
this.noteCreationData = noteCreationData.map(data => ({
|
||||
...data,
|
||||
noteId: data.noteId || generateUniqueId('KGMidiNote')
|
||||
}));
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
const core = KGCore.instance();
|
||||
const currentProject = core.getCurrentProject();
|
||||
const tracks = currentProject.getTracks();
|
||||
|
||||
// Clear any existing created note data to prevent duplicates on re-execution
|
||||
this.createdNotes = [];
|
||||
|
||||
// Create all notes
|
||||
for (const noteData of this.noteCreationData) {
|
||||
// Find the target region
|
||||
let targetRegion: KGMidiRegion | null = null;
|
||||
|
||||
for (const track of tracks) {
|
||||
const regions = track.getRegions();
|
||||
const region = regions.find(r => r.getId() === noteData.regionId);
|
||||
if (region && region instanceof KGMidiRegion) {
|
||||
targetRegion = region;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!targetRegion) {
|
||||
throw new Error(`MIDI region with ID ${noteData.regionId} not found`);
|
||||
}
|
||||
|
||||
// Create the new MIDI note
|
||||
const newNote = new KGMidiNote(
|
||||
noteData.noteId!,
|
||||
noteData.startBeat,
|
||||
noteData.endBeat,
|
||||
noteData.pitch,
|
||||
noteData.velocity
|
||||
);
|
||||
|
||||
// Add the note to the region
|
||||
targetRegion.addNote(newNote);
|
||||
|
||||
// Store for undo
|
||||
this.createdNotes.push({
|
||||
note: newNote,
|
||||
regionId: noteData.regionId
|
||||
});
|
||||
}
|
||||
|
||||
const noteCount = this.createdNotes.length;
|
||||
const regionCount = new Set(this.createdNotes.map(data => data.regionId)).size;
|
||||
console.log(`Created ${noteCount} notes in ${regionCount} region${regionCount > 1 ? 's' : ''}`);
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
if (this.createdNotes.length === 0) {
|
||||
throw new Error('Cannot undo: no notes were created');
|
||||
}
|
||||
|
||||
const core = KGCore.instance();
|
||||
const currentProject = core.getCurrentProject();
|
||||
const tracks = currentProject.getTracks();
|
||||
|
||||
// Remove all created notes from their regions
|
||||
for (const data of this.createdNotes) {
|
||||
// Find the region
|
||||
for (const track of tracks) {
|
||||
const regions = track.getRegions();
|
||||
const region = regions.find(r => r.getId() === data.regionId);
|
||||
|
||||
if (region && region instanceof KGMidiRegion) {
|
||||
region.removeNote(data.note.getId());
|
||||
|
||||
// Clear selection if this note was selected
|
||||
const selectedItems = core.getSelectedItems();
|
||||
const selectedNote = selectedItems.find(item =>
|
||||
item instanceof KGMidiNote && item.getId() === data.note.getId()
|
||||
);
|
||||
if (selectedNote) {
|
||||
core.removeSelectedItem(selectedNote);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Removed ${this.createdNotes.length} created notes from ${new Set(this.createdNotes.map(d => d.regionId)).size} regions`);
|
||||
}
|
||||
|
||||
getDescription(): string {
|
||||
if (this.noteCreationData.length === 1) {
|
||||
const noteData = this.noteCreationData[0];
|
||||
// Convert MIDI pitch to note name for user-friendly description
|
||||
const noteNames = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'];
|
||||
const octave = Math.floor(noteData.pitch / 12) - 1;
|
||||
const noteName = noteNames[noteData.pitch % 12];
|
||||
return `Create note ${noteName}${octave}`;
|
||||
}
|
||||
return `Create ${this.noteCreationData.length} notes`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the note creation data that was/will be processed
|
||||
*/
|
||||
public getNoteCreationData(): NoteCreationData[] {
|
||||
return this.noteCreationData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the created note instances (only available after execute)
|
||||
*/
|
||||
public getCreatedNotes(): Array<{note: KGMidiNote; regionId: string}> {
|
||||
return this.createdNotes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the regions that were affected by this creation
|
||||
*/
|
||||
public getAffectedRegionIds(): string[] {
|
||||
return Array.from(new Set(this.noteCreationData.map(data => data.regionId)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the IDs of notes that were/will be created
|
||||
*/
|
||||
public getCreatedNoteIds(): string[] {
|
||||
return this.noteCreationData.map(data => data.noteId!);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Command to create a single MIDI note (convenience wrapper)
|
||||
*/
|
||||
export class CreateNoteCommand extends CreateNotesCommand {
|
||||
constructor(
|
||||
regionId: string,
|
||||
startBeat: number,
|
||||
endBeat: number,
|
||||
pitch: number,
|
||||
velocity: number = 127,
|
||||
noteId?: string
|
||||
) {
|
||||
super([{
|
||||
regionId,
|
||||
startBeat,
|
||||
endBeat,
|
||||
pitch,
|
||||
velocity,
|
||||
noteId
|
||||
}]);
|
||||
}
|
||||
|
||||
getDescription(): string {
|
||||
const noteData = this.getNoteCreationData()[0];
|
||||
// Convert MIDI pitch to note name for user-friendly description
|
||||
const noteNames = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'];
|
||||
const octave = Math.floor(noteData.pitch / 12) - 1;
|
||||
const noteName = noteNames[noteData.pitch % 12];
|
||||
return `Create note ${noteName}${octave}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the ID of the note that was/will be created
|
||||
*/
|
||||
public getNoteId(): string {
|
||||
return this.getCreatedNoteIds()[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the created note instance (only available after execute)
|
||||
*/
|
||||
public getCreatedNote(): KGMidiNote | null {
|
||||
const createdNotes = this.getCreatedNotes();
|
||||
return createdNotes.length > 0 ? createdNotes[0].note : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the region ID where the note was/will be created
|
||||
*/
|
||||
public getRegionId(): string {
|
||||
return this.getNoteCreationData()[0].regionId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory method to create a note command from UI coordinates
|
||||
*/
|
||||
public static fromUICoordinates(
|
||||
regionId: string,
|
||||
mouseX: number,
|
||||
mouseY: number,
|
||||
pianoGridElement: HTMLElement,
|
||||
beatWidth: number,
|
||||
noteHeight: number,
|
||||
regionStartBeat: number,
|
||||
noteLength: number,
|
||||
velocity: number = 127
|
||||
): CreateNoteCommand {
|
||||
// Calculate relative position within the grid
|
||||
const rect = pianoGridElement.getBoundingClientRect();
|
||||
const x = mouseX - rect.left;
|
||||
const y = mouseY - rect.top;
|
||||
|
||||
// Calculate the beat number (relative to region start)
|
||||
const rawBeatNumber = x / beatWidth;
|
||||
const beatNumber = Math.floor(rawBeatNumber); // Snap to beat grid
|
||||
|
||||
// Calculate the pitch (MIDI note number)
|
||||
// The piano roll is drawn from bottom to top, with higher notes at the top
|
||||
const pitchIndex = Math.floor(y / noteHeight);
|
||||
const pitch = 107 - pitchIndex; // Convert index to pitch (B7 is 107)
|
||||
|
||||
// Calculate note start and end beats
|
||||
const noteStartBeat = beatNumber;
|
||||
const noteEndBeat = noteStartBeat + noteLength;
|
||||
|
||||
return new CreateNoteCommand(
|
||||
regionId,
|
||||
noteStartBeat,
|
||||
noteEndBeat,
|
||||
pitch,
|
||||
velocity
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
import { KGCommand } from '../KGCommand';
|
||||
import { KGCore } from '../../KGCore';
|
||||
import { KGMidiNote } from '../../midi/KGMidiNote';
|
||||
import { KGMidiRegion } from '../../region/KGMidiRegion';
|
||||
|
||||
/**
|
||||
* Command to delete multiple MIDI notes from regions
|
||||
* Handles bulk deletion as a single undoable operation
|
||||
*/
|
||||
export class DeleteNotesCommand extends KGCommand {
|
||||
private noteIds: string[];
|
||||
private deletedNoteData: Array<{
|
||||
note: KGMidiNote;
|
||||
regionId: string;
|
||||
originalIndex: number;
|
||||
}> = [];
|
||||
|
||||
constructor(noteIds: string[]) {
|
||||
super();
|
||||
this.noteIds = noteIds;
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
const core = KGCore.instance();
|
||||
const currentProject = core.getCurrentProject();
|
||||
const tracks = currentProject.getTracks();
|
||||
|
||||
// Clear any existing deleted note data to prevent duplicates on re-execution
|
||||
this.deletedNoteData = [];
|
||||
|
||||
// Find and store all notes to delete
|
||||
for (const noteId of this.noteIds) {
|
||||
for (const track of tracks) {
|
||||
const regions = track.getRegions();
|
||||
|
||||
for (const region of regions) {
|
||||
if (region instanceof KGMidiRegion) {
|
||||
const notes = region.getNotes();
|
||||
const noteIndex = notes.findIndex(note => note.getId() === noteId);
|
||||
|
||||
if (noteIndex !== -1) {
|
||||
const noteToDelete = notes[noteIndex];
|
||||
|
||||
// Store for undo
|
||||
this.deletedNoteData.push({
|
||||
note: noteToDelete,
|
||||
regionId: region.getId(),
|
||||
originalIndex: noteIndex
|
||||
});
|
||||
|
||||
break; // Found the note, move to next noteId
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (this.deletedNoteData.length === 0) {
|
||||
throw new Error('No notes found to delete');
|
||||
}
|
||||
|
||||
// Sort by original index in descending order to maintain correct indices during deletion
|
||||
this.deletedNoteData.sort((a, b) => b.originalIndex - a.originalIndex);
|
||||
|
||||
// Delete all notes from their regions
|
||||
for (const data of this.deletedNoteData) {
|
||||
// Find the region again
|
||||
for (const track of tracks) {
|
||||
const regions = track.getRegions();
|
||||
const region = regions.find(r => r.getId() === data.regionId);
|
||||
|
||||
if (region && region instanceof KGMidiRegion) {
|
||||
region.removeNote(data.note.getId());
|
||||
|
||||
// Clear selection if this note was selected
|
||||
const selectedItems = core.getSelectedItems();
|
||||
const selectedNote = selectedItems.find(item =>
|
||||
item instanceof KGMidiNote && item.getId() === data.note.getId()
|
||||
);
|
||||
if (selectedNote) {
|
||||
core.removeSelectedItem(selectedNote);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Deleted ${this.deletedNoteData.length} notes from ${new Set(this.deletedNoteData.map(d => d.regionId)).size} regions`);
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
if (this.deletedNoteData.length === 0) {
|
||||
throw new Error('Cannot undo: no note data stored');
|
||||
}
|
||||
|
||||
const core = KGCore.instance();
|
||||
const currentProject = core.getCurrentProject();
|
||||
const tracks = currentProject.getTracks();
|
||||
|
||||
// Restore all notes in reverse order (original index ascending)
|
||||
const sortedData = [...this.deletedNoteData].sort((a, b) => a.originalIndex - b.originalIndex);
|
||||
|
||||
for (const data of sortedData) {
|
||||
// Find the target region
|
||||
for (const track of tracks) {
|
||||
const regions = track.getRegions();
|
||||
const region = regions.find(r => r.getId() === data.regionId);
|
||||
|
||||
if (region && region instanceof KGMidiRegion) {
|
||||
const notes = region.getNotes();
|
||||
|
||||
// Insert note at original position
|
||||
if (data.originalIndex >= 0 && data.originalIndex <= notes.length) {
|
||||
notes.splice(data.originalIndex, 0, data.note);
|
||||
region.setNotes(notes);
|
||||
} else {
|
||||
// Fallback: add to the end
|
||||
region.addNote(data.note);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Restored ${this.deletedNoteData.length} notes to their original positions`);
|
||||
}
|
||||
|
||||
getDescription(): string {
|
||||
if (this.deletedNoteData.length === 1) {
|
||||
const note = this.deletedNoteData[0].note;
|
||||
// Convert MIDI pitch to note name for user-friendly description
|
||||
const noteNames = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'];
|
||||
const octave = Math.floor(note.getPitch() / 12) - 1;
|
||||
const noteName = noteNames[note.getPitch() % 12];
|
||||
return `Delete note ${noteName}${octave}`;
|
||||
}
|
||||
return `Delete ${this.noteIds.length} notes`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the IDs of notes that were/will be deleted
|
||||
*/
|
||||
public getNoteIds(): string[] {
|
||||
return this.noteIds;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the deleted note data (only available after execute)
|
||||
*/
|
||||
public getDeletedNoteData(): Array<{note: KGMidiNote; regionId: string; originalIndex: number}> {
|
||||
return this.deletedNoteData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the regions that were affected by this deletion
|
||||
*/
|
||||
public getAffectedRegionIds(): string[] {
|
||||
return Array.from(new Set(this.deletedNoteData.map(data => data.regionId)));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Command to delete a single MIDI note (convenience wrapper)
|
||||
*/
|
||||
export class DeleteNoteCommand extends DeleteNotesCommand {
|
||||
constructor(noteId: string) {
|
||||
super([noteId]);
|
||||
}
|
||||
|
||||
getDescription(): string {
|
||||
if (this.getDeletedNoteData().length === 1) {
|
||||
const note = this.getDeletedNoteData()[0].note;
|
||||
// Convert MIDI pitch to note name for user-friendly description
|
||||
const noteNames = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'];
|
||||
const octave = Math.floor(note.getPitch() / 12) - 1;
|
||||
const noteName = noteNames[note.getPitch() % 12];
|
||||
return `Delete note ${noteName}${octave}`;
|
||||
}
|
||||
return 'Delete note';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
import { KGCommand } from '../KGCommand';
|
||||
import { KGCore } from '../../KGCore';
|
||||
import { KGMidiNote } from '../../midi/KGMidiNote';
|
||||
import { KGMidiRegion } from '../../region/KGMidiRegion';
|
||||
import { KGTrack } from '../../track/KGTrack';
|
||||
|
||||
/**
|
||||
* Interface for storing move data for each note
|
||||
*/
|
||||
interface NoteMoveData {
|
||||
noteId: string;
|
||||
originalStartBeat: number;
|
||||
originalEndBeat: number;
|
||||
originalPitch: number;
|
||||
newStartBeat: number;
|
||||
newEndBeat: number;
|
||||
newPitch: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Command to move multiple notes simultaneously
|
||||
* Handles moving notes in both time (beat position) and pitch with delta application
|
||||
*/
|
||||
export class MoveNotesCommand extends KGCommand {
|
||||
private primaryNoteId: string;
|
||||
private startBeatDelta: number;
|
||||
private pitchDelta: number;
|
||||
private regionId: string;
|
||||
private noteIdsToMove: string[]; // Store the specific note IDs to move
|
||||
private noteMoveData: NoteMoveData[] = [];
|
||||
private targetRegion: KGMidiRegion | null = null;
|
||||
private parentTrack: KGTrack | null = null;
|
||||
|
||||
constructor(
|
||||
primaryNoteId: string,
|
||||
startBeatDelta: number,
|
||||
pitchDelta: number,
|
||||
regionId: string,
|
||||
noteIdsToMove: string[]
|
||||
) {
|
||||
super();
|
||||
this.primaryNoteId = primaryNoteId;
|
||||
this.startBeatDelta = startBeatDelta;
|
||||
this.pitchDelta = pitchDelta;
|
||||
this.regionId = regionId;
|
||||
this.noteIdsToMove = [...noteIdsToMove]; // Create a copy to avoid reference issues
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
const core = KGCore.instance();
|
||||
const currentProject = core.getCurrentProject();
|
||||
const tracks = currentProject.getTracks();
|
||||
|
||||
// Find the region containing the notes
|
||||
let targetRegion: KGMidiRegion | null = null;
|
||||
let parentTrack: KGTrack | null = null;
|
||||
|
||||
for (const track of tracks) {
|
||||
const regions = track.getRegions();
|
||||
const region = regions.find(r => r.getId() === this.regionId) as KGMidiRegion | undefined;
|
||||
if (region) {
|
||||
targetRegion = region;
|
||||
parentTrack = track;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!targetRegion || !parentTrack) {
|
||||
throw new Error(`Region with ID ${this.regionId} not found`);
|
||||
}
|
||||
|
||||
this.targetRegion = targetRegion;
|
||||
this.parentTrack = parentTrack;
|
||||
|
||||
// Clear any existing move data (for re-execution)
|
||||
this.noteMoveData = [];
|
||||
|
||||
// Find all notes to move based on the stored note IDs
|
||||
const notesToMove = this.noteIdsToMove
|
||||
.map(noteId => targetRegion.getNotes().find(n => n.getId() === noteId))
|
||||
.filter(note => note !== undefined) as KGMidiNote[];
|
||||
|
||||
if (notesToMove.length === 0) {
|
||||
throw new Error(`No notes found to move from the provided note IDs`);
|
||||
}
|
||||
|
||||
// Verify the primary note is included
|
||||
const primaryNote = notesToMove.find(n => n.getId() === this.primaryNoteId);
|
||||
if (!primaryNote) {
|
||||
throw new Error(`Primary note with ID ${this.primaryNoteId} not found in notes to move`);
|
||||
}
|
||||
|
||||
// Store original positions and calculate new positions
|
||||
notesToMove.forEach(note => {
|
||||
const originalStartBeat = note.getStartBeat();
|
||||
const originalEndBeat = note.getEndBeat();
|
||||
const originalPitch = note.getPitch();
|
||||
|
||||
// Apply the deltas to calculate new positions
|
||||
const newStartBeat = originalStartBeat + this.startBeatDelta;
|
||||
const newEndBeat = originalEndBeat + this.startBeatDelta; // End beat moves by same amount as start
|
||||
const newPitch = originalPitch + this.pitchDelta;
|
||||
|
||||
// Store move data for undo
|
||||
this.noteMoveData.push({
|
||||
noteId: note.getId(),
|
||||
originalStartBeat,
|
||||
originalEndBeat,
|
||||
originalPitch,
|
||||
newStartBeat,
|
||||
newEndBeat,
|
||||
newPitch
|
||||
});
|
||||
});
|
||||
|
||||
// Apply all the move operations
|
||||
this.noteMoveData.forEach(data => {
|
||||
const note = targetRegion.getNotes().find(n => n.getId() === data.noteId);
|
||||
if (note) {
|
||||
note.setStartBeat(data.newStartBeat);
|
||||
note.setEndBeat(data.newEndBeat);
|
||||
note.setPitch(data.newPitch);
|
||||
}
|
||||
});
|
||||
|
||||
const beatDescription = this.startBeatDelta !== 0 ? `position: ${this.startBeatDelta > 0 ? '+' : ''}${this.startBeatDelta.toFixed(3)} beats` : '';
|
||||
const pitchDescription = this.pitchDelta !== 0 ? `pitch: ${this.pitchDelta > 0 ? '+' : ''}${this.pitchDelta} semitones` : '';
|
||||
const deltaDescription = [beatDescription, pitchDescription].filter(d => d).join(', ');
|
||||
|
||||
console.log(`Moved ${this.noteMoveData.length} notes (${deltaDescription}, primary note: ${this.primaryNoteId})`);
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
if (!this.targetRegion || this.noteMoveData.length === 0) {
|
||||
throw new Error('Cannot undo: no notes were moved');
|
||||
}
|
||||
|
||||
// Restore all notes to their original positions
|
||||
this.noteMoveData.forEach(data => {
|
||||
const note = this.targetRegion!.getNotes().find(n => n.getId() === data.noteId);
|
||||
if (note) {
|
||||
note.setStartBeat(data.originalStartBeat);
|
||||
note.setEndBeat(data.originalEndBeat);
|
||||
note.setPitch(data.originalPitch);
|
||||
}
|
||||
});
|
||||
|
||||
console.log(`Restored ${this.noteMoveData.length} notes to their original positions`);
|
||||
}
|
||||
|
||||
getDescription(): string {
|
||||
const noteCount = this.noteMoveData.length;
|
||||
|
||||
// Create a description based on the type of movement
|
||||
const movements: string[] = [];
|
||||
if (this.startBeatDelta !== 0) {
|
||||
const direction = this.startBeatDelta > 0 ? 'right' : 'left';
|
||||
movements.push(`${direction} ${Math.abs(this.startBeatDelta).toFixed(3)} beats`);
|
||||
}
|
||||
if (this.pitchDelta !== 0) {
|
||||
const direction = this.pitchDelta > 0 ? 'up' : 'down';
|
||||
movements.push(`${direction} ${Math.abs(this.pitchDelta)} semitones`);
|
||||
}
|
||||
|
||||
const movementDescription = movements.length > 0 ? ` ${movements.join(' and ')}` : '';
|
||||
|
||||
if (noteCount === 1) {
|
||||
return `Move note${movementDescription}`;
|
||||
} else {
|
||||
return `Move ${noteCount} notes${movementDescription}`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the primary note ID
|
||||
*/
|
||||
public getPrimaryNoteId(): string {
|
||||
return this.primaryNoteId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the start beat delta
|
||||
*/
|
||||
public getStartBeatDelta(): number {
|
||||
return this.startBeatDelta;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the pitch delta
|
||||
*/
|
||||
public getPitchDelta(): number {
|
||||
return this.pitchDelta;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the region ID
|
||||
*/
|
||||
public getRegionId(): string {
|
||||
return this.regionId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the note IDs that will be moved
|
||||
*/
|
||||
public getNoteIdsToMove(): string[] {
|
||||
return [...this.noteIdsToMove];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the note move data (only available after execute)
|
||||
*/
|
||||
public getNoteMoveData(): NoteMoveData[] {
|
||||
return [...this.noteMoveData];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the target region instance (only available after execute)
|
||||
*/
|
||||
public getTargetRegion(): KGMidiRegion | null {
|
||||
return this.targetRegion;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the parent track instance (only available after execute)
|
||||
*/
|
||||
public getParentTrack(): KGTrack | null {
|
||||
return this.parentTrack;
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory method to create a move command from note drag parameters
|
||||
*/
|
||||
public static fromNoteDrag(
|
||||
primaryNoteId: string,
|
||||
originalStartBeat: number,
|
||||
originalPitch: number,
|
||||
newStartBeat: number,
|
||||
newPitch: number,
|
||||
regionId: string,
|
||||
noteIdsToMove: string[]
|
||||
): MoveNotesCommand {
|
||||
const startBeatDelta = newStartBeat - originalStartBeat;
|
||||
const pitchDelta = newPitch - originalPitch;
|
||||
|
||||
return new MoveNotesCommand(
|
||||
primaryNoteId,
|
||||
startBeatDelta,
|
||||
pitchDelta,
|
||||
regionId,
|
||||
noteIdsToMove
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
import { KGCommand } from '../KGCommand';
|
||||
import { KGCore } from '../../KGCore';
|
||||
import { KGMidiNote } from '../../midi/KGMidiNote';
|
||||
import { KGMidiRegion } from '../../region/KGMidiRegion';
|
||||
import { KGTrack } from '../../track/KGTrack';
|
||||
import { generateUniqueId } from '../../../util/miscUtil';
|
||||
|
||||
/**
|
||||
* Interface for storing created note data for undo
|
||||
*/
|
||||
interface CreatedNoteData {
|
||||
noteId: string;
|
||||
startBeat: number;
|
||||
endBeat: number;
|
||||
pitch: number;
|
||||
velocity: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Command to paste notes to a target region
|
||||
* Handles creating new notes with new IDs and maintaining relative positions
|
||||
*/
|
||||
export class PasteNotesCommand extends KGCommand {
|
||||
private regionId: string;
|
||||
private pastePosition: number;
|
||||
private sourceNotes: KGMidiNote[];
|
||||
private createdNotes: CreatedNoteData[] = [];
|
||||
private targetRegion: KGMidiRegion | null = null;
|
||||
private parentTrack: KGTrack | null = null;
|
||||
|
||||
constructor(regionId: string, pastePosition: number, sourceNotes: KGMidiNote[]) {
|
||||
super();
|
||||
this.regionId = regionId;
|
||||
this.pastePosition = pastePosition;
|
||||
this.sourceNotes = [...sourceNotes]; // Create a copy to avoid reference issues
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
if (this.sourceNotes.length === 0) {
|
||||
throw new Error('No notes to paste');
|
||||
}
|
||||
|
||||
const core = KGCore.instance();
|
||||
const currentProject = core.getCurrentProject();
|
||||
const tracks = currentProject.getTracks();
|
||||
|
||||
// Find the target region
|
||||
let targetRegion: KGMidiRegion | null = null;
|
||||
let parentTrack: KGTrack | null = null;
|
||||
|
||||
for (const track of tracks) {
|
||||
const regions = track.getRegions();
|
||||
const region = regions.find(r => r.getId() === this.regionId);
|
||||
if (region && region instanceof KGMidiRegion) {
|
||||
targetRegion = region;
|
||||
parentTrack = track;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!targetRegion || !parentTrack) {
|
||||
throw new Error(`Target MIDI region with ID ${this.regionId} not found`);
|
||||
}
|
||||
|
||||
this.targetRegion = targetRegion;
|
||||
this.parentTrack = parentTrack;
|
||||
|
||||
// Clear any previously created notes (for re-execution)
|
||||
this.createdNotes = [];
|
||||
|
||||
// Calculate relative position within the region
|
||||
const relativeStartPosition = this.pastePosition - targetRegion.getStartFromBeat();
|
||||
|
||||
// Calculate the base position from the first note to maintain relative positions
|
||||
const basePosition = this.sourceNotes[0].getStartBeat();
|
||||
|
||||
// Create new notes at the target position
|
||||
this.sourceNotes.forEach((originalNote) => {
|
||||
// Generate new ID for the pasted note
|
||||
const newId = generateUniqueId('KGMidiNote');
|
||||
|
||||
// Calculate new position maintaining relative offset
|
||||
const noteOffset = originalNote.getStartBeat() - basePosition;
|
||||
const newStartBeat = relativeStartPosition + noteOffset;
|
||||
const duration = originalNote.getEndBeat() - originalNote.getStartBeat();
|
||||
const newEndBeat = newStartBeat + duration;
|
||||
|
||||
// Create a copy of the note with new position and ID
|
||||
const newNote = new KGMidiNote(
|
||||
newId,
|
||||
newStartBeat,
|
||||
newEndBeat,
|
||||
originalNote.getPitch(),
|
||||
originalNote.getVelocity()
|
||||
);
|
||||
|
||||
// Store created note data for undo
|
||||
this.createdNotes.push({
|
||||
noteId: newId,
|
||||
startBeat: newStartBeat,
|
||||
endBeat: newEndBeat,
|
||||
pitch: originalNote.getPitch(),
|
||||
velocity: originalNote.getVelocity()
|
||||
});
|
||||
|
||||
// Add the new note to the target region
|
||||
targetRegion.addNote(newNote);
|
||||
});
|
||||
|
||||
console.log(`Pasted ${this.sourceNotes.length} notes to region ${this.regionId} at position ${this.pastePosition}`);
|
||||
console.log(`Region now has ${targetRegion.getNotes().length} notes total`);
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
if (!this.targetRegion || this.createdNotes.length === 0) {
|
||||
throw new Error('Cannot undo: no notes were pasted');
|
||||
}
|
||||
|
||||
// Remove all created notes from the target region
|
||||
const currentNotes = this.targetRegion.getNotes();
|
||||
const noteIdsToRemove = new Set(this.createdNotes.map(data => data.noteId));
|
||||
|
||||
const filteredNotes = currentNotes.filter(note => !noteIdsToRemove.has(note.getId()));
|
||||
this.targetRegion.setNotes(filteredNotes);
|
||||
|
||||
console.log(`Removed ${this.createdNotes.length} pasted notes from region ${this.regionId}`);
|
||||
console.log(`Region now has ${this.targetRegion.getNotes().length} notes total`);
|
||||
}
|
||||
|
||||
getDescription(): string {
|
||||
const noteCount = this.sourceNotes.length;
|
||||
const regionName = this.targetRegion ? this.targetRegion.getName() : `Region ${this.regionId}`;
|
||||
|
||||
if (noteCount === 1) {
|
||||
return `Paste note to "${regionName}"`;
|
||||
} else {
|
||||
return `Paste ${noteCount} notes to "${regionName}"`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the target region ID
|
||||
*/
|
||||
public getRegionId(): string {
|
||||
return this.regionId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the paste position
|
||||
*/
|
||||
public getPastePosition(): number {
|
||||
return this.pastePosition;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the source notes being pasted
|
||||
*/
|
||||
public getSourceNotes(): KGMidiNote[] {
|
||||
return [...this.sourceNotes];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the created note data (only available after execute)
|
||||
*/
|
||||
public getCreatedNotes(): CreatedNoteData[] {
|
||||
return [...this.createdNotes];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the target region instance (only available after execute)
|
||||
*/
|
||||
public getTargetRegion(): KGMidiRegion | null {
|
||||
return this.targetRegion;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the parent track instance (only available after execute)
|
||||
*/
|
||||
public getParentTrack(): KGTrack | null {
|
||||
return this.parentTrack;
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory method to create a paste command from the clipboard
|
||||
*/
|
||||
public static fromClipboard(regionId: string, pastePosition: number): PasteNotesCommand | null {
|
||||
const core = KGCore.instance();
|
||||
const copiedItems = core.getCopiedItems();
|
||||
const notesToCreate = copiedItems.filter(item => item instanceof KGMidiNote) as KGMidiNote[];
|
||||
|
||||
if (notesToCreate.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new PasteNotesCommand(regionId, pastePosition, notesToCreate);
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory method to create a paste command from specific notes
|
||||
*/
|
||||
public static fromNotes(regionId: string, pastePosition: number, notes: KGMidiNote[]): PasteNotesCommand {
|
||||
return new PasteNotesCommand(regionId, pastePosition, notes);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
import { KGCommand } from '../KGCommand';
|
||||
import { KGCore } from '../../KGCore';
|
||||
import { KGMidiNote } from '../../midi/KGMidiNote';
|
||||
import { KGMidiRegion } from '../../region/KGMidiRegion';
|
||||
import { KGTrack } from '../../track/KGTrack';
|
||||
|
||||
/**
|
||||
* Interface for storing resize data for each note
|
||||
*/
|
||||
interface NoteResizeData {
|
||||
noteId: string;
|
||||
originalStartBeat: number;
|
||||
originalEndBeat: number;
|
||||
newStartBeat: number;
|
||||
newEndBeat: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Command to resize multiple notes simultaneously
|
||||
* Handles resizing notes from either start or end edge with delta application
|
||||
*/
|
||||
export class ResizeNotesCommand extends KGCommand {
|
||||
private primaryNoteId: string;
|
||||
private resizeEdge: 'start' | 'end';
|
||||
private primaryStartBeatDelta: number;
|
||||
private primaryEndBeatDelta: number;
|
||||
private regionId: string;
|
||||
private noteIdsToResize: string[]; // Store the specific note IDs to resize
|
||||
private noteResizeData: NoteResizeData[] = [];
|
||||
private targetRegion: KGMidiRegion | null = null;
|
||||
private parentTrack: KGTrack | null = null;
|
||||
|
||||
constructor(
|
||||
primaryNoteId: string,
|
||||
resizeEdge: 'start' | 'end',
|
||||
primaryStartBeatDelta: number,
|
||||
primaryEndBeatDelta: number,
|
||||
regionId: string,
|
||||
noteIdsToResize: string[]
|
||||
) {
|
||||
super();
|
||||
this.primaryNoteId = primaryNoteId;
|
||||
this.resizeEdge = resizeEdge;
|
||||
this.primaryStartBeatDelta = primaryStartBeatDelta;
|
||||
this.primaryEndBeatDelta = primaryEndBeatDelta;
|
||||
this.regionId = regionId;
|
||||
this.noteIdsToResize = [...noteIdsToResize]; // Create a copy to avoid reference issues
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
const core = KGCore.instance();
|
||||
const currentProject = core.getCurrentProject();
|
||||
const tracks = currentProject.getTracks();
|
||||
|
||||
// Find the region containing the notes
|
||||
let targetRegion: KGMidiRegion | null = null;
|
||||
let parentTrack: KGTrack | null = null;
|
||||
|
||||
for (const track of tracks) {
|
||||
const regions = track.getRegions();
|
||||
const region = regions.find(r => r.getId() === this.regionId) as KGMidiRegion | undefined;
|
||||
if (region) {
|
||||
targetRegion = region;
|
||||
parentTrack = track;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!targetRegion || !parentTrack) {
|
||||
throw new Error(`Region with ID ${this.regionId} not found`);
|
||||
}
|
||||
|
||||
this.targetRegion = targetRegion;
|
||||
this.parentTrack = parentTrack;
|
||||
|
||||
// Clear any existing resize data (for re-execution)
|
||||
this.noteResizeData = [];
|
||||
|
||||
// Find all notes to resize based on the stored note IDs
|
||||
const notesToResize = this.noteIdsToResize
|
||||
.map(noteId => targetRegion.getNotes().find(n => n.getId() === noteId))
|
||||
.filter(note => note !== undefined) as KGMidiNote[];
|
||||
|
||||
if (notesToResize.length === 0) {
|
||||
throw new Error(`No notes found to resize from the provided note IDs`);
|
||||
}
|
||||
|
||||
// Verify the primary note is included
|
||||
const primaryNote = notesToResize.find(n => n.getId() === this.primaryNoteId);
|
||||
if (!primaryNote) {
|
||||
throw new Error(`Primary note with ID ${this.primaryNoteId} not found in notes to resize`);
|
||||
}
|
||||
|
||||
// Store original positions and calculate new positions
|
||||
notesToResize.forEach(note => {
|
||||
const originalStartBeat = note.getStartBeat();
|
||||
const originalEndBeat = note.getEndBeat();
|
||||
let newStartBeat = originalStartBeat;
|
||||
let newEndBeat = originalEndBeat;
|
||||
|
||||
if (note.getId() === this.primaryNoteId) {
|
||||
// Primary note: apply the exact deltas
|
||||
newStartBeat = originalStartBeat + this.primaryStartBeatDelta;
|
||||
newEndBeat = originalEndBeat + this.primaryEndBeatDelta;
|
||||
} else {
|
||||
// Other selected notes: apply the same delta as primary note
|
||||
if (this.resizeEdge === 'start') {
|
||||
newStartBeat = originalStartBeat + this.primaryStartBeatDelta;
|
||||
// End beat stays the same for start resize
|
||||
} else if (this.resizeEdge === 'end') {
|
||||
newEndBeat = originalEndBeat + this.primaryEndBeatDelta;
|
||||
// Start beat stays the same for end resize
|
||||
}
|
||||
}
|
||||
|
||||
// Store resize data for undo
|
||||
this.noteResizeData.push({
|
||||
noteId: note.getId(),
|
||||
originalStartBeat,
|
||||
originalEndBeat,
|
||||
newStartBeat,
|
||||
newEndBeat
|
||||
});
|
||||
});
|
||||
|
||||
// Apply all the resize operations
|
||||
this.noteResizeData.forEach(data => {
|
||||
const note = targetRegion.getNotes().find(n => n.getId() === data.noteId);
|
||||
if (note) {
|
||||
note.setStartBeat(data.newStartBeat);
|
||||
note.setEndBeat(data.newEndBeat);
|
||||
}
|
||||
});
|
||||
|
||||
console.log(`Resized ${this.noteResizeData.length} notes (edge: ${this.resizeEdge}, primary note: ${this.primaryNoteId})`);
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
if (!this.targetRegion || this.noteResizeData.length === 0) {
|
||||
throw new Error('Cannot undo: no notes were resized');
|
||||
}
|
||||
|
||||
// Restore all notes to their original positions
|
||||
this.noteResizeData.forEach(data => {
|
||||
const note = this.targetRegion!.getNotes().find(n => n.getId() === data.noteId);
|
||||
if (note) {
|
||||
note.setStartBeat(data.originalStartBeat);
|
||||
note.setEndBeat(data.originalEndBeat);
|
||||
}
|
||||
});
|
||||
|
||||
console.log(`Restored ${this.noteResizeData.length} notes to their original positions`);
|
||||
}
|
||||
|
||||
getDescription(): string {
|
||||
const noteCount = this.noteResizeData.length;
|
||||
const edgeText = this.resizeEdge === 'start' ? 'start' : 'end';
|
||||
|
||||
if (noteCount === 1) {
|
||||
return `Resize note from ${edgeText}`;
|
||||
} else {
|
||||
return `Resize ${noteCount} notes from ${edgeText}`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the primary note ID
|
||||
*/
|
||||
public getPrimaryNoteId(): string {
|
||||
return this.primaryNoteId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the resize edge
|
||||
*/
|
||||
public getResizeEdge(): 'start' | 'end' {
|
||||
return this.resizeEdge;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the region ID
|
||||
*/
|
||||
public getRegionId(): string {
|
||||
return this.regionId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the note resize data (only available after execute)
|
||||
*/
|
||||
public getNoteResizeData(): NoteResizeData[] {
|
||||
return [...this.noteResizeData];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the target region instance (only available after execute)
|
||||
*/
|
||||
public getTargetRegion(): KGMidiRegion | null {
|
||||
return this.targetRegion;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the parent track instance (only available after execute)
|
||||
*/
|
||||
public getParentTrack(): KGTrack | null {
|
||||
return this.parentTrack;
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory method to create a resize command from note resize parameters
|
||||
*/
|
||||
public static fromNoteResize(
|
||||
primaryNoteId: string,
|
||||
resizeEdge: 'start' | 'end',
|
||||
originalStartBeat: number,
|
||||
originalEndBeat: number,
|
||||
newStartBeat: number,
|
||||
newEndBeat: number,
|
||||
regionId: string,
|
||||
noteIdsToResize: string[]
|
||||
): ResizeNotesCommand {
|
||||
const startBeatDelta = newStartBeat - originalStartBeat;
|
||||
const endBeatDelta = newEndBeat - originalEndBeat;
|
||||
|
||||
return new ResizeNotesCommand(
|
||||
primaryNoteId,
|
||||
resizeEdge,
|
||||
startBeatDelta,
|
||||
endBeatDelta,
|
||||
regionId,
|
||||
noteIdsToResize
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the note IDs that will be resized
|
||||
*/
|
||||
public getNoteIdsToResize(): string[] {
|
||||
return [...this.noteIdsToResize];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
import { KGCommand } from '../KGCommand';
|
||||
import { KGCore } from '../../KGCore';
|
||||
import { KGProject, type KeySignature } from '../../KGProject';
|
||||
import type { TimeSignature } from '../../../types/projectTypes';
|
||||
|
||||
/**
|
||||
* Interface defining properties that can be updated on a project
|
||||
*/
|
||||
export interface ProjectUpdateProperties {
|
||||
name?: string;
|
||||
maxBars?: number;
|
||||
currentBars?: number;
|
||||
bpm?: number;
|
||||
timeSignature?: TimeSignature;
|
||||
keySignature?: KeySignature;
|
||||
}
|
||||
|
||||
/**
|
||||
* Command to update project properties
|
||||
* Handles updating project name, BPM, time signature, etc. with undo support
|
||||
*/
|
||||
export class ChangeProjectPropertyCommand extends KGCommand {
|
||||
private newProperties: ProjectUpdateProperties;
|
||||
private originalProperties: ProjectUpdateProperties = {};
|
||||
private targetProject: KGProject | null = null;
|
||||
private changedProperties: Set<keyof ProjectUpdateProperties> = new Set();
|
||||
|
||||
constructor(properties: ProjectUpdateProperties) {
|
||||
super();
|
||||
this.newProperties = properties;
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
const core = KGCore.instance();
|
||||
this.targetProject = core.getCurrentProject();
|
||||
|
||||
// Store original properties for undo
|
||||
this.originalProperties = {
|
||||
name: this.targetProject.getName(),
|
||||
maxBars: this.targetProject.getMaxBars(),
|
||||
currentBars: this.targetProject.getCurrentBars(),
|
||||
bpm: this.targetProject.getBpm(),
|
||||
timeSignature: { ...this.targetProject.getTimeSignature() }, // Create a copy
|
||||
keySignature: this.targetProject.getKeySignature(),
|
||||
};
|
||||
|
||||
// Apply updates and track what actually changes
|
||||
const updatedProperties: string[] = [];
|
||||
|
||||
// Update name
|
||||
if (this.newProperties.name !== undefined && this.newProperties.name !== this.originalProperties.name) {
|
||||
this.targetProject.setName(this.newProperties.name);
|
||||
this.changedProperties.add('name');
|
||||
updatedProperties.push(`name: "${this.originalProperties.name}" → "${this.newProperties.name}"`);
|
||||
}
|
||||
|
||||
// Update maxBars
|
||||
if (this.newProperties.maxBars !== undefined && this.newProperties.maxBars !== this.originalProperties.maxBars) {
|
||||
this.targetProject.setMaxBars(this.newProperties.maxBars);
|
||||
this.changedProperties.add('maxBars');
|
||||
updatedProperties.push(`maxBars: ${this.originalProperties.maxBars} → ${this.newProperties.maxBars}`);
|
||||
}
|
||||
|
||||
// Update currentBars
|
||||
if (this.newProperties.currentBars !== undefined && this.newProperties.currentBars !== this.originalProperties.currentBars) {
|
||||
this.targetProject.setCurrentBars(this.newProperties.currentBars);
|
||||
this.changedProperties.add('currentBars');
|
||||
updatedProperties.push(`currentBars: ${this.originalProperties.currentBars} → ${this.newProperties.currentBars}`);
|
||||
}
|
||||
|
||||
// Update BPM
|
||||
if (this.newProperties.bpm !== undefined && this.newProperties.bpm !== this.originalProperties.bpm) {
|
||||
this.targetProject.setBpm(this.newProperties.bpm);
|
||||
this.changedProperties.add('bpm');
|
||||
updatedProperties.push(`bpm: ${this.originalProperties.bpm} → ${this.newProperties.bpm}`);
|
||||
}
|
||||
|
||||
// Update time signature
|
||||
if (this.newProperties.timeSignature !== undefined) {
|
||||
const originalTS = this.originalProperties.timeSignature!;
|
||||
const newTS = this.newProperties.timeSignature;
|
||||
|
||||
// Compare time signatures
|
||||
if (originalTS.numerator !== newTS.numerator || originalTS.denominator !== newTS.denominator) {
|
||||
this.targetProject.setTimeSignature(newTS);
|
||||
this.changedProperties.add('timeSignature');
|
||||
updatedProperties.push(`timeSignature: ${originalTS.numerator}/${originalTS.denominator} → ${newTS.numerator}/${newTS.denominator}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Update key signature
|
||||
if (this.newProperties.keySignature !== undefined && this.newProperties.keySignature !== this.originalProperties.keySignature) {
|
||||
this.targetProject.setKeySignature(this.newProperties.keySignature);
|
||||
this.changedProperties.add('keySignature');
|
||||
updatedProperties.push(`keySignature: "${this.originalProperties.keySignature}" → "${this.newProperties.keySignature}"`);
|
||||
}
|
||||
|
||||
if (updatedProperties.length > 0) {
|
||||
console.log(`Updated project: ${updatedProperties.join(', ')}`);
|
||||
} else {
|
||||
console.log('No changes applied to project');
|
||||
}
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
if (!this.targetProject) {
|
||||
throw new Error('Cannot undo: no project was updated');
|
||||
}
|
||||
|
||||
// Only restore properties that were actually changed
|
||||
const restoredProperties: string[] = [];
|
||||
|
||||
// Restore name (only if it was changed)
|
||||
if (this.changedProperties.has('name') && this.originalProperties.name !== undefined) {
|
||||
this.targetProject.setName(this.originalProperties.name);
|
||||
restoredProperties.push(`name: "${this.originalProperties.name}"`);
|
||||
}
|
||||
|
||||
// Restore maxBars (only if it was changed)
|
||||
if (this.changedProperties.has('maxBars') && this.originalProperties.maxBars !== undefined) {
|
||||
this.targetProject.setMaxBars(this.originalProperties.maxBars);
|
||||
restoredProperties.push(`maxBars: ${this.originalProperties.maxBars}`);
|
||||
}
|
||||
|
||||
// Restore currentBars (only if it was changed)
|
||||
if (this.changedProperties.has('currentBars') && this.originalProperties.currentBars !== undefined) {
|
||||
this.targetProject.setCurrentBars(this.originalProperties.currentBars);
|
||||
restoredProperties.push(`currentBars: ${this.originalProperties.currentBars}`);
|
||||
}
|
||||
|
||||
// Restore BPM (only if it was changed)
|
||||
if (this.changedProperties.has('bpm') && this.originalProperties.bpm !== undefined) {
|
||||
this.targetProject.setBpm(this.originalProperties.bpm);
|
||||
restoredProperties.push(`bpm: ${this.originalProperties.bpm}`);
|
||||
}
|
||||
|
||||
// Restore time signature (only if it was changed)
|
||||
if (this.changedProperties.has('timeSignature') && this.originalProperties.timeSignature !== undefined) {
|
||||
this.targetProject.setTimeSignature(this.originalProperties.timeSignature);
|
||||
const ts = this.originalProperties.timeSignature;
|
||||
restoredProperties.push(`timeSignature: ${ts.numerator}/${ts.denominator}`);
|
||||
}
|
||||
|
||||
// Restore key signature (only if it was changed)
|
||||
if (this.changedProperties.has('keySignature') && this.originalProperties.keySignature !== undefined) {
|
||||
this.targetProject.setKeySignature(this.originalProperties.keySignature);
|
||||
restoredProperties.push(`keySignature: "${this.originalProperties.keySignature}"`);
|
||||
}
|
||||
|
||||
console.log(`Restored project: ${restoredProperties.join(', ')}`);
|
||||
}
|
||||
|
||||
getDescription(): string {
|
||||
const updatedProps: string[] = [];
|
||||
|
||||
if (this.newProperties.name !== undefined) {
|
||||
updatedProps.push('name');
|
||||
}
|
||||
if (this.newProperties.maxBars !== undefined) {
|
||||
updatedProps.push('maxBars');
|
||||
}
|
||||
if (this.newProperties.currentBars !== undefined) {
|
||||
updatedProps.push('currentBars');
|
||||
}
|
||||
if (this.newProperties.bpm !== undefined) {
|
||||
updatedProps.push('BPM');
|
||||
}
|
||||
if (this.newProperties.timeSignature !== undefined) {
|
||||
updatedProps.push('time signature');
|
||||
}
|
||||
if (this.newProperties.keySignature !== undefined) {
|
||||
updatedProps.push('key signature');
|
||||
}
|
||||
|
||||
if (updatedProps.length === 1) {
|
||||
return `Change project ${updatedProps[0]}`;
|
||||
} else if (updatedProps.length > 1) {
|
||||
return `Change project properties (${updatedProps.join(', ')})`;
|
||||
}
|
||||
|
||||
return `Change project properties`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the new properties being applied
|
||||
*/
|
||||
public getNewProperties(): ProjectUpdateProperties {
|
||||
return this.newProperties;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the original properties (only available after execute)
|
||||
*/
|
||||
public getOriginalProperties(): ProjectUpdateProperties {
|
||||
return this.originalProperties;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the target project instance (only available after execute)
|
||||
*/
|
||||
public getTargetProject(): KGProject | null {
|
||||
return this.targetProject;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the properties that were actually changed (only available after execute)
|
||||
*/
|
||||
public getChangedProperties(): Set<keyof ProjectUpdateProperties> {
|
||||
return new Set(this.changedProperties);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import { KGCommand } from '../KGCommand';
|
||||
import { KGCore } from '../../KGCore';
|
||||
import { KGMidiRegion } from '../../region/KGMidiRegion';
|
||||
import { generateUniqueId } from '../../../util/miscUtil';
|
||||
import { useProjectStore } from '../../../stores/projectStore';
|
||||
|
||||
/**
|
||||
* Command to create a new region in a track
|
||||
* Handles both the core model update and provides undo functionality
|
||||
*/
|
||||
export class CreateRegionCommand extends KGCommand {
|
||||
private trackId: string;
|
||||
private trackIndex: number;
|
||||
private regionName: string;
|
||||
private startBeat: number;
|
||||
private lengthInBeats: number;
|
||||
private regionId: string;
|
||||
private createdRegion: KGMidiRegion | null = null;
|
||||
|
||||
constructor(
|
||||
trackId: string,
|
||||
trackIndex: number,
|
||||
startBeat: number,
|
||||
lengthInBeats: number,
|
||||
regionName?: string,
|
||||
regionId?: string
|
||||
) {
|
||||
super();
|
||||
this.trackId = trackId;
|
||||
this.trackIndex = trackIndex;
|
||||
this.startBeat = startBeat;
|
||||
this.lengthInBeats = lengthInBeats;
|
||||
this.regionId = regionId || generateUniqueId('KGMidiRegion');
|
||||
|
||||
// Generate region name if not provided
|
||||
const core = KGCore.instance();
|
||||
const tracks = core.getCurrentProject().getTracks();
|
||||
const track = tracks.find(t => t.getId().toString() === trackId);
|
||||
this.regionName = regionName || (track ? `${track.getName()} Region` : 'Region');
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
const core = KGCore.instance();
|
||||
const currentProject = core.getCurrentProject();
|
||||
const tracks = currentProject.getTracks();
|
||||
|
||||
// Find the target track
|
||||
const targetTrack = tracks.find(track => track.getId().toString() === this.trackId);
|
||||
if (!targetTrack) {
|
||||
throw new Error(`Track with ID ${this.trackId} not found`);
|
||||
}
|
||||
|
||||
// Create the new MIDI region
|
||||
this.createdRegion = new KGMidiRegion(
|
||||
this.regionId,
|
||||
this.trackId,
|
||||
this.trackIndex,
|
||||
this.regionName,
|
||||
this.startBeat,
|
||||
this.lengthInBeats
|
||||
);
|
||||
|
||||
// Add the region to the track
|
||||
targetTrack.addRegion(this.createdRegion);
|
||||
|
||||
console.log(`Created region "${this.regionName}" in track ${this.trackId} at beat ${this.startBeat}`);
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
if (!this.createdRegion) {
|
||||
throw new Error('Cannot undo: no region was created');
|
||||
}
|
||||
|
||||
const core = KGCore.instance();
|
||||
const currentProject = core.getCurrentProject();
|
||||
const tracks = currentProject.getTracks();
|
||||
|
||||
// Find the target track
|
||||
const targetTrack = tracks.find(track => track.getId().toString() === this.trackId);
|
||||
if (!targetTrack) {
|
||||
throw new Error(`Track with ID ${this.trackId} not found during undo`);
|
||||
}
|
||||
|
||||
// Close piano roll if it's open for this region
|
||||
const { activeRegionId, showPianoRoll, setShowPianoRoll, setActiveRegionId } = useProjectStore.getState();
|
||||
if (showPianoRoll && activeRegionId === this.regionId) {
|
||||
setShowPianoRoll(false);
|
||||
setActiveRegionId(null);
|
||||
console.log(`Closed piano roll because active region ${this.regionId} is being removed`);
|
||||
}
|
||||
|
||||
// Remove the region from the track
|
||||
targetTrack.removeRegion(this.regionId);
|
||||
|
||||
console.log(`Removed region "${this.regionName}" from track ${this.trackId}`);
|
||||
}
|
||||
|
||||
getDescription(): string {
|
||||
return `Create region "${this.regionName}"`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the ID of the region that was/will be created
|
||||
*/
|
||||
public getRegionId(): string {
|
||||
return this.regionId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the created region instance (only available after execute)
|
||||
*/
|
||||
public getCreatedRegion(): KGMidiRegion | null {
|
||||
return this.createdRegion;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a region command from bar-based coordinates (common UI pattern)
|
||||
*/
|
||||
public static fromBarCoordinates(
|
||||
trackId: string,
|
||||
trackIndex: number,
|
||||
barNumber: number,
|
||||
lengthInBars: number,
|
||||
beatsPerBar: number,
|
||||
regionName?: string,
|
||||
regionId?: string
|
||||
): CreateRegionCommand {
|
||||
const startBeat = (barNumber - 1) * beatsPerBar;
|
||||
const lengthInBeats = lengthInBars * beatsPerBar;
|
||||
|
||||
return new CreateRegionCommand(
|
||||
trackId,
|
||||
trackIndex,
|
||||
startBeat,
|
||||
lengthInBeats,
|
||||
regionName,
|
||||
regionId
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
import { KGCommand } from '../KGCommand';
|
||||
import { KGCore } from '../../KGCore';
|
||||
import { KGRegion } from '../../region/KGRegion';
|
||||
import { KGTrack } from '../../track/KGTrack';
|
||||
import { useProjectStore } from '../../../stores/projectStore';
|
||||
|
||||
/**
|
||||
* Command to delete a region from a track
|
||||
* Handles both the core model update and provides undo functionality
|
||||
*/
|
||||
export class DeleteRegionCommand extends KGCommand {
|
||||
private regionId: string;
|
||||
private trackId: string | null = null;
|
||||
private deletedRegion: KGRegion | null = null;
|
||||
private originalRegionIndex: number = -1;
|
||||
|
||||
constructor(regionId: string) {
|
||||
super();
|
||||
this.regionId = regionId;
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
const core = KGCore.instance();
|
||||
const currentProject = core.getCurrentProject();
|
||||
const tracks = currentProject.getTracks();
|
||||
|
||||
// Find the track that contains this region
|
||||
let targetTrack: KGTrack | null = null;
|
||||
let regionToDelete: KGRegion | null = null;
|
||||
|
||||
for (const track of tracks) {
|
||||
const regions = track.getRegions();
|
||||
const regionIndex = regions.findIndex(region => region.getId() === this.regionId);
|
||||
|
||||
if (regionIndex !== -1) {
|
||||
targetTrack = track;
|
||||
regionToDelete = regions[regionIndex];
|
||||
this.originalRegionIndex = regionIndex;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!targetTrack || !regionToDelete) {
|
||||
throw new Error(`Region with ID ${this.regionId} not found`);
|
||||
}
|
||||
|
||||
// Store data for undo
|
||||
this.trackId = targetTrack.getId().toString();
|
||||
this.deletedRegion = regionToDelete;
|
||||
|
||||
// Remove the region from the track
|
||||
targetTrack.removeRegion(this.regionId);
|
||||
|
||||
// Clear selection if this region was selected
|
||||
const selectedItems = core.getSelectedItems();
|
||||
const selectedRegion = selectedItems.find(item =>
|
||||
item instanceof KGRegion && item.getId() === this.regionId
|
||||
);
|
||||
if (selectedRegion) {
|
||||
core.removeSelectedItem(selectedRegion);
|
||||
}
|
||||
|
||||
// Close piano roll if it's open for this region
|
||||
const { activeRegionId, showPianoRoll, setShowPianoRoll, setActiveRegionId } = useProjectStore.getState();
|
||||
if (showPianoRoll && activeRegionId === this.regionId) {
|
||||
setShowPianoRoll(false);
|
||||
setActiveRegionId(null);
|
||||
console.log(`Closed piano roll because active region ${this.regionId} is being deleted`);
|
||||
}
|
||||
|
||||
const regionName = regionToDelete.getName();
|
||||
console.log(`Deleted region "${regionName}" from track ${this.trackId}`);
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
if (!this.deletedRegion || !this.trackId) {
|
||||
throw new Error('Cannot undo: no region data stored');
|
||||
}
|
||||
|
||||
const core = KGCore.instance();
|
||||
const currentProject = core.getCurrentProject();
|
||||
const tracks = currentProject.getTracks();
|
||||
|
||||
// Find the target track
|
||||
const targetTrack = tracks.find(track => track.getId().toString() === this.trackId);
|
||||
if (!targetTrack) {
|
||||
throw new Error(`Track with ID ${this.trackId} not found during undo`);
|
||||
}
|
||||
|
||||
// Restore the region to its original position
|
||||
const regions = targetTrack.getRegions();
|
||||
if (this.originalRegionIndex >= 0 && this.originalRegionIndex <= regions.length) {
|
||||
// Insert at the original position
|
||||
regions.splice(this.originalRegionIndex, 0, this.deletedRegion);
|
||||
targetTrack.setRegions(regions);
|
||||
} else {
|
||||
// Fallback: add to the end
|
||||
targetTrack.addRegion(this.deletedRegion);
|
||||
}
|
||||
|
||||
const regionName = this.deletedRegion.getName();
|
||||
console.log(`Restored region "${regionName}" to track ${this.trackId}`);
|
||||
}
|
||||
|
||||
getDescription(): string {
|
||||
const regionName = this.deletedRegion ? this.deletedRegion.getName() : `Region ${this.regionId}`;
|
||||
return `Delete region "${regionName}"`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the ID of the region that was/will be deleted
|
||||
*/
|
||||
public getRegionId(): string {
|
||||
return this.regionId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the deleted region instance (only available after execute)
|
||||
*/
|
||||
public getDeletedRegion(): KGRegion | null {
|
||||
return this.deletedRegion;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Command to delete multiple regions at once
|
||||
* More efficient than individual delete commands for bulk operations
|
||||
*/
|
||||
export class DeleteMultipleRegionsCommand extends KGCommand {
|
||||
private regionIds: string[];
|
||||
private deletedRegionData: Array<{
|
||||
region: KGRegion;
|
||||
trackId: string;
|
||||
originalIndex: number;
|
||||
}> = [];
|
||||
|
||||
constructor(regionIds: string[]) {
|
||||
super();
|
||||
this.regionIds = regionIds;
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
const core = KGCore.instance();
|
||||
const currentProject = core.getCurrentProject();
|
||||
const tracks = currentProject.getTracks();
|
||||
|
||||
// Clear any existing deleted region data to prevent duplicates on re-execution
|
||||
this.deletedRegionData = [];
|
||||
|
||||
// Find and store all regions to delete
|
||||
for (const regionId of this.regionIds) {
|
||||
for (const track of tracks) {
|
||||
const regions = track.getRegions();
|
||||
const regionIndex = regions.findIndex(region => region.getId() === regionId);
|
||||
|
||||
if (regionIndex !== -1) {
|
||||
const regionToDelete = regions[regionIndex];
|
||||
|
||||
// Store for undo
|
||||
this.deletedRegionData.push({
|
||||
region: regionToDelete,
|
||||
trackId: track.getId().toString(),
|
||||
originalIndex: regionIndex
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (this.deletedRegionData.length === 0) {
|
||||
throw new Error('No regions found to delete');
|
||||
}
|
||||
|
||||
// Sort by original index in descending order to maintain correct indices during deletion
|
||||
this.deletedRegionData.sort((a, b) => b.originalIndex - a.originalIndex);
|
||||
|
||||
// Check if piano roll should be closed before deleting regions
|
||||
const { activeRegionId, showPianoRoll, setShowPianoRoll, setActiveRegionId } = useProjectStore.getState();
|
||||
const regionIdsToDelete = new Set(this.deletedRegionData.map(data => data.region.getId()));
|
||||
|
||||
if (showPianoRoll && activeRegionId && regionIdsToDelete.has(activeRegionId)) {
|
||||
setShowPianoRoll(false);
|
||||
setActiveRegionId(null);
|
||||
console.log(`Closed piano roll because active region ${activeRegionId} is being deleted`);
|
||||
}
|
||||
|
||||
// Delete all regions
|
||||
for (const data of this.deletedRegionData) {
|
||||
const targetTrack = tracks.find(track => track.getId().toString() === data.trackId);
|
||||
if (targetTrack) {
|
||||
targetTrack.removeRegion(data.region.getId());
|
||||
|
||||
// Clear selection if this region was selected
|
||||
const selectedItems = core.getSelectedItems();
|
||||
const selectedRegion = selectedItems.find(item =>
|
||||
item instanceof KGRegion && item.getId() === data.region.getId()
|
||||
);
|
||||
if (selectedRegion) {
|
||||
core.removeSelectedItem(selectedRegion);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Deleted ${this.deletedRegionData.length} regions`);
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
if (this.deletedRegionData.length === 0) {
|
||||
throw new Error('Cannot undo: no region data stored');
|
||||
}
|
||||
|
||||
const core = KGCore.instance();
|
||||
const currentProject = core.getCurrentProject();
|
||||
const tracks = currentProject.getTracks();
|
||||
|
||||
// Restore all regions in reverse order (original index ascending)
|
||||
const sortedData = [...this.deletedRegionData].sort((a, b) => a.originalIndex - b.originalIndex);
|
||||
|
||||
for (const data of sortedData) {
|
||||
const targetTrack = tracks.find(track => track.getId().toString() === data.trackId);
|
||||
if (targetTrack) {
|
||||
const regions = targetTrack.getRegions();
|
||||
if (data.originalIndex >= 0 && data.originalIndex <= regions.length) {
|
||||
regions.splice(data.originalIndex, 0, data.region);
|
||||
targetTrack.setRegions(regions);
|
||||
} else {
|
||||
targetTrack.addRegion(data.region);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Restored ${this.deletedRegionData.length} regions`);
|
||||
}
|
||||
|
||||
getDescription(): string {
|
||||
if (this.deletedRegionData.length === 1) {
|
||||
const regionName = this.deletedRegionData[0].region.getName();
|
||||
return `Delete region "${regionName}"`;
|
||||
}
|
||||
return `Delete ${this.regionIds.length} regions`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the IDs of regions that were/will be deleted
|
||||
*/
|
||||
public getRegionIds(): string[] {
|
||||
return this.regionIds;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the deleted region data (only available after execute)
|
||||
*/
|
||||
public getDeletedRegionData(): Array<{region: KGRegion; trackId: string; originalIndex: number}> {
|
||||
return this.deletedRegionData;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
import { KGCommand } from '../KGCommand';
|
||||
import { KGCore } from '../../KGCore';
|
||||
import { KGRegion } from '../../region/KGRegion';
|
||||
import { KGMidiRegion } from '../../region/KGMidiRegion';
|
||||
import { KGTrack } from '../../track/KGTrack';
|
||||
|
||||
/**
|
||||
* Command to move a region to a new position and/or track
|
||||
* Handles both position changes within the same track and moves between tracks
|
||||
*/
|
||||
export class MoveRegionCommand extends KGCommand {
|
||||
private regionId: string;
|
||||
private newStartFromBeat: number;
|
||||
private newTrackId: string;
|
||||
private newTrackIndex: number;
|
||||
|
||||
// Original state for undo
|
||||
private originalStartFromBeat: number = 0;
|
||||
private originalTrackId: string = '';
|
||||
private originalTrackIndex: number = 0;
|
||||
private targetRegion: KGRegion | null = null;
|
||||
private originalTrack: KGTrack | null = null;
|
||||
private targetTrack: KGTrack | null = null;
|
||||
|
||||
constructor(regionId: string, newStartFromBeat: number, newTrackId: string, newTrackIndex: number) {
|
||||
super();
|
||||
this.regionId = regionId;
|
||||
this.newStartFromBeat = newStartFromBeat;
|
||||
this.newTrackId = newTrackId;
|
||||
this.newTrackIndex = newTrackIndex;
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
const core = KGCore.instance();
|
||||
const currentProject = core.getCurrentProject();
|
||||
const tracks = currentProject.getTracks();
|
||||
|
||||
// Find the region to move
|
||||
let targetRegion: KGRegion | null = null;
|
||||
let originalTrack: KGTrack | null = null;
|
||||
|
||||
for (const track of tracks) {
|
||||
const regions = track.getRegions();
|
||||
const region = regions.find(r => r.getId() === this.regionId);
|
||||
if (region) {
|
||||
targetRegion = region;
|
||||
originalTrack = track;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!targetRegion || !originalTrack) {
|
||||
throw new Error(`Region with ID ${this.regionId} not found`);
|
||||
}
|
||||
|
||||
// Find the target track
|
||||
const targetTrack = tracks.find(t => t.getId().toString() === this.newTrackId);
|
||||
if (!targetTrack) {
|
||||
throw new Error(`Target track with ID ${this.newTrackId} not found`);
|
||||
}
|
||||
|
||||
this.targetRegion = targetRegion;
|
||||
this.originalTrack = originalTrack;
|
||||
this.targetTrack = targetTrack;
|
||||
|
||||
// Store original values for undo
|
||||
this.originalStartFromBeat = targetRegion.getStartFromBeat();
|
||||
this.originalTrackId = targetRegion.getTrackId();
|
||||
this.originalTrackIndex = targetRegion.getTrackIndex();
|
||||
|
||||
// Check if we're moving to a different track
|
||||
const isTrackChange = this.originalTrackId !== this.newTrackId;
|
||||
|
||||
if (isTrackChange) {
|
||||
// Remove from original track
|
||||
const originalRegions = originalTrack.getRegions();
|
||||
originalTrack.setRegions(originalRegions.filter(r => r.getId() !== this.regionId));
|
||||
|
||||
// Add to target track
|
||||
const targetRegions = targetTrack.getRegions();
|
||||
targetTrack.setRegions([...targetRegions, targetRegion]);
|
||||
|
||||
console.log(`Moved region "${targetRegion.getName()}" from track ${this.originalTrackIndex} to track ${this.newTrackIndex}`);
|
||||
}
|
||||
|
||||
// Update region properties
|
||||
targetRegion.setStartFromBeat(this.newStartFromBeat);
|
||||
targetRegion.setTrackId(this.newTrackId);
|
||||
targetRegion.setTrackIndex(this.newTrackIndex);
|
||||
|
||||
const regionName = targetRegion.getName();
|
||||
console.log(`Moved region "${regionName}": position ${this.originalStartFromBeat} → ${this.newStartFromBeat}, track ${this.originalTrackIndex} → ${this.newTrackIndex}`);
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
if (!this.targetRegion || !this.originalTrack || !this.targetTrack) {
|
||||
throw new Error('Cannot undo: region or tracks not found');
|
||||
}
|
||||
|
||||
// Check if we moved between tracks
|
||||
const wasTrackChange = this.originalTrackId !== this.newTrackId;
|
||||
|
||||
if (wasTrackChange) {
|
||||
// Remove from current track
|
||||
const currentRegions = this.targetTrack.getRegions();
|
||||
this.targetTrack.setRegions(currentRegions.filter(r => r.getId() !== this.regionId));
|
||||
|
||||
// Add back to original track
|
||||
const originalRegions = this.originalTrack.getRegions();
|
||||
this.originalTrack.setRegions([...originalRegions, this.targetRegion]);
|
||||
|
||||
console.log(`Restored region "${this.targetRegion.getName()}" back to original track ${this.originalTrackIndex}`);
|
||||
}
|
||||
|
||||
// Restore original region properties
|
||||
this.targetRegion.setStartFromBeat(this.originalStartFromBeat);
|
||||
this.targetRegion.setTrackId(this.originalTrackId);
|
||||
this.targetRegion.setTrackIndex(this.originalTrackIndex);
|
||||
|
||||
const regionName = this.targetRegion.getName();
|
||||
console.log(`Restored region "${regionName}": position ${this.newStartFromBeat} → ${this.originalStartFromBeat}, track ${this.newTrackIndex} → ${this.originalTrackIndex}`);
|
||||
}
|
||||
|
||||
getDescription(): string {
|
||||
const regionName = this.targetRegion ? this.targetRegion.getName() : `Region ${this.regionId}`;
|
||||
|
||||
// Check if it's a track change or position change
|
||||
const isTrackChange = this.originalTrackId !== this.newTrackId;
|
||||
const isPositionChange = this.originalStartFromBeat !== this.newStartFromBeat;
|
||||
|
||||
if (isTrackChange && isPositionChange) {
|
||||
return `Move region "${regionName}" to different track and position`;
|
||||
} else if (isTrackChange) {
|
||||
return `Move region "${regionName}" to different track`;
|
||||
} else if (isPositionChange) {
|
||||
return `Move region "${regionName}" to new position`;
|
||||
} else {
|
||||
return `Move region "${regionName}"`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the ID of the region being moved
|
||||
*/
|
||||
public getRegionId(): string {
|
||||
return this.regionId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the new start position
|
||||
*/
|
||||
public getNewStartFromBeat(): number {
|
||||
return this.newStartFromBeat;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the new track ID
|
||||
*/
|
||||
public getNewTrackId(): string {
|
||||
return this.newTrackId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the new track index
|
||||
*/
|
||||
public getNewTrackIndex(): number {
|
||||
return this.newTrackIndex;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the original start position (only available after execute)
|
||||
*/
|
||||
public getOriginalStartFromBeat(): number {
|
||||
return this.originalStartFromBeat;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the original track ID (only available after execute)
|
||||
*/
|
||||
public getOriginalTrackId(): string {
|
||||
return this.originalTrackId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the original track index (only available after execute)
|
||||
*/
|
||||
public getOriginalTrackIndex(): number {
|
||||
return this.originalTrackIndex;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the target region instance (only available after execute)
|
||||
*/
|
||||
public getTargetRegion(): KGRegion | null {
|
||||
return this.targetRegion;
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory method to create a move command from bar-based coordinates
|
||||
*/
|
||||
public static fromBarCoordinates(
|
||||
regionId: string,
|
||||
newBarNumber: number,
|
||||
newTrackId: string,
|
||||
newTrackIndex: number,
|
||||
timeSignature: { numerator: number; denominator: number }
|
||||
): MoveRegionCommand {
|
||||
const beatsPerBar = timeSignature.numerator;
|
||||
const newStartFromBeat = (newBarNumber - 1) * beatsPerBar;
|
||||
|
||||
return new MoveRegionCommand(regionId, newStartFromBeat, newTrackId, newTrackIndex);
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory method to create a position-only move command (same track)
|
||||
*/
|
||||
public static createPositionOnlyMove(
|
||||
regionId: string,
|
||||
newStartFromBeat: number,
|
||||
currentTrackId: string,
|
||||
currentTrackIndex: number
|
||||
): MoveRegionCommand {
|
||||
return new MoveRegionCommand(regionId, newStartFromBeat, currentTrackId, currentTrackIndex);
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory method to create a track-only move command (same position)
|
||||
*/
|
||||
public static createTrackOnlyMove(
|
||||
regionId: string,
|
||||
currentStartFromBeat: number,
|
||||
newTrackId: string,
|
||||
newTrackIndex: number
|
||||
): MoveRegionCommand {
|
||||
return new MoveRegionCommand(regionId, currentStartFromBeat, newTrackId, newTrackIndex);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
import { KGCommand } from '../KGCommand';
|
||||
import { KGCore } from '../../KGCore';
|
||||
import { KGRegion } from '../../region/KGRegion';
|
||||
import { KGMidiRegion } from '../../region/KGMidiRegion';
|
||||
import { KGMidiNote } from '../../midi/KGMidiNote';
|
||||
import { KGTrack } from '../../track/KGTrack';
|
||||
import { generateUniqueId } from '../../../util/miscUtil';
|
||||
import { useProjectStore } from '../../../stores/projectStore';
|
||||
|
||||
/**
|
||||
* Command to paste regions with their notes to a target track at a specific position
|
||||
* Handles creating new regions with new IDs and copying all notes with proper positioning
|
||||
*/
|
||||
export class PasteRegionsCommand extends KGCommand {
|
||||
private targetTrackId: string;
|
||||
private pastePosition: number;
|
||||
private sourceRegions: KGRegion[];
|
||||
private createdRegions: KGRegion[] = [];
|
||||
private targetTrack: KGTrack | null = null;
|
||||
|
||||
constructor(targetTrackId: string, pastePosition: number, sourceRegions: KGRegion[]) {
|
||||
super();
|
||||
this.targetTrackId = targetTrackId;
|
||||
this.pastePosition = pastePosition;
|
||||
this.sourceRegions = [...sourceRegions]; // Create a copy to avoid reference issues
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
if (this.sourceRegions.length === 0) {
|
||||
throw new Error('No regions to paste');
|
||||
}
|
||||
|
||||
const core = KGCore.instance();
|
||||
const currentProject = core.getCurrentProject();
|
||||
const tracks = currentProject.getTracks();
|
||||
|
||||
// Find the target track
|
||||
const targetTrack = tracks.find(track => track.getId().toString() === this.targetTrackId);
|
||||
if (!targetTrack) {
|
||||
throw new Error(`Target track with ID ${this.targetTrackId} not found`);
|
||||
}
|
||||
|
||||
this.targetTrack = targetTrack;
|
||||
|
||||
// Clear any previously created regions (for re-execution)
|
||||
this.createdRegions = [];
|
||||
|
||||
// Calculate the base position from the first region to maintain relative positions
|
||||
const basePosition = this.sourceRegions[0].getStartFromBeat();
|
||||
|
||||
// Create new regions at the target position
|
||||
this.sourceRegions.forEach((originalRegion) => {
|
||||
// Generate new ID for the pasted region
|
||||
const newId = generateUniqueId('KGMidiRegion');
|
||||
|
||||
// Calculate new position maintaining relative offset
|
||||
const relativeOffset = originalRegion.getStartFromBeat() - basePosition;
|
||||
const newPosition = this.pastePosition + relativeOffset;
|
||||
|
||||
// Create a copy of the region with new position and ID
|
||||
let newRegion: KGRegion;
|
||||
|
||||
if (originalRegion instanceof KGMidiRegion) {
|
||||
newRegion = new KGMidiRegion(
|
||||
newId,
|
||||
targetTrack.getId().toString(),
|
||||
targetTrack.getTrackIndex(),
|
||||
`${originalRegion.getName()} (Copy)`,
|
||||
newPosition,
|
||||
originalRegion.getLength()
|
||||
);
|
||||
|
||||
// Copy all notes from the original region
|
||||
const originalNotes = originalRegion.getNotes();
|
||||
originalNotes.forEach(note => {
|
||||
const copiedNote = new KGMidiNote(
|
||||
generateUniqueId('KGMidiNote'),
|
||||
note.getStartBeat(),
|
||||
note.getEndBeat(),
|
||||
note.getPitch(),
|
||||
note.getVelocity()
|
||||
);
|
||||
(newRegion as KGMidiRegion).addNote(copiedNote);
|
||||
});
|
||||
|
||||
console.log(`Created MIDI region "${newRegion.getName()}" with ${originalNotes.length} notes`);
|
||||
} else {
|
||||
// Fallback for other region types
|
||||
newRegion = new KGRegion(
|
||||
newId,
|
||||
targetTrack.getId().toString(),
|
||||
targetTrack.getTrackIndex(),
|
||||
`${originalRegion.getName()} (Copy)`,
|
||||
newPosition,
|
||||
originalRegion.getLength()
|
||||
);
|
||||
|
||||
console.log(`Created region "${newRegion.getName()}"`);
|
||||
}
|
||||
|
||||
// Add the new region to the target track
|
||||
const currentRegions = targetTrack.getRegions();
|
||||
targetTrack.setRegions([...currentRegions, newRegion]);
|
||||
|
||||
// Track the created region for undo
|
||||
this.createdRegions.push(newRegion);
|
||||
});
|
||||
|
||||
console.log(`Pasted ${this.sourceRegions.length} regions to track ${this.targetTrackId} at position ${this.pastePosition}`);
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
if (!this.targetTrack || this.createdRegions.length === 0) {
|
||||
throw new Error('Cannot undo: no regions were pasted');
|
||||
}
|
||||
|
||||
// Check if piano roll is open for any of the regions we're about to remove
|
||||
const regionIdsToRemove = new Set(this.createdRegions.map(r => r.getId()));
|
||||
|
||||
// Get current active region from the store
|
||||
const { activeRegionId, showPianoRoll, setShowPianoRoll, setActiveRegionId } = useProjectStore.getState();
|
||||
|
||||
// Close piano roll if it's open for one of the regions we're removing
|
||||
if (showPianoRoll && activeRegionId && regionIdsToRemove.has(activeRegionId)) {
|
||||
setShowPianoRoll(false);
|
||||
setActiveRegionId(null);
|
||||
console.log(`Closed piano roll because active region ${activeRegionId} is being removed`);
|
||||
}
|
||||
|
||||
// Remove all created regions from the target track
|
||||
const currentRegions = this.targetTrack.getRegions();
|
||||
const filteredRegions = currentRegions.filter(region => !regionIdsToRemove.has(region.getId()));
|
||||
this.targetTrack.setRegions(filteredRegions);
|
||||
|
||||
console.log(`Removed ${this.createdRegions.length} pasted regions from track ${this.targetTrackId}`);
|
||||
}
|
||||
|
||||
getDescription(): string {
|
||||
const regionCount = this.sourceRegions.length;
|
||||
const trackName = this.targetTrack ? this.targetTrack.getName() : `Track ${this.targetTrackId}`;
|
||||
|
||||
if (regionCount === 1) {
|
||||
const regionName = this.sourceRegions[0].getName();
|
||||
return `Paste region "${regionName}" to "${trackName}"`;
|
||||
} else {
|
||||
return `Paste ${regionCount} regions to "${trackName}"`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the target track ID
|
||||
*/
|
||||
public getTargetTrackId(): string {
|
||||
return this.targetTrackId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the paste position
|
||||
*/
|
||||
public getPastePosition(): number {
|
||||
return this.pastePosition;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the source regions being pasted
|
||||
*/
|
||||
public getSourceRegions(): KGRegion[] {
|
||||
return [...this.sourceRegions];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the created regions (only available after execute)
|
||||
*/
|
||||
public getCreatedRegions(): KGRegion[] {
|
||||
return [...this.createdRegions];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the target track instance (only available after execute)
|
||||
*/
|
||||
public getTargetTrack(): KGTrack | null {
|
||||
return this.targetTrack;
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory method to create a paste command from the clipboard
|
||||
*/
|
||||
public static fromClipboard(targetTrackId: string, pastePosition: number): PasteRegionsCommand | null {
|
||||
const core = KGCore.instance();
|
||||
const copiedItems = core.getCopiedItems();
|
||||
const regionsToCreate = copiedItems.filter(item => item instanceof KGRegion) as KGRegion[];
|
||||
|
||||
if (regionsToCreate.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new PasteRegionsCommand(targetTrackId, pastePosition, regionsToCreate);
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory method to create a paste command from specific regions
|
||||
*/
|
||||
public static fromRegions(targetTrackId: string, pastePosition: number, regions: KGRegion[]): PasteRegionsCommand {
|
||||
return new PasteRegionsCommand(targetTrackId, pastePosition, regions);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
import { KGCommand } from '../KGCommand';
|
||||
import { KGCore } from '../../KGCore';
|
||||
import { KGRegion } from '../../region/KGRegion';
|
||||
import { KGMidiRegion } from '../../region/KGMidiRegion';
|
||||
import { KGMidiNote } from '../../midi/KGMidiNote';
|
||||
|
||||
/**
|
||||
* Command to resize a region (change start position and/or length)
|
||||
* Handles both the core model update and note adjustments for start position changes
|
||||
*/
|
||||
export class ResizeRegionCommand extends KGCommand {
|
||||
private regionId: string;
|
||||
private newStartFromBeat: number;
|
||||
private newLength: number;
|
||||
private originalStartFromBeat: number = 0;
|
||||
private originalLength: number = 0;
|
||||
private targetRegion: KGRegion | null = null;
|
||||
|
||||
// Store note adjustments for undo
|
||||
private noteAdjustments: Array<{
|
||||
noteId: string;
|
||||
originalStartBeat: number;
|
||||
originalEndBeat: number;
|
||||
}> = [];
|
||||
|
||||
constructor(regionId: string, newStartFromBeat: number, newLength: number) {
|
||||
super();
|
||||
this.regionId = regionId;
|
||||
this.newStartFromBeat = newStartFromBeat;
|
||||
this.newLength = newLength;
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
const core = KGCore.instance();
|
||||
const currentProject = core.getCurrentProject();
|
||||
const tracks = currentProject.getTracks();
|
||||
|
||||
// Find the region to resize
|
||||
let targetRegion: KGRegion | null = null;
|
||||
|
||||
for (const track of tracks) {
|
||||
const regions = track.getRegions();
|
||||
const region = regions.find(r => r.getId() === this.regionId);
|
||||
if (region) {
|
||||
targetRegion = region;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!targetRegion) {
|
||||
throw new Error(`Region with ID ${this.regionId} not found`);
|
||||
}
|
||||
|
||||
this.targetRegion = targetRegion;
|
||||
|
||||
// Store original values for undo
|
||||
this.originalStartFromBeat = targetRegion.getStartFromBeat();
|
||||
this.originalLength = targetRegion.getLength();
|
||||
|
||||
// Handle note adjustments if start position changes (left-edge resize)
|
||||
if (this.newStartFromBeat !== this.originalStartFromBeat && targetRegion instanceof KGMidiRegion) {
|
||||
const beatOffset = this.newStartFromBeat - this.originalStartFromBeat;
|
||||
|
||||
// Store original note positions and adjust notes to maintain absolute positions
|
||||
const notes = targetRegion.getNotes();
|
||||
notes.forEach(note => {
|
||||
// Store original positions for undo
|
||||
this.noteAdjustments.push({
|
||||
noteId: note.getId(),
|
||||
originalStartBeat: note.getStartBeat(),
|
||||
originalEndBeat: note.getEndBeat()
|
||||
});
|
||||
|
||||
// Adjust note positions to maintain absolute position
|
||||
note.setStartBeat(note.getStartBeat() - beatOffset);
|
||||
note.setEndBeat(note.getEndBeat() - beatOffset);
|
||||
});
|
||||
|
||||
console.log(`Adjusted ${notes.length} notes by offset ${-beatOffset} beats to maintain absolute positions`);
|
||||
}
|
||||
|
||||
// Apply the resize
|
||||
targetRegion.setStartFromBeat(this.newStartFromBeat);
|
||||
targetRegion.setLength(this.newLength);
|
||||
|
||||
const regionName = targetRegion.getName();
|
||||
console.log(`Resized region "${regionName}": start ${this.originalStartFromBeat} → ${this.newStartFromBeat}, length ${this.originalLength} → ${this.newLength}`);
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
if (!this.targetRegion) {
|
||||
throw new Error('Cannot undo: no region was resized');
|
||||
}
|
||||
|
||||
// Restore note positions if they were adjusted
|
||||
if (this.noteAdjustments.length > 0 && this.targetRegion instanceof KGMidiRegion) {
|
||||
const notes = this.targetRegion.getNotes();
|
||||
|
||||
// Restore each note to its original position
|
||||
this.noteAdjustments.forEach(adjustment => {
|
||||
const note = notes.find(n => n.getId() === adjustment.noteId);
|
||||
if (note) {
|
||||
note.setStartBeat(adjustment.originalStartBeat);
|
||||
note.setEndBeat(adjustment.originalEndBeat);
|
||||
}
|
||||
});
|
||||
|
||||
console.log(`Restored ${this.noteAdjustments.length} notes to their original positions`);
|
||||
}
|
||||
|
||||
// Restore original region values
|
||||
this.targetRegion.setStartFromBeat(this.originalStartFromBeat);
|
||||
this.targetRegion.setLength(this.originalLength);
|
||||
|
||||
const regionName = this.targetRegion.getName();
|
||||
console.log(`Restored region "${regionName}": start ${this.newStartFromBeat} → ${this.originalStartFromBeat}, length ${this.newLength} → ${this.originalLength}`);
|
||||
}
|
||||
|
||||
getDescription(): string {
|
||||
const regionName = this.targetRegion ? this.targetRegion.getName() : `Region ${this.regionId}`;
|
||||
return `Resize region "${regionName}"`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the ID of the region being resized
|
||||
*/
|
||||
public getRegionId(): string {
|
||||
return this.regionId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the new start position
|
||||
*/
|
||||
public getNewStartFromBeat(): number {
|
||||
return this.newStartFromBeat;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the new length
|
||||
*/
|
||||
public getNewLength(): number {
|
||||
return this.newLength;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the original start position (only available after execute)
|
||||
*/
|
||||
public getOriginalStartFromBeat(): number {
|
||||
return this.originalStartFromBeat;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the original length (only available after execute)
|
||||
*/
|
||||
public getOriginalLength(): number {
|
||||
return this.originalLength;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the target region instance (only available after execute)
|
||||
*/
|
||||
public getTargetRegion(): KGRegion | null {
|
||||
return this.targetRegion;
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory method to create a resize command for position only (move without length change)
|
||||
*/
|
||||
public static createMoveCommand(regionId: string, newStartFromBeat: number, currentLength: number): ResizeRegionCommand {
|
||||
return new ResizeRegionCommand(regionId, newStartFromBeat, currentLength);
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory method to create a resize command for length only (resize without position change)
|
||||
*/
|
||||
public static createLengthChangeCommand(regionId: string, currentStartFromBeat: number, newLength: number): ResizeRegionCommand {
|
||||
return new ResizeRegionCommand(regionId, currentStartFromBeat, newLength);
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory method to create a resize command from bar-based coordinates
|
||||
*/
|
||||
public static fromBarCoordinates(
|
||||
regionId: string,
|
||||
newBarNumber: number,
|
||||
newLengthInBars: number,
|
||||
timeSignature: { numerator: number; denominator: number }
|
||||
): ResizeRegionCommand {
|
||||
const beatsPerBar = timeSignature.numerator;
|
||||
const newStartFromBeat = (newBarNumber - 1) * beatsPerBar;
|
||||
const newLength = newLengthInBars * beatsPerBar;
|
||||
|
||||
return new ResizeRegionCommand(regionId, newStartFromBeat, newLength);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import { KGCommand } from '../KGCommand';
|
||||
import { KGCore } from '../../KGCore';
|
||||
import { KGRegion } from '../../region/KGRegion';
|
||||
import { KGTrack } from '../../track/KGTrack';
|
||||
|
||||
/**
|
||||
* Interface defining properties that can be updated on a region
|
||||
*/
|
||||
export interface RegionUpdateProperties {
|
||||
name?: string;
|
||||
// Future properties can be added here (e.g., color, instrument, etc.)
|
||||
}
|
||||
|
||||
/**
|
||||
* Command to update region properties
|
||||
* Handles updating region name and other properties with undo support
|
||||
*/
|
||||
export class UpdateRegionCommand extends KGCommand {
|
||||
private regionId: string;
|
||||
private newProperties: RegionUpdateProperties;
|
||||
private originalProperties: RegionUpdateProperties = {};
|
||||
private targetRegion: KGRegion | null = null;
|
||||
private parentTrack: KGTrack | null = null;
|
||||
private changedProperties: Set<keyof RegionUpdateProperties> = new Set();
|
||||
|
||||
constructor(regionId: string, properties: RegionUpdateProperties) {
|
||||
super();
|
||||
this.regionId = regionId;
|
||||
this.newProperties = properties;
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
const core = KGCore.instance();
|
||||
const currentProject = core.getCurrentProject();
|
||||
const tracks = currentProject.getTracks();
|
||||
|
||||
// Find the region across all tracks
|
||||
let targetRegion: KGRegion | null = null;
|
||||
let parentTrack: KGTrack | null = null;
|
||||
|
||||
for (const track of tracks) {
|
||||
const regions = track.getRegions();
|
||||
const region = regions.find(r => r.getId() === this.regionId);
|
||||
if (region) {
|
||||
targetRegion = region;
|
||||
parentTrack = track;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!targetRegion || !parentTrack) {
|
||||
throw new Error(`Region with ID ${this.regionId} not found`);
|
||||
}
|
||||
|
||||
this.targetRegion = targetRegion;
|
||||
this.parentTrack = parentTrack;
|
||||
|
||||
// Store original properties for undo
|
||||
this.originalProperties = {
|
||||
name: this.targetRegion.getName(),
|
||||
};
|
||||
|
||||
// Apply updates and track what actually changes
|
||||
const updatedProperties: string[] = [];
|
||||
|
||||
// Update name
|
||||
if (this.newProperties.name !== undefined && this.newProperties.name !== this.originalProperties.name) {
|
||||
this.targetRegion.setName(this.newProperties.name);
|
||||
this.changedProperties.add('name');
|
||||
updatedProperties.push(`name: "${this.originalProperties.name}" → "${this.newProperties.name}"`);
|
||||
}
|
||||
|
||||
if (updatedProperties.length > 0) {
|
||||
console.log(`Updated region ${this.regionId}: ${updatedProperties.join(', ')}`);
|
||||
} else {
|
||||
console.log(`No changes applied to region ${this.regionId}`);
|
||||
}
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
if (!this.targetRegion) {
|
||||
throw new Error('Cannot undo: no region was updated');
|
||||
}
|
||||
|
||||
// Only restore properties that were actually changed
|
||||
const restoredProperties: string[] = [];
|
||||
|
||||
// Restore name (only if it was changed)
|
||||
if (this.changedProperties.has('name') && this.originalProperties.name !== undefined) {
|
||||
this.targetRegion.setName(this.originalProperties.name);
|
||||
restoredProperties.push(`name: "${this.originalProperties.name}"`);
|
||||
}
|
||||
|
||||
console.log(`Restored region ${this.regionId}: ${restoredProperties.join(', ')}`);
|
||||
}
|
||||
|
||||
getDescription(): string {
|
||||
const regionName = this.originalProperties.name || `Region ${this.regionId}`;
|
||||
const updatedProps: string[] = [];
|
||||
|
||||
if (this.newProperties.name !== undefined) {
|
||||
updatedProps.push('name');
|
||||
}
|
||||
|
||||
if (updatedProps.length === 1) {
|
||||
return `Update region "${regionName}" ${updatedProps[0]}`;
|
||||
} else if (updatedProps.length > 1) {
|
||||
return `Update region "${regionName}" properties`;
|
||||
}
|
||||
|
||||
return `Update region "${regionName}"`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the ID of the region being updated
|
||||
*/
|
||||
public getRegionId(): string {
|
||||
return this.regionId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the new properties being applied
|
||||
*/
|
||||
public getNewProperties(): RegionUpdateProperties {
|
||||
return this.newProperties;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the original properties (only available after execute)
|
||||
*/
|
||||
public getOriginalProperties(): RegionUpdateProperties {
|
||||
return this.originalProperties;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the target region instance (only available after execute)
|
||||
*/
|
||||
public getTargetRegion(): KGRegion | null {
|
||||
return this.targetRegion;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the parent track of the region (only available after execute)
|
||||
*/
|
||||
public getParentTrack(): KGTrack | null {
|
||||
return this.parentTrack;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the properties that were actually changed (only available after execute)
|
||||
*/
|
||||
public getChangedProperties(): Set<keyof RegionUpdateProperties> {
|
||||
return new Set(this.changedProperties);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { KGCommand } from '../KGCommand';
|
||||
import { KGCore } from '../../KGCore';
|
||||
import { KGMidiTrack, type InstrumentType } from '../../track/KGMidiTrack';
|
||||
import { KGAudioInterface } from '../../audio-interface/KGAudioInterface';
|
||||
|
||||
/**
|
||||
* Command to add a new track to the project
|
||||
* Handles both the core model update and audio interface setup
|
||||
*/
|
||||
export class AddTrackCommand extends KGCommand {
|
||||
private trackId: number;
|
||||
private trackName: string;
|
||||
private instrument: InstrumentType;
|
||||
private trackIndex: number;
|
||||
private createdTrack: KGMidiTrack | null = null;
|
||||
|
||||
constructor(trackId?: number, trackName?: string, instrument: InstrumentType = 'acoustic_grand_piano') {
|
||||
super();
|
||||
|
||||
// If trackId not provided, calculate it
|
||||
if (trackId === undefined) {
|
||||
const currentProject = KGCore.instance().getCurrentProject();
|
||||
const tracks = currentProject.getTracks();
|
||||
this.trackId = tracks.length > 0
|
||||
? Math.max(...tracks.map(track => track.getId())) + 1
|
||||
: 1;
|
||||
} else {
|
||||
this.trackId = trackId;
|
||||
}
|
||||
|
||||
this.trackName = trackName || `Track ${this.trackId}`;
|
||||
this.instrument = instrument;
|
||||
|
||||
// Track index will be set during execution
|
||||
this.trackIndex = 0;
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
const core = KGCore.instance();
|
||||
const currentProject = core.getCurrentProject();
|
||||
const tracks = currentProject.getTracks();
|
||||
|
||||
// Set the track index to be the last index in the array
|
||||
this.trackIndex = tracks.length;
|
||||
|
||||
// Create a new MIDI track
|
||||
this.createdTrack = new KGMidiTrack(this.trackName, this.trackId);
|
||||
this.createdTrack.setTrackIndex(this.trackIndex);
|
||||
this.createdTrack.setInstrument(this.instrument);
|
||||
|
||||
// Update the core model
|
||||
const updatedTracks = [...tracks, this.createdTrack];
|
||||
currentProject.setTracks(updatedTracks);
|
||||
|
||||
// Create audio synth for the new track (initialize with track volume)
|
||||
const audioInterface = KGAudioInterface.instance();
|
||||
audioInterface.createTrackSynth(this.trackId.toString(), this.instrument);
|
||||
|
||||
console.log(`Added track ${this.trackId} with ${this.instrument} instrument`);
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
if (!this.createdTrack) {
|
||||
throw new Error('Cannot undo: no track was created');
|
||||
}
|
||||
|
||||
const core = KGCore.instance();
|
||||
const currentProject = core.getCurrentProject();
|
||||
const tracks = currentProject.getTracks();
|
||||
|
||||
// Remove the track from the core model
|
||||
const updatedTracks = tracks.filter(track => track.getId() !== this.trackId);
|
||||
currentProject.setTracks(updatedTracks);
|
||||
|
||||
// Remove audio synth for the track
|
||||
const audioInterface = KGAudioInterface.instance();
|
||||
audioInterface.removeTrackSynth(this.trackId.toString());
|
||||
|
||||
console.log(`Removed track ${this.trackId} and its audio synth`);
|
||||
}
|
||||
|
||||
getDescription(): string {
|
||||
return `Add track "${this.trackName}"`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the ID of the track that was/will be created
|
||||
*/
|
||||
public getTrackId(): number {
|
||||
return this.trackId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the created track instance (only available after execute)
|
||||
*/
|
||||
public getCreatedTrack(): KGMidiTrack | null {
|
||||
return this.createdTrack;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import { KGCommand } from '../KGCommand';
|
||||
import { KGCore } from '../../KGCore';
|
||||
import { KGTrack } from '../../track/KGTrack';
|
||||
import { KGMidiTrack, type InstrumentType } from '../../track/KGMidiTrack';
|
||||
import { KGAudioInterface } from '../../audio-interface/KGAudioInterface';
|
||||
|
||||
/**
|
||||
* Command to remove a track from the project
|
||||
* Handles both the core model update and audio interface cleanup
|
||||
*/
|
||||
export class RemoveTrackCommand extends KGCommand {
|
||||
private trackId: number;
|
||||
private removedTrack: KGTrack | null = null;
|
||||
private originalTrackIndex: number = 0;
|
||||
private originalInstrument: InstrumentType = 'acoustic_grand_piano';
|
||||
|
||||
constructor(trackId: number) {
|
||||
super();
|
||||
this.trackId = trackId;
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
const core = KGCore.instance();
|
||||
const currentProject = core.getCurrentProject();
|
||||
const tracks = currentProject.getTracks();
|
||||
|
||||
// Find the track to remove
|
||||
const trackToRemove = tracks.find(track => track.getId() === this.trackId);
|
||||
if (!trackToRemove) {
|
||||
throw new Error(`Track with ID ${this.trackId} not found`);
|
||||
}
|
||||
|
||||
// Store the track data for undo
|
||||
this.removedTrack = trackToRemove;
|
||||
this.originalTrackIndex = trackToRemove.getTrackIndex();
|
||||
|
||||
// Store instrument if it's a MIDI track
|
||||
if (trackToRemove.constructor.name === 'KGMidiTrack' && 'getInstrument' in trackToRemove) {
|
||||
this.originalInstrument = (trackToRemove as KGMidiTrack).getInstrument();
|
||||
}
|
||||
|
||||
// Remove audio synth for the track
|
||||
const audioInterface = KGAudioInterface.instance();
|
||||
audioInterface.removeTrackSynth(this.trackId.toString());
|
||||
|
||||
// Remove the track from the core model
|
||||
const updatedTracks = tracks.filter(track => track.getId() !== this.trackId);
|
||||
|
||||
// Update track indices to match new array positions
|
||||
updatedTracks.forEach((track, index) => {
|
||||
track.setTrackIndex(index);
|
||||
});
|
||||
|
||||
currentProject.setTracks(updatedTracks);
|
||||
|
||||
console.log(`Removed track ${this.trackId} and its audio synth`);
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
if (!this.removedTrack) {
|
||||
throw new Error('Cannot undo: no track was removed');
|
||||
}
|
||||
|
||||
const core = KGCore.instance();
|
||||
const currentProject = core.getCurrentProject();
|
||||
const tracks = currentProject.getTracks();
|
||||
|
||||
// Restore the track to its original position
|
||||
const updatedTracks = [...tracks];
|
||||
|
||||
// Insert the track at the correct position
|
||||
if (this.originalTrackIndex >= updatedTracks.length) {
|
||||
// Add to the end
|
||||
updatedTracks.push(this.removedTrack);
|
||||
} else {
|
||||
// Insert at the original position
|
||||
updatedTracks.splice(this.originalTrackIndex, 0, this.removedTrack);
|
||||
}
|
||||
|
||||
// Update track indices to match array positions
|
||||
updatedTracks.forEach((track, index) => {
|
||||
track.setTrackIndex(index);
|
||||
});
|
||||
|
||||
// Update the core model
|
||||
currentProject.setTracks(updatedTracks);
|
||||
|
||||
// Recreate audio synth for the track
|
||||
const audioInterface = KGAudioInterface.instance();
|
||||
audioInterface.createTrackSynth(this.trackId.toString(), this.originalInstrument);
|
||||
|
||||
console.log(`Restored track ${this.trackId} with ${this.originalInstrument} instrument`);
|
||||
}
|
||||
|
||||
getDescription(): string {
|
||||
const trackName = this.removedTrack ? this.removedTrack.getName() : `Track ${this.trackId}`;
|
||||
return `Remove track "${trackName}"`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the ID of the track that was/will be removed
|
||||
*/
|
||||
public getTrackId(): number {
|
||||
return this.trackId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the removed track instance (only available after execute)
|
||||
*/
|
||||
public getRemovedTrack(): KGTrack | null {
|
||||
return this.removedTrack;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { KGCommand } from '../KGCommand';
|
||||
import { KGCore } from '../../KGCore';
|
||||
import { KGTrack } from '../../track/KGTrack';
|
||||
|
||||
/**
|
||||
* Command to reorder tracks in the project
|
||||
* Handles moving a track from one position to another with undo support
|
||||
*/
|
||||
export class ReorderTracksCommand extends KGCommand {
|
||||
private sourceIndex: number;
|
||||
private destinationIndex: number;
|
||||
private originalTrackOrder: KGTrack[] = [];
|
||||
|
||||
constructor(sourceIndex: number, destinationIndex: number) {
|
||||
super();
|
||||
this.sourceIndex = sourceIndex;
|
||||
this.destinationIndex = destinationIndex;
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
const core = KGCore.instance();
|
||||
const currentProject = core.getCurrentProject();
|
||||
const tracks = currentProject.getTracks();
|
||||
|
||||
// Store original track order for undo
|
||||
this.originalTrackOrder = [...tracks];
|
||||
|
||||
// Validate indices
|
||||
if (this.sourceIndex < 0 || this.sourceIndex >= tracks.length) {
|
||||
throw new Error(`Invalid source index: ${this.sourceIndex}`);
|
||||
}
|
||||
if (this.destinationIndex < 0 || this.destinationIndex >= tracks.length) {
|
||||
throw new Error(`Invalid destination index: ${this.destinationIndex}`);
|
||||
}
|
||||
|
||||
// If source and destination are the same, do nothing
|
||||
if (this.sourceIndex === this.destinationIndex) {
|
||||
console.log('Source and destination indices are the same, no reordering needed');
|
||||
return;
|
||||
}
|
||||
|
||||
// Create a copy of the tracks array
|
||||
const updatedTracks = [...tracks];
|
||||
|
||||
// Remove the track from the source index
|
||||
const [movedTrack] = updatedTracks.splice(this.sourceIndex, 1);
|
||||
|
||||
// Insert the track at the destination index
|
||||
updatedTracks.splice(this.destinationIndex, 0, movedTrack);
|
||||
|
||||
// Update trackIndex for all tracks to match their array indices
|
||||
updatedTracks.forEach((track, index) => {
|
||||
track.setTrackIndex(index);
|
||||
});
|
||||
|
||||
// Update the core model
|
||||
currentProject.setTracks(updatedTracks);
|
||||
|
||||
const movedTrackName = movedTrack.getName();
|
||||
console.log(`Reordered track "${movedTrackName}" from index ${this.sourceIndex} to ${this.destinationIndex}`);
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
if (this.originalTrackOrder.length === 0) {
|
||||
throw new Error('Cannot undo: no original track order stored');
|
||||
}
|
||||
|
||||
const core = KGCore.instance();
|
||||
const currentProject = core.getCurrentProject();
|
||||
|
||||
// Restore the original track order
|
||||
const restoredTracks = [...this.originalTrackOrder];
|
||||
|
||||
// Update trackIndex for all tracks to match their array indices
|
||||
restoredTracks.forEach((track, index) => {
|
||||
track.setTrackIndex(index);
|
||||
});
|
||||
|
||||
// Update the core model
|
||||
currentProject.setTracks(restoredTracks);
|
||||
|
||||
console.log(`Restored original track order (undoing reorder from ${this.sourceIndex} to ${this.destinationIndex})`);
|
||||
}
|
||||
|
||||
getDescription(): string {
|
||||
if (this.originalTrackOrder.length > 0) {
|
||||
const movedTrack = this.originalTrackOrder[this.sourceIndex];
|
||||
const trackName = movedTrack ? movedTrack.getName() : `Track ${this.sourceIndex}`;
|
||||
return `Reorder track "${trackName}" from position ${this.sourceIndex + 1} to ${this.destinationIndex + 1}`;
|
||||
}
|
||||
return `Reorder track from position ${this.sourceIndex + 1} to ${this.destinationIndex + 1}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the source index of the reorder operation
|
||||
*/
|
||||
public getSourceIndex(): number {
|
||||
return this.sourceIndex;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the destination index of the reorder operation
|
||||
*/
|
||||
public getDestinationIndex(): number {
|
||||
return this.destinationIndex;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the original track order (only available after execute)
|
||||
*/
|
||||
public getOriginalTrackOrder(): KGTrack[] {
|
||||
return this.originalTrackOrder;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
import { KGCommand } from '../KGCommand';
|
||||
import { KGCore } from '../../KGCore';
|
||||
import { KGTrack, TrackType } from '../../track/KGTrack';
|
||||
import { KGMidiTrack, type InstrumentType } from '../../track/KGMidiTrack';
|
||||
import { KGAudioInterface } from '../../audio-interface/KGAudioInterface';
|
||||
|
||||
/**
|
||||
* Interface defining properties that can be updated on a track
|
||||
*/
|
||||
export interface TrackUpdateProperties {
|
||||
name?: string;
|
||||
instrument?: InstrumentType; // Only applies to MIDI tracks
|
||||
type?: TrackType;
|
||||
volume?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Command to update track properties
|
||||
* Handles updating track name, instrument, and type with undo support
|
||||
*/
|
||||
export class UpdateTrackCommand extends KGCommand {
|
||||
private trackId: number;
|
||||
private newProperties: TrackUpdateProperties;
|
||||
private originalProperties: TrackUpdateProperties = {};
|
||||
private targetTrack: KGTrack | null = null;
|
||||
private changedProperties: Set<keyof TrackUpdateProperties> = new Set();
|
||||
|
||||
constructor(trackId: number, properties: TrackUpdateProperties) {
|
||||
super();
|
||||
this.trackId = trackId;
|
||||
this.newProperties = properties;
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
const core = KGCore.instance();
|
||||
const currentProject = core.getCurrentProject();
|
||||
const tracks = currentProject.getTracks();
|
||||
|
||||
// Find the target track
|
||||
this.targetTrack = tracks.find(track => track.getId() === this.trackId) || null;
|
||||
if (!this.targetTrack) {
|
||||
throw new Error(`Track with ID ${this.trackId} not found`);
|
||||
}
|
||||
|
||||
// Store original properties for undo
|
||||
this.originalProperties = {
|
||||
name: this.targetTrack.getName(),
|
||||
type: this.targetTrack.getType(),
|
||||
volume: this.targetTrack.getVolume(),
|
||||
};
|
||||
|
||||
// Store original instrument if it's a MIDI track
|
||||
if (this.targetTrack instanceof KGMidiTrack) {
|
||||
this.originalProperties.instrument = this.targetTrack.getInstrument();
|
||||
}
|
||||
|
||||
// Apply updates and track what actually changes
|
||||
const updatedProperties: string[] = [];
|
||||
|
||||
// Update name
|
||||
if (this.newProperties.name !== undefined && this.newProperties.name !== this.originalProperties.name) {
|
||||
this.targetTrack.setName(this.newProperties.name);
|
||||
this.changedProperties.add('name');
|
||||
updatedProperties.push(`name: "${this.originalProperties.name}" → "${this.newProperties.name}"`);
|
||||
}
|
||||
|
||||
// Update type
|
||||
if (this.newProperties.type !== undefined && this.newProperties.type !== this.originalProperties.type) {
|
||||
this.targetTrack.setType(this.newProperties.type);
|
||||
this.changedProperties.add('type');
|
||||
updatedProperties.push(`type: ${this.originalProperties.type} → ${this.newProperties.type}`);
|
||||
}
|
||||
|
||||
// Update instrument (only for MIDI tracks)
|
||||
if (this.newProperties.instrument !== undefined && this.targetTrack instanceof KGMidiTrack) {
|
||||
const originalInstrument = this.originalProperties.instrument;
|
||||
if (this.newProperties.instrument !== originalInstrument) {
|
||||
// Update the track model
|
||||
this.targetTrack.setInstrument(this.newProperties.instrument);
|
||||
|
||||
// Update the audio interface
|
||||
const audioInterface = KGAudioInterface.instance();
|
||||
audioInterface.setTrackInstrument(this.trackId.toString(), this.newProperties.instrument);
|
||||
|
||||
this.changedProperties.add('instrument');
|
||||
updatedProperties.push(`instrument: ${originalInstrument} → ${this.newProperties.instrument}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Update volume
|
||||
if (this.newProperties.volume !== undefined && this.newProperties.volume !== this.originalProperties.volume) {
|
||||
const newVolume = this.newProperties.volume;
|
||||
const originalVolume = this.originalProperties.volume;
|
||||
|
||||
// Update the track model
|
||||
this.targetTrack.setVolume(newVolume);
|
||||
|
||||
// Update the audio interface
|
||||
const audioInterface = KGAudioInterface.instance();
|
||||
audioInterface.setTrackVolume(this.trackId.toString(), newVolume);
|
||||
|
||||
this.changedProperties.add('volume');
|
||||
updatedProperties.push(`volume: ${originalVolume} → ${newVolume}`);
|
||||
}
|
||||
|
||||
if (updatedProperties.length > 0) {
|
||||
console.log(`Updated track ${this.trackId}: ${updatedProperties.join(', ')}`);
|
||||
} else {
|
||||
console.log(`No changes applied to track ${this.trackId}`);
|
||||
}
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
if (!this.targetTrack) {
|
||||
throw new Error('Cannot undo: no track was updated');
|
||||
}
|
||||
|
||||
// Only restore properties that were actually changed
|
||||
const restoredProperties: string[] = [];
|
||||
|
||||
// Restore name (only if it was changed)
|
||||
if (this.changedProperties.has('name') && this.originalProperties.name !== undefined) {
|
||||
this.targetTrack.setName(this.originalProperties.name);
|
||||
restoredProperties.push(`name: "${this.originalProperties.name}"`);
|
||||
}
|
||||
|
||||
// Restore type (only if it was changed)
|
||||
if (this.changedProperties.has('type') && this.originalProperties.type !== undefined) {
|
||||
this.targetTrack.setType(this.originalProperties.type);
|
||||
restoredProperties.push(`type: ${this.originalProperties.type}`);
|
||||
}
|
||||
|
||||
// Restore instrument (only if it was changed and track is MIDI)
|
||||
if (this.changedProperties.has('instrument') &&
|
||||
this.originalProperties.instrument !== undefined &&
|
||||
this.targetTrack instanceof KGMidiTrack) {
|
||||
// Update the track model
|
||||
this.targetTrack.setInstrument(this.originalProperties.instrument);
|
||||
|
||||
// Update the audio interface
|
||||
const audioInterface = KGAudioInterface.instance();
|
||||
audioInterface.setTrackInstrument(this.trackId.toString(), this.originalProperties.instrument);
|
||||
|
||||
restoredProperties.push(`instrument: ${this.originalProperties.instrument}`);
|
||||
}
|
||||
|
||||
// Restore volume (only if it was changed)
|
||||
if (this.changedProperties.has('volume') && this.originalProperties.volume !== undefined) {
|
||||
// Update the track model
|
||||
this.targetTrack.setVolume(this.originalProperties.volume);
|
||||
|
||||
// Update the audio interface
|
||||
const audioInterface = KGAudioInterface.instance();
|
||||
audioInterface.setTrackVolume(this.trackId.toString(), this.originalProperties.volume);
|
||||
|
||||
restoredProperties.push(`volume: ${this.originalProperties.volume}`);
|
||||
}
|
||||
|
||||
console.log(`Restored track ${this.trackId}: ${restoredProperties.join(', ')}`);
|
||||
}
|
||||
|
||||
getDescription(): string {
|
||||
const trackName = this.originalProperties.name || `Track ${this.trackId}`;
|
||||
const updatedProps: string[] = [];
|
||||
|
||||
if (this.newProperties.name !== undefined) {
|
||||
updatedProps.push('name');
|
||||
}
|
||||
if (this.newProperties.instrument !== undefined) {
|
||||
updatedProps.push('instrument');
|
||||
}
|
||||
if (this.newProperties.type !== undefined) {
|
||||
updatedProps.push('type');
|
||||
}
|
||||
if (this.newProperties.volume !== undefined) {
|
||||
updatedProps.push('volume');
|
||||
}
|
||||
|
||||
if (updatedProps.length === 1) {
|
||||
return `Update track "${trackName}" ${updatedProps[0]}`;
|
||||
} else if (updatedProps.length > 1) {
|
||||
return `Update track "${trackName}" properties`;
|
||||
}
|
||||
|
||||
return `Update track "${trackName}"`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the ID of the track being updated
|
||||
*/
|
||||
public getTrackId(): number {
|
||||
return this.trackId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the new properties being applied
|
||||
*/
|
||||
public getNewProperties(): TrackUpdateProperties {
|
||||
return this.newProperties;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the original properties (only available after execute)
|
||||
*/
|
||||
public getOriginalProperties(): TrackUpdateProperties {
|
||||
return this.originalProperties;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the target track instance (only available after execute)
|
||||
*/
|
||||
public getTargetTrack(): KGTrack | null {
|
||||
return this.targetTrack;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the properties that were actually changed (only available after execute)
|
||||
*/
|
||||
public getChangedProperties(): Set<keyof TrackUpdateProperties> {
|
||||
return new Set(this.changedProperties);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,489 @@
|
||||
import { KGStorage } from '../io/KGStorage';
|
||||
import { DB_CONSTANTS } from '../../constants/coreConstants';
|
||||
|
||||
/**
|
||||
* Application configuration interface
|
||||
*/
|
||||
interface AppConfig {
|
||||
general: {
|
||||
language: string;
|
||||
llm_provider: 'openai' | 'gemini' | 'claude' | 'openai_compatible';
|
||||
openai: {
|
||||
api_key: string;
|
||||
flex: boolean;
|
||||
model: string;
|
||||
};
|
||||
gemini: {
|
||||
api_key: string;
|
||||
model: string;
|
||||
};
|
||||
claude: {
|
||||
api_key: string;
|
||||
model: string;
|
||||
};
|
||||
openai_compatible: {
|
||||
api_key: string;
|
||||
base_url: string;
|
||||
model: string;
|
||||
};
|
||||
soundfont: {
|
||||
base_url: string;
|
||||
};
|
||||
};
|
||||
hotkeys: {
|
||||
main: {
|
||||
hold_to_create_region: string;
|
||||
play: string;
|
||||
undo: string;
|
||||
redo: string;
|
||||
copy: string;
|
||||
cut: string;
|
||||
paste: string;
|
||||
save: string;
|
||||
};
|
||||
piano_roll: {
|
||||
select: string;
|
||||
pencil: string;
|
||||
hold_to_create_note: string;
|
||||
snap_none: string;
|
||||
snap_1_4: string;
|
||||
snap_1_8: string;
|
||||
snap_1_16: string;
|
||||
qua_pos_1_4: string;
|
||||
qua_pos_1_8: string;
|
||||
qua_pos_1_16: string;
|
||||
qua_len_1_4: string;
|
||||
qua_len_1_8: string;
|
||||
qua_len_1_16: string;
|
||||
};
|
||||
};
|
||||
chatbox: {
|
||||
default_open: boolean;
|
||||
};
|
||||
templates: {
|
||||
custom_instructions: string;
|
||||
};
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* ConfigManager - Manages application configuration with IndexedDB persistence
|
||||
* Implements the singleton pattern for global access
|
||||
*/
|
||||
export class ConfigManager {
|
||||
// Private static instance for singleton pattern
|
||||
private static _instance: ConfigManager | null = null;
|
||||
|
||||
// Configuration key for storage
|
||||
private static readonly CONFIG_KEY = 'userConfig';
|
||||
|
||||
// Configuration state
|
||||
private config: AppConfig;
|
||||
private storage: KGStorage;
|
||||
private isInitialized: boolean = false;
|
||||
private defaultConfig: AppConfig | null = null;
|
||||
|
||||
// Private constructor to prevent direct instantiation
|
||||
private constructor() {
|
||||
// Initialize with empty config, will be loaded during initialize()
|
||||
this.config = {} as AppConfig;
|
||||
this.storage = KGStorage.getInstance();
|
||||
console.log('ConfigManager initialized');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the singleton instance of ConfigManager
|
||||
* Creates the instance if it doesn't exist yet
|
||||
*/
|
||||
public static instance(): ConfigManager {
|
||||
if (!ConfigManager._instance) {
|
||||
ConfigManager._instance = new ConfigManager();
|
||||
}
|
||||
return ConfigManager._instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the config manager and load saved configuration
|
||||
*/
|
||||
public async initialize(): Promise<void> {
|
||||
if (this.isInitialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Load default configuration from JSON file
|
||||
await this.loadDefaultConfig();
|
||||
|
||||
// Load saved configuration from storage
|
||||
const savedConfig = await this.loadFromStorage();
|
||||
|
||||
// Merge with default config (saved config overrides defaults)
|
||||
this.config = this.mergeConfigs(this.defaultConfig!, savedConfig);
|
||||
|
||||
this.isInitialized = true;
|
||||
console.log('ConfigManager initialized successfully with config:', this.config);
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize ConfigManager:', error);
|
||||
// Fall back to default config if initialization fails
|
||||
if (this.defaultConfig) {
|
||||
this.config = { ...this.defaultConfig };
|
||||
}
|
||||
this.isInitialized = true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load default configuration from config.json file
|
||||
*/
|
||||
private async loadDefaultConfig(): Promise<void> {
|
||||
try {
|
||||
const response = await fetch('/config.json');
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch config.json: ${response.status}`);
|
||||
}
|
||||
|
||||
const configData = await response.json();
|
||||
this.defaultConfig = configData as AppConfig;
|
||||
console.log('Loaded default config from config.json:', this.defaultConfig);
|
||||
} catch (error) {
|
||||
console.error('Error loading default config from config.json:', error);
|
||||
|
||||
// Fallback to minimal hardcoded config
|
||||
this.defaultConfig = {
|
||||
general: {
|
||||
language: 'en_us',
|
||||
llm_provider: 'openai',
|
||||
openai: {
|
||||
api_key: '',
|
||||
flex: false,
|
||||
model: 'gpt-4o'
|
||||
},
|
||||
gemini: {
|
||||
api_key: '',
|
||||
model: 'gemini-2.5-flash'
|
||||
},
|
||||
claude: {
|
||||
api_key: '',
|
||||
model: 'claude-sonnet-4-0'
|
||||
},
|
||||
openai_compatible: {
|
||||
api_key: '',
|
||||
base_url: '',
|
||||
model: ''
|
||||
},
|
||||
soundfont: {
|
||||
base_url: 'https://cdn.jsdelivr.net/npm/soundfont-for-samplers/FluidR3_GM/'
|
||||
}
|
||||
},
|
||||
hotkeys: {
|
||||
main: {
|
||||
hold_to_create_region: 'ctrl',
|
||||
play: 'space',
|
||||
undo: 'ctrl+z',
|
||||
redo: 'ctrl+shift+z',
|
||||
copy: 'ctrl+c',
|
||||
cut: 'ctrl+x',
|
||||
paste: 'ctrl+v',
|
||||
save: 'ctrl+s'
|
||||
},
|
||||
piano_roll: {
|
||||
select: 'q',
|
||||
pencil: 'w',
|
||||
hold_to_create_note: 'ctrl',
|
||||
snap_none: '1',
|
||||
snap_1_4: '2',
|
||||
snap_1_8: '3',
|
||||
snap_1_16: '4',
|
||||
qua_pos_1_4: '5',
|
||||
qua_pos_1_8: '6',
|
||||
qua_pos_1_16: '7',
|
||||
qua_len_1_4: '8',
|
||||
qua_len_1_8: '9',
|
||||
qua_len_1_16: '0'
|
||||
},
|
||||
},
|
||||
chatbox: {
|
||||
default_open: true
|
||||
},
|
||||
templates: {
|
||||
custom_instructions: ''
|
||||
}
|
||||
};
|
||||
console.log('Using fallback hardcoded config due to load error');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load configuration from storage
|
||||
*/
|
||||
private async loadFromStorage(): Promise<Partial<AppConfig> | null> {
|
||||
try {
|
||||
// For config, we don't use class-transformer since it's plain objects
|
||||
// So we'll use a simple object approach and handle it directly with KGStorage
|
||||
const savedConfigData = await this.storage.load(
|
||||
DB_CONSTANTS.DB_NAME,
|
||||
DB_CONSTANTS.CONFIG_STORE_NAME,
|
||||
ConfigManager.CONFIG_KEY,
|
||||
Object, // Simple object class
|
||||
DB_CONSTANTS.DB_VERSION
|
||||
);
|
||||
|
||||
if (savedConfigData) {
|
||||
console.log('Loaded config from storage:', savedConfigData);
|
||||
return savedConfigData as Partial<AppConfig>;
|
||||
} else {
|
||||
console.log('No saved config found in storage, using defaults');
|
||||
return null;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading config from storage:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save configuration to storage
|
||||
*/
|
||||
private async saveToStorage(): Promise<void> {
|
||||
try {
|
||||
// For security: if not running on a local host, do not persist API keys.
|
||||
// We still keep them in memory (this.config) for runtime usage.
|
||||
const configToPersist = this.isRunningOnLocalhost()
|
||||
? this.config
|
||||
: this.getSanitizedConfigForStorage();
|
||||
|
||||
await this.storage.save(
|
||||
DB_CONSTANTS.DB_NAME,
|
||||
DB_CONSTANTS.CONFIG_STORE_NAME,
|
||||
ConfigManager.CONFIG_KEY,
|
||||
configToPersist,
|
||||
true, // Always overwrite config
|
||||
DB_CONSTANTS.DB_VERSION
|
||||
);
|
||||
console.log('Saved config to storage:', configToPersist);
|
||||
} catch (error) {
|
||||
console.error('Error saving config to storage:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deep merge two configuration objects recursively
|
||||
* The override config takes precedence over the base config
|
||||
* Only existing keys in the override are merged, preserving all base config structure
|
||||
*/
|
||||
private mergeConfigs(baseConfig: AppConfig, overrideConfig: Partial<AppConfig> | null): AppConfig {
|
||||
if (!overrideConfig) {
|
||||
return { ...baseConfig };
|
||||
}
|
||||
|
||||
return this.deepMerge(baseConfig, overrideConfig) as AppConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively deep merge two objects
|
||||
* Only overwrites primitive values, recursively merges objects
|
||||
*/
|
||||
private deepMerge(base: Record<string, unknown>, override: Record<string, unknown>): Record<string, unknown> {
|
||||
const result = { ...base };
|
||||
|
||||
Object.keys(override).forEach(key => {
|
||||
const overrideValue = override[key];
|
||||
const baseValue = result[key];
|
||||
|
||||
if (overrideValue === null || overrideValue === undefined) {
|
||||
// Skip null/undefined values in override
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof overrideValue === 'object' && !Array.isArray(overrideValue)) {
|
||||
// Both values are objects - recursively merge
|
||||
if (typeof baseValue === 'object' && baseValue !== null && !Array.isArray(baseValue)) {
|
||||
result[key] = this.deepMerge(baseValue as Record<string, unknown>, overrideValue as Record<string, unknown>);
|
||||
} else {
|
||||
// Base value is not an object, use override value as-is
|
||||
result[key] = { ...(overrideValue as Record<string, unknown>) };
|
||||
}
|
||||
} else {
|
||||
// Override value is primitive - use it directly
|
||||
result[key] = overrideValue;
|
||||
}
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a configuration value using dot notation
|
||||
* Example: get('general.language') returns 'en_us'
|
||||
*/
|
||||
public get(key: string): unknown {
|
||||
if (!this.isInitialized) {
|
||||
console.warn('ConfigManager not initialized, returning default value');
|
||||
if (this.defaultConfig) {
|
||||
return this.getFromObject(this.defaultConfig as Record<string, unknown>, key);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return this.getFromObject(this.config as Record<string, unknown>, key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a configuration value using dot notation and save to storage
|
||||
* Example: set('general.language', 'fr_fr')
|
||||
*/
|
||||
public async set(key: string, value: unknown): Promise<void> {
|
||||
if (!this.isInitialized) {
|
||||
throw new Error('ConfigManager not initialized');
|
||||
}
|
||||
|
||||
// Update the configuration object
|
||||
this.setInObject(this.config as Record<string, unknown>, key, value);
|
||||
|
||||
// Save to storage
|
||||
await this.saveToStorage();
|
||||
|
||||
console.log(`Config updated: ${key} = ${value}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update multiple configuration values at once
|
||||
*/
|
||||
public async update(updates: Partial<AppConfig>): Promise<void> {
|
||||
if (!this.isInitialized) {
|
||||
throw new Error('ConfigManager not initialized');
|
||||
}
|
||||
|
||||
// Merge updates with current config
|
||||
this.config = this.mergeConfigs(this.config, updates);
|
||||
|
||||
// Save to storage
|
||||
await this.saveToStorage();
|
||||
|
||||
console.log('Config updated with multiple values:', updates);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset configuration to defaults
|
||||
*/
|
||||
public async resetToDefaults(): Promise<void> {
|
||||
if (!this.isInitialized) {
|
||||
throw new Error('ConfigManager not initialized');
|
||||
}
|
||||
|
||||
if (!this.defaultConfig) {
|
||||
throw new Error('Default config not loaded');
|
||||
}
|
||||
|
||||
this.config = { ...this.defaultConfig };
|
||||
await this.saveToStorage();
|
||||
|
||||
console.log('Config reset to defaults');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the entire configuration object (read-only copy)
|
||||
*/
|
||||
public getAll(): Readonly<AppConfig> {
|
||||
return { ...this.config };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a value from an object using dot notation
|
||||
*/
|
||||
private getFromObject(obj: Record<string, unknown>, path: string): unknown {
|
||||
const keys = path.split('.');
|
||||
let current: unknown = obj;
|
||||
|
||||
for (const key of keys) {
|
||||
if (current && typeof current === 'object' && key in (current as Record<string, unknown>)) {
|
||||
current = (current as Record<string, unknown>)[key];
|
||||
} else {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
return current;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a value in an object using dot notation
|
||||
*/
|
||||
private setInObject(obj: Record<string, unknown>, path: string, value: unknown): void {
|
||||
const keys = path.split('.');
|
||||
let current: Record<string, unknown> = obj;
|
||||
|
||||
// Navigate to the parent of the target key
|
||||
for (let i = 0; i < keys.length - 1; i++) {
|
||||
const key = keys[i];
|
||||
if (!(key in current) || typeof current[key] !== 'object') {
|
||||
current[key] = {};
|
||||
}
|
||||
current = current[key] as Record<string, unknown>;
|
||||
}
|
||||
|
||||
// Set the final value
|
||||
current[keys[keys.length - 1]] = value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if ConfigManager is initialized
|
||||
*/
|
||||
public getIsInitialized(): boolean {
|
||||
return this.isInitialized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the app is being served from a local host
|
||||
* Consider localhost, 127.0.0.1, ::1, and 0.0.0.0 as local.
|
||||
*/
|
||||
private isRunningOnLocalhost(): boolean {
|
||||
try {
|
||||
if (typeof window === 'undefined' || typeof window.location === 'undefined') {
|
||||
return false;
|
||||
}
|
||||
const { protocol, hostname } = window.location;
|
||||
if (protocol === 'file:') return true; // treat file protocol as local usage
|
||||
const localHosts = new Set(['localhost', '127.0.0.1', '::1', '0.0.0.0']);
|
||||
return localHosts.has(hostname);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a deep-copied config with all provider API keys scrubbed (empty strings)
|
||||
* for persistence to storage in non-local environments.
|
||||
*/
|
||||
private getSanitizedConfigForStorage(): AppConfig {
|
||||
// Deep copy to avoid mutating in-memory config
|
||||
const copied: AppConfig = JSON.parse(JSON.stringify(this.config));
|
||||
if (copied?.general) {
|
||||
if (copied.general.openai) copied.general.openai.api_key = '';
|
||||
if (copied.general.gemini) copied.general.gemini.api_key = '';
|
||||
if (copied.general.claude) copied.general.claude.api_key = '';
|
||||
if (copied.general.openai_compatible) copied.general.openai_compatible.api_key = '';
|
||||
}
|
||||
return copied;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the default configuration
|
||||
*/
|
||||
public getDefaults(): Readonly<AppConfig> | null {
|
||||
return this.defaultConfig ? { ...this.defaultConfig } : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up resources
|
||||
*/
|
||||
public async dispose(): Promise<void> {
|
||||
try {
|
||||
this.isInitialized = false;
|
||||
console.log('ConfigManager disposed successfully');
|
||||
} catch (error) {
|
||||
console.error('Error disposing ConfigManager:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { ConfigManager } from './ConfigManager';
|
||||
@@ -0,0 +1,132 @@
|
||||
// src/core/io/KGStorage.ts
|
||||
|
||||
import { openDB } from 'idb'
|
||||
import type { IDBPDatabase } from 'idb'
|
||||
import { plainToClass, instanceToPlain } from 'class-transformer'
|
||||
import { DB_CONSTANTS } from '../../constants/coreConstants'
|
||||
|
||||
export interface StorageEntry {
|
||||
name: string
|
||||
data: Record<string, unknown>
|
||||
lastModified: number
|
||||
}
|
||||
|
||||
export class DuplicateEntryError extends Error {
|
||||
constructor(name: string) {
|
||||
super(`Entry "${name}" already exists`)
|
||||
this.name = 'DuplicateEntryError'
|
||||
}
|
||||
}
|
||||
|
||||
export class KGStorage {
|
||||
private static instance: KGStorage
|
||||
private dbPromises: Map<string, Promise<IDBPDatabase>>
|
||||
|
||||
private constructor() {
|
||||
this.dbPromises = new Map()
|
||||
}
|
||||
|
||||
public static getInstance(): KGStorage {
|
||||
if (!KGStorage.instance) {
|
||||
KGStorage.instance = new KGStorage()
|
||||
}
|
||||
return KGStorage.instance
|
||||
}
|
||||
|
||||
private getDB(dbName: string, _storeName: string, version: number = 1): Promise<IDBPDatabase> {
|
||||
const key = `${dbName}_${version}`
|
||||
|
||||
if (!this.dbPromises.has(key)) {
|
||||
const dbPromise = openDB(dbName, version, {
|
||||
upgrade(db) {
|
||||
// Create all required object stores for this database
|
||||
const requiredStores = [
|
||||
DB_CONSTANTS.PROJECTS_STORE_NAME,
|
||||
DB_CONSTANTS.CONFIG_STORE_NAME
|
||||
];
|
||||
|
||||
for (const store of requiredStores) {
|
||||
if (!db.objectStoreNames.contains(store)) {
|
||||
db.createObjectStore(store, { keyPath: 'name' })
|
||||
console.log(`Created object store: ${store}`)
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
this.dbPromises.set(key, dbPromise)
|
||||
}
|
||||
|
||||
return this.dbPromises.get(key)!
|
||||
}
|
||||
|
||||
public async save<T>(
|
||||
dbName: string,
|
||||
storeName: string,
|
||||
name: string,
|
||||
data: T,
|
||||
overwrite: boolean = false,
|
||||
version: number = 1
|
||||
): Promise<void> {
|
||||
const db = await this.getDB(dbName, storeName, version)
|
||||
const existing = await db.get(storeName, name)
|
||||
if (existing && !overwrite) {
|
||||
throw new DuplicateEntryError(name)
|
||||
}
|
||||
const entry: StorageEntry = {
|
||||
name: name,
|
||||
data: instanceToPlain(data) as Record<string, unknown>,
|
||||
lastModified: Date.now(),
|
||||
}
|
||||
await db.put(storeName, entry)
|
||||
}
|
||||
|
||||
public async load<T>(
|
||||
dbName: string,
|
||||
storeName: string,
|
||||
name: string,
|
||||
classType: new() => T,
|
||||
version: number = 1
|
||||
): Promise<T | null> {
|
||||
try {
|
||||
const db = await this.getDB(dbName, storeName, version)
|
||||
const entry = await db.get(storeName, name)
|
||||
|
||||
if (!entry?.data) {
|
||||
console.log(`No data found for entry "${name}" in store "${storeName}" of database "${dbName}"`)
|
||||
return null
|
||||
}
|
||||
|
||||
const instance = plainToClass(classType, entry.data)
|
||||
const loadedInstance = Array.isArray(instance) ? instance[0] || null : instance
|
||||
|
||||
if (loadedInstance && typeof (loadedInstance as { setName?: (projectName: string) => void }).setName === 'function') {
|
||||
(loadedInstance as { setName: (projectName: string) => void }).setName(name)
|
||||
}
|
||||
|
||||
return loadedInstance
|
||||
} catch (error) {
|
||||
console.log(`Error loading entry "${name}" from store "${storeName}" of database "${dbName}":`, error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
public async list(
|
||||
dbName: string,
|
||||
storeName: string,
|
||||
version: number = 1
|
||||
): Promise<string[]> {
|
||||
const db = await this.getDB(dbName, storeName, version)
|
||||
const all = await db.getAllKeys(storeName)
|
||||
return all as string[]
|
||||
}
|
||||
|
||||
public async delete(
|
||||
dbName: string,
|
||||
storeName: string,
|
||||
name: string,
|
||||
version: number = 1
|
||||
): Promise<void> {
|
||||
const db = await this.getDB(dbName, storeName, version)
|
||||
await db.delete(storeName, name)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { Expose } from 'class-transformer';
|
||||
import type { Selectable } from '../../components/interfaces';
|
||||
|
||||
/**
|
||||
* KGMidiNote - Class representing a MIDI note in the DAW
|
||||
* Contains note timing, pitch and volume information
|
||||
*/
|
||||
export class KGMidiNote implements Selectable {
|
||||
@Expose()
|
||||
private id: string = '';
|
||||
|
||||
@Expose()
|
||||
private startBeat: number = 0;
|
||||
|
||||
@Expose()
|
||||
private endBeat: number = 0;
|
||||
|
||||
@Expose()
|
||||
private pitch: number = 0;
|
||||
|
||||
@Expose()
|
||||
private velocity: number = 127;
|
||||
|
||||
@Expose()
|
||||
private selected: boolean = false;
|
||||
|
||||
constructor(id: string, startBeat: number = 0, endBeat: number = 0, pitch: number = 0, velocity: number = 127) {
|
||||
this.id = id;
|
||||
this.startBeat = startBeat;
|
||||
this.endBeat = endBeat;
|
||||
this.pitch = pitch;
|
||||
this.velocity = velocity;
|
||||
}
|
||||
|
||||
// Getters
|
||||
public getId(): string {
|
||||
return this.id;
|
||||
}
|
||||
|
||||
public getStartBeat(): number {
|
||||
return this.startBeat;
|
||||
}
|
||||
|
||||
public getEndBeat(): number {
|
||||
return this.endBeat;
|
||||
}
|
||||
|
||||
public getPitch(): number {
|
||||
return this.pitch;
|
||||
}
|
||||
|
||||
public getVelocity(): number {
|
||||
return this.velocity;
|
||||
}
|
||||
|
||||
// Setters
|
||||
public setId(id: string): void {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
public setStartBeat(startBeat: number): void {
|
||||
this.startBeat = startBeat;
|
||||
}
|
||||
|
||||
public setEndBeat(endBeat: number): void {
|
||||
this.endBeat = endBeat;
|
||||
}
|
||||
|
||||
public setPitch(pitch: number): void {
|
||||
this.pitch = pitch;
|
||||
}
|
||||
|
||||
public setVelocity(velocity: number): void {
|
||||
this.velocity = velocity;
|
||||
}
|
||||
|
||||
// Selectable interface methods
|
||||
public select(): void {
|
||||
this.selected = true;
|
||||
}
|
||||
|
||||
public deselect(): void {
|
||||
this.selected = false;
|
||||
}
|
||||
|
||||
public isSelected(): boolean {
|
||||
return this.selected;
|
||||
}
|
||||
|
||||
// Type identification method for performance-optimized instanceof checks
|
||||
public getRootType(): string {
|
||||
return 'KGMidiNote';
|
||||
}
|
||||
|
||||
// Current type identification for copy/paste operations
|
||||
public getCurrentType(): string {
|
||||
return 'KGMidiNote';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { KGProject } from '../KGProject';
|
||||
import { upgradeToV1 } from './upgradeToV1';
|
||||
|
||||
/**
|
||||
* Upgrade the given project to the latest structure version, one version at a time.
|
||||
* This function is safe to call multiple times; it will no-op if up-to-date.
|
||||
*/
|
||||
export function upgradeProjectToLatest(project: KGProject): KGProject {
|
||||
if (!project) return project;
|
||||
|
||||
const currentVersion = project.getProjectStructureVersion?.() ?? 0;
|
||||
const targetVersion = KGProject.CURRENT_PROJECT_STRUCTURE_VERSION;
|
||||
|
||||
if (currentVersion >= targetVersion) {
|
||||
return project;
|
||||
}
|
||||
|
||||
let workingProject: KGProject = project;
|
||||
|
||||
for (let nextVersion = currentVersion + 1; nextVersion <= targetVersion; nextVersion++) {
|
||||
switch (nextVersion) {
|
||||
case 1: {
|
||||
workingProject = upgradeToV1(workingProject);
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
// If an upgrader is missing, throw to prevent loading incompatible structures
|
||||
throw new Error(`No upgrader found for project structure version ${nextVersion}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return workingProject;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { KGProject } from '../KGProject';
|
||||
import { KGTrack } from '../track/KGTrack';
|
||||
import { KGMidiTrack, type InstrumentType } from '../track/KGMidiTrack';
|
||||
|
||||
/**
|
||||
* Upgrade a project from structure version 0 to 1.
|
||||
* Keep logic minimal for now; future migrations should extend this.
|
||||
*/
|
||||
export function upgradeToV1(project: KGProject): KGProject {
|
||||
try {
|
||||
const tracks: KGTrack[] = project.getTracks();
|
||||
|
||||
const legacyToNewInstrumentMap: Record<string, InstrumentType> = {
|
||||
PIANO: 'acoustic_grand_piano',
|
||||
GUITAR: 'acoustic_guitar_nylon',
|
||||
BASS: 'electric_bass_finger',
|
||||
DRUMS: 'standard',
|
||||
} as const;
|
||||
|
||||
const isMidiTrack = (track: KGTrack): track is KGMidiTrack => {
|
||||
return track.getCurrentType() === 'KGMidiTrack';
|
||||
};
|
||||
|
||||
tracks.forEach(track => {
|
||||
if (!isMidiTrack(track)) return;
|
||||
|
||||
const currentInstrument = (track as KGMidiTrack).getInstrument() as unknown as string;
|
||||
const mapped = legacyToNewInstrumentMap[currentInstrument];
|
||||
if (mapped) {
|
||||
(track as KGMidiTrack).setInstrument(mapped);
|
||||
}
|
||||
});
|
||||
} finally {
|
||||
// Always set the project structure version to 1 to mark migration complete
|
||||
project.setProjectStructureVersion(1);
|
||||
}
|
||||
|
||||
return project;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { Expose, Type } from 'class-transformer';
|
||||
import { KGRegion } from './KGRegion';
|
||||
import { KGMidiNote } from '../midi/KGMidiNote';
|
||||
|
||||
/**
|
||||
* KGMidiRegion - Class representing a MIDI region in the DAW
|
||||
* Contains MIDI notes and inherits position/length from KGRegion
|
||||
*/
|
||||
export class KGMidiRegion extends KGRegion {
|
||||
@Expose()
|
||||
protected override __type: string = 'KGMidiRegion';
|
||||
|
||||
@Expose()
|
||||
@Type(() => KGMidiNote)
|
||||
protected notes: KGMidiNote[] = [];
|
||||
|
||||
constructor(id: string, trackId: string, trackIndex: number, name: string, startFromBeat: number = 0, length: number = 0) {
|
||||
super(id, trackId, trackIndex, name, startFromBeat, length);
|
||||
this.__type = 'KGMidiRegion';
|
||||
}
|
||||
|
||||
// Getter
|
||||
public getNotes(): KGMidiNote[] {
|
||||
return this.notes;
|
||||
}
|
||||
|
||||
// Setter
|
||||
public setNotes(notes: KGMidiNote[]): void {
|
||||
this.notes = notes;
|
||||
}
|
||||
|
||||
// Add a single note
|
||||
public addNote(note: KGMidiNote): void {
|
||||
this.notes.push(note);
|
||||
}
|
||||
|
||||
// Remove a note by ID
|
||||
public removeNote(noteId: string): void {
|
||||
this.notes = this.notes.filter(note => note.getId() !== noteId);
|
||||
}
|
||||
|
||||
// Override getCurrentType to return specific subclass type
|
||||
public override getCurrentType(): string {
|
||||
return 'KGMidiRegion';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { Expose } from 'class-transformer';
|
||||
import type { Selectable } from '../../components/interfaces';
|
||||
|
||||
/**
|
||||
* KGRegion - Base class for regions in the DAW
|
||||
* Contains position and length information
|
||||
*/
|
||||
export class KGRegion implements Selectable {
|
||||
@Expose()
|
||||
protected __type: string = 'KGRegion';
|
||||
|
||||
@Expose()
|
||||
protected id: string = '';
|
||||
|
||||
@Expose()
|
||||
protected trackId: string = '';
|
||||
|
||||
@Expose()
|
||||
protected trackIndex: number = 0;
|
||||
|
||||
@Expose()
|
||||
protected name: string = '';
|
||||
|
||||
@Expose()
|
||||
protected startFromBeat: number = 0;
|
||||
|
||||
@Expose()
|
||||
protected length: number = 0;
|
||||
|
||||
@Expose()
|
||||
protected selected: boolean = false;
|
||||
|
||||
constructor(id: string, trackId: string, trackIndex: number, name: string, startFromBeat: number = 0, length: number = 0) {
|
||||
this.id = id;
|
||||
this.trackId = trackId;
|
||||
this.trackIndex = trackIndex;
|
||||
this.name = name;
|
||||
this.startFromBeat = startFromBeat;
|
||||
this.length = length;
|
||||
|
||||
this.selected = false;
|
||||
}
|
||||
|
||||
// Getters
|
||||
public getId(): string {
|
||||
return this.id;
|
||||
}
|
||||
|
||||
public getTrackId(): string {
|
||||
return this.trackId;
|
||||
}
|
||||
|
||||
public getTrackIndex(): number {
|
||||
return this.trackIndex;
|
||||
}
|
||||
|
||||
public getName(): string {
|
||||
return this.name;
|
||||
}
|
||||
|
||||
public getStartFromBeat(): number {
|
||||
return this.startFromBeat;
|
||||
}
|
||||
|
||||
public getLength(): number {
|
||||
return this.length;
|
||||
}
|
||||
|
||||
// Setters
|
||||
public setId(id: string): void {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
public setTrackId(trackId: string): void {
|
||||
this.trackId = trackId;
|
||||
}
|
||||
|
||||
public setTrackIndex(trackIndex: number): void {
|
||||
this.trackIndex = trackIndex;
|
||||
}
|
||||
|
||||
public setName(name: string): void {
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
public setStartFromBeat(startFromBeat: number): void {
|
||||
this.startFromBeat = startFromBeat;
|
||||
}
|
||||
|
||||
public setLength(length: number): void {
|
||||
this.length = length;
|
||||
}
|
||||
|
||||
// interface methods
|
||||
public select(): void {
|
||||
this.selected = true;
|
||||
}
|
||||
|
||||
public deselect(): void {
|
||||
this.selected = false;
|
||||
}
|
||||
|
||||
public isSelected(): boolean {
|
||||
return this.selected;
|
||||
}
|
||||
|
||||
// Type identification method for performance-optimized instanceof checks
|
||||
public getRootType(): string {
|
||||
return 'KGRegion';
|
||||
}
|
||||
|
||||
// Current type identification for copy/paste operations
|
||||
public getCurrentType(): string {
|
||||
return 'KGRegion';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* KGMainContentState - State management for the main content area
|
||||
* Implements the singleton pattern for global access
|
||||
*/
|
||||
export class KGMainContentState {
|
||||
private static _instance: KGMainContentState | null = null;
|
||||
|
||||
private activeTool: string = "pointer";
|
||||
|
||||
private constructor() {
|
||||
console.log("KGMainContentState initialized");
|
||||
}
|
||||
|
||||
public static instance(): KGMainContentState {
|
||||
if (!KGMainContentState._instance) {
|
||||
KGMainContentState._instance = new KGMainContentState();
|
||||
}
|
||||
return KGMainContentState._instance;
|
||||
}
|
||||
|
||||
// Getters and setters
|
||||
public getActiveTool(): string {
|
||||
return this.activeTool;
|
||||
}
|
||||
|
||||
public setActiveTool(tool: string): void {
|
||||
this.activeTool = tool;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* KGPianoRollState - State management for the piano roll
|
||||
* Implements the singleton pattern for global access
|
||||
*/
|
||||
export class KGPianoRollState {
|
||||
private static _instance: KGPianoRollState | null = null;
|
||||
|
||||
public static SNAP_OPTIONS: string[] = ['NO SNAP', '1/3', '1/4', '1/6', '1/8', '1/12', '1/16', '1/24', '1/32'];
|
||||
public static QUANT_POS_OPTIONS: string[] = ['1/3', '1/4', '1/6', '1/8', '1/12', '1/16', '1/24', '1/32'];
|
||||
public static QUANT_LEN_OPTIONS: string[] = ['1/1', '1/2', '1/3', '1/4', '1/6', '1/8', '1/12', '1/16', '1/24', '1/32'];
|
||||
|
||||
private activeTool: string = "pointer";
|
||||
private currentSnap: string = "NO SNAP";
|
||||
private lastEditedNoteLength: number = 1; // Default to 1 beat
|
||||
|
||||
private constructor() {
|
||||
console.log("KGPianoRollState initialized");
|
||||
}
|
||||
|
||||
public static instance(): KGPianoRollState {
|
||||
if (!KGPianoRollState._instance) {
|
||||
KGPianoRollState._instance = new KGPianoRollState();
|
||||
}
|
||||
return KGPianoRollState._instance;
|
||||
}
|
||||
|
||||
// Getters and setters
|
||||
public getActiveTool(): string {
|
||||
return this.activeTool;
|
||||
}
|
||||
|
||||
public setActiveTool(tool: string): void {
|
||||
this.activeTool = tool;
|
||||
}
|
||||
|
||||
public getCurrentSnap(): string {
|
||||
return this.currentSnap;
|
||||
}
|
||||
|
||||
public setCurrentSnap(snap: string): void {
|
||||
this.currentSnap = snap;
|
||||
}
|
||||
|
||||
public getLastEditedNoteLength(): number {
|
||||
return this.lastEditedNoteLength;
|
||||
}
|
||||
|
||||
public setLastEditedNoteLength(length: number): void {
|
||||
this.lastEditedNoteLength = length;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { Expose, Type, Transform } from 'class-transformer';
|
||||
import { KGTrack, TrackType } from './KGTrack';
|
||||
import { KGMidiRegion } from '../region/KGMidiRegion';
|
||||
import { KGRegion } from '../region/KGRegion';
|
||||
import { FLUIDR3_INSTRUMENT_MAP } from '../../constants/generalMidiConstants';
|
||||
import { AUDIO_INTERFACE_CONSTANTS } from '../../constants/coreConstants';
|
||||
|
||||
export type InstrumentType = keyof typeof FLUIDR3_INSTRUMENT_MAP;
|
||||
|
||||
export class KGMidiTrack extends KGTrack {
|
||||
@Expose()
|
||||
protected override __type: string = 'KGMidiTrack';
|
||||
|
||||
@Expose()
|
||||
@Transform(({ value }) => value || 'acoustic_grand_piano', { toClassOnly: true })
|
||||
protected instrument: InstrumentType = 'acoustic_grand_piano';
|
||||
|
||||
@Expose()
|
||||
@Type(() => KGRegion, {
|
||||
discriminator: {
|
||||
property: '__type',
|
||||
subTypes: [
|
||||
{ value: KGRegion, name: 'KGRegion' },
|
||||
{ value: KGMidiRegion, name: 'KGMidiRegion' },
|
||||
],
|
||||
},
|
||||
})
|
||||
protected override regions: KGMidiRegion[] = [];
|
||||
|
||||
constructor(name: string = 'Untitled MIDI Track', id: number = 0, instrument: InstrumentType = 'acoustic_grand_piano', volume: number = AUDIO_INTERFACE_CONSTANTS.DEFAULT_TRACK_VOLUME) {
|
||||
super(name, id, TrackType.MIDI);
|
||||
this.__type = 'KGMidiTrack';
|
||||
this.instrument = instrument;
|
||||
this.volume = volume;
|
||||
}
|
||||
|
||||
// Override parent setRegions to enforce KGMidiRegion type
|
||||
public override setRegions(regions: KGMidiRegion[]): void {
|
||||
this.regions = regions;
|
||||
}
|
||||
|
||||
// Instrument getters and setters
|
||||
public getInstrument(): InstrumentType {
|
||||
// Backward compatibility: default to acoustic_grand_piano if undefined
|
||||
return this.instrument || 'acoustic_grand_piano';
|
||||
}
|
||||
|
||||
public setInstrument(instrument: InstrumentType): void {
|
||||
this.instrument = instrument;
|
||||
}
|
||||
|
||||
// Override getCurrentType to return specific subclass type
|
||||
public override getCurrentType(): string {
|
||||
return 'KGMidiTrack';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import { Expose, Type } from 'class-transformer';
|
||||
import { KGRegion } from '../region/KGRegion';
|
||||
import { KGMidiRegion } from '../region/KGMidiRegion';
|
||||
import { AUDIO_INTERFACE_CONSTANTS } from '../../constants/coreConstants';
|
||||
import { WithDefault } from '../../types/projectTypes';
|
||||
|
||||
// Track type enum
|
||||
export enum TrackType {
|
||||
MIDI = 'MIDI',
|
||||
Chords = 'Chords',
|
||||
Wave = 'Wave'
|
||||
}
|
||||
|
||||
/**
|
||||
* KGTrack - Class representing a track in the DAW
|
||||
* Contains track settings and data
|
||||
*/
|
||||
export class KGTrack {
|
||||
@Expose()
|
||||
protected __type: string = 'KGTrack';
|
||||
|
||||
@Expose()
|
||||
protected name: string = '';
|
||||
|
||||
@Expose()
|
||||
protected id: number = 0;
|
||||
|
||||
@Expose()
|
||||
protected trackIndex: number = 0;
|
||||
|
||||
@Expose()
|
||||
protected type: TrackType;
|
||||
|
||||
@Expose()
|
||||
@WithDefault(AUDIO_INTERFACE_CONSTANTS.DEFAULT_TRACK_VOLUME)
|
||||
protected volume: number = AUDIO_INTERFACE_CONSTANTS.DEFAULT_TRACK_VOLUME;
|
||||
|
||||
@Expose()
|
||||
@Type(() => KGRegion, {
|
||||
discriminator: {
|
||||
property: '__type',
|
||||
subTypes: [
|
||||
{ value: KGRegion, name: 'KGRegion' },
|
||||
{ value: KGMidiRegion, name: 'KGMidiRegion' },
|
||||
],
|
||||
},
|
||||
})
|
||||
protected regions: KGRegion[] = [];
|
||||
|
||||
constructor(name: string = 'Untitled Track', id: number = 0, type: TrackType = TrackType.MIDI, volume: number = AUDIO_INTERFACE_CONSTANTS.DEFAULT_TRACK_VOLUME) {
|
||||
this.name = name;
|
||||
this.id = id;
|
||||
this.type = type;
|
||||
this.volume = volume;
|
||||
}
|
||||
|
||||
// Getters
|
||||
public getName(): string {
|
||||
return this.name;
|
||||
}
|
||||
|
||||
public getId(): number {
|
||||
return this.id;
|
||||
}
|
||||
|
||||
public getTrackIndex(): number {
|
||||
return this.trackIndex;
|
||||
}
|
||||
|
||||
public getType(): TrackType {
|
||||
return this.type;
|
||||
}
|
||||
|
||||
public getRegions(): KGRegion[] {
|
||||
return this.regions;
|
||||
}
|
||||
|
||||
public getVolume(): number {
|
||||
return this.volume;
|
||||
}
|
||||
|
||||
// Setters
|
||||
public setName(name: string): void {
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
public setId(id: number): void {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
public setTrackIndex(trackIndex: number): void {
|
||||
this.trackIndex = trackIndex;
|
||||
|
||||
// Update the track index for all regions
|
||||
this.regions.forEach(region => {
|
||||
region.setTrackIndex(trackIndex);
|
||||
});
|
||||
}
|
||||
|
||||
public setType(type: TrackType): void {
|
||||
this.type = type;
|
||||
}
|
||||
|
||||
public setVolume(volume: number): void {
|
||||
this.volume = volume;
|
||||
}
|
||||
|
||||
public setRegions(regions: KGRegion[]): void {
|
||||
this.regions = regions;
|
||||
}
|
||||
|
||||
// Add a single region
|
||||
public addRegion(region: KGRegion): void {
|
||||
this.regions.push(region);
|
||||
}
|
||||
|
||||
// Remove a region by ID
|
||||
public removeRegion(regionId: string): void {
|
||||
this.regions = this.regions.filter(region => region.getId() !== regionId);
|
||||
}
|
||||
|
||||
// Type identification method for performance-optimized instanceof checks
|
||||
public getRootType(): string {
|
||||
return 'KGTrack';
|
||||
}
|
||||
|
||||
// Current type identification for copy/paste operations
|
||||
public getCurrentType(): string {
|
||||
return 'KGTrack';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user